diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 35c0848..220d2a3 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -9,6 +9,7 @@ becomes its release notes, which the app shows when it offers that update ### Improved - When you sync over your home network and Patch Pilot can't find one of your linked machines, it now says which one and, when it can tell, why: a VPN or virtual network adapter is carrying its messages, Windows has the network set to Public, your Mac isn't letting it use the local network, this machine isn't on a network, or the other machine runs a different Patch Pilot version. Open Sync & backup to see each machine and a short list of what to check. It no longer says "In sync" while none of your other machines are on the network. +- Your linked machines now tell each other about their home network, so Patch Pilot can also say when the other machine is on a different network, has a VPN or virtual network adapter in the way, isn't using Home network sync, hasn't checked in for a while (asleep or closed), or can hear this machine but not the other way round (usually a firewall). It names a reason only when both machines agree. ## 0.4.58 - 2026-11-06 diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index b5c98e7..f60ccf3 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -15,6 +15,11 @@ //! gathered here, and `patchpilot_local::netinfo` looks at the network only //! while a machine is missing, so an idle machine with everyone found does //! no extra work. +//! +//! The same loop keeps this machine's home-network report on the relay +//! (decision #124): sent when it changes and, while a machine is missing or +//! can't hear this one, every few minutes. The other machines' reports come +//! back through `sync` and name what only they can see. use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; @@ -26,6 +31,7 @@ use patchpilot_local::backup::{self, BackupSettings}; use patchpilot_local::github::FileIndex; use patchpilot_local::lan::{self, Beacon, Connection, Heard, Progress, PullReport}; use patchpilot_local::lan_diagnosis::{self, Diagnosis, Egress, Expected, Facts, Profile, Seen}; +use patchpilot_local::net_report::{self, NetReport, Subnet}; use patchpilot_local::netinfo::{self, AdapterInfo}; use serde::Serialize; use tauri::{AppHandle, Emitter, Listener, Manager}; @@ -46,14 +52,22 @@ const MAX_CONNECTIONS: usize = 8; const NET_EVERY: Duration = Duration::from_secs(30); /// Ask Windows about an adapter (PowerShell, about a second) this often. const ADAPTER_EVERY: Duration = Duration::from_secs(600); +/// …or this often while it couldn't say (WMI timed out, PowerShell blocked). +const ADAPTER_RETRY: Duration = Duration::from_secs(60); +/// A report's network facts older than this are left out: they were last +/// looked at during an earlier problem and may no longer hold. +const NET_FACTS_FOR: Duration = Duration::from_secs(90); /// Emitted with a [`Progress`] while a pull runs, then with null. pub const LAN_PROGRESS: &str = "lan-progress"; struct Peer { name: String, addr: SocketAddr, - seen: Instant, + /// When last heard (Unix seconds). The wall clock, not an `Instant`: a + /// Mac's stops while it sleeps. seen_at: i64, + /// When this stretch of hearing it began (Unix seconds). + since: i64, /// Its copy's state when last heard. state: String, /// The state last pulled in full. @@ -81,6 +95,9 @@ struct LanState { last: Mutex>, /// Since when this machine has listened, for "Looking for…" first. listening_since: Mutex>, + /// The same as Unix seconds, for this machine's report: fixed once, so + /// the report doesn't look changed every time it's built. + listening_at: Mutex>, /// When a beacon of this rig came in that this version can't read. other_version: Mutex>, /// When a Patch Pilot beacon under another rig key came in. @@ -92,16 +109,24 @@ struct LanState { net: Mutex, /// The diagnosis last told to the UI. diagnosis: Mutex>, + /// When `sync` was last asked to send this machine's report: a put + /// that keeps failing is tried again, but not every beacon. + asked: Mutex>, } /// This machine's network as last looked at, while a machine was missing. #[derive(Default, Clone)] struct NetCache { - checked: Option, + /// When it was last looked at (Unix seconds): the wall clock, since a + /// Mac's `Instant` stops while it sleeps and facts from before sleep + /// would pass as fresh after waking somewhere else. + checked_at: Option, broadcast_egress: Option, route_egress: Option, has_lan: Option, profile: Option, + /// The Wi-Fi and Ethernet subnets, for this machine's report. + subnets: Vec, /// What Windows said about each adapter, by name, and when. adapters: HashMap)>, } @@ -149,12 +174,22 @@ pub fn setup(app: &AppHandle) { let handle = app.clone(); std::thread::spawn(move || { refresh_names(&handle); + let mut last_round = patchpilot_local::now(); loop { + // A round that came much later than it should: the machine slept. + // What the relay said before may be hours old, and the + // connection may be dead without knowing it yet. + let now = patchpilot_local::now(); + if now - last_round > 3 * BEACON_EVERY.as_secs() as i64 { + crate::sync::woke(&handle); + } + last_round = now; if in_use() { start(&handle); announce(&handle, None); check(&handle); } + share(&handle); std::thread::sleep(BEACON_EVERY); } }); @@ -226,6 +261,11 @@ fn start(app: &AppHandle) { .lock() .unwrap() .get_or_insert_with(Instant::now); + state + .listening_at + .lock() + .unwrap() + .get_or_insert_with(patchpilot_local::now); let handle = app.clone(); std::thread::spawn(move || { @@ -320,7 +360,8 @@ fn announce(app: &AppHandle, to: Option) { // Only the broadcast says something about this machine's network: a // machine heard before that left answers "unreachable" too. if ip == broadcast { - *state.broadcast_error.lock().unwrap() = sent.err().and_then(|e| e.raw_os_error()); + *state.broadcast_error.lock().unwrap() = + sent.err().map(|e| e.raw_os_error().unwrap_or(-1)); } } } @@ -341,22 +382,22 @@ fn heard(app: &AppHandle, beacon: Beacon, ip: IpAddr) { let (new, wanted) = { let mut peers = state.peers.lock().unwrap(); let addr = SocketAddr::new(ip, beacon.port); - let new = peers - .get(&beacon.machine_id) - .is_none_or(|p| p.seen.elapsed() > GONE); + let new = peers.get(&beacon.machine_id).is_none_or(|p| !is_here(p)); let peer = peers.entry(beacon.machine_id.clone()).or_insert(Peer { name: String::new(), addr, - seen: Instant::now(), seen_at: 0, + since: 0, state: String::new(), pulled: None, failed: None, }); peer.name = beacon.machine_name; peer.addr = addr; - peer.seen = Instant::now(); peer.seen_at = patchpilot_local::now(); + if new { + peer.since = peer.seen_at; + } peer.state = beacon.state; let wanted = peer.pulled.as_ref() != Some(&peer.state) && peer.failed.is_none_or(|f| f.elapsed() > RETRY); @@ -395,7 +436,7 @@ pub fn sync_now(app: &AppHandle) -> Result<(), String> { .lock() .unwrap() .iter() - .filter(|(_, p)| p.seen.elapsed() < GONE) + .filter(|(_, p)| is_here(p)) .map(|(id, _)| id.clone()) .collect(); for id in ids { @@ -522,12 +563,14 @@ fn check(app: &AppHandle) { .net .lock() .unwrap() - .checked - .is_none_or(|t| t.elapsed() >= NET_EVERY); - // Only while nobody is here: otherwise this machine's network works, - // and diagnose doesn't look at it. - if !d.missing.is_empty() && d.here.is_empty() && !d.looking && net_due { - look_at_network(&state); + .checked_at + .is_none_or(|t| !net_age_within(t, NET_EVERY)); + // Only while something is wrong: a machine that might still turn up is + // missing (not one that said it doesn't use the home network), or one + // here can't hear this one. + let wrong = (d.awaiting() && !d.looking) || !d.deaf.is_empty() || d.maybe_deaf; + if wrong && net_due { + look_at_network(app); d = lan_diagnosis::diagnose(&facts(app)); } let changed = { @@ -554,7 +597,7 @@ fn clear_stale_error(state: &LanState) -> bool { .lock() .unwrap() .get(id) - .is_none_or(|p| p.seen.elapsed() >= GONE) + .is_none_or(|p| !is_here(p)) }); if gone { *from = None; @@ -565,9 +608,11 @@ fn clear_stale_error(state: &LanState) -> bool { gone } -/// Which way this machine's packets leave, and what Windows calls the -/// network. At background priority: it can run PowerShell. -fn look_at_network(state: &LanState) { +/// Which way this machine's packets leave, its subnets, and what Windows +/// calls the network. At background priority: it can run PowerShell, at +/// most every 10 minutes per adapter. +fn look_at_network(app: &AppHandle) { + let state = app.state::(); let _quiet = patchpilot_local::quiet::background(); let mut net = state.net.lock().unwrap().clone(); let mut ifaces = netinfo::interfaces(); @@ -576,6 +621,7 @@ fn look_at_network(state: &LanState) { // What Windows says about the adapters the packets leave on, since many // VPNs call theirs "Ethernet 4" and say what they are only there. let mut broadcast_info = None; + let mut undescribed: Vec = Vec::new(); for (src, is_broadcast) in [(broadcast, true), (route, false)] { let Some(name) = src .and_then(|ip| netinfo::interface_of(ip, &ifaces)) @@ -586,8 +632,17 @@ fn look_at_network(state: &LanState) { let fresh = net .adapters .get(&name) - .filter(|(at, _)| at.elapsed() < ADAPTER_EVERY) + .filter(|(at, info)| { + at.elapsed() + < if info.is_some() { + ADAPTER_EVERY + } else { + ADAPTER_RETRY + } + }) .map(|(_, info)| info.clone()); + // Without it a VPN called "Ethernet 4" passes for the home network, + // so ask even while another machine is here. let info = fresh.unwrap_or_else(|| { let info = netinfo::adapter_info(&name); net.adapters @@ -597,12 +652,32 @@ fn look_at_network(state: &LanState) { if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { i.description = info.as_ref().and_then(|a| a.description.clone()); } + if info.as_ref().and_then(|a| a.description.as_ref()).is_none() { + undescribed.push(name.clone()); + } if is_broadcast { broadcast_info = info; } } - net.broadcast_egress = broadcast.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); - net.route_egress = route.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); + // On Windows a VPN often calls its adapter "Ethernet 4" and says what it + // is only in the description: without one, the name alone can't tell. + let egress = |ip: IpAddr| { + let kind = lan_diagnosis::egress_kind(ip, &ifaces, route); + let unsure = cfg!(windows) + && kind == Egress::Lan + && netinfo::interface_of(ip, &ifaces) + .is_some_and(|n| undescribed.iter().any(|u| u == n)); + if unsure { Egress::Unknown } else { kind } + }; + net.broadcast_egress = broadcast.map(egress); + net.route_egress = route.map(egress); + let subnets = lan_diagnosis::subnets(&ifaces, route); + if !net.subnets.is_empty() && subnets != net.subnets { + // Another network: the public address the relay saw belongs to the + // old one until the next upload says. + crate::sync::forget_public(app); + } + net.subnets = subnets; // Windows lists no adapter at all when none is connected; with no route // anywhere either, this machine isn't on a network. net.has_lan = if !ifaces.is_empty() { @@ -617,7 +692,7 @@ fn look_at_network(state: &LanState) { Some(Egress::Lan) => broadcast_info.and_then(|a| a.profile), _ => None, }; - net.checked = Some(Instant::now()); + net.checked_at = Some(patchpilot_local::now()); *state.net.lock().unwrap() = net; } @@ -635,11 +710,19 @@ fn facts(app: &AppHandle) -> Facts { .map(|(id, p)| Seen { id: id.clone(), name: p.name.clone(), - here: p.seen.elapsed() < GONE, + here: is_here(p), seen_at: p.seen_at, + since: p.since, }) .collect(); - let net = state.net.lock().unwrap().clone(); + let mut net = state.net.lock().unwrap().clone(); + if !net + .checked_at + .is_some_and(|t| net_age_within(t, NET_FACTS_FOR)) + { + // From an earlier look that may no longer hold: as if never looked. + net = NetCache::default(); + } Facts { listening_for: state .listening_since @@ -656,6 +739,129 @@ fn facts(app: &AppHandle) -> Facts { has_lan: net.has_lan, profile: net.profile, macos: cfg!(target_os = "macos"), + this_id: this_machine().unwrap_or_default(), + now: patchpilot_local::now(), + relay_online: crate::sync::online(app), + subnets: net.subnets, + public: crate::sync::public(app), + reports: crate::sync::reports(app), + } +} + +/// A wall-clock time at most `within` ago. A clock stepped back makes it +/// "not within", so the network is looked at again and old facts aren't +/// taken as fresh. +fn net_age_within(t: i64, within: Duration) -> bool { + let age = patchpilot_local::now() - t; + (0..within.as_secs() as i64).contains(&age) +} + +/// Heard from in the last 45 s. +fn is_here(p: &Peer) -> bool { + patchpilot_local::now() - p.seen_at < GONE.as_secs() as i64 +} + +/// What this machine tells the others about its home network: on and +/// listening, its subnets and whether its broadcasts go astray as last +/// looked at, and who it hears now. Quick: no store, no command. +pub(crate) fn own_report(app: &AppHandle) -> NetReport { + let now = patchpilot_local::now(); + // Sync can start before the home network is set up. + let Some(state) = app.try_state::() else { + return NetReport::off(now); + }; + if !in_use() { + return NetReport::off(now); + } + if state.udp.lock().unwrap().is_none() { + // Picked, but the port wouldn't open: this machine says why itself. + return NetReport { + on: true, + listening: false, + ..NetReport::off(now) + }; + } + let net = state.net.lock().unwrap().clone(); + // Facts from an earlier problem may no longer hold: say nothing then. + let recent = net + .checked_at + .is_some_and(|t| net_age_within(t, NET_FACTS_FOR)); + // A virtual adapter isn't a VPN: its own field, so the others can say so. + let through_vpn = |e: &Option| match e { + Some(Egress::Lan | Egress::Virtual { .. }) => Some(false), + Some(Egress::Vpn { .. }) => Some(true), + Some(Egress::Unknown) | None => None, + }; + let virtual_adapter = match &net.broadcast_egress { + Some(Egress::Virtual { name }) if recent => Some(name.clone()), + _ => None, + }; + let mut hears: Vec = state + .peers + .lock() + .unwrap() + .iter() + .filter(|(_, p)| is_here(p)) + .map(|(id, _)| id.clone()) + .collect(); + hears.sort(); + let listening_since = *state.listening_at.lock().unwrap(); + let send_error = state.broadcast_error.lock().unwrap().is_some(); + NetReport { + version: net_report::REPORT_VERSION, + at: now, + on: true, + listening: true, + listening_since, + subnets: if recent { net.subnets } else { Vec::new() }, + vpn: if recent { + through_vpn(&net.broadcast_egress) + } else { + None + }, + virtual_adapter, + send_error, + internet_vpn: if recent { + through_vpn(&net.route_egress) + } else { + None + }, + hears, + public: crate::sync::public(app), + } +} + +/// Every beacon: have `sync` send this machine's report when it says +/// something new, or, while a machine is missing or can't hear this one, +/// when the others should hear it's still running. Not linked, `sync` +/// isn't running and nothing goes out. +fn share(app: &AppHandle) { + let state = app.state::(); + let report = own_report(app); + // While a machine here can't hear this one, sooner: the other side + // needs a report from late enough to tell. + let heartbeat = state.diagnosis.lock().unwrap().as_ref().and_then(|d| { + if !d.deaf.is_empty() { + Some(net_report::HEARTBEAT_SOON) + } else if d.awaiting() { + Some(net_report::HEARTBEAT) + } else { + None + } + }); + // Against what actually reached the relay, so a failed put is retried. + let last = crate::sync::last_report(app); + if !net_report::should_send(&report, last.as_ref().map(|(r, ago)| (r, *ago)), heartbeat) { + return; + } + // Offline, the next session sends one at its start anyway. + if !crate::sync::online(app) { + return; + } + let mut asked = state.asked.lock().unwrap(); + if asked.is_none_or(|t| t.elapsed() >= net_report::MIN_GAP) { + *asked = Some(Instant::now()); + crate::sync::share_report(app); } } @@ -699,7 +905,7 @@ pub fn view(app: &AppHandle) -> Option { .map(|p| LanPeer { machine_name: p.name.clone(), seen_at: p.seen_at, - here: p.seen.elapsed() < GONE, + here: is_here(p), }) .collect(); peers.sort_by_key(|p| std::cmp::Reverse(p.seen_at)); @@ -733,6 +939,7 @@ pub async fn lan_use(app: AppHandle) -> Result<(), String> { // Picking the home network (again) starts the "Looking for…" time over. let state = app.state::(); *state.listening_since.lock().unwrap() = Some(Instant::now()); + *state.listening_at.lock().unwrap() = Some(patchpilot_local::now()); *state.diagnosis.lock().unwrap() = None; { // Look at the network afresh; what Windows said about each adapter diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index 94d3d47..4db0e72 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -5,16 +5,22 @@ //! Idle cost: one hibernating WebSocket and a ping every 50 s. Uploads happen //! after a changed scan (`rig::RIG_CHANGED`), on start, and after making Primary Rig. //! +//! Each machine also keeps a small `net/` report there about its home +//! network (decision #124), sent by `lan` when it changes or while a machine +//! is missing; the others read it to say why they can't find that machine. +//! //! The relay's Durable Objects bill by rows read (5 million a day free), and a //! rig holds one row per shared file, often tens of thousands. So nothing here //! lists every object: the start lists `machine/` only, and uploads ask about //! files this run hasn't already seen on the relay. +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; +use patchpilot_local::net_report::{NetReport, Received}; use patchpilot_sync::{LinkCode, RigKey, link}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Listener, Manager, State}; @@ -51,6 +57,21 @@ pub struct Sync { /// Ids of the other machines on the relay: the ones actually linked, /// unlike machines added from an export file. None until listed. linked: Mutex>>, + /// The other machines' home-network reports, by id. + reports: Mutex>, + /// Send this machine's report (`share_report`). + report_nudge: Notify, + /// Start a new session: the machine woke from sleep (`woke`). + restart: Notify, + /// Woke from sleep and the reports haven't been read again yet: until + /// then nothing read from the relay counts as current. + woken: AtomicBool, + /// This machine's report as last put on the relay, and when. Only puts + /// that went through, so one that failed is tried again. + report_put: Mutex>, + /// This machine's public address as the relay last saw it + /// (`net_report::public_tag`), from its answer to an upload. + public: Mutex>, running: AtomicBool, nudge: Notify, stop: Notify, @@ -143,6 +164,12 @@ async fn run(app: &AppHandle, state: &Sync) { async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), String> { let relay = Relay::new(rig); let this = this_machine()?; + // A new session often means a new network (waking somewhere else): the + // address the relay saw before may not be this one. The first upload + // says again. + *state.public.lock().expect("public lock") = None; + // A wake signal meant for the session before this one is handled. + let _ = futures_util::FutureExt::now_or_never(state.restart.notified()); // Upload first: the first upload claims a new rig, and live needs a claimed one. upload(&relay, state, &this, rig).await?; let mut ws = relay.live().await?; @@ -166,6 +193,16 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri for name in &others { changed |= pull(&relay, &this, rig, name).await?; } + send_report(app, &relay, &this, rig).await; + // Their home-network reports: one read each, only for linked machines. + let mut reports = BTreeMap::new(); + for id in others.iter().filter_map(|n| n.strip_prefix("machine/")) { + if let Some(r) = fetch_report(&relay, rig, id, false).await { + reports.insert(id.to_owned(), r); + } + } + *state.reports.lock().expect("reports lock") = reports; + state.woken.store(false, Ordering::SeqCst); if changed { let _ = app.emit("data-changed", ()); } @@ -175,9 +212,17 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri }); let mut ping = tokio::time::interval(PING_EVERY); + // When a ping went out that nothing has answered yet. A connection that + // died silently (sleep, a dropped Wi-Fi) is noticed from that, so this + // machine doesn't keep trusting reports it can no longer see change. + let mut unanswered: Option = None; loop { tokio::select! { + // Messages first, so an answer that came in during a long pull + // is seen before the next ping is judged. + biased; msg = ws.next() => { + unanswered = None; let text = match msg { Some(Ok(Message::Text(t))) => t, Some(Ok(Message::Close(_))) | None => return Ok(()), @@ -195,10 +240,35 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri if pull(&relay, &this, rig, &event.name).await? { let _ = app.emit("data-changed", ()); } + } else if event.kind == "changed" + && let Some(id) = patchpilot_local::sync::other_report(&event.name, &this) + && let Some(r) = fetch_report(&relay, rig, id, true).await + { + // `lan` looks at it within one beacon. + state.reports.lock().expect("reports lock").insert(id.to_owned(), r); } } () = state.nudge.notified() => upload(&relay, state, &this, rig).await?, - _ = ping.tick() => ws.send(Message::text("ping")).await.map_err(|e| e.to_string())?, + () = state.report_nudge.notified() => { + // A nudge left from before this session or a failed one: the + // session start just sent the same. + let current = crate::lan::own_report(app); + let just_sent = last_report(app) + .is_some_and(|(r, ago)| ago < patchpilot_local::net_report::MIN_GAP && r.same_news(¤t)); + if !just_sent { + send_report(app, &relay, &this, rig).await; + } + } + () = state.restart.notified() => return Err("woke from sleep".into()), + tick = ping.tick() => { + // Timed by the ticks themselves, so one unanswered ping is + // enough, not two. + if unanswered.is_some_and(|t| tick - t >= PING_EVERY) { + return Err("the relay stopped answering".into()); + } + ws.send(Message::text("ping")).await.map_err(|e| e.to_string())?; + unanswered.get_or_insert(tick); + } } } } @@ -216,7 +286,8 @@ async fn upload(relay: &Relay, state: &Sync, this: &str, rig: &RigKey) -> Result let Some((object, sealed)) = out else { return Ok(()); }; - relay.put(&object, sealed).await?; + let ip = relay.put(&object, sealed).await?; + remember_public(state, ip); upload_files(relay, rig, &files, &state.on_relay).await } @@ -263,7 +334,7 @@ async fn upload_files( // it doesn't take live sync offline. A few in a row means the // connection is gone, and the next upload starts over. match relay.put(&name, sealed).await { - Ok(()) => remember(on_relay, [name]), + Ok(_) => remember(on_relay, [name]), Err(e) => { eprintln!("sync: {} not shared: {e}", file.rel); failed += 1; @@ -295,6 +366,84 @@ pub(crate) async fn fetch_file( } } +/// Put this machine's home-network report on the relay, made now: one kept +/// from before going offline could say it hears nobody. One that doesn't go +/// up waits for the next change or session; it never takes live sync +/// offline. +async fn send_report(app: &AppHandle, relay: &Relay, this: &str, rig: &RigKey) { + let state = app.state::(); + // The put's answer may show a new public address (this machine moved): + // then the report it just sent is wrong about it, so send it again. + for _ in 0..2 { + let report = crate::lan::own_report(app); + let sent = match patchpilot_local::sync::outgoing_report(this, rig, &report) { + Ok((object, sealed)) => relay.put(&object, sealed).await, + Err(e) => Err(e), + }; + match sent { + Ok(ip) => { + remember_public(&state, ip); + let moved = ip.map(patchpilot_local::net_report::public_tag) != report.public + && ip.is_some(); + *state.report_put.lock().expect("report lock") = + Some((report, std::time::Instant::now())); + if !moved { + return; + } + } + Err(e) => { + eprintln!("sync: home-network report not sent: {e}"); + return; + } + } + } +} + +/// The machine woke from sleep: forget the reports read before (they may be +/// hours old, and the connection may be dead without knowing it yet) and +/// start a new session, which reads them again. +pub fn woke(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + state.reports.lock().expect("reports lock").clear(); + state.woken.store(true, Ordering::SeqCst); + // A session that's running starts over (and reconnects at once); one + // that isn't reads everything again when it starts. + if state.status.lock().expect("sync status lock").online { + state.restart.notify_one(); + } +} + +/// This machine moved to another network: the public address the relay +/// last saw is the old one's until the next upload says. +pub fn forget_public(app: &AppHandle) { + if let Some(state) = app.try_state::() { + *state.public.lock().expect("public lock") = None; + } +} + +/// Keep the public address the relay saw this machine at. +fn remember_public(state: &Sync, ip: Option) { + if let Some(ip) = ip { + *state.public.lock().expect("public lock") = + Some(patchpilot_local::net_report::public_tag(ip)); + } +} + +/// Another machine's home-network report, with when it reached the relay +/// by this machine's clock; None when it has none (an older Patch Pilot) or +/// it won't open. `live`: fetched because it just changed. +async fn fetch_report(relay: &Relay, rig: &RigKey, id: &str, live: bool) -> Option { + let object = patchpilot_sync::net_object(id).ok()?; + let (sealed, held_ms) = relay.get_aged(&object).await.ok()??; + let report = patchpilot_local::sync::incoming_report(rig, &object, &sealed)?; + Some(Received { + report, + at: patchpilot_local::net_report::received_at(patchpilot_local::now(), held_ms, live), + }) +} + /// Fetch and merge another machine's object. True if anything changed. async fn pull(relay: &Relay, this: &str, rig: &RigKey, object: &str) -> Result { let Some(sealed) = relay.get(object).await? else { @@ -314,6 +463,47 @@ fn set(app: &AppHandle, state: &Sync, f: impl FnOnce(&mut SyncStatus)) { let _ = app.emit("sync-status", status); } +/// Send this machine's home-network report now. Offline, the next session +/// sends one at its start anyway. +pub fn share_report(app: &AppHandle) { + app.state::().report_nudge.notify_one(); +} + +/// The other machines' home-network reports, by id, as last read. +pub fn reports(app: &AppHandle) -> BTreeMap { + app.state::() + .reports + .lock() + .expect("reports lock") + .clone() +} + +/// This machine's report as last put on the relay, and how long ago. +pub fn last_report(app: &AppHandle) -> Option<(NetReport, Duration)> { + app.state::() + .report_put + .lock() + .expect("report lock") + .as_ref() + .map(|(r, at)| (r.clone(), at.elapsed())) +} + +/// This machine's public address as the relay last saw it. +pub fn public(app: &AppHandle) -> Option { + app.try_state::()? + .public + .lock() + .expect("public lock") + .clone() +} + +/// Connected to the relay now, with the other machines' reports read since +/// waking. +pub fn online(app: &AppHandle) -> bool { + let state = app.state::(); + !state.woken.load(Ordering::SeqCst) && state.status.lock().expect("sync status lock").online +} + /// The ids of the other machines linked to this one, as the relay last /// listed them; None before the first listing this run (or after unlinking). pub fn linked_machines(app: &AppHandle) -> Option> { @@ -385,6 +575,8 @@ pub async fn link_join( pub fn unlink(state: State<'_, SyncState>) -> Result<(), String> { patchpilot_local::sync::forget_rig(&app_dir()?)?; *state.linked.lock().expect("linked lock") = None; + state.reports.lock().expect("reports lock").clear(); + *state.report_put.lock().expect("report lock") = None; state.stop.notify_waiters(); Ok(()) } @@ -480,6 +672,12 @@ struct LiveEvent { name: String, } +#[derive(Debug, Deserialize)] +struct PutReply { + #[serde(default)] + ip: Option, +} + #[derive(Debug, Deserialize)] struct ObjectInfo { name: String, @@ -512,12 +710,17 @@ impl Relay { } } - async fn put(&self, object: &str, sealed: Vec) -> Result<(), String> { + /// Store one object. Gives the public address the relay saw this + /// machine at, when it says (older relays don't). + async fn put(&self, object: &str, sealed: Vec) -> Result, String> { let url = format!("{}/objects/{object}", self.base); let res = self .send(|| self.http.put(&url).body(sealed.clone())) .await?; - check(res).await.map(|_| ()) + let reply = check(res).await?.bytes().await.unwrap_or_default(); + Ok(serde_json::from_slice::(&reply) + .ok() + .and_then(|r| r.ip?.parse().ok())) } /// Send with the token, again after a pause if the connection drops @@ -540,13 +743,24 @@ impl Relay { } async fn get(&self, object: &str) -> Result>, String> { + Ok(self.get_aged(object).await?.map(|(bytes, _)| bytes)) + } + + /// An object and how long the relay has held it (ms), when it says. + async fn get_aged(&self, object: &str) -> Result, Option)>, String> { let url = format!("{}/objects/{object}", self.base); let res = self.send(|| self.http.get(&url)).await?; if res.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } let res = check(res).await?; - Ok(Some(res.bytes().await.map_err(|e| e.to_string())?.to_vec())) + let held = res + .headers() + .get("x-age") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()); + let bytes = res.bytes().await.map_err(|e| e.to_string())?.to_vec(); + Ok(Some((bytes, held))) } /// The objects whose names start with `prefix`. A relay from before @@ -739,6 +953,52 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + #[ignore = "needs a relay (PATCHPILOT_RELAY)"] + async fn the_live_connection_answers_pings() { + // A session counts as offline when a ping goes unanswered. + let rig = RigKey::generate(); + let relay = Relay::new(&rig); + relay.put("machine/pc", b"sealed".to_vec()).await.unwrap(); + let mut ws = relay.live().await.unwrap(); + ws.send(Message::text("ping")).await.unwrap(); + let answer = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("an answer"); + let Some(Ok(Message::Text(t))) = answer else { + panic!("no pong: {answer:?}") + }; + assert_eq!(t.as_str(), "pong"); + } + + #[tokio::test] + #[ignore = "needs a relay (PATCHPILOT_RELAY)"] + async fn home_network_reports_travel_with_their_age() { + let rig = RigKey::generate(); + let relay = Relay::new(&rig); + // The first put claims the rig; the relay says where it came from. + let ip = relay.put("machine/pc", b"sealed".to_vec()).await.unwrap(); + assert!( + ip.is_some(), + "the relay answers with this machine's address" + ); + let report = NetReport { + on: true, + hears: vec!["mac".into()], + ..NetReport::off(patchpilot_local::now()) + }; + let (object, sealed) = + patchpilot_local::sync::outgoing_report("pc", &rig, &report).unwrap(); + relay.put(&object, sealed).await.unwrap(); + tokio::time::sleep(Duration::from_millis(1100)).await; + let got = fetch_report(&relay, &rig, "pc", false).await.unwrap(); + assert_eq!(got.report, report); + let got = got.at.expect("this relay says how long it held it"); + assert!(got < patchpilot_local::now(), "held about a second: {got}"); + assert!(got >= patchpilot_local::now() - 5); + assert!(fetch_report(&relay, &rig, "nobody", false).await.is_none()); + } + #[tokio::test] #[ignore = "needs a relay (PATCHPILOT_RELAY)"] async fn a_code_still_works_after_keep_alive_pings() { diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index cbf5c01..9632f04 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -29,6 +29,8 @@ import { type FileToBring, type GitHubProgress, type LanCause, + type LanDiagnosis, + type LanWhy, type LanProgress, type LanView, type RestoreReport, @@ -224,7 +226,13 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan } // Home network (decision #123): never "in sync" while nobody is here, and // a machine that's missing while another is here is named after the line. + // What only the other machine knows comes from its report (#124). const d = lan?.diagnosis; + const deaf = d?.deaf ?? []; + if (d && d.missing.length === 0 && deaf.length > 0) { + const what = `${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}`; + return ["warn", d.deaf_cause ? `${what}: ${lanCauseText(d.deaf_cause)}` : `${what}. Show what to check.`]; + } if (d && d.missing.length > 0) { const who = andList(d.missing.map((m) => m.name)); const isnt = d.missing.length === 1 ? "isn't" : "aren't"; @@ -232,8 +240,26 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan return d.here.length > 0 ? withNote(filesLine(sync, where, github, lan), `looking for ${who}…`) : ["accent", `Looking for ${who} on this network…`]; - if (d.cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(d.cause)}`]; - if (d.here.length > 0) return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); + const only = d.missing.length === 1 ? d.missing[0] : null; + const cause = causeGivesWay(d) ? undefined : d.cause; + if (cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(cause)}`]; + if (d.here.length > 0) { + // Someone here can't hear this machine too: that's not "in sync". + if (deaf.length > 0) + return [ + "warn", + `${who} ${isnt} on this network now, and ${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}${d.deaf_cause ? `: ${lanCauseText(d.deaf_cause)}` : ". Show what to check."}`, + ]; + const note = d.missing.every((m) => m.why) + ? d.missing.map((m) => `${m.name}: ${m.why ? lanWhyShort(m.why) : ""}`).join("; ") + "." + : `${who} ${isnt} on this network now.`; + return withNote(filesLine(sync, where, github, lan), note); + } + const allQuiet = d.missing.every((m) => m.why?.kind === "quiet"); + if (only?.why) + return [allQuiet ? "accent" : "warn", `Can't find ${only.name} on this network: ${lanWhyText(only.why, only.name)}`]; + if (d.missing.some((m) => m.why)) + return [allQuiet ? "accent" : "warn", `Can't find ${who} on this network. Show why.`]; if (d.missing.every((m) => m.seen_at)) { const latest = Math.max(...d.missing.map((m) => m.seen_at ?? 0)); return ["accent", `${who} ${isnt} on this network now (seen ${timeAgo(latest)}).`]; @@ -251,7 +277,10 @@ function withNote([tone, text]: [Tone, string], note: string): [Tone, string] { /** What the other machines' backups brought, or why nothing came yet. */ function filesLine(sync: SyncView, where: string, github: string | null, lan: LanView | null): [Tone, string] { const last = sync.last; - const sources = (last?.sources ?? []).filter((s) => s.linked && !s.unsigned); + // A machine whose report says it doesn't use the home network isn't in + // sync through it, whatever it left in the copy here. + const off = new Set((lan?.diagnosis.missing ?? []).filter((m) => m.why?.kind === "off").map((m) => m.name)); + const sources = (last?.sources ?? []).filter((s) => s.linked && !s.unsigned && !off.has(s.machine_name)); if (!last) return ["ok", `In sync through ${where}. Checking your other machines' files…`]; if (sources.length === 0 && lan && !lan.peers.some((p) => p.here)) return [ @@ -296,6 +325,83 @@ function lanCauseText(c: LanCause): string { } } +/** A guess on this machine: a VPN carrying only internet traffic, or another rig nearby. */ +function isGuess(c: LanCause | undefined): boolean { + return c?.kind === "vpn_on" || c?.kind === "other_rig"; +} + +/** Reasons that hold whatever this machine's own network does. */ +const THEIR_SIDE = new Set([ + "off", + "cant_listen", + "quiet", + "their_vpn", + "their_virtual", + "their_vpn_on", + "other_network", +]); + +/** + * Whether this machine's own cause gives way to what the missing machines + * say: a guess gives way to any of their reasons, and any cause gives way + * when every one of them has a reason on its own side (off, asleep, its VPN, + * another network): a laptop at a café is told it's on another network, not + * to make the café's Wi-Fi Private. The line and the details agree. + */ +function causeGivesWay(d: LanDiagnosis): boolean { + if (!d.cause || d.missing.length === 0) return false; + if (isGuess(d.cause) && d.missing.some((m) => m.why)) return true; + return d.missing.every((m) => m.why && THEIR_SIDE.has(m.why.kind)); +} + +/** What a missing machine's own report says, after "Can't find X on this network: ". */ +function lanWhyText(w: LanWhy, name: string): string { + switch (w.kind) { + case "off": + return `Home network sync isn't on there. On ${name}, pick Home network… under Sync & backup.`; + case "cant_listen": + return `Patch Pilot there can't open its network port. Open Sync & backup on ${name} to see why.`; + case "quiet": + return `it last checked in ${timeAgo(w.since)}. Make sure it's awake, with Patch Pilot open and online.`; + case "their_vpn": + return "a VPN is on there, so its messages go through it instead of your home network. Turn it off there, or let it allow local network access."; + case "their_virtual": + return `its messages go to a virtual network on it (${w.name}) instead of your home network.`; + case "their_vpn_on": + return "a VPN is on there. If it blocks local network traffic, turn it off there or let it allow local network access."; + case "one_way": + return "it hears this machine, but nothing it sends gets here. Allow Patch Pilot through the firewall on this machine."; + case "other_network": + return "it's on a different network. Connect both machines to the same Wi-Fi or wired network."; + case "blocked": + return "both are on the same network, but neither hears the other. A firewall may be blocking Patch Pilot, or the router keeps devices apart (a guest network, or “client isolation” in its settings)."; + } +} + +/** The same, short, for the machine's row in the details. */ +function lanWhyShort(w: LanWhy): string { + switch (w.kind) { + case "off": + return "Home network isn't on there"; + case "cant_listen": + return "can't open its network port"; + case "quiet": + return `last checked in ${timeAgo(w.since)}`; + case "their_vpn": + return "a VPN is on there"; + case "their_virtual": + return `its messages go to ${w.name}`; + case "their_vpn_on": + return "a VPN is on there"; + case "one_way": + return "hears this machine, but nothing from it gets here"; + case "other_network": + return "on a different network"; + case "blocked": + return "its report says the same network, but neither hears the other"; + } +} + /** "A", "A and B", "A, B and C". */ function andList(items: string[]): string { return items.length <= 1 ? (items[0] ?? "") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; @@ -305,34 +411,71 @@ function andList(items: string[]): string { function LanMachines({ lan }: { lan: LanView }) { const d = lan.diagnosis; if (d.here.length === 0 && d.missing.length === 0) return null; - const cause = d.cause?.kind; + // The same rule as the line: a guess that gave way isn't shown at all; a + // sure cause that gave way is still worth fixing, so it stays, as "also". + const givesWay = causeGivesWay(d); + const shown = givesWay && isGuess(d.cause) ? undefined : d.cause; + // Only the reason shown as the reason marks the checklist. + const cause = givesWay ? undefined : shown?.kind; + const deaf = d.deaf ?? []; + const whys = new Set(d.missing.map((m) => m.why?.kind)); const items: [string, boolean][] = [ - ["Both machines are on the same Wi-Fi or wired network.", false], - ["No VPN is on, or it allows local network access.", cause === "vpn" || cause === "vpn_on"], + ["Both machines are on the same Wi-Fi or wired network.", whys.has("other_network")], + [ + "No VPN is on, or it allows local network access.", + cause === "vpn" || cause === "vpn_on" || whys.has("their_vpn") || whys.has("their_vpn_on"), + ], ["On Windows, the network is set to Private (Settings › Network & internet).", cause === "public_network"], - ["On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", false], + [ + "On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", + whys.has("one_way") || whys.has("blocked"), + ], ["On a Mac, Patch Pilot is on in System Settings › Privacy & Security › Local Network.", cause === "local_network_denied"], - ["Patch Pilot is open on the other machine, with Home network picked under Sync & backup.", false], + [ + "Patch Pilot is open on the other machine, with Home network picked under Sync & backup.", + whys.has("off") || whys.has("quiet"), + ], ["Both machines run the same Patch Pilot version.", cause === "other_version"], ]; + // Reasons from the other machines' reports, in full: the line is cut. + const reasons = d.missing.flatMap((m) => (m.why ? [`${m.name}: ${lanWhyText(m.why, m.name)}`] : [])); + const lost = d.missing.length > 0 && !d.looking; + const troubled = lost || deaf.length > 0; return (
{d.here.map((n, i) => (

{n} · on this network + {deaf.includes(n) && ", but it can't hear this machine"}

))} {d.missing.map((m, i) => (

{m.name} ·{" "} {m.seen_at ? `last on this network ${timeAgo(m.seen_at)}` : d.looking ? "looking…" : "not found on this network"} + {m.why && !d.looking && ` · ${lanWhyShort(m.why)}`}

))} - {d.missing.length > 0 && !d.looking && ( + {troubled && ( <> {/* The line above is cut to one line; the reason is here in full. */} - {d.cause &&

Likely reason: {lanCauseText(d.cause)}

} -

Can't find a machine? Check that:

+ {shown && ( +

+ {givesWay ? "Also worth checking" : "Likely reason"}: {lanCauseText(shown)} +

+ )} + {d.deaf_cause && ( +

+ Why {andList(deaf)} can't hear this machine: {lanCauseText(d.deaf_cause)} +

+ )} + {!d.looking && + reasons.map((r, i) => ( +

+ {r} +

+ ))} +

{lost ? "Can't find a machine?" : "Files not reaching a machine?"} Check that:

    {items.map(([text, likely]) => (
  • diff --git a/app/src/api.ts b/app/src/api.ts index 575092c..26d1f3b 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -584,15 +584,31 @@ export type LanCause = | { kind: "other_rig" } | { kind: "vpn_on"; adapter?: string }; +/** Why one machine can't be found, from its own report over the relay; mirrors patchpilot_local::lan_diagnosis::Why. */ +export type LanWhy = + | { kind: "off" } + | { kind: "cant_listen" } + | { kind: "quiet"; since: number } + | { kind: "their_vpn" } + | { kind: "their_virtual"; name: string } + | { kind: "their_vpn_on" } + | { kind: "one_way" } + | { kind: "other_network" } + | { kind: "blocked" }; + /** Who's on this network, who's missing and why; mirrors patchpilot_local::lan_diagnosis::Diagnosis. */ export interface LanDiagnosis { /** Linked machines on this network now, by name. */ here: string[]; - /** Linked machines not on this network now; `seen_at` if heard since launch. */ - missing: { name: string; seen_at?: number }[]; + /** Linked machines not on this network now; `seen_at` if heard since launch, `why` from its own report. */ + missing: { name: string; seen_at?: number; why?: LanWhy }[]; /** Still in the first 30 s: "Looking for…", not "Can't find…". */ looking: boolean; cause?: LanCause; + /** Machines heard here that say they don't hear this one, by name. */ + deaf?: string[]; + /** Why they can't hear this machine, when this machine can tell. */ + deaf_cause?: LanCause; } export interface LanView { diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index 1359e84..1693b3d 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -4,10 +4,12 @@ //! and [`diagnose`] turns them into what the Sync & backup line says. Nothing //! here opens a socket or runs a command; [`crate::netinfo`] does the looking. //! -//! A cause is only named when this machine can tell. "The other machine is -//! on a different Wi-Fi" or "its firewall blocks us" can't be seen from here, -//! so those stay on the checklist the details show. +//! A cause is only named when this machine can tell. What only the other +//! machine knows (it's on another Wi-Fi, its VPN is on, it never hears this +//! one) comes from its [`NetReport`] over the relay (decision #124) and is +//! named per machine as a [`Why`], only while that report is fresh. +use std::collections::BTreeMap; use std::io::ErrorKind; use std::net::IpAddr; use std::time::Duration; @@ -15,6 +17,7 @@ use std::time::Duration; use serde::Serialize; use crate::lan::PORT; +use crate::net_report::{FRESH_SECS, Received, Subnet, same_public, share_a_network}; /// How long the line says "Looking for…" before it says "Can't find…". The /// app checks every beacon (10 s), so in practice 30-40 s. @@ -35,8 +38,29 @@ pub struct Seen { /// Heard from in the last 45 s. pub here: bool, pub seen_at: i64, + /// When this stretch of hearing it began (Unix seconds). + pub since: i64, } +/// A machine that's heard here only counts as unable to hear this one once +/// its report reached the relay this long after it was first heard here +/// (both on this machine's clock)… +const DEAF_AFTER_SECS: i64 = 90; +/// …and it had been listening this long when it made the report (on its +/// own clock): time enough for several beacons and the hello back, so a +/// machine that just restarted isn't taken as deaf. +const LISTEN_FIRST_SECS: i64 = 30; +/// A missing machine's report says how things are now only if it reached +/// the relay at least this long after this machine last heard it (two +/// beacons, for one lost and clocks in whole seconds): an earlier one was +/// made while it was still here, before it went to sleep, quit or moved. +const AFTER_HEARD_SECS: i64 = 20; +/// How long a report's `hears` keeps a machine after last hearing it (the +/// app's 45 s "gone"), plus a beacon: "it hears this machine" only means now +/// in a report made at least this long after this machine last heard it, or +/// after this machine started listening (it may have heard the run before). +const HEARS_FOR_SECS: i64 = 55; + /// A network interface: its name as the OS shows it ("Wi-Fi", "en0"), its /// addresses with their prefix lengths, and on Windows the adapter's /// description when it was looked up ("PANGP Virtual Ethernet Adapter"): @@ -116,6 +140,20 @@ pub struct Facts { /// Windows only; None elsewhere or when unknown. pub profile: Option, pub macos: bool, + /// This machine's id, to find it in the others' reports. + pub this_id: String, + /// Unix seconds now. + pub now: i64, + /// Connected to the relay, so a quiet machine is really quiet. + pub relay_online: bool, + /// This machine's home-network subnets, as last looked at; empty when + /// it hasn't looked. + pub subnets: Vec, + /// This machine's public address as the relay saw it + /// ([`crate::net_report::public_tag`]). + pub public: Option, + /// The other machines' latest reports, by id (decision #124). + pub reports: BTreeMap, } /// What the Sync & backup line says about the home network. Mirrored in @@ -131,6 +169,19 @@ pub struct Diagnosis { /// The most likely reason this machine can see, while one is missing. #[serde(skip_serializing_if = "Option::is_none")] pub cause: Option, + /// Machines heard here that say they don't hear this one, by name: they + /// get nothing from this machine. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub deaf: Vec, + /// Why they can't hear this machine, when this machine can tell. Its + /// own: this machine's VPN says nothing about why another is missing. + #[serde(skip_serializing_if = "Option::is_none")] + pub deaf_cause: Option, + /// A machine heard here has a fresh report that doesn't list this one + /// yet, too soon to call it deaf: worth looking at this machine's + /// network now, so its report says how its sends go. Not shown. + #[serde(skip)] + pub maybe_deaf: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -139,6 +190,38 @@ pub struct Missing { /// When it was last heard on this network, since launch. #[serde(skip_serializing_if = "Option::is_none")] pub seen_at: Option, + /// What its own report says is wrong. + #[serde(skip_serializing_if = "Option::is_none")] + pub why: Option, +} + +/// Why one machine can't be found, from its own report. Mirrored in +/// `app/src/api.ts`; the words are in `Backup.tsx`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Why { + /// Home network sync isn't picked there. + Off, + /// It's picked there, but Patch Pilot couldn't open its network port. + CantListen, + /// No report for a while: asleep, Patch Pilot closed, or offline. + Quiet { since: i64 }, + /// Its broadcasts leave through a VPN. + TheirVpn, + /// Its broadcasts leave into a virtual network inside it. + TheirVirtual { name: String }, + /// A VPN carries its internet traffic and it's on this machine's range + /// but hears nothing: many VPNs block local traffic. A guess. + TheirVpnOn, + /// It hears this machine, but nothing it sends arrives here. + OneWay, + /// It's on another network: no home subnet in common, or the same + /// range behind another router (another public address). + OtherNetwork, + /// The same network (same subnet and public address), both listening, + /// no VPN either side, and neither hears the other: a firewall, or the + /// router keeping devices apart. + Blocked, } /// Mirrored in `app/src/api.ts`; the words are in `Backup.tsx`. @@ -194,27 +277,193 @@ pub fn diagnose(f: &Facts) -> Diagnosis { // The name it last broadcast is the freshest. name: seen.map_or_else(|| e.name.clone(), |s| s.name.clone()), seen_at: seen.map(|s| s.seen_at), + why: why(f, &e.id, seen), } }) .collect(); + let mut deaf: Vec = f + .seen + .iter() + .filter(|s| s.here && f.expected.iter().any(|e| e.id == s.id)) + .filter(|s| { + f.reports.get(&s.id).is_some_and(|rx| { + let r = &rx.report; + r.on && r.listening + && rx.is_fresh(f.now) + && rx.at.is_some_and(|at| at >= s.since + DEAF_AFTER_SECS) + && r.listened_for(LISTEN_FIRST_SECS) + && !r.hears.contains(&f.this_id) + }) + }) + .map(|s| s.name.clone()) + .collect(); + deaf.sort(); + let maybe_deaf = f + .seen + .iter() + .filter(|s| s.here && f.expected.iter().any(|e| e.id == s.id)) + .any(|s| { + f.reports.get(&s.id).is_some_and(|rx| { + rx.report.on + && rx.is_fresh(f.now) + && rx.at.is_some_and(|at| at >= s.since) + && !rx.report.hears.contains(&f.this_id) + }) + }); let looking = !missing.is_empty() && f.listening_for < GRACE; - let cause = if missing.is_empty() || looking { - None + // A missing machine that reports from somewhere this machine can't + // confirm is its own network: advice about this network (make it + // Private, a VPN for the internet here) may be about a café's Wi-Fi. + let elsewhere = f.expected.iter().any(|e| { + let seen = f.seen.iter().find(|s| s.id == e.id); + seen.is_none_or(|s| !s.here) + && current(f, &e.id, seen).is_some_and(|(rx, _)| !same_network(f, &rx.report)) + }); + let cause = if !missing.is_empty() && !looking { + cause(f, !here.is_empty(), elsewhere) } else { - cause(f, !here.is_empty()) + None }; + let deaf_cause = if deaf.is_empty() { None } else { deaf_cause(f) }; Diagnosis { here, missing, looking, cause, + deaf, + deaf_cause, + maybe_deaf, + } +} + +/// A missing machine's report that says how things are now, and when it +/// reached the relay: on, fresh, this machine online (offline, a newer one +/// could be waiting unseen), and reached the relay after this machine last +/// heard that machine (an earlier one was made while it was still here). +fn current<'a>(f: &'a Facts, id: &str, seen: Option<&Seen>) -> Option<(&'a Received, i64)> { + let rx = f.reports.get(id)?; + let at = rx.at?; + (rx.report.on + && rx.is_fresh(f.now) + && f.relay_online + && seen.is_none_or(|s| at >= s.seen_at + AFTER_HEARD_SECS)) + .then_some((rx, at)) +} + +/// The report shows it on this machine's network: a subnet in common and +/// the same public address, compared directly (no VPN for the internet on +/// either side). +fn same_network(f: &Facts, r: &crate::net_report::NetReport) -> bool { + let direct = matches!(f.route_egress, Some(Egress::Lan)) && r.internet_vpn == Some(false); + direct + && share_a_network(&r.subnets, &f.subnets) + && matches!((&r.public, &f.public), (Some(a), Some(b)) if same_public(a, b) == Some(true)) +} + +/// What a missing machine's own report says, surest first. Only "off" holds +/// however old the report is: the machine would have said if it turned the +/// home network on. +fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { + let rx = f.reports.get(id)?; + let r = &rx.report; + if !r.on { + return Some(Why::Off); + } + // How old it is is unknown (an older relay): only "off" holds. + let at = rx.at?; + if !rx.is_fresh(f.now) { + // Offline here, every report goes stale; and one heard here lately + // was running, its report just hadn't needed refreshing. + let heard_lately = seen.is_some_and(|s| f.now - s.seen_at < FRESH_SECS); + let last = seen.map_or(at, |s| s.seen_at.max(at)); + return (f.relay_online && !heard_lately).then_some(Why::Quiet { since: last }); + } + let (_, at) = current(f, id, seen)?; + if !r.listening { + return Some(Why::CantListen); + } + if r.vpn == Some(true) { + return Some(Why::TheirVpn); + } + if let Some(name) = &r.virtual_adapter { + return Some(Why::TheirVirtual { name: name.clone() }); + } + let both_looked = !r.subnets.is_empty() && !f.subnets.is_empty(); + if both_looked && !share_a_network(&r.subnets, &f.subnets) { + return Some(Why::OtherNetwork); + } + // Both sides' sends go where they should, and no VPN on either side + // that might drop local traffic, so what it hears or doesn't says + // something about the firewalls and the network between. + let sends_fine = r.vpn == Some(false) + && r.internet_vpn == Some(false) + && !r.send_error + && matches!(f.broadcast_egress, Some(Egress::Lan)) + && matches!(f.route_egress, Some(Egress::Lan)) + && f.broadcast_error.is_none(); + let listening_from = f.now - f.listening_for.as_secs() as i64; + if r.hears.contains(&f.this_id) { + // Its `hears` keeps a machine 45 s: it must postdate both this + // machine losing it and this run starting. + let current = at >= listening_from + HEARS_FOR_SECS + && seen.is_none_or(|s| at >= s.seen_at + HEARS_FOR_SECS); + return (sends_fine && current).then_some(Why::OneWay); + } + if !both_looked { + return None; + } + // The same range. Most routers hand out the same few, so it's the same + // network only if the public address matches too, and that only says + // something when neither side's internet goes through a VPN. + // Its VPN carries its internet traffic and it hears nothing here: many + // VPNs block local traffic too. + if r.internet_vpn == Some(true) && matches!(f.route_egress, Some(Egress::Lan)) { + return Some(Why::TheirVpnOn); + } + let direct = matches!(f.route_egress, Some(Egress::Lan)) && r.internet_vpn == Some(false); + let same = match (&r.public, &f.public) { + (Some(a), Some(b)) if direct => same_public(a, b)?, + _ => return None, + }; + if !same { + return Some(Why::OtherNetwork); + } + // Same network: blocked only when neither side's packets go astray, and + // it had time to hear this run's beacons. + (sends_fine && at >= listening_from + AFTER_HEARD_SECS).then_some(Why::Blocked) +} + +impl Diagnosis { + /// A linked machine is missing that might still turn up: one whose + /// report says it doesn't use the home network won't, and needs + /// nothing more from this machine. + pub fn awaiting(&self) -> bool { + self.missing.iter().any(|m| m.why != Some(Why::Off)) + } +} + +/// Why machines heard here can't hear this one, when this machine can +/// tell: its own packets leave the wrong way, or it runs a version they +/// can't read. +fn deaf_cause(f: &Facts) -> Option { + if f.other_version { + return Some(Cause::OtherVersion); + } + match &f.broadcast_egress { + Some(Egress::Vpn { adapter }) => Some(Cause::Vpn { + adapter: adapter.clone(), + }), + Some(Egress::Virtual { name }) => Some(Cause::VirtualAdapter { name: name.clone() }), + _ => None, } } /// The first cause that fits, surest first. While another linked machine is /// here, this machine's network works, so only causes on the other side -/// (another version, another rig) are named. -fn cause(f: &Facts, someone_here: bool) -> Option { +/// (another version, another rig) are named. `elsewhere`: a missing machine +/// reports from a network this machine can't confirm is its own, so advice +/// about this network is left out. +fn cause(f: &Facts, someone_here: bool, elsewhere: bool) -> Option { if f.other_version { return Some(Cause::OtherVersion); } @@ -242,14 +491,17 @@ fn cause(f: &Facts, someone_here: bool) -> Option { { return Some(Cause::LocalNetworkDenied); } - if f.profile == Some(Profile::Public) { + if f.profile == Some(Profile::Public) && !elsewhere { return Some(Cause::PublicNetwork); } } if f.other_rig { return Some(Cause::OtherRig); } - if !someone_here && let Some(Egress::Vpn { adapter }) = &f.route_egress { + if !someone_here + && !elsewhere + && let Some(Egress::Vpn { adapter }) = &f.route_egress + { return Some(Cause::VpnOn { adapter: adapter.clone(), }); @@ -333,6 +585,24 @@ fn kind_with_route(i: &Iface, route: Option) -> AdapterKind { } } +/// This machine's home-network subnets: the Wi-Fi and Ethernet ones, +/// sorted, for its [`NetReport`]. +pub fn subnets(ifaces: &[Iface], route: Option) -> Vec { + let mut out: Vec = ifaces + .iter() + .filter(|i| kind_with_route(i, route) == AdapterKind::Lan) + .flat_map(|i| &i.ips) + .filter(|(ip, _)| lan_v4(ip)) + .filter_map(|(ip, prefix)| match ip { + IpAddr::V4(v4) => Subnet::of(*v4, *prefix), + IpAddr::V6(_) => None, + }) + .collect(); + out.sort(); + out.dedup(); + out +} + /// True when some Wi-Fi or Ethernet interface has a home-network address. /// `route`: the address internet traffic leaves from, if known. pub fn has_lan(ifaces: &[Iface], route: Option) -> bool { @@ -391,6 +661,7 @@ pub fn bind_error_text(kind: ErrorKind, raw_os_error: Option, detail: &str) #[cfg(test)] mod tests { use super::*; + use crate::net_report::NetReport; use std::net::Ipv4Addr; fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr { @@ -470,6 +741,7 @@ mod tests { name: "Laptop (renamed)".into(), here: false, seen_at: 1_700_000_000, + since: 1_699_999_000, }], ..alone() }; @@ -478,7 +750,8 @@ mod tests { d.missing, vec![Missing { name: "Laptop (renamed)".into(), - seen_at: Some(1_700_000_000) + seen_at: Some(1_700_000_000), + why: None, }] ); } @@ -490,6 +763,7 @@ mod tests { name: "Desktop".into(), here: true, seen_at: 1, + since: 1, }; let f = Facts { expected: vec![ @@ -527,6 +801,7 @@ mod tests { name: "MacBook".into(), here: true, seen_at: 1, + since: 1, }], ..alone() }); @@ -809,4 +1084,565 @@ mod tests { serde_json::json!({ "kind": "virtual_adapter", "name": "docker0" }) ); } + + const NOW: i64 = 1_800_000_000; + const HOME_IP: &str = "v4:203.0.113.5"; + + fn home() -> Subnet { + Subnet::of(Ipv4Addr::new(192, 168, 1, 20), 24).unwrap() + } + + /// The laptop's report: on and listening for ten minutes, fresh, on the + /// home network behind the home router, no VPN, hears nobody. + fn report() -> NetReport { + NetReport { + version: 1, + at: NOW - 60, + on: true, + listening_since: Some(NOW - 660), + subnets: vec![home()], + vpn: Some(false), + internet_vpn: Some(false), + public: Some(HOME_IP.into()), + ..NetReport::default() + } + } + + /// As if it reached the relay when it was made. + fn rx(r: NetReport) -> Received { + Received { + at: Some(r.at), + report: r, + } + } + + /// Alone for an hour, with the laptop's report in and this machine's + /// network known. + fn compared(r: NetReport) -> Facts { + Facts { + listening_for: Duration::from_secs(3600), + this_id: "desk".into(), + now: NOW, + relay_online: true, + subnets: vec![home()], + public: Some(HOME_IP.into()), + reports: [("laptop".to_string(), rx(r))].into(), + ..alone() + } + } + + fn why_of(f: &Facts) -> Option { + diagnose(f).missing[0].why.clone() + } + + #[test] + fn the_other_machines_report_says_why_surest_first() { + assert_eq!(why_of(&compared(report())), Some(Why::Blocked)); + let off = NetReport { + at: NOW - 86_400, + ..NetReport::off(0) + }; + assert_eq!( + why_of(&compared(off)), + Some(Why::Off), + "off holds however old" + ); + let old = NetReport { + at: NOW - 3_600, + ..report() + }; + assert_eq!( + why_of(&compared(old.clone())), + Some(Why::Quiet { since: NOW - 3_600 }) + ); + assert_eq!( + why_of(&Facts { + relay_online: false, + ..compared(old.clone()) + }), + None, + "offline here, every report looks old" + ); + // Heard here a few minutes ago: running, its report just old. + let lately = Facts { + seen: vec![Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: false, + seen_at: NOW - 120, + since: NOW - 7_200, + }], + ..compared(old) + }; + assert_eq!(why_of(&lately), None); + let no_port = NetReport { + listening: false, + ..report() + }; + assert_eq!(why_of(&compared(no_port)), Some(Why::CantListen)); + let vpn = NetReport { + vpn: Some(true), + hears: vec!["desk".into()], + ..report() + }; + assert_eq!(why_of(&compared(vpn)), Some(Why::TheirVpn)); + let hears_us = NetReport { + hears: vec!["desk".into()], + ..report() + }; + assert_eq!(why_of(&compared(hears_us)), Some(Why::OneWay)); + let elsewhere = NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 0, 0, 7), 24).unwrap()], + ..report() + }; + assert_eq!(why_of(&compared(elsewhere)), Some(Why::OtherNetwork)); + } + + #[test] + fn the_same_range_behind_another_router_is_another_network() { + // A friend's router hands out 192.168.1.x too. + let friends = NetReport { + public: Some("v4:198.51.100.7".into()), + ..report() + }; + assert_eq!(why_of(&compared(friends.clone())), Some(Why::OtherNetwork)); + // Can't compare: IPv6 to the relay there, IPv4 here. + let v6 = NetReport { + public: Some("v6:2001:db8:1:2::/64".into()), + ..report() + }; + assert_eq!(why_of(&compared(v6)), None); + // Its internet through a VPN: its public address is the VPN's, so + // it can't say which network; but on this range and hearing nothing, + // the VPN is the likely reason (many block local traffic). + let tunneled = NetReport { + internet_vpn: Some(true), + ..friends.clone() + }; + assert_eq!(why_of(&compared(tunneled)), Some(Why::TheirVpnOn)); + assert_eq!( + why_of(&Facts { + route_egress: Some(Egress::Vpn { adapter: None }), + ..compared(friends.clone()) + }), + None + ); + // An older relay that doesn't say: same range, nothing to name. + assert_eq!( + why_of(&Facts { + public: None, + ..compared(friends) + }), + None + ); + assert_eq!( + why_of(&compared(NetReport { + public: None, + ..report() + })), + None, + "not blocked without knowing it's the same router" + ); + } + + #[test] + fn a_report_names_nothing_it_cant_back() { + // Neither side looked at its network yet. + let unknown = NetReport { + subnets: vec![], + ..report() + }; + assert_eq!(why_of(&compared(unknown)), None); + assert_eq!( + why_of(&Facts { + subnets: vec![], + ..compared(report()) + }), + None + ); + // Same network, but its VPN state unknown, or this machine's + // broadcasts going astray: not "blocked". + let vpn_unknown = NetReport { + vpn: None, + ..report() + }; + assert_eq!(why_of(&compared(vpn_unknown)), None); + assert_eq!( + why_of(&Facts { + broadcast_egress: Some(Egress::Unknown), + ..compared(report()) + }), + None + ); + // No report at all: an older Patch Pilot there. + assert_eq!( + why_of(&Facts { + reports: BTreeMap::new(), + ..compared(report()) + }), + None + ); + // Another machine's report doesn't speak for the laptop. + assert_eq!( + why_of(&Facts { + reports: [("studio".to_string(), rx(NetReport::off(NOW)))].into(), + ..compared(report()) + }), + None + ); + } + + #[test] + fn a_machine_heard_here_that_doesnt_hear_this_one_is_deaf() { + let heard = |since: i64| Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: true, + seen_at: NOW, + since, + }; + let f = Facts { + seen: vec![heard(NOW - 600)], + ..compared(report()) + }; + let d = diagnose(&f); + assert!(d.missing.is_empty()); + assert_eq!(d.deaf, vec!["Brian's Laptop".to_string()]); + assert_eq!(d.cause, None, "nothing wrong this machine can see"); + // Its report reached the relay before it could have heard this one. + let early = Facts { + seen: vec![heard(NOW - 100)], + ..f.clone() + }; + assert!(diagnose(&early).deaf.is_empty()); + // It had only just started (a quick restart, an update) when it + // made the report, though this machine kept hearing it. + let restarted = Facts { + reports: [( + "laptop".to_string(), + rx(NetReport { + listening_since: Some(NOW - 70), + ..report() + }), + )] + .into(), + ..f.clone() + }; + assert!(diagnose(&restarted).deaf.is_empty()); + // It does hear this one. + let fine = compared(NetReport { + hears: vec!["desk".into()], + ..report() + }); + assert!( + diagnose(&Facts { + seen: f.seen.clone(), + ..fine + }) + .deaf + .is_empty() + ); + // Another rig nearby has nothing to do with it. + let d = diagnose(&Facts { + other_rig: true, + ..f.clone() + }); + assert_eq!(d.cause, None); + // This machine's broadcasts leave through a VPN: that's why. + let d = diagnose(&Facts { + broadcast_egress: Some(Egress::Vpn { + adapter: Some("NordLynx".into()), + }), + ..f + }); + assert_eq!( + d.deaf_cause, + Some(Cause::Vpn { + adapter: Some("NordLynx".into()) + }) + ); + assert_eq!(d.cause, None, "nothing is missing"); + } + + fn left(seen_at: i64) -> Seen { + Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: false, + seen_at, + since: seen_at - 3_600, + } + } + + #[test] + fn a_report_from_before_the_machine_left_says_nothing_about_now() { + // It heard this machine, then went to sleep 4 minutes ago; its last + // report is from 8 minutes ago, when it was still here. + let asleep = Facts { + seen: vec![left(NOW - 240)], + ..compared(NetReport { + at: NOW - 480, + hears: vec!["desk".into()], + ..report() + }) + }; + assert_eq!(why_of(&asleep), None); + // A report from after it left does count. + let moved = Facts { + seen: vec![left(NOW - 240)], + ..compared(NetReport { + at: NOW - 60, + vpn: Some(true), + ..report() + }) + }; + assert_eq!(why_of(&moved), Some(Why::TheirVpn)); + // Offline here: a newer report may be waiting unseen. + assert_eq!( + why_of(&Facts { + relay_online: false, + ..moved + }), + None + ); + // Unknown age (an older relay): only "off". + let mut unknown = compared(report()); + unknown.reports.get_mut("laptop").unwrap().at = None; + assert_eq!(why_of(&unknown), None); + unknown.reports.get_mut("laptop").unwrap().report.on = false; + assert_eq!(why_of(&unknown), Some(Why::Off)); + } + + #[test] + fn hearing_this_machine_only_counts_from_this_run_with_its_own_sends_fine() { + let hears_us = NetReport { + hears: vec!["desk".into()], + ..report() + }; + // It heard the Patch Pilot that ran here before this one. + let restarted = Facts { + listening_for: Duration::from_secs(40), + ..compared(hears_us.clone()) + }; + assert_eq!(why_of(&restarted), None); + // Its own VPN state unknown, or its broadcasts failing: this + // machine's firewall isn't the likely reason. + let unknown_vpn = NetReport { + vpn: None, + ..hears_us.clone() + }; + assert_eq!(why_of(&compared(unknown_vpn)), None); + let failing = NetReport { + send_error: true, + ..hears_us.clone() + }; + assert_eq!(why_of(&compared(failing)), None); + let failing_blocked = NetReport { + send_error: true, + ..report() + }; + assert_eq!(why_of(&compared(failing_blocked)), None); + // A virtual adapter there is its own reason, not "a VPN". + let wsl = NetReport { + virtual_adapter: Some("vEthernet (WSL)".into()), + ..hears_us + }; + assert_eq!( + why_of(&compared(wsl)), + Some(Why::TheirVirtual { + name: "vEthernet (WSL)".into() + }) + ); + } + + #[test] + fn hearing_this_machine_needs_a_report_well_after_it_was_last_heard() { + let hears_us = |at: i64| NetReport { + at, + hears: vec!["desk".into()], + ..report() + }; + // A beacon was lost, then it sent a heartbeat and closed its lid: + // its "hears" still had this machine in it. + let lid = Facts { + seen: vec![left(NOW - 120)], + ..compared(hears_us(NOW - 107)) + }; + assert_eq!(why_of(&lid), None); + let later = Facts { + seen: vec![left(NOW - 120)], + ..compared(hears_us(NOW - 60)) + }; + assert_eq!(why_of(&later), Some(Why::OneWay)); + // It moved to another network while it still listed this machine. + let moved = Facts { + seen: vec![left(NOW - 120)], + ..compared(NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 0, 0, 7), 24).unwrap()], + ..hears_us(NOW - 100) + }) + }; + assert_eq!(why_of(&moved), Some(Why::OtherNetwork)); + // It heard the run before this one, which quit moments ago. + let rerun = Facts { + listening_for: Duration::from_secs(90), + ..compared(hears_us(NOW - 60)) + }; + assert_eq!(why_of(&rerun), None); + } + + #[test] + fn this_machines_own_failing_sends_rule_out_blocked_and_one_way() { + let failing = Facts { + broadcast_error: Some(10051), + ..compared(report()) + }; + assert_eq!(why_of(&failing), None); + let hears_us = Facts { + broadcast_error: Some(10051), + ..compared(NetReport { + hears: vec!["desk".into()], + ..report() + }) + }; + assert_eq!(why_of(&hears_us), None); + } + + #[test] + fn this_machines_vpn_explains_a_deaf_machine_not_a_missing_one() { + // Studio is here but can't hear this machine (its broadcasts go into + // a VPN); the laptop is asleep, with no reason of its own. + let studio = Seen { + id: "studio".into(), + name: "Studio".into(), + here: true, + seen_at: NOW, + since: NOW - 600, + }; + let studio_report = Received { + at: Some(NOW - 30), + report: report(), + }; + let mut f = Facts { + expected: vec![ + laptop(), + Expected { + id: "studio".into(), + name: "Studio".into(), + }, + ], + seen: vec![studio, left(NOW - 300)], + broadcast_egress: Some(Egress::Vpn { adapter: None }), + ..compared(NetReport { + at: NOW - 400, + ..report() + }) + }; + f.reports.insert("studio".into(), studio_report); + let d = diagnose(&f); + assert_eq!(d.deaf, vec!["Studio".to_string()]); + assert_eq!(d.deaf_cause, Some(Cause::Vpn { adapter: None })); + assert_eq!(d.cause, None, "the VPN isn't why the laptop is missing"); + } + + #[test] + fn no_advice_about_this_network_while_the_other_may_be_elsewhere() { + let public = |r: NetReport| Facts { + profile: Some(Profile::Public), + ..compared(r) + }; + // It reports from a network this machine can't confirm is its own + // (another range, or one it can't compare): a café's Wi-Fi + // shouldn't be made Private. + let elsewhere = NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 0, 0, 7), 24).unwrap()], + ..report() + }; + assert_eq!(diagnose(&public(elsewhere)).cause, None); + let v6 = NetReport { + public: Some("v6:2001:db8:1:2::/64".into()), + ..report() + }; + assert_eq!(diagnose(&public(v6)).cause, None); + // Confirmed on this network: Public is the likely reason. + assert_eq!( + diagnose(&public(report())).cause, + Some(Cause::PublicNetwork) + ); + // No report from it (an older Patch Pilot): as before. + assert_eq!( + diagnose(&Facts { + profile: Some(Profile::Public), + ..alone() + }) + .cause, + Some(Cause::PublicNetwork) + ); + } + + #[test] + fn a_fresh_report_without_this_machine_prompts_a_look_before_deafness() { + let heard = Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: true, + seen_at: NOW, + since: NOW - 70, + }; + let d = diagnose(&Facts { + seen: vec![heard], + ..compared(report()) + }); + assert!(d.deaf.is_empty(), "too soon to say"); + assert!(d.maybe_deaf); + assert!(!diagnose(&compared(report())).maybe_deaf); + } + + #[test] + fn quiet_counts_from_when_it_was_last_heard_here() { + let old = NetReport { + at: NOW - 5 * 3_600, + ..report() + }; + let d = Facts { + seen: vec![left(NOW - 13 * 60)], + ..compared(old) + }; + assert_eq!( + why_of(&d), + Some(Why::Quiet { + since: NOW - 13 * 60 + }) + ); + } + + #[test] + fn a_machine_off_the_home_network_isnt_awaited() { + let off = diagnose(&compared(NetReport::off(NOW))); + assert!(!off.awaiting(), "it said it doesn't use the home network"); + assert!(diagnose(&compared(report())).awaiting()); + assert!(diagnose(&alone()).awaiting(), "no report: it might turn up"); + } + + #[test] + fn subnets_come_from_wifi_and_ethernet_only() { + let ifaces = vec![ + Iface { + ips: vec![(ip(192, 168, 1, 20), 24), (ip(192, 168, 1, 21), 24)], + ..iface("Wi-Fi", ip(0, 0, 0, 0)) + }, + iface("NordLynx", ip(10, 5, 0, 2)), + iface("vEthernet (WSL)", ip(172, 29, 0, 1)), + Iface { + ips: vec![ + (ip(169, 254, 3, 4), 16), + (ip(10, 9, 9, 9), 0), + ("fe80::1".parse().unwrap(), 64), + ], + ..iface("Ethernet", ip(0, 0, 0, 0)) + }, + ]; + assert_eq!(subnets(&ifaces, None), vec![home()]); + assert!(subnets(&[], None).is_empty()); + } } diff --git a/crates/local/src/lib.rs b/crates/local/src/lib.rs index f9d135f..b26d8cb 100644 --- a/crates/local/src/lib.rs +++ b/crates/local/src/lib.rs @@ -20,6 +20,7 @@ pub mod ignore; pub mod lan; pub mod lan_diagnosis; pub mod mac_keys; +pub mod net_report; pub mod netinfo; pub mod open; pub mod quiet; diff --git a/crates/local/src/net_report.rs b/crates/local/src/net_report.rs new file mode 100644 index 0000000..d7ee4aa --- /dev/null +++ b/crates/local/src/net_report.rs @@ -0,0 +1,448 @@ +//! What each linked machine tells the others about its home network, over +//! the relay (decision #124, amends #123). One machine alone can't see that +//! the other is on another Wi-Fi, has a VPN on, doesn't use the home network +//! or never hears it; the other machine can say so. Pure: the app seals it +//! as `net/` and moves the bytes. +//! +//! It goes out when what it says changes and, only while a linked machine is +//! missing or can't hear this one, every [`HEARTBEAT`] so the others know +//! this machine is still running. Nothing goes out while everything works. + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Always this when written by this build. Newer fields are added with +/// defaults, so any version reads. +pub const REPORT_VERSION: u32 = 1; +/// While something is wrong, a report goes out at least this often. +pub const HEARTBEAT: Duration = Duration::from_secs(5 * 60); +/// …and this often while a machine here can't hear this one, so the other +/// side gets a report late enough to tell (a firewall here, say) in about +/// a minute rather than five. +pub const HEARTBEAT_SOON: Duration = Duration::from_secs(60); +/// A changed report waits this long after the last one, so a flapping +/// network doesn't keep writing to the relay. +pub const MIN_GAP: Duration = Duration::from_secs(30); +/// A report older than this says nothing about now: the machine is asleep, +/// Patch Pilot is closed, or it's offline. Seconds; over two heartbeats. +/// Measured on this machine's clock from when the report reached the relay, +/// so the other machine's clock never matters. +pub const FRESH_SECS: i64 = 12 * 60; +/// A subnet wider than this says nothing about which network it is. +const MIN_PREFIX: u8 = 8; +/// Biggest object this reads; a report is a few hundred bytes. +const MAX_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NetReport { + #[serde(default)] + pub version: u32, + /// Unix seconds UTC, on the sending machine's clock. + pub at: i64, + /// Home network sync is picked there. + pub on: bool, + /// It could open its network port. Only meaningful while `on`. + #[serde(default = "yes", skip_serializing_if = "is_true")] + pub listening: bool, + /// Since when it has listened (Unix seconds, its own clock): a machine + /// that only just started can't have heard anyone yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listening_since: Option, + /// Its home-network subnets (Wi-Fi, Ethernet): network address and + /// prefix only. Empty when it hasn't looked. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subnets: Vec, + /// Its broadcasts leave through a VPN. None when it hasn't looked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vpn: Option, + /// Its broadcasts leave into a virtual network inside it (WSL, Hyper-V, + /// Docker), by adapter name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub virtual_adapter: Option, + /// Its last broadcast failed to go out (macOS Local Network off, say): + /// nothing it sends reaches anyone, whatever this machine does. + #[serde(default, skip_serializing_if = "is_false")] + pub send_error: bool, + /// Its internet traffic leaves through a VPN, so `public` is the VPN's, + /// not its home router's. None when it hasn't looked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub internet_vpn: Option, + /// Linked machines it hears on its network now, by id. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hears: Vec, + /// Its public address as the relay saw it ([`public_tag`]): two + /// machines with the same home subnet are on the same network only if + /// this matches too, since most routers hand out the same few ranges. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public: Option, +} + +impl Default for NetReport { + fn default() -> Self { + Self { + version: 0, + at: 0, + on: false, + listening: true, + listening_since: None, + subnets: Vec::new(), + vpn: None, + virtual_adapter: None, + send_error: false, + internet_vpn: None, + hears: Vec::new(), + public: None, + } + } +} + +fn yes() -> bool { + true +} + +fn is_true(b: &bool) -> bool { + *b +} + +fn is_false(b: &bool) -> bool { + !*b +} + +impl NetReport { + /// A machine that doesn't use the home network. + pub fn off(at: i64) -> Self { + Self { + version: REPORT_VERSION, + at, + ..Self::default() + } + } + + /// Says the same as `other`, whenever each was made. + pub fn same_news(&self, other: &Self) -> bool { + Self { + at: 0, + ..self.clone() + } == Self { + at: 0, + ..other.clone() + } + } + + /// It had listened for `secs` when it made this report, by its own + /// clock. False when it doesn't say. + pub fn listened_for(&self, secs: i64) -> bool { + self.listening_since + .is_some_and(|since| self.at - since >= secs) + } + + pub fn to_bytes(&self) -> Vec { + serde_json::to_vec(self).expect("a report serializes") + } + + /// None for anything that isn't a report. + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() > MAX_BYTES { + return None; + } + serde_json::from_slice(bytes).ok() + } +} + +/// Another machine's report, with when it reached the relay on this +/// machine's clock. None when that's unknown: fetched from an older relay +/// that doesn't say how long it held it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Received { + pub report: NetReport, + pub at: Option, +} + +impl Received { + /// Known to be recent enough to say something about now. + pub fn is_fresh(&self, now: i64) -> bool { + self.at.is_some_and(|at| now - at.min(now) <= FRESH_SECS) + } +} + +/// When a report reached the relay, on this machine's clock. `live`: it +/// just arrived as a change, so now. Otherwise now less how long the relay +/// says it held it, or unknown when it doesn't say. +pub fn received_at(now: i64, held_ms: Option, live: bool) -> Option { + match held_ms { + _ if live => Some(now), + Some(ms) => Some(now - ms.max(0) / 1000), + None => None, + } +} + +/// How a public address is compared: an IPv4 address whole (one per home +/// router), an IPv6 one by its /64 (one per home network). +pub fn public_tag(ip: IpAddr) -> String { + match ip { + IpAddr::V4(v4) => format!("v4:{v4}"), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => format!("v4:{v4}"), + None => { + let s = v6.segments(); + format!("v6:{:x}:{:x}:{:x}:{:x}::/64", s[0], s[1], s[2], s[3]) + } + }, + } +} + +/// Whether two [`public_tag`]s are the same place. None when they can't be +/// compared: one machine reached the relay over IPv4, the other over IPv6. +pub fn same_public(a: &str, b: &str) -> Option { + (a.get(..3)? == b.get(..3)?).then(|| a == b) +} + +/// An IPv4 network: its address with the host bits cleared, and the prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Subnet { + pub net: Ipv4Addr, + pub prefix: u8, +} + +impl Subnet { + /// The network `ip` is on. None for a prefix over 32, or one so wide + /// (under /8) it would match nearly anything. + pub fn of(ip: Ipv4Addr, prefix: u8) -> Option { + (MIN_PREFIX..=32).contains(&prefix).then(|| Self { + net: mask(ip, prefix), + prefix, + }) + } + + /// One holds the other: the same network, even when two machines were + /// given different prefixes for it. + pub fn overlaps(&self, other: &Self) -> bool { + let p = self.prefix.min(other.prefix).min(32); + mask(self.net, p) == mask(other.net, p) + } +} + +fn mask(ip: Ipv4Addr, prefix: u8) -> Ipv4Addr { + let bits = match prefix { + 0 => 0, + p => u32::MAX << (32 - u32::from(p.min(32))), + }; + Ipv4Addr::from(u32::from(ip) & bits) +} + +/// True when two machines' subnet lists share a network. +pub fn share_a_network(a: &[Subnet], b: &[Subnet]) -> bool { + a.iter().any(|x| b.iter().any(|y| x.overlaps(y))) +} + +/// Whether to send `new` now. `last`: the report last sent and how long ago. +/// `heartbeat`: how often the others should hear this machine is still +/// running, while something is wrong ([`HEARTBEAT`], [`HEARTBEAT_SOON`]). +pub fn should_send( + new: &NetReport, + last: Option<(&NetReport, Duration)>, + heartbeat: Option, +) -> bool { + match last { + None => true, + Some((sent, ago)) => { + (!new.same_news(sent) && ago >= MIN_GAP) + || (new.on && heartbeat.is_some_and(|every| ago >= every)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn net(a: u8, b: u8, c: u8, d: u8, prefix: u8) -> Subnet { + Subnet::of(Ipv4Addr::new(a, b, c, d), prefix).unwrap() + } + + fn on() -> NetReport { + NetReport { + version: REPORT_VERSION, + at: 1_000, + on: true, + listening_since: Some(400), + subnets: vec![net(192, 168, 1, 20, 24)], + vpn: Some(false), + internet_vpn: Some(false), + hears: vec!["desktop".into()], + public: Some("v4:203.0.113.5".into()), + ..NetReport::default() + } + } + + #[test] + fn a_subnet_keeps_only_the_network_part() { + let s = net(192, 168, 1, 20, 24); + assert_eq!(s.net, Ipv4Addr::new(192, 168, 1, 0)); + assert_eq!(net(10, 1, 2, 3, 8).net, Ipv4Addr::new(10, 0, 0, 0)); + assert_eq!(net(10, 1, 2, 3, 32).net, Ipv4Addr::new(10, 1, 2, 3)); + assert_eq!( + Subnet::of(Ipv4Addr::new(10, 1, 2, 3), 0), + None, + "matches anything" + ); + assert_eq!(Subnet::of(Ipv4Addr::new(10, 1, 2, 3), 7), None); + assert_eq!(Subnet::of(Ipv4Addr::LOCALHOST, 33), None); + } + + #[test] + fn networks_match_even_with_different_prefixes() { + let home = net(192, 168, 1, 20, 24); + assert!(home.overlaps(&net(192, 168, 1, 77, 24))); + assert!( + home.overlaps(&net(192, 168, 0, 5, 16)), + "a /16 holds the /24" + ); + assert!(!home.overlaps(&net(192, 168, 2, 5, 24))); + assert!(!home.overlaps(&net(10, 0, 0, 5, 8))); + assert!(share_a_network( + &[net(10, 0, 0, 2, 24), home], + &[net(192, 168, 1, 9, 24)] + )); + assert!(!share_a_network(&[home], &[])); + } + + #[test] + fn public_addresses_compare_within_one_family() { + let a = public_tag("203.0.113.5".parse().unwrap()); + assert_eq!(a, "v4:203.0.113.5"); + assert_eq!(public_tag("::ffff:203.0.113.5".parse().unwrap()), a); + let v6 = public_tag("2001:db8:1:2:aaaa::1".parse().unwrap()); + assert_eq!(v6, "v6:2001:db8:1:2::/64"); + assert_eq!( + public_tag("2001:db8:1:2:bbbb::9".parse().unwrap()), + v6, + "the same home network's /64" + ); + assert_eq!(same_public(&a, "v4:203.0.113.5"), Some(true)); + assert_eq!(same_public(&a, "v4:198.51.100.7"), Some(false)); + assert_eq!(same_public(&a, &v6), None, "IPv4 here, IPv6 there"); + assert_eq!(same_public("", &a), None); + } + + #[test] + fn a_report_round_trips_and_reads_older_and_newer_ones() { + let r = on(); + assert_eq!(NetReport::from_bytes(&r.to_bytes()), Some(r.clone())); + let json = String::from_utf8(r.to_bytes()).unwrap(); + assert!(json.contains(r#""net":"192.168.1.0""#), "{json}"); + assert!( + !json.contains("listening\""), + "listening is the default: {json}" + ); + let deaf_port = NetReport { + listening: false, + ..on() + }; + assert_eq!( + NetReport::from_bytes(&deaf_port.to_bytes()).map(|r| r.listening), + Some(false) + ); + // A newer build's extra field, and a bare one. + let newer = br#"{"version":2,"at":5,"on":true,"wifi":"Home"}"#; + assert_eq!(NetReport::from_bytes(newer).map(|r| r.on), Some(true)); + let bare = NetReport::from_bytes(br#"{"at":5,"on":false}"#).unwrap(); + assert_eq!( + bare, + NetReport { + at: 5, + ..NetReport::default() + } + ); + assert!(bare.listening); + assert_eq!(NetReport::from_bytes(b"not json"), None); + assert_eq!(NetReport::from_bytes(&vec![b' '; MAX_BYTES + 1]), None); + // Off says nothing else. + let off = String::from_utf8(NetReport::off(5).to_bytes()).unwrap(); + assert_eq!(off, r#"{"version":1,"at":5,"on":false}"#); + } + + #[test] + fn fresh_means_recently_on_the_relay_by_this_machines_clock() { + let r = Received { + report: on(), + at: Some(10_000), + }; + assert!(r.is_fresh(10_000)); + assert!(r.is_fresh(10_000 + FRESH_SECS)); + assert!(!r.is_fresh(10_000 + FRESH_SECS + 1)); + assert!(r.is_fresh(10_000 - 60)); + let unknown = Received { at: None, ..r }; + assert!(!unknown.is_fresh(10_000), "an unknown age is never fresh"); + assert_eq!(received_at(10_000, Some(90_500), false), Some(10_000 - 90)); + assert_eq!(received_at(10_000, Some(-5), false), Some(10_000)); + assert_eq!(received_at(10_000, None, false), None, "an older relay"); + assert_eq!( + received_at(10_000, None, true), + Some(10_000), + "just arrived" + ); + } + + #[test] + fn listened_for_counts_on_the_senders_own_clock() { + let r = on(); + assert!(r.listened_for(600)); + assert!(!r.listened_for(601)); + assert!( + !NetReport { + listening_since: None, + ..on() + } + .listened_for(0) + ); + } + + #[test] + fn sends_on_change_with_a_gap_and_heartbeats_only_while_something_is_wrong() { + let sent = on(); + let later = NetReport { at: 2_000, ..on() }; + assert!(should_send(&later, None, None), "the first one"); + assert!( + !should_send(&later, Some((&sent, Duration::from_secs(3600))), None), + "nothing new, nothing wrong" + ); + let moved = NetReport { + subnets: vec![net(10, 0, 0, 2, 24)], + ..later.clone() + }; + assert!( + !should_send(&moved, Some((&sent, MIN_GAP / 2)), None), + "too soon" + ); + assert!(should_send(&moved, Some((&sent, MIN_GAP)), None)); + assert!(!should_send( + &later, + Some((&sent, HEARTBEAT / 2)), + Some(HEARTBEAT) + )); + assert!(should_send( + &later, + Some((&sent, HEARTBEAT)), + Some(HEARTBEAT) + )); + let off = NetReport::off(2_000); + assert!( + !should_send(&off, Some((&NetReport::off(1), HEARTBEAT)), Some(HEARTBEAT)), + "off needs no heartbeat" + ); + // While a machine here can't hear this one, sooner. + assert!(should_send( + &later, + Some((&sent, HEARTBEAT_SOON)), + Some(HEARTBEAT_SOON) + )); + assert!(!should_send( + &later, + Some((&sent, HEARTBEAT_SOON / 2)), + Some(HEARTBEAT_SOON) + )); + } +} diff --git a/crates/local/src/sync.rs b/crates/local/src/sync.rs index ee9dece..680b026 100644 --- a/crates/local/src/sync.rs +++ b/crates/local/src/sync.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use patchpilot_store::{ImportReport, Store}; -use patchpilot_sync::{RigKey, machine_object}; +use patchpilot_sync::{RigKey, machine_object, net_object}; + +use crate::net_report::NetReport; use crate::now; @@ -100,6 +102,32 @@ pub fn is_other_machine(object: &str, this: &str) -> bool { object.starts_with("machine/") && Some(object) != machine_object(this).ok().as_deref() } +/// This machine's home-network report for the relay, sealed (decision #124). +pub fn outgoing_report( + this: &str, + rig: &RigKey, + report: &NetReport, +) -> Result<(String, Vec), String> { + let object = net_object(this).map_err(|e| e.to_string())?; + let sealed = rig + .seal_object(&object, &report.to_bytes()) + .map_err(|e| e.to_string())?; + Ok((object, sealed)) +} + +/// The machine id when `object` is another machine's home-network report. +pub fn other_report<'a>(object: &'a str, this: &str) -> Option<&'a str> { + object + .strip_prefix("net/") + .filter(|id| *id != this && net_object(id).is_ok()) +} + +/// Open another machine's home-network report. None when it won't open or +/// isn't one. +pub fn incoming_report(rig: &RigKey, object: &str, sealed: &[u8]) -> Option { + NetReport::from_bytes(&rig.open_object(object, sealed).ok()?) +} + /// Open another machine's object and add what's new, with the same add-only /// rules as an export file. pub fn incoming( @@ -205,4 +233,19 @@ mod tests { .is_err() ); } + + #[test] + fn reports_seal_for_linked_machines_only() { + let rig = RigKey::generate(); + let report = NetReport::off(5); + let (object, sealed) = outgoing_report("pc", &rig, &report).unwrap(); + assert_eq!(object, "net/pc"); + assert_eq!(incoming_report(&rig, &object, &sealed), Some(report)); + assert_eq!(incoming_report(&RigKey::generate(), &object, &sealed), None); + assert_eq!(incoming_report(&rig, "net/mac", &sealed), None); + assert_eq!(other_report("net/mac", "pc"), Some("mac")); + assert_eq!(other_report("net/pc", "pc"), None, "this machine's own"); + assert_eq!(other_report("machine/mac", "pc"), None); + assert_eq!(other_report("net/Mac", "pc"), None); + } } diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs index 6363e40..a4c519f 100644 --- a/crates/sync/src/lib.rs +++ b/crates/sync/src/lib.rs @@ -16,7 +16,7 @@ pub mod link; mod rig; pub use code::LinkCode; -pub use rig::{RigKey, content_hash, machine_object}; +pub use rig::{RigKey, content_hash, machine_object, net_object}; #[derive(Debug, thiserror::Error)] pub enum Error { diff --git a/crates/sync/src/rig.rs b/crates/sync/src/rig.rs index c2ba5c4..4cd50ab 100644 --- a/crates/sync/src/rig.rs +++ b/crates/sync/src/rig.rs @@ -134,6 +134,24 @@ impl RigKey { Ok(bytes) } + /// Encrypt a small object for storing under `object` on the relay, such + /// as a machine's home-network report ([`net_object`]). Bound to its + /// name, so it opens under no other. + pub fn seal_object(&self, object: &str, bytes: &[u8]) -> Result> { + check_object(object)?; + Ok(envelope::seal( + &self.encryption_key(), + &self.aad(object), + bytes, + )) + } + + /// Decrypt what [`Self::seal_object`] sealed under `object`. + pub fn open_object(&self, object: &str, sealed: &[u8]) -> Result> { + check_object(object)?; + envelope::open(&self.encryption_key(), &self.aad(object), sealed) + } + /// Encrypt a message between linked machines on the home network /// (decision #94). `label` says what it is (a beacon, a request, the /// answer to one request), so a message replayed as something else fails @@ -197,6 +215,14 @@ pub fn machine_object(machine_id: &str) -> Result { Ok(name) } +/// The relay object holding what a machine says about its home network +/// (decision #124): `net/`. +pub fn net_object(machine_id: &str) -> Result { + let name = format!("net/{machine_id}"); + check_object(&name)?; + Ok(name) +} + /// Lowercase letters, digits, `-` and `/`-separated segments only, so object /// names are safe as relay keys and URL paths on every platform. fn check_object(name: &str) -> Result<()> { @@ -244,6 +270,20 @@ mod tests { b } + #[test] + fn small_objects_open_only_under_their_own_name_and_rig() { + let rig = RigKey::generate(); + let name = net_object("a1").unwrap(); + assert_eq!(name, "net/a1"); + let sealed = rig.seal_object(&name, b"{}").unwrap(); + assert_eq!(rig.open_object(&name, &sealed).unwrap(), b"{}"); + assert!(rig.open_object("net/b2", &sealed).is_err()); + assert!(rig.open_object("machine/a1", &sealed).is_err()); + assert!(RigKey::generate().open_object(&name, &sealed).is_err()); + assert!(net_object("A1").is_err()); + assert!(rig.seal_object("net/../x", b"").is_err()); + } + #[test] fn backup_signatures_check_only_for_the_same_rig_and_bytes() { let rig = RigKey::generate(); diff --git a/docs/decisions.md b/docs/decisions.md index 96393be..8aa76a4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,3 +128,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | | 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | +| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts leave through a VPN or into a virtual adapter (named), whether its last broadcast failed, whether its internet traffic leaves through a VPN, which linked machines it hears now (ids), and its public address as the relay saw it (forgotten at the start of every relay session and when its subnets change, and the report is sent again at once when an upload reveals a new one). Network facts are only included while they were looked at in the last 90 s by the wall clock (they're looked at only while something is wrong, Windows adapter descriptions at most every 10 min, or every minute while Windows can't say; an adapter Windows gave no description for counts as unknown, not the home network), so a report never carries facts from an earlier problem or from before a Mac slept. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine that might still turn up is missing (not one whose report says Home network is off) or one here can't hear this one, every 5 min (every minute while one here can't hear this one, so the other side gets a report late enough to tell) so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is, and is the only reason named when the report's age is unknown); no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline; "last checked in" counts from the later of the report and when it was last heard). Every other reason needs a fresh report that reached the relay at least 20 s after this machine last heard that machine (an earlier one was made while it was still here, before it slept, quit or moved), and this machine online: picked there but its port won't open; a VPN is on there; its messages go into a virtual adapter there; a VPN carries its internet traffic and it's on this range but hears nothing (a guess: many VPNs block local traffic); it's on a different network (no subnet in common); it hears this machine but nothing it sends arrives (a firewall here; only when both sides' sends are known fine: no VPN for broadcasts or internet on either side, no failed broadcast either side, and the report made at least 55 s after both this machine last heard it and this run started, since its list keeps a machine 45 s); a different network: the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray or failing, the report made at least 20 s after this run started, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, in its own field: it says nothing about why another machine is missing. A machine heard here whose fresh report doesn't list this one yet makes this machine look at its network straight away, so its report says how its sends go. On the line and in the details alike, a guess here (a VPN carrying internet traffic, another rig nearby) gives way to any missing machine's own reason, and any cause here gives way when every missing machine has a reason on its own side (off, can't listen, asleep, its VPN or virtual adapter, another network): a laptop at a café is told the desktop is on another network, not to make the café's Wi-Fi Private. A sure cause that gave way stays in the details as "also worth checking", without marking the checklist. The diagnosis also ignores this machine's own network facts older than 90 s, and the live connection counts as offline once a keep-alive ping goes unanswered until the next one (50 s), so reports aren't trusted over a connection that died silently. While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s; PowerShell at most every 10 min per adapter), so three-machine rigs get the same reasons. Advice about this machine's own network (Windows calls it Public, a VPN carries its internet traffic) is left out while a missing machine reports from a network this machine can't confirm is its own (subnet and public address both matching), so a laptop at a café is never told to make the café's Wi-Fi Private. A round of the beacon loop that comes over 30 s late means the machine slept: the reports read before are dropped and the relay session starts over, so nothing from before sleep is judged as current. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | diff --git a/relay/src/index.ts b/relay/src/index.ts index 45cf5ae..165eb98 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -12,6 +12,12 @@ // // Rig requests carry `Authorization: Bearer `. The first PUT to a // rig claims it; the relay keeps only a SHA-256 of the token. +// +// A PUT answers with the address it came from (`ip`) and a GET says how long +// the relay has held the object (`x-age`, ms): a machine compares its public +// address with its linked machines' and judges how old their reports are on +// its own clock (decision #124). The relay sees the address anyway; it keeps +// nothing more. import { DurableObject } from "cloudflare:workers"; @@ -156,7 +162,8 @@ export class Rig extends DurableObject { updated, ); this.broadcast({ type: "changed", name, updated }); - return Response.json({ name, updated }); + const ip = request.headers.get("CF-Connecting-IP") ?? undefined; + return Response.json({ name, updated, ip }); } case "GET": { if (auth !== "ok") return text(401, "unauthorized"); @@ -171,6 +178,7 @@ export class Rig extends DurableObject { headers: { "content-type": "application/octet-stream", "x-updated": String(row.updated), + "x-age": String(Math.max(0, Date.now() - row.updated)), }, }); } diff --git a/relay/test/relay.test.ts b/relay/test/relay.test.ts index 12f3f3e..49dd305 100644 --- a/relay/test/relay.test.ts +++ b/relay/test/relay.test.ts @@ -46,6 +46,7 @@ describe("rig objects", () => { expect(ok.status).toBe(200); expect(new TextDecoder().decode(await ok.arrayBuffer())).toBe("sealed-1"); expect(Number(ok.headers.get("x-updated"))).toBeGreaterThan(0); + expect(Number(ok.headers.get("x-age"))).toBeGreaterThanOrEqual(0); const nope = await SELF.fetch(`https://relay.test/v1/rigs/${rig}/objects/machine/pc`, { headers: auth(OTHER), @@ -53,6 +54,19 @@ describe("rig objects", () => { expect(nope.status).toBe(401); }); + it("a PUT says which address it came from", async () => { + const rig = "5".repeat(32); + const res = await SELF.fetch(`https://relay.test/v1/rigs/${rig}/objects/net/pc`, { + method: "PUT", + headers: { ...auth(), "CF-Connecting-IP": "203.0.113.5" }, + body: "sealed", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { name: string; ip?: string }; + expect(body.name).toBe("net/pc"); + expect(body.ip).toBe("203.0.113.5"); + }); + it("an unclaimed rig reveals nothing", async () => { const res = await SELF.fetch(`https://relay.test/v1/rigs/${"2".repeat(32)}/objects`, { headers: auth(),