From 552c009120383ced1debf4feb9deb452a6bd9bf1 Mon Sep 17 00:00:00 2001 From: netadmin Date: Sun, 25 Oct 2026 22:48:38 +0300 Subject: [PATCH] a.0.0.7 --- src/cli.rs | 39 ++++++++++++++++++++++---- src/main.rs | 12 ++++---- src/models.rs | 15 ++++++++-- src/net.rs | 8 ++++-- src/static/app.js | 37 +++++++++++++++++++++---- src/static/index.html | 13 +++++---- src/storage.rs | 9 +++--- src/ui.rs | 64 ++++++++----------------------------------- 8 files changed, 114 insertions(+), 83 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index da63adb..bd603cd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -46,6 +46,24 @@ fn fmt_bytes(b: u64) -> String { } } +fn fmt_ago(ts: u64, now: u64) -> String { + if ts == 0 { + return "никогда".into(); + } + let d = now.saturating_sub(ts); + if d < 3 { + "сейчас".into() + } else if d < 60 { + format!("{} с назад", d) + } else if d < 3600 { + format!("{} мин назад", d / 60) + } else if d < 86400 { + format!("{} ч назад", d / 3600) + } else { + format!("{} дн назад", d / 86400) + } +} + fn app_err(e: AppError) -> String { if e.0 == StatusCode::FORBIDDEN { format!("{} (выполните: ramshare_ui trust <устройство>)", e.1) @@ -91,13 +109,16 @@ fn print_peers(state: &AppState, json: bool, only_online: bool) { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into())); return; } + let now = now_sec(); + let last_p = state.last_ping.load(Ordering::Relaxed); + eprintln!("Последний опрос (ping): {}", fmt_ago(last_p, now)); if v.is_empty() { println!("Устройства не найдены."); return; } println!( - "{:<9} {:<9} {:<24} {:<22} {:<24} {}", - "СТАТУС", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID" + "{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}", + "СТАТУС", "БЫЛ ОНЛАЙН", "ПОСЛ. ПИНГ", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID" ); for p in v { let addr = p @@ -106,10 +127,12 @@ fn print_peers(state: &AppState, json: bool, only_online: bool) { .map(|ip| format!("{}:{}", ip, p.port)) .unwrap_or_else(|| "—".into()); println!( - "{:<9} {:<9} {:<24} {:<22} {:<24} {}", + "{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}", if p.online { "● online" } else { "○ offline" }, + fmt_ago(p.last_seen, now), + fmt_ago(p.last_ping, now), trust_label(&p), - trunc(&p.name, 24), + trunc(&p.name, 20), addr, p.pin_code, short_id(&p.fingerprint) @@ -320,6 +343,7 @@ pub async fn cmd_trust(state: &Arc, sel: &str, opts: &CliOpts) -> Resu .map_or(false, |x| x.verified && x.trusts_me) }; if mutual() { + state.persist_trust(); println!("✓ Взаимная сверка завершена: {}", p.name); return Ok(()); } @@ -337,6 +361,7 @@ pub async fn cmd_trust(state: &Arc, sel: &str, opts: &CliOpts) -> Resu tokio::time::sleep(Duration::from_secs(2)).await; net::push_trust(state, &p.id).await; if mutual() { + state.persist_trust(); println!("✓ Взаимная сверка завершена: {}", p.name); return Ok(()); } @@ -383,6 +408,7 @@ pub async fn cmd_send_text( } let p = find_peer(state, sel, opts, true).await?; let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?; + state.persist_trust(); println!("✓ Текст доставлен в ОЗУ устройства «{}»", name); Ok(()) } @@ -457,7 +483,10 @@ pub async fn cmd_send_files( eprint!("\r{}\r", " ".repeat(72)); } match res { - Ok(peer) => println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size)), + Ok(peer) => { + state.persist_trust(); + println!("✓ {} → «{}» ({})", name, peer, fmt_bytes(size)); + } Err(e) => return Err(format!("{}: {}", name, app_err(e))), } } diff --git a/src/main.rs b/src/main.rs index e1109ff..eb5e720 100644 --- a/src/main.rs +++ b/src/main.rs @@ -282,6 +282,7 @@ fn build_state(cfg: &Config, serving: bool) -> Built { 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)); @@ -322,6 +323,7 @@ fn build_state(cfg: &Config, serving: bool) -> Built { 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, @@ -361,10 +363,12 @@ async fn run_serve(cfg: &Config, built: Built) { net::start_discovery(state.clone()); - // периодический опрос + обслуживание; интервал меняется на лету (UI / config.json / --ping-interval) + // периодический опрос + обслуживание; первый пинг выполняется сразу при старте { let st = state.clone(); tokio::spawn(async move { + net::ping_all_peers(&st).await; + st.maintenance(); loop { let secs = st.ping_interval_sec.load(Ordering::Relaxed); tokio::select! { @@ -394,15 +398,13 @@ async fn run_serve(cfg: &Config, built: Built) { let ui_task = match ui_listener { Some(listener) => { let guard = Arc::new(ui::UiGuard { - token: random_hex(16), - cookie_name: format!("rs_token_{}", cfg.ui_port), hosts: vec![ format!("127.0.0.1:{}", cfg.ui_port), format!("localhost:{}", cfg.ui_port), ], }); - println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):"); - println!(" http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token); + println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:"); + println!(" http://127.0.0.1:{}/", cfg.ui_port); let ui_router = Router::new() .route("/", get(ui::ui_index)) diff --git a/src/models.rs b/src/models.rs index 844d7cc..d975353 100644 --- a/src/models.rs +++ b/src/models.rs @@ -90,7 +90,10 @@ pub struct PeerDevice { /// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info). pub trusts_me: bool, pub online: bool, + /// Когда устройство последний раз отвечало по сети (unix timestamp, сек). pub last_seen: u64, + /// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек). + pub last_ping: u64, } impl PeerDevice { @@ -106,6 +109,7 @@ impl PeerDevice { trusts_me: false, online: false, last_seen: 0, + last_ping: 0, } } } @@ -198,6 +202,8 @@ pub struct AppState { pub probe_cooldown: PlMutex>, /// Интервал опроса известных пиров (сек), меняется на лету pub ping_interval_sec: AtomicU64, + /// Время последнего глобального опроса устройств (unix timestamp, сек) + pub last_ping: AtomicU64, pub ping_notify: Notify, /// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом) pub log_stdout: bool, @@ -303,6 +309,7 @@ impl AppState { ips: p.ips.clone(), port: p.port, trusts_me: p.trusts_me, + last_seen: p.last_seen, }) .collect(); match storage::save_trusted(&self.data_dir, &entries) { @@ -367,6 +374,7 @@ impl AppState { p.port = port; p.online = true; p.last_seen = now; + p.last_ping = now; p.ips.retain(|x| x != &ip_s); p.ips.insert(0, ip_s.clone()); p.ips.truncate(MAX_PEER_IPS); @@ -378,6 +386,7 @@ impl AppState { let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false); p.online = true; p.last_seen = now; + p.last_ping = now; v.insert(p); created = true; } @@ -405,11 +414,12 @@ impl AppState { return; } let ip_s = ip.to_string(); + let now = now_sec(); let mut dirty = false; if let Some(p) = self.peers.write().get_mut(fp) { let changed = p.ips.first() != Some(&ip_s); p.online = true; - p.last_seen = now_sec(); + p.last_seen = now; if changed { p.ips.retain(|x| x != &ip_s); p.ips.insert(0, ip_s); @@ -496,7 +506,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 }; @@ -528,6 +537,7 @@ impl AppState { let port = if e.port == 0 { 8443 } else { e.port }; let mut np = PeerDevice::new(&e.fp, clean_label(&e.name, 48), ips, port, &self.my_fingerprint, true); np.trusts_me = e.trusts_me; + np.last_seen = e.last_seen; peers.insert(e.fp.clone(), np); } } @@ -581,7 +591,6 @@ pub fn short_id(fp: &str) -> String { } /// 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(); diff --git a/src/net.rs b/src/net.rs index 722c1a9..8eb1418 100644 --- a/src/net.rs +++ b/src/net.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use socket2::{Domain, Protocol, Socket, Type}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}, - sync::Arc, + sync::{atomic::Ordering, Arc}, time::{Duration, Instant}, }; use tokio::{net::UdpSocket, sync::Semaphore}; @@ -194,6 +194,8 @@ pub async fn ping_all_peers(state: &Arc) { ping_one(state, &id, ips, port).await; }) .await; + + state.last_ping.store(now_sec(), Ordering::Relaxed); } async fn ping_one(state: &AppState, id: &str, ips: Vec, port: u16) { @@ -208,9 +210,11 @@ async fn ping_one(state: &AppState, id: &str, ips: Vec, port: u16) { // по этому адресу теперь другое устройство — игнорируем } } + let now = now_sec(); let mut peers = state.peers.write(); if let Some(p) = peers.get_mut(id) { - if now_sec().saturating_sub(p.last_seen) > 15 { + p.last_ping = now; + if now.saturating_sub(p.last_seen) > 15 { p.online = false; } } diff --git a/src/static/app.js b/src/static/app.js index 1e78269..4543207 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -28,6 +28,20 @@ function formatBytes(b) { return `${(b / 1024 ** 3).toFixed(2)} ГБ`; } +function formatTimeAgo(ts, nowSec) { + if (!ts) return 'никогда'; + const now = nowSec || Math.floor(Date.now() / 1000); + const diff = Math.max(0, now - ts); + const dt = new Date(ts * 1000); + const timeStr = dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + if (diff < 3) return `только что (${timeStr})`; + if (diff < 60) return `${diff} с назад (${timeStr})`; + if (diff < 3600) return `${Math.floor(diff / 60)} мин назад (${timeStr})`; + if (diff < 86400) return `${Math.floor(diff / 3600)} ч назад (${timeStr})`; + const dateStr = dt.toLocaleDateString('ru-RU'); + return `${dateStr} ${timeStr}`; +} + // Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки) function setHtml(el, html) { if (el._h !== html) { el._h = html; el.innerHTML = html; } @@ -53,6 +67,7 @@ function renderHeader(d) { $('my-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`; $('my-pin').textContent = d.my_pin; $('media-dir').textContent = d.media_dir; + $('last-ping-info').textContent = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`; const pi = $('ping-interval-input'); pi.min = d.ping_min; pi.max = d.ping_max; @@ -113,7 +128,7 @@ function trustInfo(p) { return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false }; } -function renderPeers(peers) { +function renderPeers(peers, nowSec) { const box = $('peers-list'); // уведомление о входящем запросе на сверку (один раз на запрос) @@ -131,6 +146,10 @@ function renderPeers(peers) { } else { setHtml(box, peers.map((p) => { const t = trustInfo(p); + const seenLabel = p.online + ? `сейчас онлайн (активность: ${formatTimeAgo(p.last_seen, nowSec)})` + : formatTimeAgo(p.last_seen, nowSec); + const pingLabel = formatTimeAgo(p.last_ping, nowSec); return `
@@ -140,7 +159,8 @@ function renderPeers(peers) {
Код сверки: ${esc(p.pin_code)}
- Статус: ${esc(t.text)} + Статус: ${esc(t.text)}
+ Был онлайн: ${esc(seenLabel)} | Пинг: ${esc(pingLabel)}
+
+
+ + + сек (10–30) + +
+
Последний пинг: —
diff --git a/src/storage.rs b/src/storage.rs index 76f7a4e..1eddfcf 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -18,6 +18,9 @@ pub struct TrustedEntry { /// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе) #[serde(default)] pub trusts_me: bool, + /// когда устройство последний раз было онлайн (unix timestamp, сек) + #[serde(default)] + pub last_seen: u64, } /// Папка, где лежит исполняемый файл ramshare_ui. @@ -87,9 +90,6 @@ fn migrate_legacy(config: &Path, p2p_port: u16) { /// Раскладка каталогов: /// <папка с ramshare_ui>/config/ — config.json, trusted.json /// <папка с ramshare_ui>/received_media/ — принятые файлы -/// `--data-dir X` заменяет «папку с ramshare_ui» на X. Для не-стандартного P2P-порта без --data-dir -/// каталоги получают суффикс (config-8444), чтобы экземпляры не делили одну идентичность. -/// Если папка программы недоступна для записи — запасной вариант: каталог данных ОС (с предупреждением). pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result { if let Some(b) = base_override { let base = if b.is_absolute() { @@ -331,8 +331,7 @@ pub fn save_config(dir: &Path, cfg: &Config) -> io::Result<()> { write_atomic(&config_path(dir), &json, true) } -/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность). Файл обязан существовать, -/// чтобы случайно не создать конфиг без идентичности. +/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность). pub fn update_config(dir: &Path, f: impl FnOnce(&mut Config)) -> io::Result<()> { let mut cfg = load_config(dir) .map_err(io::Error::other)? diff --git a/src/ui.rs b/src/ui.rs index 8dbd829..1a41a6e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,7 +8,11 @@ use axum::{ Json, }; use futures_util::StreamExt; -use std::{io, net::IpAddr, sync::Arc}; +use std::{ + io, + net::IpAddr, + sync::{atomic::Ordering, Arc}, +}; use tokio_util::io::ReaderStream; const INDEX_HTML: &str = include_str!("static/index.html"); @@ -17,31 +21,11 @@ const APP_JS: &str = include_str!("static/app.js"); const CSP: &str = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; \ connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; -// ───────────────────────── Защита UI (CSRF / DNS-rebinding) ───────────────────────── +// ───────────────────────── Защита UI (анти-CSRF / DNS-rebinding без токена) ───────────────────────── pub struct UiGuard { - pub token: String, - pub cookie_name: String, pub hosts: Vec, } -fn ct_eq(a: &[u8], b: &[u8]) -> bool { - a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 -} - -fn cookie_ok(headers: &HeaderMap, g: &UiGuard) -> bool { - headers - .get_all(header::COOKIE) - .iter() - .filter_map(|v| v.to_str().ok()) - .flat_map(|s| s.split(';')) - .any(|kv| { - kv.trim() - .strip_prefix(g.cookie_name.as_str()) - .and_then(|r| r.strip_prefix('=')) - .map_or(false, |v| ct_eq(v.as_bytes(), g.token.as_bytes())) - }) -} - fn with_security_headers(mut resp: Response) -> Response { let items: [(HeaderName, &'static str); 5] = [ (header::CONTENT_SECURITY_POLICY, CSP), @@ -57,9 +41,8 @@ fn with_security_headers(mut resp: Response) -> Response { resp } -/// 1) Host только 127.0.0.1/localhost:порт (анти-rebinding) -/// 2) POST только с Origin нашего же хоста (анти-CSRF) -/// 3) Доступ по токену из консоли: /?t=… выставляет HttpOnly SameSite=Strict cookie +/// 1) Host только 127.0.0.1/localhost:порт (анти-DNS-rebinding) +/// 2) POST только с Origin нашего же локального хоста (анти-CSRF) pub async fn ui_guard(State(g): State>, req: Request, next: Next) -> Response { let headers = req.headers(); @@ -82,31 +65,6 @@ pub async fn ui_guard(State(g): State>, req: Request, next: Next) - } } - if req.uri().path() == "/" { - if let Some(q) = req.uri().query() { - let token_ok = q - .split('&') - .filter_map(|kv| kv.strip_prefix("t=")) - .any(|t| ct_eq(t.as_bytes(), g.token.as_bytes())); - if token_ok { - let mut resp = StatusCode::SEE_OTHER.into_response(); - resp.headers_mut().insert(header::LOCATION, HeaderValue::from_static("/")); - if let Ok(c) = HeaderValue::from_str(&format!( - "{}={}; Path=/; HttpOnly; SameSite=Strict", - g.cookie_name, g.token - )) { - resp.headers_mut().insert(header::SET_COOKIE, c); - } - return with_security_headers(resp); - } - } - } - - if !cookie_ok(headers, &g) { - return with_security_headers( - (StatusCode::FORBIDDEN, "Откройте адрес из консоли (с токеном ?t=…)").into_response(), - ); - } with_security_headers(next.run(req).await) } @@ -137,7 +95,9 @@ pub async fn ui_get_state(State(state): State>) -> Json>) -> StatusCode { - tokio::spawn(async move { net::ping_all_peers(&state).await }); + net::ping_all_peers(&state).await; StatusCode::OK }