add android icon
This commit is contained in:
1 parent
f22d954105
commit
55a69c743a
521 files changed
+8146
-14086
No files matched your search
@@ -2,6 +2,7 @@
|
||||
name = "ramshare-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
autobins = false
|
||||
|
||||
[lib]
|
||||
name = "ramshare_core"
|
||||
|
||||
+42
-33
@@ -1,5 +1,3 @@
|
||||
//! Локальный HTTP API управления узлом (127.0.0.1): используется Web UI на ПК и Jetpack Compose на Android.
|
||||
|
||||
use crate::{models::*, net, storage, transfer};
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -25,7 +23,41 @@ pub struct UiGuard {
|
||||
pub hosts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Создаёт базовый роутер `/ui/*` (без статических веб-файлов) с защитой Host/Origin.
|
||||
pub fn state_snapshot_json(state: &AppState) -> serde_json::Value {
|
||||
if let Some(ip) = net::detect_lan_ip() {
|
||||
state.update_my_ip(ip);
|
||||
}
|
||||
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
||||
peers.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name)));
|
||||
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
||||
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
|
||||
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
|
||||
let scan = state.scan_status.read().clone();
|
||||
let logs: Vec<String> = state.logs.read().iter().take(30).cloned().collect();
|
||||
|
||||
serde_json::json!({
|
||||
"device_name": state.device_name,
|
||||
"my_ip": state.current_ip(),
|
||||
"p2p_port": state.p2p_port,
|
||||
"my_fingerprint": state.my_fingerprint,
|
||||
"my_pin": short_id(&state.my_fingerprint),
|
||||
"media_dir": state.current_media_dir().display().to_string(),
|
||||
"configured_media_dir": state.configured_media_dir.read().clone().unwrap_or_default(),
|
||||
"ping_interval_sec": state.ping_interval_sec.load(Ordering::Relaxed),
|
||||
"last_ping": state.last_ping.load(Ordering::Relaxed),
|
||||
"max_ping_fails": MAX_PING_FAILS,
|
||||
"now": now_sec(),
|
||||
"ping_min": MIN_PING_SEC,
|
||||
"ping_max": MAX_PING_SEC,
|
||||
"peers": peers,
|
||||
"texts": texts,
|
||||
"media": media,
|
||||
"transfers": transfers,
|
||||
"scan": scan,
|
||||
"logs": logs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn control_router(extra_routes: Router<Arc<AppState>>, state: Arc<AppState>, ui_port: u16) -> Router {
|
||||
let guard = Arc::new(UiGuard {
|
||||
hosts: vec![
|
||||
@@ -93,35 +125,7 @@ pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -
|
||||
}
|
||||
|
||||
pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||
if let Some(ip) = net::detect_lan_ip() {
|
||||
state.update_my_ip(ip);
|
||||
}
|
||||
let mut peers: Vec<PeerDevice> = state.peers.read().values().cloned().collect();
|
||||
peers.sort_by(|a, b| b.online.cmp(&a.online).then_with(|| a.name.cmp(&b.name)));
|
||||
let texts: Vec<TextItem> = state.ram_texts.read().iter().cloned().collect();
|
||||
let media: Vec<MediaItem> = state.disk_media.read().iter().cloned().collect();
|
||||
let transfers: Vec<TransferProgress> = state.transfers.read().clone();
|
||||
let scan = state.scan_status.read().clone();
|
||||
|
||||
Json(serde_json::json!({
|
||||
"device_name": state.device_name,
|
||||
"my_ip": state.current_ip(),
|
||||
"p2p_port": state.p2p_port,
|
||||
"my_fingerprint": state.my_fingerprint,
|
||||
"my_pin": short_id(&state.my_fingerprint),
|
||||
"media_dir": state.media_dir.display().to_string(),
|
||||
"ping_interval_sec": state.ping_interval_sec.load(Ordering::Relaxed),
|
||||
"last_ping": state.last_ping.load(Ordering::Relaxed),
|
||||
"max_ping_fails": MAX_PING_FAILS,
|
||||
"now": now_sec(),
|
||||
"ping_min": MIN_PING_SEC,
|
||||
"ping_max": MAX_PING_SEC,
|
||||
"peers": peers,
|
||||
"texts": texts,
|
||||
"media": media,
|
||||
"transfers": transfers,
|
||||
"scan": scan,
|
||||
}))
|
||||
Json(state_snapshot_json(&state))
|
||||
}
|
||||
|
||||
pub async fn ui_add_manual_peer(
|
||||
@@ -232,6 +236,11 @@ pub async fn ui_set_settings(
|
||||
state
|
||||
.set_ping_interval(req.ping_interval_sec)
|
||||
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
|
||||
if let Some(md) = req.media_dir.as_deref() {
|
||||
state
|
||||
.set_media_dir(Some(md))
|
||||
.map_err(|e| AppError::new(StatusCode::BAD_REQUEST, e))?;
|
||||
}
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -276,7 +285,7 @@ pub async fn ui_serve_saved_file(State(state): State<Arc<AppState>>, Path(filena
|
||||
{
|
||||
return not_found();
|
||||
}
|
||||
let path = state.media_dir.join(&filename);
|
||||
let path = state.current_media_dir().join(&filename);
|
||||
let Ok(meta) = tokio::fs::metadata(&path).await else { return not_found() };
|
||||
if !meta.is_file() {
|
||||
return not_found();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Единый файл конфигурации `<data-dir>/config.json`:
|
||||
//! имя устройства, идентичность (сертификат + закрытый ключ) и интервал опроса.
|
||||
//! имя устройства, идентичность (сертификат + закрытый ключ), интервал опроса и путь к `received_media`.
|
||||
|
||||
use crate::{
|
||||
models::{clean_label, MAX_PING_SEC, MIN_PING_SEC},
|
||||
@@ -12,6 +12,7 @@ pub struct Loaded {
|
||||
pub identity: Identity,
|
||||
pub device_name: String,
|
||||
pub ping_interval_sec: u64,
|
||||
pub configured_media_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn migrate_or_generate(data_dir: &Path, legacy_port: u16) -> Result<Identity, String> {
|
||||
@@ -76,6 +77,13 @@ pub fn load_or_init(data_dir: &Path, legacy_port: u16, default_name: &str) -> Re
|
||||
}
|
||||
};
|
||||
|
||||
let configured_media_dir = cfg
|
||||
.media_dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from);
|
||||
|
||||
if dirty {
|
||||
storage::save_config(data_dir, &cfg)
|
||||
.map_err(|e| format!("запись {}: {}", storage::config_path(data_dir).display(), e))?;
|
||||
@@ -87,5 +95,6 @@ pub fn load_or_init(data_dir: &Path, legacy_port: u16, default_name: &str) -> Re
|
||||
identity,
|
||||
device_name,
|
||||
ping_interval_sec,
|
||||
configured_media_dir,
|
||||
})
|
||||
}
|
||||
+13
-5
@@ -12,7 +12,7 @@ use parking_lot::{Mutex as PlMutex, RwLock};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
path::Path,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64},
|
||||
Arc,
|
||||
@@ -22,8 +22,7 @@ use tokio::sync::{Mutex, Notify, Semaphore};
|
||||
|
||||
/// Единая точка инициализации состояния узла для Desktop, CLI и Android.
|
||||
pub fn init_app_state(
|
||||
data_dir: PathBuf,
|
||||
media_dir: PathBuf,
|
||||
base_override: Option<&Path>,
|
||||
name_override: Option<String>,
|
||||
default_name: &str,
|
||||
p2p_port: u16,
|
||||
@@ -42,11 +41,18 @@ pub fn init_app_state(
|
||||
}
|
||||
};
|
||||
|
||||
let data_dir = storage::resolve_config_dir(base_override, p2p_port)?;
|
||||
let loaded = config::load_or_init(&data_dir, p2p_port, default_name)?;
|
||||
let media_dir = storage::resolve_media_dir(
|
||||
loaded.configured_media_dir.as_deref(),
|
||||
base_override,
|
||||
p2p_port,
|
||||
)?;
|
||||
|
||||
if serving {
|
||||
storage::cleanup_partials(&media_dir);
|
||||
}
|
||||
|
||||
let loaded = config::load_or_init(&data_dir, p2p_port, default_name)?;
|
||||
let device_name = name_override
|
||||
.map(|s| clean_label(&s, 48))
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -99,7 +105,9 @@ pub fn init_app_state(
|
||||
cert_der: identity.cert_der,
|
||||
key_der: identity.key_der,
|
||||
data_dir,
|
||||
media_dir,
|
||||
base_override: base_override.map(Path::to_path_buf),
|
||||
media_dir: RwLock::new(media_dir),
|
||||
configured_media_dir: RwLock::new(loaded.configured_media_dir),
|
||||
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
|
||||
disk_media: RwLock::new(disk_media),
|
||||
peers,
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod models;
|
||||
mod net;
|
||||
mod p2p;
|
||||
mod storage;
|
||||
mod tls;
|
||||
mod transfer;
|
||||
mod ui;
|
||||
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use models::*;
|
||||
use parking_lot::{Mutex as PlMutex, RwLock};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::{Mutex, Notify, Semaphore};
|
||||
|
||||
const USAGE: &str = r#"ramshare_ui — обмен текстом и файлами в локальной сети (TLS 1.3, mTLS, пиннинг сертификатов)
|
||||
|
||||
Использование: ramshare_ui [параметры] [команда]
|
||||
|
||||
Команды:
|
||||
serve запустить узел и веб-интерфейс (команда по умолчанию)
|
||||
scan [диапазон] просканировать сеть и показать найденные устройства
|
||||
(all, 192.168.1.1-254, 10.0.0.0/24, список через запятую)
|
||||
peers показать, кто онлайн (известные + UDP-обнаружение)
|
||||
config показать путь и значения config.json (создаёт файл при первом запуске)
|
||||
trust <устройство> сверить код и заверить устройство (сверка взаимная: второе
|
||||
устройство получит запрос и тоже должно подтвердить код)
|
||||
untrust <устройство> отозвать доверие
|
||||
send-text <устройство> <текст…|-> отправить текст ("-" — читать из stdin)
|
||||
send-file <устройство> <файл…> отправить один или несколько файлов
|
||||
|
||||
<устройство> — имя, IP[:порт], префикс ID (≥4 hex) или полный отпечаток
|
||||
|
||||
Колонка ДОВЕРИЕ в peers/scan:
|
||||
взаимно — оба подтвердили код; ждём — вы подтвердили, ждём его;
|
||||
запрос — он подтвердил и ждёт вас (выполните trust); нет — не заверено
|
||||
|
||||
Параметры:
|
||||
--name <имя> имя устройства на этот запуск (постоянное задаётся в config.json)
|
||||
--p2p-port <порт> порт P2P (HTTPS, mTLS), по умолчанию 8443; для scan/send — порт поиска
|
||||
--ui-port <порт> порт локального веб-интерфейса (только 127.0.0.1), по умолчанию 8080
|
||||
--ip <IPv4> адрес интерфейса (по умолчанию определяется автоматически)
|
||||
--data-dir <путь> базовый каталог вместо папки с ramshare_ui; внутри создаются
|
||||
config/ (config.json, trusted.json) и received_media/ (принятые файлы)
|
||||
--ping-interval <с> интервал опроса известных устройств, 10–30 с (иначе config.json, иначе 15)
|
||||
--no-ui (serve) без веб-интерфейса; входящий текст печатается в терминал
|
||||
--wait <с> (CLI) сколько слушать UDP-обнаружение, 1–60, по умолчанию 4
|
||||
--scan <диапазон> (CLI) если устройство не найдено — просканировать этот диапазон
|
||||
--confirm-wait <с> (trust) ждать подтверждения на втором устройстве, 0–600 (по умолчанию 0)
|
||||
--json (scan/peers) вывод в JSON
|
||||
-y, --yes (trust) не спрашивать подтверждение (использовать осознанно!)
|
||||
-h, --help эта справка
|
||||
|
||||
Примеры:
|
||||
ramshare_ui serve --no-ui --name Server
|
||||
ramshare_ui scan
|
||||
ramshare_ui peers --json
|
||||
ramshare_ui trust laptop
|
||||
ramshare_ui send-text laptop "привет из консоли"
|
||||
echo "секрет" | ramshare_ui send-text 192.168.1.20 -
|
||||
ramshare_ui send-file laptop ./report.pdf ./photo.jpg
|
||||
"#;
|
||||
|
||||
enum Command {
|
||||
Serve,
|
||||
Scan(Option<String>),
|
||||
Peers,
|
||||
Config,
|
||||
Trust(String),
|
||||
Untrust(String),
|
||||
SendText { peer: String, words: Vec<String> },
|
||||
SendFile { peer: String, paths: Vec<PathBuf> },
|
||||
}
|
||||
|
||||
struct Config {
|
||||
name: Option<String>,
|
||||
p2p_port: u16,
|
||||
ui_port: u16,
|
||||
ip: Option<Ipv4Addr>,
|
||||
data_dir: Option<PathBuf>,
|
||||
ping_interval: Option<u64>,
|
||||
json: bool,
|
||||
wait: u64,
|
||||
confirm_wait: u64,
|
||||
scan: Option<String>,
|
||||
yes: bool,
|
||||
no_ui: bool,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
fn fatal(msg: &str) -> ! {
|
||||
eprintln!("Ошибка: {}", msg);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn default_device_name() -> String {
|
||||
let n = std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.unwrap_or_default();
|
||||
let n = clean_label(&n, 48);
|
||||
if n.is_empty() {
|
||||
"My-Device".into()
|
||||
} else {
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Config, String> {
|
||||
let mut cfg = Config {
|
||||
name: None,
|
||||
p2p_port: 8443,
|
||||
ui_port: 8080,
|
||||
ip: None,
|
||||
data_dir: None,
|
||||
ping_interval: None,
|
||||
json: false,
|
||||
wait: 4,
|
||||
confirm_wait: 0,
|
||||
scan: None,
|
||||
yes: false,
|
||||
no_ui: false,
|
||||
command: Command::Serve,
|
||||
};
|
||||
|
||||
let mut positional: Vec<String> = Vec::new();
|
||||
let mut only_pos = false;
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(a) = it.next() {
|
||||
if !only_pos && a == "--" {
|
||||
only_pos = true;
|
||||
continue;
|
||||
}
|
||||
if only_pos || (!a.starts_with("--") && a != "-h" && a != "-y") {
|
||||
positional.push(a);
|
||||
continue;
|
||||
}
|
||||
let mut val = |flag: &str| it.next().ok_or_else(|| format!("{} требует значение", flag));
|
||||
match a.as_str() {
|
||||
"--name" => cfg.name = Some(clean_label(&val("--name")?, 48)),
|
||||
"--p2p-port" => {
|
||||
cfg.p2p_port = val("--p2p-port")?.parse().map_err(|_| "Некорректный --p2p-port".to_string())?
|
||||
}
|
||||
"--ui-port" => {
|
||||
cfg.ui_port = val("--ui-port")?.parse().map_err(|_| "Некорректный --ui-port".to_string())?
|
||||
}
|
||||
"--ip" => cfg.ip = Some(val("--ip")?.parse().map_err(|_| "Некорректный --ip (нужен IPv4)".to_string())?),
|
||||
"--data-dir" => cfg.data_dir = Some(PathBuf::from(val("--data-dir")?)),
|
||||
"--ping-interval" => {
|
||||
let s: u64 = val("--ping-interval")?
|
||||
.parse()
|
||||
.map_err(|_| "Некорректный --ping-interval".to_string())?;
|
||||
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&s) {
|
||||
return Err(format!("--ping-interval: допустимо {}–{} секунд", MIN_PING_SEC, MAX_PING_SEC));
|
||||
}
|
||||
cfg.ping_interval = Some(s);
|
||||
}
|
||||
"--wait" => {
|
||||
let w: u64 = val("--wait")?.parse().map_err(|_| "Некорректный --wait".to_string())?;
|
||||
if !(1..=60).contains(&w) {
|
||||
return Err("--wait: допустимо 1–60 секунд".into());
|
||||
}
|
||||
cfg.wait = w;
|
||||
}
|
||||
"--scan" => cfg.scan = Some(val("--scan")?),
|
||||
"--confirm-wait" => {
|
||||
let w: u64 = val("--confirm-wait")?.parse().map_err(|_| "Некорректный --confirm-wait".to_string())?;
|
||||
if w > 600 {
|
||||
return Err("--confirm-wait: допустимо 0–600 секунд".into());
|
||||
}
|
||||
cfg.confirm_wait = w;
|
||||
}
|
||||
"--json" => cfg.json = true,
|
||||
"--no-ui" => cfg.no_ui = true,
|
||||
"-y" | "--yes" => cfg.yes = true,
|
||||
"-h" | "--help" => {
|
||||
println!("{}", USAGE);
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("Неизвестный параметр: {}\n\n{}", other, USAGE)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut pos = positional.into_iter();
|
||||
let sub = pos.next();
|
||||
cfg.command = match sub.as_deref() {
|
||||
None | Some("serve") => Command::Serve,
|
||||
Some("scan") => Command::Scan(pos.next()),
|
||||
Some("peers") | Some("list") | Some("ls") => Command::Peers,
|
||||
Some("config") => Command::Config,
|
||||
Some("trust") => Command::Trust(pos.next().ok_or("trust: укажите устройство")?),
|
||||
Some("untrust") => Command::Untrust(pos.next().ok_or("untrust: укажите устройство")?),
|
||||
Some("send-text") | Some("text") => {
|
||||
let peer = pos.next().ok_or("send-text: укажите устройство")?;
|
||||
let words: Vec<String> = pos.collect();
|
||||
if words.is_empty() {
|
||||
return Err("send-text: укажите текст (или «-» для чтения из stdin)".into());
|
||||
}
|
||||
Command::SendText { peer, words }
|
||||
}
|
||||
Some("send-file") | Some("file") => {
|
||||
let peer = pos.next().ok_or("send-file: укажите устройство")?;
|
||||
let paths: Vec<PathBuf> = pos.map(PathBuf::from).collect();
|
||||
if paths.is_empty() {
|
||||
return Err("send-file: укажите хотя бы один файл".into());
|
||||
}
|
||||
Command::SendFile { peer, paths }
|
||||
}
|
||||
Some("help") => {
|
||||
println!("{}", USAGE);
|
||||
std::process::exit(0);
|
||||
}
|
||||
Some(other) => return Err(format!("Неизвестная команда: {}\n\n{}", other, USAGE)),
|
||||
};
|
||||
|
||||
if cfg.name.as_deref() == Some("") {
|
||||
return Err("Пустое имя устройства".into());
|
||||
}
|
||||
if cfg.p2p_port == 0 || cfg.ui_port == 0 {
|
||||
return Err("Порт не может быть 0".into());
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
struct Built {
|
||||
state: Arc<AppState>,
|
||||
bind_ip: Ipv4Addr,
|
||||
}
|
||||
|
||||
/// Общая инициализация для `serve` и CLI-команд: адреса, каталоги, идентичность, доверие, клиенты.
|
||||
fn build_state(cfg: &Config, serving: bool) -> Built {
|
||||
let (bind_ip, my_ip, fixed_ip) = match cfg.ip {
|
||||
Some(ip) if !ip.is_unspecified() => (ip, ip, true),
|
||||
_ => {
|
||||
let ip = net::detect_lan_ip().unwrap_or_else(|| {
|
||||
eprintln!("[WARN] Локальный IP не определён, используется 127.0.0.1 (подхватится при появлении сети)");
|
||||
Ipv4Addr::LOCALHOST
|
||||
});
|
||||
(Ipv4Addr::UNSPECIFIED, ip, false)
|
||||
}
|
||||
};
|
||||
|
||||
// <папка с ramshare_ui>/config (вся конфигурация) и <папка с ramshare_ui>/received_media
|
||||
let dirs = storage::resolve_dirs(cfg.data_dir.as_deref(), cfg.p2p_port).unwrap_or_else(|e| fatal(&e));
|
||||
let data_dir = dirs.config; // каталог конфигурации: config.json, trusted.json
|
||||
let media_dir = dirs.media;
|
||||
if serving {
|
||||
// .part-файлы чистит только работающий узел (CLI не должен трогать чужие передачи)
|
||||
storage::cleanup_partials(&media_dir);
|
||||
}
|
||||
|
||||
// config.json: имя устройства, идентичность (сертификат + ключ), интервал опроса
|
||||
let loaded = config::load_or_init(&data_dir, cfg.p2p_port, &default_device_name()).unwrap_or_else(|e| fatal(&e));
|
||||
let device_name = cfg.name.clone().unwrap_or(loaded.device_name); // --name важнее config.json
|
||||
let identity = loaded.identity;
|
||||
|
||||
let mut known: HashMap<String, PeerDevice> = HashMap::new();
|
||||
for t in storage::load_trusted(&data_dir) {
|
||||
if !is_valid_fp(&t.fp) || t.fp == identity.fingerprint {
|
||||
continue;
|
||||
}
|
||||
let ips: Vec<String> = t
|
||||
.ips
|
||||
.into_iter()
|
||||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||||
.take(MAX_PEER_IPS)
|
||||
.collect();
|
||||
let port = if t.port == 0 { 8443 } else { t.port };
|
||||
let mut p = PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true);
|
||||
p.trusts_me = t.trusts_me;
|
||||
p.last_seen = t.last_seen;
|
||||
known.insert(t.fp.clone(), p);
|
||||
}
|
||||
let peers = Arc::new(RwLock::new(known));
|
||||
|
||||
let probe_client = tls::probe_client(&identity.cert_der, &identity.key_der).unwrap_or_else(|e| fatal(&e));
|
||||
|
||||
// приоритет: --ping-interval > config.json > значение по умолчанию
|
||||
let ping_sec = cfg
|
||||
.ping_interval
|
||||
.unwrap_or(loaded.ping_interval_sec)
|
||||
.clamp(MIN_PING_SEC, MAX_PING_SEC);
|
||||
|
||||
let disk_media: VecDeque<MediaItem> = if serving {
|
||||
storage::scan_media_dir(&media_dir).into_iter().collect()
|
||||
} else {
|
||||
VecDeque::new()
|
||||
};
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
device_name,
|
||||
my_ip: RwLock::new(my_ip.to_string()),
|
||||
fixed_ip,
|
||||
p2p_port: cfg.p2p_port,
|
||||
my_fingerprint: identity.fingerprint.clone(),
|
||||
cert_der: identity.cert_der,
|
||||
key_der: identity.key_der,
|
||||
data_dir,
|
||||
media_dir,
|
||||
ram_texts: RwLock::new(VecDeque::with_capacity(MAX_RAM_TEXTS)),
|
||||
disk_media: RwLock::new(disk_media),
|
||||
peers,
|
||||
probe_client,
|
||||
transfers: RwLock::new(Vec::new()),
|
||||
scan_status: RwLock::new(ScanProgress::default()),
|
||||
logs: RwLock::new(VecDeque::new()),
|
||||
incoming_slots: Arc::new(Semaphore::new(MAX_INCOMING)),
|
||||
ping_lock: Mutex::new(()),
|
||||
trust_dirty: AtomicBool::new(false),
|
||||
probe_cooldown: PlMutex::new(HashMap::new()),
|
||||
ping_interval_sec: AtomicU64::new(ping_sec),
|
||||
last_ping: AtomicU64::new(0),
|
||||
ping_notify: Notify::new(),
|
||||
log_stdout: serving,
|
||||
headless: serving && cfg.no_ui,
|
||||
trust_mtime: PlMutex::new(None),
|
||||
});
|
||||
|
||||
Built { state, bind_ip }
|
||||
}
|
||||
|
||||
async fn run_serve(cfg: &Config, built: Built) {
|
||||
let Built { state, bind_ip } = built;
|
||||
|
||||
let server_cfg = tls::server_config(&state.cert_der, &state.key_der).unwrap_or_else(|e| fatal(&e));
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(server_cfg);
|
||||
|
||||
// слушатели привязываем ДО запуска задач: занятый порт — фатальная ошибка
|
||||
let p2p_listener = tokio::net::TcpListener::bind((bind_ip, cfg.p2p_port))
|
||||
.await
|
||||
.unwrap_or_else(|e| fatal(&format!("P2P-порт {}:{} недоступен: {}", bind_ip, cfg.p2p_port, e)));
|
||||
let ui_listener = if cfg.no_ui {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, cfg.ui_port))
|
||||
.await
|
||||
.unwrap_or_else(|e| fatal(&format!("UI-порт {} недоступен: {}", cfg.ui_port, e))),
|
||||
)
|
||||
};
|
||||
|
||||
state.add_log(format!(
|
||||
"Узел '{}' запущен на {}:{} (ID: {})",
|
||||
state.device_name,
|
||||
state.current_ip(),
|
||||
cfg.p2p_port,
|
||||
short_id(&state.my_fingerprint)
|
||||
));
|
||||
|
||||
net::start_discovery(state.clone());
|
||||
|
||||
// периодический опрос + обслуживание; после 5 неудач пир переводится в offline и больше не опрашивается автоматически
|
||||
{
|
||||
let st = state.clone();
|
||||
tokio::spawn(async move {
|
||||
net::ping_all_peers(&st, false).await;
|
||||
st.maintenance();
|
||||
loop {
|
||||
let secs = st.ping_interval_sec.load(Ordering::Relaxed);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(secs)) => {}
|
||||
_ = st.ping_notify.notified() => {}
|
||||
}
|
||||
net::ping_all_peers(&st, false).await;
|
||||
st.maintenance();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
println!("==============================================================");
|
||||
println!(" Устройство: {}", state.device_name);
|
||||
println!(" P2P (HTTPS, mTLS): {}:{}", bind_ip, cfg.p2p_port);
|
||||
println!(" Адрес в сети: {}", state.current_ip());
|
||||
println!(" ID устройства: {}", short_id(&state.my_fingerprint));
|
||||
println!(" Папка config: {}", state.data_dir.display());
|
||||
println!(" Файл настроек: {}", state.data_dir.join("config.json").display());
|
||||
println!(" Принятые файлы: {}", state.media_dir.display());
|
||||
println!(
|
||||
" Опрос устройств: каждые {} с (до {} попыток при недоступности)",
|
||||
state.ping_interval_sec.load(Ordering::Relaxed),
|
||||
MAX_PING_FAILS
|
||||
);
|
||||
println!("--------------------------------------------------------------");
|
||||
|
||||
let ui_task = match ui_listener {
|
||||
Some(listener) => {
|
||||
let guard = Arc::new(ui::UiGuard {
|
||||
hosts: vec![
|
||||
format!("127.0.0.1:{}", cfg.ui_port),
|
||||
format!("localhost:{}", cfg.ui_port),
|
||||
],
|
||||
});
|
||||
println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:");
|
||||
println!(" http://127.0.0.1:{}/", cfg.ui_port);
|
||||
|
||||
let ui_router = Router::new()
|
||||
.route("/", get(ui::ui_index))
|
||||
.route("/app.js", get(ui::ui_js))
|
||||
.route("/ui/state", get(ui::ui_get_state))
|
||||
.route("/ui/manual-peer", post(ui::ui_add_manual_peer))
|
||||
.route("/ui/scan-range", post(ui::ui_scan_range))
|
||||
.route("/ui/ping", post(ui::ui_ping_peers))
|
||||
.route("/ui/ping-peer", post(ui::ui_ping_one_peer))
|
||||
.route("/ui/trust", post(ui::ui_trust_peer))
|
||||
.route("/ui/clear-ram", post(ui::ui_clear_ram))
|
||||
.route("/ui/settings", post(ui::ui_set_settings))
|
||||
.route("/ui/send-text", post(ui::ui_send_text))
|
||||
.route("/ui/send-media", post(ui::ui_send_media)) // сырой Body — без лимита экстрактора
|
||||
.route("/ui/files/:filename", get(ui::ui_serve_saved_file))
|
||||
.layer(DefaultBodyLimit::max(512 * 1024)) // для JSON-эндпоинтов
|
||||
.layer(middleware::from_fn_with_state(guard, ui::ui_guard))
|
||||
.with_state(state.clone());
|
||||
Some(tokio::spawn(async move { axum::serve(listener, ui_router).await }))
|
||||
}
|
||||
None => {
|
||||
println!(" Режим без UI (--no-ui). Входящий текст печатается в терминал,");
|
||||
println!(" файлы сохраняются в {}", state.media_dir.display());
|
||||
None
|
||||
}
|
||||
};
|
||||
println!("==============================================================");
|
||||
|
||||
let p2p_task = tokio::spawn(p2p::serve(p2p_listener, acceptor, p2p::router(state.clone())));
|
||||
|
||||
let ui_wait = async {
|
||||
match ui_task {
|
||||
Some(t) => eprintln!("UI-сервер остановился: {:?}", t.await),
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = ui_wait => {}
|
||||
r = p2p_task => eprintln!("P2P-сервер остановился: {:?}", r),
|
||||
_ = tokio::signal::ctrl_c() => println!("\nЗавершение работы…"),
|
||||
}
|
||||
|
||||
state.sync_trust_from_disk();
|
||||
state.persist_trust();
|
||||
storage::cleanup_partials(&state.media_dir);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cfg = parse_args().unwrap_or_else(|e| fatal(&e));
|
||||
let serving = matches!(cfg.command, Command::Serve);
|
||||
let built = build_state(&cfg, serving);
|
||||
|
||||
if serving {
|
||||
run_serve(&cfg, built).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let state = built.state;
|
||||
let opts = cli::CliOpts {
|
||||
json: cfg.json,
|
||||
wait: cfg.wait,
|
||||
scan: cfg.scan.clone(),
|
||||
yes: cfg.yes,
|
||||
confirm_wait: cfg.confirm_wait,
|
||||
};
|
||||
let res = match &cfg.command {
|
||||
Command::Scan(r) => cli::cmd_scan(&state, r.as_deref().unwrap_or("all"), &opts).await,
|
||||
Command::Peers => cli::cmd_peers(&state, &opts).await,
|
||||
Command::Trust(s) => cli::cmd_trust(&state, s, &opts).await,
|
||||
Command::Untrust(s) => cli::cmd_untrust(&state, s, &opts).await,
|
||||
Command::SendText { peer, words } => cli::cmd_send_text(&state, peer, words, &opts).await,
|
||||
Command::SendFile { peer, paths } => cli::cmd_send_files(&state, peer, paths, &opts).await,
|
||||
Command::Config => {
|
||||
println!("Файл конфигурации: {}", state.data_dir.join("config.json").display());
|
||||
println!("device_name: {}", state.device_name);
|
||||
println!("ping_interval_sec: {}", state.ping_interval_sec.load(Ordering::Relaxed));
|
||||
println!("identity.fingerprint: {}", state.my_fingerprint);
|
||||
Ok(())
|
||||
}
|
||||
Command::Serve => Ok(()),
|
||||
};
|
||||
if let Err(e) = res {
|
||||
eprintln!("Ошибка: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
+31
-24
@@ -21,8 +21,8 @@ use std::{
|
||||
use tokio::sync::{Mutex, Notify, Semaphore};
|
||||
|
||||
// ───────────────────────── Лимиты ─────────────────────────
|
||||
pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // 2 ГБ на один файл
|
||||
pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024; // 10 ГБ всего в received_media
|
||||
pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
|
||||
pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024;
|
||||
pub const MAX_TEXT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_RAM_TEXTS: usize = 50;
|
||||
pub const MAX_LOGS: usize = 100;
|
||||
@@ -44,7 +44,6 @@ impl AppError {
|
||||
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
|
||||
Self(status, msg.into())
|
||||
}
|
||||
/// Детали пишем в stderr, наружу отдаём общий текст (без путей и внутренностей).
|
||||
pub fn internal(ctx: &str, e: impl Display) -> Self {
|
||||
eprintln!("[ERR] {}: {}", ctx, e);
|
||||
Self(StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка".into())
|
||||
@@ -84,18 +83,12 @@ pub struct PeerDevice {
|
||||
pub ips: Vec<String>,
|
||||
pub port: u16,
|
||||
pub fingerprint: String,
|
||||
/// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков.
|
||||
pub pin_code: String,
|
||||
/// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем).
|
||||
pub verified: bool,
|
||||
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
||||
pub trusts_me: bool,
|
||||
pub online: bool,
|
||||
/// Когда устройство последний раз отвечало по сети (unix timestamp, сек).
|
||||
pub last_seen: u64,
|
||||
/// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек).
|
||||
pub last_ping: u64,
|
||||
/// Количество подряд неудачных попыток пинга (при достижении 5 автоопрос прекращается).
|
||||
pub fail_count: u8,
|
||||
}
|
||||
|
||||
@@ -185,6 +178,8 @@ pub struct UiScanRangeRequest {
|
||||
#[derive(Deserialize)]
|
||||
pub struct UiSettingsRequest {
|
||||
pub ping_interval_sec: u64,
|
||||
#[serde(default)]
|
||||
pub media_dir: Option<String>,
|
||||
}
|
||||
|
||||
// ───────────────────────── Состояние ─────────────────────────
|
||||
@@ -197,7 +192,9 @@ pub struct AppState {
|
||||
pub cert_der: Vec<u8>,
|
||||
pub key_der: Vec<u8>,
|
||||
pub data_dir: PathBuf,
|
||||
pub media_dir: PathBuf,
|
||||
pub base_override: Option<PathBuf>,
|
||||
pub media_dir: RwLock<PathBuf>,
|
||||
pub configured_media_dir: RwLock<Option<String>>,
|
||||
pub ram_texts: RwLock<VecDeque<TextItem>>,
|
||||
pub disk_media: RwLock<VecDeque<MediaItem>>,
|
||||
pub peers: Arc<RwLock<HashMap<String, PeerDevice>>>,
|
||||
@@ -209,16 +206,11 @@ pub struct AppState {
|
||||
pub ping_lock: Mutex<()>,
|
||||
pub trust_dirty: AtomicBool,
|
||||
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
||||
/// Интервал опроса известных пиров (сек), меняется на лету
|
||||
pub ping_interval_sec: AtomicU64,
|
||||
/// Время последнего глобального опроса устройств (unix timestamp, сек)
|
||||
pub last_ping: AtomicU64,
|
||||
pub ping_notify: Notify,
|
||||
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
||||
pub log_stdout: bool,
|
||||
/// true в `serve --no-ui`: входящий текст печатается в терминал
|
||||
pub headless: bool,
|
||||
/// mtime trusted.json, чтобы подхватывать правки из CLI (trust/untrust) работающим узлом
|
||||
pub trust_mtime: PlMutex<Option<SystemTime>>,
|
||||
}
|
||||
|
||||
@@ -227,6 +219,10 @@ impl AppState {
|
||||
self.my_ip.read().clone()
|
||||
}
|
||||
|
||||
pub fn current_media_dir(&self) -> PathBuf {
|
||||
self.media_dir.read().clone()
|
||||
}
|
||||
|
||||
pub fn update_my_ip(&self, ip: Ipv4Addr) -> bool {
|
||||
if self.fixed_ip {
|
||||
return false;
|
||||
@@ -342,7 +338,6 @@ impl AppState {
|
||||
}
|
||||
|
||||
// ── пиры ──
|
||||
/// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из TLS-рукопожатия.
|
||||
pub fn upsert_peer(&self, name: &str, ip: IpAddr, port: u16, fp: &str) -> bool {
|
||||
if fp == self.my_fingerprint || !is_valid_fp(fp) || port == 0 {
|
||||
return false;
|
||||
@@ -418,7 +413,6 @@ impl AppState {
|
||||
created
|
||||
}
|
||||
|
||||
/// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене).
|
||||
pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) {
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() {
|
||||
return;
|
||||
@@ -445,7 +439,6 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Сбрасывает счётчик неудачных пингов у всех известных устройств.
|
||||
pub fn reset_all_ping_failures(&self) {
|
||||
let mut peers = self.peers.write();
|
||||
for p in peers.values_mut() {
|
||||
@@ -453,7 +446,6 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен.
|
||||
pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool {
|
||||
let ip_s = ip.to_string();
|
||||
self.peers.read().get(fp).map_or(false, |p| {
|
||||
@@ -479,7 +471,6 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Меняет интервал опроса: применяется сразу и сохраняется в config.json.
|
||||
pub fn set_ping_interval(&self, sec: u64) -> Result<(), String> {
|
||||
if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&sec) {
|
||||
return Err(format!("Интервал должен быть от {} до {} секунд", MIN_PING_SEC, MAX_PING_SEC));
|
||||
@@ -493,7 +484,26 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Бит «собеседник подтвердил сверку с нами».
|
||||
/// Изменяет папку сохранения файлов (`media_dir`), создаёт её на диске и сохраняет в `config.json`.
|
||||
/// Пустая строка сбрасывает путь к папке по умолчанию (`./received_media` там, где запущено приложение).
|
||||
pub fn set_media_dir(&self, raw: Option<&str>) -> Result<PathBuf, String> {
|
||||
let cleaned = raw.map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||
let resolved = storage::resolve_media_dir(
|
||||
cleaned.as_deref(),
|
||||
self.base_override.as_deref(),
|
||||
self.p2p_port,
|
||||
)?;
|
||||
*self.media_dir.write() = resolved.clone();
|
||||
*self.configured_media_dir.write() = cleaned.clone();
|
||||
*self.disk_media.write() = storage::scan_media_dir(&resolved).into_iter().collect();
|
||||
|
||||
if let Err(e) = storage::update_config(&self.data_dir, |c| c.media_dir = cleaned) {
|
||||
self.add_log(format!("Не удалось сохранить media_dir в config.json: {}", e));
|
||||
}
|
||||
self.add_log(format!("Папка входящих файлов: {}", resolved.display()));
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub fn set_remote_trust(&self, fp: &str, v: bool) {
|
||||
let (msg, hint) = {
|
||||
let mut peers = self.peers.write();
|
||||
@@ -523,7 +533,6 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
||||
pub fn sync_trust_from_disk(&self) {
|
||||
let path = self.data_dir.join("trusted.json");
|
||||
let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return };
|
||||
@@ -608,7 +617,6 @@ pub fn short_id(fp: &str) -> String {
|
||||
fp.get(..8).unwrap_or(fp).to_uppercase()
|
||||
}
|
||||
|
||||
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
|
||||
pub fn sas_code(a: &str, b: &str) -> String {
|
||||
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut h = Sha256::new();
|
||||
@@ -661,7 +669,6 @@ pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
h.get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Локальные IPv4: RFC 1918 (10/8, 172.16/12, 192.168/16), Link-Local (169.254/16) и CGNAT/VPN (100.64/10).
|
||||
pub fn is_lan_v4(ip: Ipv4Addr) -> bool {
|
||||
let o = ip.octets();
|
||||
ip.is_private() || ip.is_link_local() || (o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000)
|
||||
|
||||
+25
-16
@@ -7,7 +7,11 @@ use std::{
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::Semaphore};
|
||||
use tokio::{
|
||||
net::{TcpListener, UdpSocket},
|
||||
sync::Semaphore,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
const MAX_PROBE_BODY: usize = 8 * 1024;
|
||||
|
||||
@@ -24,7 +28,7 @@ pub struct LanIf {
|
||||
pub broadcast: Ipv4Addr,
|
||||
}
|
||||
|
||||
// ───────────────────────── Интерфейсы ─────────────────────────
|
||||
// ───────────────────────── Интерфейсы и сокеты ─────────────────────────
|
||||
fn is_virtual_if(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
n.starts_with("docker")
|
||||
@@ -48,8 +52,18 @@ fn detect_lan_ip_udp() -> Option<Ipv4Addr> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Привязка P2P TCP-слушателя с SO_REUSEADDR (предотвращает ошибку занятого порта при перезапуске APK).
|
||||
pub fn bind_p2p_listener(bind_ip: Ipv4Addr, port: u16) -> std::io::Result<TcpListener> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
let addr = SocketAddr::new(IpAddr::V4(bind_ip), port);
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(128)?;
|
||||
TcpListener::from_std(socket.into())
|
||||
}
|
||||
|
||||
/// Все локальные (private/link-local/CGNAT) IPv4-интерфейсы с реальными масками подсети.
|
||||
/// На Android при блокировке getifaddrs() через SELinux используется fallback через UDP-маршрут.
|
||||
pub fn lan_interfaces() -> Vec<LanIf> {
|
||||
let mut list: Vec<LanIf> = if_addrs::get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
@@ -88,7 +102,6 @@ pub fn lan_interfaces() -> Vec<LanIf> {
|
||||
list
|
||||
}
|
||||
|
||||
/// Адрес основного локального интерфейса.
|
||||
pub fn detect_lan_ip() -> Option<Ipv4Addr> {
|
||||
if let Some(ip) = detect_lan_ip_udp() {
|
||||
return Some(ip);
|
||||
@@ -103,7 +116,6 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
|
||||
.or_else(|| lan_interfaces().first().map(|i| i.ip))
|
||||
}
|
||||
|
||||
/// Сортирует список IP пира так, чтобы адреса из наших текущих подсетей проверялись первыми.
|
||||
pub fn prioritize_ips(ips: &[IpAddr]) -> Vec<IpAddr> {
|
||||
let my_ifs = lan_interfaces();
|
||||
let in_local_subnet = |ip: &IpAddr| match ip {
|
||||
@@ -201,7 +213,6 @@ pub async fn probe_and_register_hint(
|
||||
state.upsert_peer(&info.name, ip, port, &info.fp);
|
||||
state.set_remote_trust(&info.fp, info.trusts_you);
|
||||
|
||||
// Если сканер/ручной ввод нашёл доверенный узел на новом IP без hint_fp, сразу сообщаем ему наш статус доверия
|
||||
if !had_hint_or_ip && state.trusted_name(&info.fp).is_some() {
|
||||
if let Ok(info2) = probe_with_hint(state, ip, port, Some(&info.fp)).await {
|
||||
state.set_remote_trust(&info.fp, info2.trusts_you);
|
||||
@@ -211,8 +222,6 @@ pub async fn probe_and_register_hint(
|
||||
}
|
||||
|
||||
// ───────────────────────── Ping и синхронизация доверия ─────────────────────────
|
||||
/// `force = true`: ручной опрос (дожидается замка, сбрасывает `fail_count` и опрашивает всех).
|
||||
/// `force = false`: фоновый автоопрос (пропускает устройства, не ответившие 5 раз подряд).
|
||||
pub async fn ping_all_peers(state: &Arc<AppState>, force: bool) {
|
||||
if force {
|
||||
state.reset_all_ping_failures();
|
||||
@@ -281,7 +290,6 @@ async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) -> bo
|
||||
false
|
||||
}
|
||||
|
||||
/// Ручной пинг одного конкретного пира (сбрасывает его `fail_count`, давая ещё 5 попыток).
|
||||
pub async fn ping_peer_by_id(state: &AppState, id: &str) -> Result<bool, String> {
|
||||
let (ips, port) = {
|
||||
let mut peers = state.peers.write();
|
||||
@@ -297,7 +305,6 @@ pub async fn ping_peer_by_id(state: &AppState, id: &str) -> Result<bool, String>
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// Мгновенно отправляет обновлённый статус доверия пиру и обновляет его `trusts_me`.
|
||||
pub async fn push_trust(state: &AppState, id: &str) {
|
||||
let Some((ips, port)) = state
|
||||
.peers
|
||||
@@ -310,7 +317,6 @@ pub async fn push_trust(state: &AppState, id: &str) {
|
||||
let _ = ping_one(state, id, ips, port).await;
|
||||
}
|
||||
|
||||
/// Возвращает рабочий IP пира (для потоковой отправки файлов при возможной смене подсети).
|
||||
pub async fn select_peer_ip(state: &AppState, id: &str) -> Option<(IpAddr, u16)> {
|
||||
let (ips, port, fresh) = {
|
||||
let peers = state.peers.read();
|
||||
@@ -382,11 +388,12 @@ fn bind_discovery_socket() -> std::io::Result<UdpSocket> {
|
||||
UdpSocket::from_std(socket.into())
|
||||
}
|
||||
|
||||
pub fn start_discovery(state: Arc<AppState>) {
|
||||
pub fn start_discovery(state: Arc<AppState>) -> Vec<JoinHandle<()>> {
|
||||
let serving = state.log_stdout;
|
||||
let mut handles = Vec::with_capacity(2);
|
||||
|
||||
let tx = state.clone();
|
||||
tokio::spawn(async move {
|
||||
handles.push(tokio::spawn(async move {
|
||||
let sock = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -431,9 +438,9 @@ pub fn start_discovery(state: Arc<AppState>) {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
tokio::spawn(async move {
|
||||
handles.push(tokio::spawn(async move {
|
||||
let socket = match bind_discovery_socket() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -496,5 +503,7 @@ pub fn start_discovery(state: Arc<AppState>) {
|
||||
let _ = probe_and_register_hint(&st, ip, port, Some(&fp_hint)).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
handles
|
||||
}
|
||||
@@ -20,7 +20,6 @@ use std::{
|
||||
use tokio::{io::AsyncWriteExt, net::TcpListener, sync::Semaphore};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
/// Личность собеседника из TLS-рукопожатия (а не из заголовков!).
|
||||
#[derive(Clone)]
|
||||
pub struct PeerCtx {
|
||||
pub fp: String,
|
||||
@@ -78,7 +77,6 @@ pub async fn serve(listener: TcpListener, acceptor: TlsAcceptor, app: Router) {
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Handlers ─────────────────────────
|
||||
async fn p2p_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(ctx): Extension<PeerCtx>,
|
||||
@@ -189,11 +187,12 @@ async fn p2p_receive_media(
|
||||
}
|
||||
|
||||
let safe_name = sanitize_filename(&pct_decode(hdr(&headers, "x-filename").unwrap_or("file.bin")));
|
||||
let media_dir = state.current_media_dir();
|
||||
|
||||
tokio::fs::create_dir_all(&state.media_dir)
|
||||
tokio::fs::create_dir_all(&media_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::internal("mkdir", e))?;
|
||||
let tmp = state.media_dir.join(format!(".part_{}", random_hex(8)));
|
||||
let tmp = media_dir.join(format!(".part_{}", random_hex(8)));
|
||||
let mut guard = TempGuard {
|
||||
path: tmp.clone(),
|
||||
armed: true,
|
||||
@@ -272,10 +271,10 @@ async fn p2p_receive_media(
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let candidate = state.media_dir.join(&safe_name);
|
||||
let candidate = media_dir.join(&safe_name);
|
||||
let (final_name, final_path) = if tokio::fs::try_exists(&candidate).await.unwrap_or(true) {
|
||||
let name = format!("{}_{}_{}", now_sec(), random_hex(3), safe_name);
|
||||
let path = state.media_dir.join(&name);
|
||||
let path = media_dir.join(&name);
|
||||
(name, path)
|
||||
} else {
|
||||
(safe_name.clone(), candidate)
|
||||
|
||||
+55
-31
@@ -43,16 +43,6 @@ fn os_data_dir(p2p_port: u16) -> PathBuf {
|
||||
base.unwrap_or_else(|| PathBuf::from(".")).join(name)
|
||||
}
|
||||
|
||||
pub struct Dirs {
|
||||
pub config: PathBuf,
|
||||
pub media: PathBuf,
|
||||
}
|
||||
|
||||
fn make_dirs(d: &Dirs) -> io::Result<()> {
|
||||
fs::create_dir_all(&d.config)?;
|
||||
fs::create_dir_all(&d.media)
|
||||
}
|
||||
|
||||
fn migrate_legacy(config: &Path, p2p_port: u16) {
|
||||
if config.join("config.json").exists() {
|
||||
return;
|
||||
@@ -76,49 +66,79 @@ fn migrate_legacy(config: &Path, p2p_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result<Dirs, String> {
|
||||
/// Определяет папку `config/` (для `config.json` и `trusted.json`).
|
||||
pub fn resolve_config_dir(base_override: Option<&Path>, p2p_port: u16) -> Result<PathBuf, String> {
|
||||
if let Some(b) = base_override {
|
||||
let base = if b.is_absolute() {
|
||||
b.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir().map_err(|e| e.to_string())?.join(b)
|
||||
};
|
||||
let d = Dirs {
|
||||
config: base.join("config"),
|
||||
media: base.join("received_media"),
|
||||
};
|
||||
make_dirs(&d).map_err(|e| format!("создание каталогов в {}: {}", base.display(), e))?;
|
||||
return Ok(d);
|
||||
let cfg_dir = base.join("config");
|
||||
fs::create_dir_all(&cfg_dir).map_err(|e| format!("создание {}: {}", cfg_dir.display(), e))?;
|
||||
return Ok(cfg_dir);
|
||||
}
|
||||
|
||||
if let Some(base) = exe_dir() {
|
||||
let suffix = if p2p_port == 8443 { String::new() } else { format!("-{}", p2p_port) };
|
||||
let d = Dirs {
|
||||
config: base.join(format!("config{}", suffix)),
|
||||
media: base.join(format!("received_media{}", suffix)),
|
||||
};
|
||||
match make_dirs(&d) {
|
||||
let cfg_dir = base.join(format!("config{}", suffix));
|
||||
match fs::create_dir_all(&cfg_dir) {
|
||||
Ok(()) => {
|
||||
migrate_legacy(&d.config, p2p_port);
|
||||
return Ok(d);
|
||||
migrate_legacy(&cfg_dir, p2p_port);
|
||||
return Ok(cfg_dir);
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"[WARN] не удалось создать {} ({}). Используется каталог данных ОС; \
|
||||
чтобы выбрать место явно, задайте --data-dir.",
|
||||
d.config.display(),
|
||||
cfg_dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let base = os_data_dir(p2p_port);
|
||||
let d = Dirs {
|
||||
config: base.join("config"),
|
||||
media: base.join("received_media"),
|
||||
let cfg_dir = base.join("config");
|
||||
fs::create_dir_all(&cfg_dir).map_err(|e| format!("создание {}: {}", cfg_dir.display(), e))?;
|
||||
migrate_legacy(&cfg_dir, p2p_port);
|
||||
Ok(cfg_dir)
|
||||
}
|
||||
|
||||
/// Определяет и создаёт папку `received_media`:
|
||||
/// 1) Если в `config.json` задан `media_dir` — используется он.
|
||||
/// 2) Иначе создаётся папка `received_media` там, где запущено приложение (`current_dir`),
|
||||
/// либо внутри `base_override` (если задан `--data-dir` или на Android).
|
||||
pub fn resolve_media_dir(
|
||||
configured: Option<&str>,
|
||||
base_override: Option<&Path>,
|
||||
p2p_port: u16,
|
||||
) -> Result<PathBuf, String> {
|
||||
let launch_dir = match base_override {
|
||||
Some(b) if b.is_absolute() => b.to_path_buf(),
|
||||
Some(b) => std::env::current_dir().map_err(|e| e.to_string())?.join(b),
|
||||
None => std::env::current_dir().map_err(|e| format!("не удалось определить текущую папку: {}", e))?,
|
||||
};
|
||||
make_dirs(&d).map_err(|e| format!("создание каталогов в {}: {}", base.display(), e))?;
|
||||
migrate_legacy(&d.config, p2p_port);
|
||||
Ok(d)
|
||||
|
||||
let dir = match configured.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(custom) => {
|
||||
let p = PathBuf::from(custom);
|
||||
if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
launch_dir.join(p)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let folder = if p2p_port == 8443 || base_override.is_some() {
|
||||
"received_media".to_string()
|
||||
} else {
|
||||
format!("received_media-{}", p2p_port)
|
||||
};
|
||||
launch_dir.join(folder)
|
||||
}
|
||||
};
|
||||
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("создание папки файлов {}: {}", dir.display(), e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn write_atomic(path: &Path, data: &[u8], private: bool) -> io::Result<()> {
|
||||
@@ -283,6 +303,8 @@ pub struct IdentityRecord {
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
/// `<data-dir>/config.json`.
|
||||
/// Если `media_dir` не указан (`null` или `""`), папка `received_media` создаётся там, где запущено приложение.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
@@ -290,6 +312,8 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub ping_interval_sec: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub media_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub identity: Option<IdentityRecord>,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user