a.0.0.7
This commit is contained in:
1 parent
b0d63c9f0f
commit
552c009120
8 files changed
+114
-83
No files matched your search
+34
-5
@@ -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 {
|
fn app_err(e: AppError) -> String {
|
||||||
if e.0 == StatusCode::FORBIDDEN {
|
if e.0 == StatusCode::FORBIDDEN {
|
||||||
format!("{} (выполните: ramshare_ui trust <устройство>)", e.1)
|
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()));
|
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()));
|
||||||
return;
|
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() {
|
if v.is_empty() {
|
||||||
println!("Устройства не найдены.");
|
println!("Устройства не найдены.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"{:<9} {:<9} {:<24} {:<22} {:<24} {}",
|
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
|
||||||
"СТАТУС", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
|
"СТАТУС", "БЫЛ ОНЛАЙН", "ПОСЛ. ПИНГ", "ДОВЕРИЕ", "ИМЯ", "АДРЕС", "КОД СВЕРКИ", "ID"
|
||||||
);
|
);
|
||||||
for p in v {
|
for p in v {
|
||||||
let addr = p
|
let addr = p
|
||||||
@@ -106,10 +127,12 @@ fn print_peers(state: &AppState, json: bool, only_online: bool) {
|
|||||||
.map(|ip| format!("{}:{}", ip, p.port))
|
.map(|ip| format!("{}:{}", ip, p.port))
|
||||||
.unwrap_or_else(|| "—".into());
|
.unwrap_or_else(|| "—".into());
|
||||||
println!(
|
println!(
|
||||||
"{:<9} {:<9} {:<24} {:<22} {:<24} {}",
|
"{:<9} {:<13} {:<13} {:<9} {:<20} {:<21} {:<24} {}",
|
||||||
if p.online { "● online" } else { "○ offline" },
|
if p.online { "● online" } else { "○ offline" },
|
||||||
|
fmt_ago(p.last_seen, now),
|
||||||
|
fmt_ago(p.last_ping, now),
|
||||||
trust_label(&p),
|
trust_label(&p),
|
||||||
trunc(&p.name, 24),
|
trunc(&p.name, 20),
|
||||||
addr,
|
addr,
|
||||||
p.pin_code,
|
p.pin_code,
|
||||||
short_id(&p.fingerprint)
|
short_id(&p.fingerprint)
|
||||||
@@ -320,6 +343,7 @@ pub async fn cmd_trust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Resu
|
|||||||
.map_or(false, |x| x.verified && x.trusts_me)
|
.map_or(false, |x| x.verified && x.trusts_me)
|
||||||
};
|
};
|
||||||
if mutual() {
|
if mutual() {
|
||||||
|
state.persist_trust();
|
||||||
println!("✓ Взаимная сверка завершена: {}", p.name);
|
println!("✓ Взаимная сверка завершена: {}", p.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -337,6 +361,7 @@ pub async fn cmd_trust(state: &Arc<AppState>, sel: &str, opts: &CliOpts) -> Resu
|
|||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
net::push_trust(state, &p.id).await;
|
net::push_trust(state, &p.id).await;
|
||||||
if mutual() {
|
if mutual() {
|
||||||
|
state.persist_trust();
|
||||||
println!("✓ Взаимная сверка завершена: {}", p.name);
|
println!("✓ Взаимная сверка завершена: {}", p.name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -383,6 +408,7 @@ pub async fn cmd_send_text(
|
|||||||
}
|
}
|
||||||
let p = find_peer(state, sel, opts, true).await?;
|
let p = find_peer(state, sel, opts, true).await?;
|
||||||
let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?;
|
let name = transfer::send_text(state, &p.id, text).await.map_err(app_err)?;
|
||||||
|
state.persist_trust();
|
||||||
println!("✓ Текст доставлен в ОЗУ устройства «{}»", name);
|
println!("✓ Текст доставлен в ОЗУ устройства «{}»", name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -457,7 +483,10 @@ pub async fn cmd_send_files(
|
|||||||
eprint!("\r{}\r", " ".repeat(72));
|
eprint!("\r{}\r", " ".repeat(72));
|
||||||
}
|
}
|
||||||
match res {
|
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))),
|
Err(e) => return Err(format!("{}: {}", name, app_err(e))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -282,6 +282,7 @@ fn build_state(cfg: &Config, serving: bool) -> Built {
|
|||||||
let port = if t.port == 0 { 8443 } else { t.port };
|
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);
|
let mut p = PeerDevice::new(&t.fp, clean_label(&t.name, 48), ips, port, &identity.fingerprint, true);
|
||||||
p.trusts_me = t.trusts_me;
|
p.trusts_me = t.trusts_me;
|
||||||
|
p.last_seen = t.last_seen;
|
||||||
known.insert(t.fp.clone(), p);
|
known.insert(t.fp.clone(), p);
|
||||||
}
|
}
|
||||||
let peers = Arc::new(RwLock::new(known));
|
let peers = Arc::new(RwLock::new(known));
|
||||||
@@ -322,6 +323,7 @@ fn build_state(cfg: &Config, serving: bool) -> Built {
|
|||||||
trust_dirty: AtomicBool::new(false),
|
trust_dirty: AtomicBool::new(false),
|
||||||
probe_cooldown: PlMutex::new(HashMap::new()),
|
probe_cooldown: PlMutex::new(HashMap::new()),
|
||||||
ping_interval_sec: AtomicU64::new(ping_sec),
|
ping_interval_sec: AtomicU64::new(ping_sec),
|
||||||
|
last_ping: AtomicU64::new(0),
|
||||||
ping_notify: Notify::new(),
|
ping_notify: Notify::new(),
|
||||||
log_stdout: serving,
|
log_stdout: serving,
|
||||||
headless: serving && cfg.no_ui,
|
headless: serving && cfg.no_ui,
|
||||||
@@ -361,10 +363,12 @@ async fn run_serve(cfg: &Config, built: Built) {
|
|||||||
|
|
||||||
net::start_discovery(state.clone());
|
net::start_discovery(state.clone());
|
||||||
|
|
||||||
// периодический опрос + обслуживание; интервал меняется на лету (UI / config.json / --ping-interval)
|
// периодический опрос + обслуживание; первый пинг выполняется сразу при старте
|
||||||
{
|
{
|
||||||
let st = state.clone();
|
let st = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
net::ping_all_peers(&st).await;
|
||||||
|
st.maintenance();
|
||||||
loop {
|
loop {
|
||||||
let secs = st.ping_interval_sec.load(Ordering::Relaxed);
|
let secs = st.ping_interval_sec.load(Ordering::Relaxed);
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -394,15 +398,13 @@ async fn run_serve(cfg: &Config, built: Built) {
|
|||||||
let ui_task = match ui_listener {
|
let ui_task = match ui_listener {
|
||||||
Some(listener) => {
|
Some(listener) => {
|
||||||
let guard = Arc::new(ui::UiGuard {
|
let guard = Arc::new(ui::UiGuard {
|
||||||
token: random_hex(16),
|
|
||||||
cookie_name: format!("rs_token_{}", cfg.ui_port),
|
|
||||||
hosts: vec![
|
hosts: vec![
|
||||||
format!("127.0.0.1:{}", cfg.ui_port),
|
format!("127.0.0.1:{}", cfg.ui_port),
|
||||||
format!("localhost:{}", cfg.ui_port),
|
format!("localhost:{}", cfg.ui_port),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
println!(" ОТКРОЙТЕ UI (ссылка содержит одноразовый токен сессии):");
|
println!(" ОТКРОЙТЕ ВЕБ-ИНТЕРФЕЙС:");
|
||||||
println!(" http://127.0.0.1:{}/?t={}", cfg.ui_port, guard.token);
|
println!(" http://127.0.0.1:{}/", cfg.ui_port);
|
||||||
|
|
||||||
let ui_router = Router::new()
|
let ui_router = Router::new()
|
||||||
.route("/", get(ui::ui_index))
|
.route("/", get(ui::ui_index))
|
||||||
|
|||||||
+12
-3
@@ -90,7 +90,10 @@ pub struct PeerDevice {
|
|||||||
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
/// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info).
|
||||||
pub trusts_me: bool,
|
pub trusts_me: bool,
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
|
/// Когда устройство последний раз отвечало по сети (unix timestamp, сек).
|
||||||
pub last_seen: u64,
|
pub last_seen: u64,
|
||||||
|
/// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек).
|
||||||
|
pub last_ping: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeerDevice {
|
impl PeerDevice {
|
||||||
@@ -106,6 +109,7 @@ impl PeerDevice {
|
|||||||
trusts_me: false,
|
trusts_me: false,
|
||||||
online: false,
|
online: false,
|
||||||
last_seen: 0,
|
last_seen: 0,
|
||||||
|
last_ping: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +202,8 @@ pub struct AppState {
|
|||||||
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
pub probe_cooldown: PlMutex<HashMap<(IpAddr, u16), Instant>>,
|
||||||
/// Интервал опроса известных пиров (сек), меняется на лету
|
/// Интервал опроса известных пиров (сек), меняется на лету
|
||||||
pub ping_interval_sec: AtomicU64,
|
pub ping_interval_sec: AtomicU64,
|
||||||
|
/// Время последнего глобального опроса устройств (unix timestamp, сек)
|
||||||
|
pub last_ping: AtomicU64,
|
||||||
pub ping_notify: Notify,
|
pub ping_notify: Notify,
|
||||||
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
/// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом)
|
||||||
pub log_stdout: bool,
|
pub log_stdout: bool,
|
||||||
@@ -303,6 +309,7 @@ impl AppState {
|
|||||||
ips: p.ips.clone(),
|
ips: p.ips.clone(),
|
||||||
port: p.port,
|
port: p.port,
|
||||||
trusts_me: p.trusts_me,
|
trusts_me: p.trusts_me,
|
||||||
|
last_seen: p.last_seen,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
match storage::save_trusted(&self.data_dir, &entries) {
|
match storage::save_trusted(&self.data_dir, &entries) {
|
||||||
@@ -367,6 +374,7 @@ impl AppState {
|
|||||||
p.port = port;
|
p.port = port;
|
||||||
p.online = true;
|
p.online = true;
|
||||||
p.last_seen = now;
|
p.last_seen = now;
|
||||||
|
p.last_ping = now;
|
||||||
p.ips.retain(|x| x != &ip_s);
|
p.ips.retain(|x| x != &ip_s);
|
||||||
p.ips.insert(0, ip_s.clone());
|
p.ips.insert(0, ip_s.clone());
|
||||||
p.ips.truncate(MAX_PEER_IPS);
|
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);
|
let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false);
|
||||||
p.online = true;
|
p.online = true;
|
||||||
p.last_seen = now;
|
p.last_seen = now;
|
||||||
|
p.last_ping = now;
|
||||||
v.insert(p);
|
v.insert(p);
|
||||||
created = true;
|
created = true;
|
||||||
}
|
}
|
||||||
@@ -405,11 +414,12 @@ impl AppState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ip_s = ip.to_string();
|
let ip_s = ip.to_string();
|
||||||
|
let now = now_sec();
|
||||||
let mut dirty = false;
|
let mut dirty = false;
|
||||||
if let Some(p) = self.peers.write().get_mut(fp) {
|
if let Some(p) = self.peers.write().get_mut(fp) {
|
||||||
let changed = p.ips.first() != Some(&ip_s);
|
let changed = p.ips.first() != Some(&ip_s);
|
||||||
p.online = true;
|
p.online = true;
|
||||||
p.last_seen = now_sec();
|
p.last_seen = now;
|
||||||
if changed {
|
if changed {
|
||||||
p.ips.retain(|x| x != &ip_s);
|
p.ips.retain(|x| x != &ip_s);
|
||||||
p.ips.insert(0, ip_s);
|
p.ips.insert(0, ip_s);
|
||||||
@@ -496,7 +506,6 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
/// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust).
|
||||||
/// Повреждённый файл игнорируется, а не «разжалует» всех пиров.
|
|
||||||
pub fn sync_trust_from_disk(&self) {
|
pub fn sync_trust_from_disk(&self) {
|
||||||
let path = self.data_dir.join("trusted.json");
|
let path = self.data_dir.join("trusted.json");
|
||||||
let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return };
|
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 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);
|
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.trusts_me = e.trusts_me;
|
||||||
|
np.last_seen = e.last_seen;
|
||||||
peers.insert(e.fp.clone(), np);
|
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 символа.
|
/// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа.
|
||||||
/// Подбор ключа «под префикс» не работает: код зависит от обоих сертификатов.
|
|
||||||
pub fn sas_code(a: &str, b: &str) -> String {
|
pub fn sas_code(a: &str, b: &str) -> String {
|
||||||
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
let (x, y) = if a <= b { (a, b) } else { (b, a) };
|
||||||
let mut h = Sha256::new();
|
let mut h = Sha256::new();
|
||||||
|
|||||||
+6
-2
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use socket2::{Domain, Protocol, Socket, Type};
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket},
|
||||||
sync::Arc,
|
sync::{atomic::Ordering, Arc},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tokio::{net::UdpSocket, sync::Semaphore};
|
use tokio::{net::UdpSocket, sync::Semaphore};
|
||||||
@@ -194,6 +194,8 @@ pub async fn ping_all_peers(state: &Arc<AppState>) {
|
|||||||
ping_one(state, &id, ips, port).await;
|
ping_one(state, &id, ips, port).await;
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
state.last_ping.store(now_sec(), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
||||||
@@ -208,9 +210,11 @@ async fn ping_one(state: &AppState, id: &str, ips: Vec<String>, port: u16) {
|
|||||||
// по этому адресу теперь другое устройство — игнорируем
|
// по этому адресу теперь другое устройство — игнорируем
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let now = now_sec();
|
||||||
let mut peers = state.peers.write();
|
let mut peers = state.peers.write();
|
||||||
if let Some(p) = peers.get_mut(id) {
|
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;
|
p.online = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-6
@@ -28,6 +28,20 @@ function formatBytes(b) {
|
|||||||
return `${(b / 1024 ** 3).toFixed(2)} ГБ`;
|
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/картинки)
|
// Обновляем DOM только если разметка реально изменилась (не сбрасывает скролл/hover/картинки)
|
||||||
function setHtml(el, html) {
|
function setHtml(el, html) {
|
||||||
if (el._h !== html) { el._h = html; el.innerHTML = 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-ip').textContent = `Наш адрес: ${d.my_ip}:${d.p2p_port} | Отпечаток: ${d.my_fingerprint.slice(0, 16)}…`;
|
||||||
$('my-pin').textContent = d.my_pin;
|
$('my-pin').textContent = d.my_pin;
|
||||||
$('media-dir').textContent = d.media_dir;
|
$('media-dir').textContent = d.media_dir;
|
||||||
|
$('last-ping-info').textContent = `Последний пинг: ${formatTimeAgo(d.last_ping, d.now)}`;
|
||||||
const pi = $('ping-interval-input');
|
const pi = $('ping-interval-input');
|
||||||
pi.min = d.ping_min;
|
pi.min = d.ping_min;
|
||||||
pi.max = d.ping_max;
|
pi.max = d.ping_max;
|
||||||
@@ -113,7 +128,7 @@ function trustInfo(p) {
|
|||||||
return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false };
|
return { color: '#f87171', text: 'Не заверен', btn: 'Заверить', btnCls: 'btn-green', trust: '1', req: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPeers(peers) {
|
function renderPeers(peers, nowSec) {
|
||||||
const box = $('peers-list');
|
const box = $('peers-list');
|
||||||
|
|
||||||
// уведомление о входящем запросе на сверку (один раз на запрос)
|
// уведомление о входящем запросе на сверку (один раз на запрос)
|
||||||
@@ -131,6 +146,10 @@ function renderPeers(peers) {
|
|||||||
} else {
|
} else {
|
||||||
setHtml(box, peers.map((p) => {
|
setHtml(box, peers.map((p) => {
|
||||||
const t = trustInfo(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 `
|
return `
|
||||||
<div class="peer-item${t.req ? ' request' : ''}">
|
<div class="peer-item${t.req ? ' request' : ''}">
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
@@ -140,7 +159,8 @@ function renderPeers(peers) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
<div class="small muted mono" style="margin-top:3px;word-break:break-all;">
|
||||||
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
Код сверки: <b style="color:#fff;">${esc(p.pin_code)}</b><br>
|
||||||
Статус: <span style="color:${t.color};">${esc(t.text)}</span>
|
Статус: <span style="color:${t.color};">${esc(t.text)}</span><br>
|
||||||
|
Был онлайн: <span style="color:#cbd5e1;">${esc(seenLabel)}</span> | Пинг: <span style="color:#cbd5e1;">${esc(pingLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
|
<button class="btn-sm ${t.btnCls}" data-action="trust" data-id="${esc(p.id)}" data-trust="${t.trust}">
|
||||||
@@ -199,7 +219,7 @@ async function fetchState() {
|
|||||||
renderHeader(d);
|
renderHeader(d);
|
||||||
renderScan(d.scan);
|
renderScan(d.scan);
|
||||||
renderTransfers(d.transfers);
|
renderTransfers(d.transfers);
|
||||||
renderPeers(d.peers);
|
renderPeers(d.peers, d.now);
|
||||||
renderTexts(d.texts);
|
renderTexts(d.texts);
|
||||||
renderMedia(d.media);
|
renderMedia(d.media);
|
||||||
}
|
}
|
||||||
@@ -249,9 +269,14 @@ function saveSettings() {
|
|||||||
|
|
||||||
function pingPeers() {
|
function pingPeers() {
|
||||||
return guarded(async () => {
|
return guarded(async () => {
|
||||||
await api('/ui/ping', { method: 'POST' });
|
$('btn-ping').disabled = true;
|
||||||
showStatus('Запущен опрос известных узлов');
|
try {
|
||||||
setTimeout(() => fetchState().catch(() => {}), 800);
|
await api('/ui/ping', { method: 'POST' });
|
||||||
|
showStatus('Опрос известных узлов выполнен');
|
||||||
|
await fetchState();
|
||||||
|
} finally {
|
||||||
|
$('btn-ping').disabled = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,11 +96,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" style="align-items:center;">
|
<div class="row" style="align-items:center;justify-content:space-between;">
|
||||||
<label class="small muted" for="ping-interval-input">Опрос известных устройств каждые</label>
|
<div class="row" style="align-items:center;">
|
||||||
<input type="number" id="ping-interval-input" min="10" max="30" step="1" style="width:80px;">
|
<label class="small muted" for="ping-interval-input">Опрос каждые</label>
|
||||||
<span class="small muted">сек (10–30)</span>
|
<input type="number" id="ping-interval-input" min="10" max="30" step="1" style="width:74px;">
|
||||||
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
|
<span class="small muted">сек (10–30)</span>
|
||||||
|
<button class="btn-sm" id="btn-save-settings">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div class="small muted" id="last-ping-info">Последний пинг: —</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="peers-list" class="col"></div>
|
<div id="peers-list" class="col"></div>
|
||||||
|
|||||||
+4
-5
@@ -18,6 +18,9 @@ pub struct TrustedEntry {
|
|||||||
/// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе)
|
/// последнее известное «собеседник подтвердил нас» (уточняется при каждом опросе)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trusts_me: bool,
|
pub trusts_me: bool,
|
||||||
|
/// когда устройство последний раз было онлайн (unix timestamp, сек)
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Папка, где лежит исполняемый файл ramshare_ui.
|
/// Папка, где лежит исполняемый файл ramshare_ui.
|
||||||
@@ -87,9 +90,6 @@ fn migrate_legacy(config: &Path, p2p_port: u16) {
|
|||||||
/// Раскладка каталогов:
|
/// Раскладка каталогов:
|
||||||
/// <папка с ramshare_ui>/config/ — config.json, trusted.json
|
/// <папка с ramshare_ui>/config/ — config.json, trusted.json
|
||||||
/// <папка с ramshare_ui>/received_media/ — принятые файлы
|
/// <папка с 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<Dirs, String> {
|
pub fn resolve_dirs(base_override: Option<&Path>, p2p_port: u16) -> Result<Dirs, String> {
|
||||||
if let Some(b) = base_override {
|
if let Some(b) = base_override {
|
||||||
let base = if b.is_absolute() {
|
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)
|
write_atomic(&config_path(dir), &json, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность). Файл обязан существовать,
|
/// Меняет одно значение, сохраняя остальные (в т.ч. идентичность).
|
||||||
/// чтобы случайно не создать конфиг без идентичности.
|
|
||||||
pub fn update_config(dir: &Path, f: impl FnOnce(&mut Config)) -> io::Result<()> {
|
pub fn update_config(dir: &Path, f: impl FnOnce(&mut Config)) -> io::Result<()> {
|
||||||
let mut cfg = load_config(dir)
|
let mut cfg = load_config(dir)
|
||||||
.map_err(io::Error::other)?
|
.map_err(io::Error::other)?
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use futures_util::StreamExt;
|
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;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
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'; \
|
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'";
|
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||||
|
|
||||||
// ───────────────────────── Защита UI (CSRF / DNS-rebinding) ─────────────────────────
|
// ───────────────────────── Защита UI (анти-CSRF / DNS-rebinding без токена) ─────────────────────────
|
||||||
pub struct UiGuard {
|
pub struct UiGuard {
|
||||||
pub token: String,
|
|
||||||
pub cookie_name: String,
|
|
||||||
pub hosts: Vec<String>,
|
pub hosts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
fn with_security_headers(mut resp: Response) -> Response {
|
||||||
let items: [(HeaderName, &'static str); 5] = [
|
let items: [(HeaderName, &'static str); 5] = [
|
||||||
(header::CONTENT_SECURITY_POLICY, CSP),
|
(header::CONTENT_SECURITY_POLICY, CSP),
|
||||||
@@ -57,9 +41,8 @@ fn with_security_headers(mut resp: Response) -> Response {
|
|||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1) Host только 127.0.0.1/localhost:порт (анти-rebinding)
|
/// 1) Host только 127.0.0.1/localhost:порт (анти-DNS-rebinding)
|
||||||
/// 2) POST только с Origin нашего же хоста (анти-CSRF)
|
/// 2) POST только с Origin нашего же локального хоста (анти-CSRF)
|
||||||
/// 3) Доступ по токену из консоли: /?t=… выставляет HttpOnly SameSite=Strict cookie
|
|
||||||
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
|
pub async fn ui_guard(State(g): State<Arc<UiGuard>>, req: Request, next: Next) -> Response {
|
||||||
let headers = req.headers();
|
let headers = req.headers();
|
||||||
|
|
||||||
@@ -82,31 +65,6 @@ pub async fn ui_guard(State(g): State<Arc<UiGuard>>, 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)
|
with_security_headers(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +95,9 @@ pub async fn ui_get_state(State(state): State<Arc<AppState>>) -> Json<serde_json
|
|||||||
"my_fingerprint": state.my_fingerprint,
|
"my_fingerprint": state.my_fingerprint,
|
||||||
"my_pin": short_id(&state.my_fingerprint),
|
"my_pin": short_id(&state.my_fingerprint),
|
||||||
"media_dir": state.media_dir.display().to_string(),
|
"media_dir": state.media_dir.display().to_string(),
|
||||||
"ping_interval_sec": state.ping_interval_sec.load(std::sync::atomic::Ordering::Relaxed),
|
"ping_interval_sec": state.ping_interval_sec.load(Ordering::Relaxed),
|
||||||
|
"last_ping": state.last_ping.load(Ordering::Relaxed),
|
||||||
|
"now": now_sec(),
|
||||||
"ping_min": MIN_PING_SEC,
|
"ping_min": MIN_PING_SEC,
|
||||||
"ping_max": MAX_PING_SEC,
|
"ping_max": MAX_PING_SEC,
|
||||||
"peers": peers,
|
"peers": peers,
|
||||||
@@ -213,7 +173,7 @@ pub async fn ui_scan_range(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
|
pub async fn ui_ping_peers(State(state): State<Arc<AppState>>) -> StatusCode {
|
||||||
tokio::spawn(async move { net::ping_all_peers(&state).await });
|
net::ping_all_peers(&state).await;
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user