diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 220d2a3..57f330d 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -8,8 +8,9 @@ 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. +- 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 is blocking local network traffic, 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 on that may block local traffic, 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. +- Home network sync now works with a VPN on, as long as the VPN allows local network traffic: Patch Pilot also sends straight onto your Wi-Fi or wired network instead of only where the VPN routes it. The same goes for virtual network adapters from WSL or Docker. ## 0.4.58 - 2026-11-06 diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index f60ccf3..65aea5b 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -16,6 +16,13 @@ //! while a machine is missing, so an idle machine with everyone found does //! no extra work. //! +//! Each beacon goes to 255.255.255.255 and also straight onto every Wi-Fi +//! and Ethernet subnet (192.168.1.255, decision #125): on Windows the first +//! follows the lowest-metric route, often a VPN's, while each directed one +//! follows its subnet's own route out of that adapter. So linked machines on +//! the same Wi-Fi find each other with a VPN on, unless it blocks local +//! traffic. +//! //! 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 @@ -30,7 +37,9 @@ use std::time::{Duration, Instant}; 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::lan_diagnosis::{ + self, Diagnosis, Egress, Expected, Facts, Iface, Profile, Seen, +}; use patchpilot_local::net_report::{self, NetReport, Subnet}; use patchpilot_local::netinfo::{self, AdapterInfo}; use serde::Serialize; @@ -54,6 +63,9 @@ const NET_EVERY: Duration = Duration::from_secs(30); 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); +/// Look again at which subnets to send beacons straight onto this often: +/// one interface list (no command), so a new Wi-Fi is used within a minute. +const DIRECT_EVERY: 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); @@ -109,11 +121,38 @@ struct LanState { net: Mutex, /// The diagnosis last told to the UI. diagnosis: Mutex>, + /// The subnets' directed broadcast addresses beacons also go to, when + /// they were worked out (Unix seconds), and the internet route's source + /// then: a new one means another network, so they're worked out again. + direct: Mutex>, + /// Held while asking Windows to describe adapters for direct sends. + describing: 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>, } +/// Where beacons go straight onto the home subnets (decision #125). +struct Direct { + /// When it was worked out (Unix seconds). + at: i64, + /// The internet route's source then. + route: Option, + /// The subnets' directed broadcast addresses. + list: Vec, + /// Worked out again on the next beacon (the network was just looked at, + /// or Home network picked again). Until then the list still stands, so + /// the diagnosis and the report don't think direct sends stopped. + stale: bool, +} + +/// Work out the direct list again on the next beacon, keeping it till then. +fn mark_direct_stale(state: &LanState) { + if let Some(d) = state.direct.lock().unwrap().as_mut() { + d.stale = true; + } +} + /// This machine's network as last looked at, while a machine was missing. #[derive(Default, Clone)] struct NetCache { @@ -337,6 +376,11 @@ fn announce(app: &AppHandle, to: Option) { state: lan::state(&lan::mirror_dir(&dir)), }; let bytes = lan::beacon_bytes(&rig, &beacon); + let direct: Vec = if to.is_none() { + direct_targets(&state).into_iter().map(IpAddr::V4).collect() + } else { + Vec::new() + }; let mut targets: Vec = match to { Some(ip) => vec![ip], None => { @@ -349,21 +393,122 @@ fn announce(app: &AppHandle, to: Option) { .map(|p| p.addr.ip()) .collect(); t.push(IpAddr::V4(Ipv4Addr::BROADCAST)); + // Straight onto each home subnet too, past a VPN's route. + t.extend(direct.iter().copied()); t } }; targets.sort(); targets.dedup(); let broadcast = IpAddr::V4(Ipv4Addr::BROADCAST); + let mut broadcast_error = None; + let (mut direct_sent, mut direct_error) = (false, None); for ip in targets { let sent = udp.send_to(&bytes, (ip, lan::PORT)); - // Only the broadcast says something about this machine's network: a + let code = sent.as_ref().err().map(|e| e.raw_os_error().unwrap_or(-1)); + // Only broadcasts say 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().map(|e| e.raw_os_error().unwrap_or(-1)); + broadcast_error = code; + } else if direct.contains(&ip) { + direct_sent |= code.is_none(); + direct_error = direct_error.or(code); } } + if to.is_none() { + // The direct sends are the ones that reach the home network when a + // VPN holds the broadcast route: all of them failing (macOS Local + // Network off, say) is a failed broadcast too. + *state.broadcast_error.lock().unwrap() = if direct_sent { + None + } else { + broadcast_error.or(direct_error) + }; + } +} + +/// The directed broadcast address of each Wi-Fi and Ethernet subnet, worked +/// out at most every minute. What Windows said about each adapter (from +/// looking at the network) keeps a VPN called "Ethernet 4" out. +fn direct_targets(state: &LanState) -> Vec { + // Which way internet traffic leaves: asks the OS, sends nothing. + let route = netinfo::route_source(); + if let Some(d) = state.direct.lock().unwrap().as_ref() + && !d.stale + && net_age_within(d.at, DIRECT_EVERY) + && d.route == route + { + return d.list.clone(); + } + let mut ifaces = netinfo::interfaces(); + describe(state, &mut ifaces, route); + // Many VPNs call their adapter "Ethernet 3" and say what they are only + // in the description: on Windows one it couldn't describe stays out, so + // no beacon goes into a work VPN. + if cfg!(windows) { + ifaces.retain(|i| i.description.is_some()); + } + let list = lan_diagnosis::direct_broadcasts(&ifaces, route); + *state.direct.lock().unwrap() = Some(Direct { + at: patchpilot_local::now(), + route, + list: list.clone(), + stale: false, + }); + list +} + +/// Fill in what Windows says each home-looking adapter is, from the cache +/// or, for one not asked about yet, by asking (PowerShell, about a second, +/// at background priority). A description doesn't change, so each adapter +/// is asked about once per launch, or again a minute after Windows couldn't +/// say. Nothing to do on other systems. +fn describe(state: &LanState, ifaces: &mut [Iface], route: Option) { + if !cfg!(windows) { + return; + } + let names: Vec = lan_diagnosis::home_adapters(ifaces, route) + .into_iter() + .map(str::to_owned) + .collect(); + // One at a time, so two threads sending beacons don't both ask. + let _one = state.describing.lock().unwrap(); + for name in names { + let cached = state + .net + .lock() + .unwrap() + .adapters + .get(&name) + // Couldn't say: ask again only every 10 minutes here, so a + // blocked PowerShell doesn't run every minute all day. + .filter(|(at, info)| info.is_some() || at.elapsed() < ADAPTER_EVERY) + .map(|(_, info)| info.clone()); + let info = cached.unwrap_or_else(|| { + let _quiet = patchpilot_local::quiet::background(); + let info = netinfo::adapter_info(&name); + state + .net + .lock() + .unwrap() + .adapters + .insert(name.clone(), (Instant::now(), info.clone())); + info + }); + if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { + i.description = info.and_then(|a| a.description); + } + } +} + +/// Beacons go straight onto at least one home subnet. +fn sends_direct(state: &LanState) -> bool { + state + .direct + .lock() + .unwrap() + .as_ref() + .is_some_and(|d| !d.list.is_empty()) } /// Send a beacon now, so linked machines nearby pull a new backup soon. @@ -690,10 +835,51 @@ fn look_at_network(app: &AppHandle) { // The profile only matters for the home network itself. net.profile = match net.broadcast_egress { Some(Egress::Lan) => broadcast_info.and_then(|a| a.profile), + // Direct sends go out of the home adapter: its profile matters. + // Direct sends go out of every home adapter: Public only when + // Windows says so of all of them (a Dante NIC on an "Unidentified + // network" isn't the Wi-Fi the beacons use). + _ if sends_direct(&state) => { + let home: Vec = lan_diagnosis::home_adapters(&ifaces, route) + .into_iter() + .map(str::to_owned) + .collect(); + let profiles: Vec> = home + .into_iter() + .map(|name| { + let fresh = net + .adapters + .get(&name) + .filter(|(at, _)| at.elapsed() < ADAPTER_EVERY) + .map(|(_, info)| info.clone()); + fresh + .unwrap_or_else(|| { + let info = netinfo::adapter_info(&name); + net.adapters.insert(name, (Instant::now(), info.clone())); + info + }) + .and_then(|a| a.profile) + }) + .collect(); + match profiles.first() { + Some(first) if profiles.iter().all(|p| p == first) => *first, + _ => None, + } + } _ => None, }; net.checked_at = Some(patchpilot_local::now()); - *state.net.lock().unwrap() = net; + { + // Keep what another thread learned about adapters meanwhile. + let mut shared = state.net.lock().unwrap(); + let mut adapters = std::mem::take(&mut shared.adapters); + adapters.extend(std::mem::take(&mut net.adapters)); + net.adapters = adapters; + *shared = net; + } + // What Windows just said about the adapters may change where direct + // sends go: work them out again on the next beacon. + mark_direct_stale(&state); } /// What [`lan_diagnosis::diagnose`] goes on, from what's already known: @@ -745,6 +931,7 @@ fn facts(app: &AppHandle) -> Facts { subnets: net.subnets, public: crate::sync::public(app), reports: crate::sync::reports(app), + direct_sends: sends_direct(&state), } } @@ -821,6 +1008,7 @@ pub(crate) fn own_report(app: &AppHandle) -> NetReport { }, virtual_adapter, send_error, + direct: sends_direct(&state), internet_vpn: if recent { through_vpn(&net.route_egress) } else { @@ -949,6 +1137,7 @@ pub async fn lan_use(app: AppHandle) -> Result<(), String> { adapters: std::mem::take(&mut net.adapters), ..NetCache::default() }; + mark_direct_stale(&state); } start(&app); let _ = app.emit(BACKUP_CHANGED, ()); diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index 9632f04..52cfd17 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -314,6 +314,8 @@ function lanCauseText(c: LanCause): string { return `a VPN${c.adapter ? ` (${c.adapter})` : ""} is on, so Patch Pilot's messages go through it instead of your home network. Turn it off, or let it allow local network access.`; case "virtual_adapter": return `Patch Pilot's messages go to a virtual network on this machine (${c.name}) instead of your home network.`; + case "vpn_blocks": + return `a VPN${c.adapter ? ` (${c.adapter})` : ""} is on. Patch Pilot also sends straight onto your home network, so the VPN is most likely blocking local traffic. Turn it off, or let it allow local network access.`; case "local_network_denied": return "macOS isn't letting Patch Pilot use the local network. Turn it on in System Settings › Privacy & Security › Local Network."; case "public_network": @@ -325,9 +327,9 @@ function lanCauseText(c: LanCause): string { } } -/** A guess on this machine: a VPN carrying only internet traffic, or another rig nearby. */ +/** A guess on this machine: a VPN that may block local traffic, or another rig nearby. */ function isGuess(c: LanCause | undefined): boolean { - return c?.kind === "vpn_on" || c?.kind === "other_rig"; + return c?.kind === "vpn_on" || c?.kind === "vpn_blocks" || c?.kind === "other_rig"; } /** Reasons that hold whatever this machine's own network does. */ @@ -423,7 +425,11 @@ function LanMachines({ lan }: { lan: LanView }) { ["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"), + cause === "vpn" || + cause === "vpn_on" || + cause === "vpn_blocks" || + whys.has("their_vpn") || + whys.has("their_vpn_on"), ], ["On Windows, the network is set to Private (Settings › Network & internet).", cause === "public_network"], [ diff --git a/app/src/api.ts b/app/src/api.ts index 26d1f3b..159c4f0 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -579,6 +579,7 @@ export type LanCause = | { kind: "no_lan" } | { kind: "vpn"; adapter?: string } | { kind: "virtual_adapter"; name: string } + | { kind: "vpn_blocks"; adapter?: string } | { kind: "local_network_denied" } | { kind: "public_network" } | { kind: "other_rig" } diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index 1693b3d..d02b255 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -154,6 +154,10 @@ pub struct Facts { pub public: Option, /// The other machines' latest reports, by id (decision #124). pub reports: BTreeMap, + /// This machine also sends its beacons straight onto each home-network + /// subnet (decision #125): a VPN or virtual adapter on the broadcast + /// route no longer takes them all. + pub direct_sends: bool, } /// What the Sync & backup line says about the home network. Mirrored in @@ -239,6 +243,12 @@ pub enum Cause { }, /// Broadcasts leave into a virtual network inside this machine. VirtualAdapter { name: String }, + /// A VPN is on and this machine also sent straight onto the home + /// network, yet nobody heard: the VPN most likely blocks local traffic. + VpnBlocks { + #[serde(skip_serializing_if = "Option::is_none")] + adapter: Option, + }, /// macOS refused to send on the local network: almost surely Local /// Network access is off for Patch Pilot. LocalNetworkDenied, @@ -319,8 +329,15 @@ pub fn diagnose(f: &Facts) -> Diagnosis { seen.is_none_or(|s| !s.here) && current(f, &e.id, seen).is_some_and(|(rx, _)| !same_network(f, &rx.report)) }); + // A missing machine whose current report says it hears this one: this + // machine's sends get out. + let heard_by_missing = 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, _)| rx.report.hears.contains(&f.this_id)) + }); let cause = if !missing.is_empty() && !looking { - cause(f, !here.is_empty(), elsewhere) + cause(f, !here.is_empty(), elsewhere, heard_by_missing) } else { None }; @@ -382,23 +399,37 @@ fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { 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() }); + // Without direct sends there, its VPN or virtual adapter takes all its + // beacons. With them (decision #125) its VPN is only a likely reason (it + // may block local traffic), named after "another network", and a + // virtual adapter none. + if !r.direct { + 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); } + // Unless it hears this machine: then local traffic gets through its VPN. + if r.direct && r.vpn == Some(true) && !r.hears.contains(&f.this_id) { + return Some(Why::TheirVpnOn); + } // 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)) + && match f.broadcast_egress { + Some(Egress::Lan) => true, + Some(Egress::Virtual { .. }) => f.direct_sends, + _ => false, + } && matches!(f.route_egress, Some(Egress::Lan)) && f.broadcast_error.is_none(); let listening_from = f.now - f.listening_for.as_secs() as i64; @@ -449,6 +480,24 @@ fn deaf_cause(f: &Facts) -> Option { if f.other_version { return Some(Cause::OtherVersion); } + // With direct sends, a machine heard here shows local traffic gets + // through whatever holds the broadcast route. + if f.direct_sends { + return None; + } + egress_cause(f) +} + +/// What the broadcast route says. Without direct sends, a VPN or virtual +/// adapter there takes every beacon. With them (decision #125) a virtual +/// adapter takes nothing that matters, and a VPN is only the likely reason: +/// the beacons went straight onto the home network too, so it most likely +/// blocks local traffic. +fn egress_cause(f: &Facts) -> Option { + if f.direct_sends { + // Only a guess now: ranked with the other guesses in `cause`. + return None; + } match &f.broadcast_egress { Some(Egress::Vpn { adapter }) => Some(Cause::Vpn { adapter: adapter.clone(), @@ -463,7 +512,7 @@ fn deaf_cause(f: &Facts) -> 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 { +fn cause(f: &Facts, someone_here: bool, elsewhere: bool, heard_by_missing: bool) -> Option { if f.other_version { return Some(Cause::OtherVersion); } @@ -471,16 +520,8 @@ fn cause(f: &Facts, someone_here: bool, elsewhere: bool) -> Option { if f.has_lan == Some(false) { return Some(Cause::NoLan); } - match &f.broadcast_egress { - Some(Egress::Vpn { adapter }) => { - return Some(Cause::Vpn { - adapter: adapter.clone(), - }); - } - Some(Egress::Virtual { name }) => { - return Some(Cause::VirtualAdapter { name: name.clone() }); - } - _ => {} + if let Some(c) = egress_cause(f) { + return Some(c); } // With no default route macOS refuses a broadcast with the same // error, so a working route to the internet is needed to tell. @@ -498,6 +539,20 @@ fn cause(f: &Facts, someone_here: bool, elsewhere: bool) -> Option { if f.other_rig { return Some(Cause::OtherRig); } + // Direct sends went straight onto the home network and still nobody + // heard: a VPN on the broadcast route most likely blocks local traffic. + // Not while a missing machine may be elsewhere, or hears this machine + // (then this machine's sends do get out). + if !someone_here + && !elsewhere + && !heard_by_missing + && f.direct_sends + && let Some(Egress::Vpn { adapter }) = &f.broadcast_egress + { + return Some(Cause::VpnBlocks { + adapter: adapter.clone(), + }); + } if !someone_here && !elsewhere && let Some(Egress::Vpn { adapter }) = &f.route_egress @@ -603,6 +658,33 @@ pub fn subnets(ifaces: &[Iface], route: Option) -> Vec { out } +/// The Wi-Fi and Ethernet adapters with a home-network address, by name: +/// the ones direct sends go out of. +pub fn home_adapters(ifaces: &[Iface], route: Option) -> Vec<&str> { + ifaces + .iter() + .filter(|i| { + kind_with_route(i, route) == AdapterKind::Lan && i.ips.iter().any(|(ip, _)| lan_v4(ip)) + }) + .map(|i| i.name.as_str()) + .collect() +} + +/// Where this machine sends its beacons besides 255.255.255.255 (decision +/// #125): the directed broadcast address of each Wi-Fi or Ethernet subnet. +/// Each follows its subnet's own route out of that adapter, so a VPN or +/// virtual adapter holding the broadcast route doesn't take them. VPN +/// adapters are left out: their own subnet is the VPN's, not home. +pub fn direct_broadcasts(ifaces: &[Iface], route: Option) -> Vec { + let mut out: Vec<_> = subnets(ifaces, route) + .iter() + .filter_map(Subnet::broadcast) + .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 { @@ -1624,6 +1706,166 @@ mod tests { assert!(diagnose(&alone()).awaiting(), "no report: it might turn up"); } + #[test] + fn beacons_also_go_straight_onto_each_home_subnet() { + let ifaces = vec![ + iface("Wi-Fi", ip(10, 0, 0, 35)), + Iface { + ips: vec![(ip(192, 168, 50, 2), 23)], + ..iface("Ethernet", ip(0, 0, 0, 0)) + }, + iface("NordLynx", ip(10, 5, 0, 2)), + Iface { + description: Some("PANGP Virtual Ethernet Adapter".into()), + ..iface("Ethernet 4", ip(10, 20, 0, 7)) + }, + Iface { + ips: vec![(ip(172, 21, 48, 1), 20)], + ..iface("vEthernet (WSL (Hyper-V firewall))", ip(0, 0, 0, 0)) + }, + Iface { + ips: vec![(ip(10, 9, 9, 9), 32)], + ..iface("Ethernet 7", ip(0, 0, 0, 0)) + }, + ]; + assert_eq!( + direct_broadcasts(&ifaces, None), + vec![ + std::net::Ipv4Addr::new(10, 0, 0, 255), + std::net::Ipv4Addr::new(192, 168, 51, 255) + ] + ); + // A Hyper-V external switch carrying the internet is home. + let ext = vec![Iface { + description: Some("Hyper-V Virtual Ethernet Adapter".into()), + ..iface("vEthernet (External Switch)", ip(192, 168, 1, 20)) + }]; + assert_eq!( + direct_broadcasts(&ext, Some(ip(192, 168, 1, 20))), + vec![std::net::Ipv4Addr::new(192, 168, 1, 255)] + ); + assert!(direct_broadcasts(&[], None).is_empty()); + } + + #[test] + fn with_direct_sends_a_vpn_is_a_likely_blocker_and_a_virtual_adapter_nothing() { + let vpn = Facts { + broadcast_egress: Some(Egress::Vpn { + adapter: Some("NordLynx".into()), + }), + direct_sends: true, + ..alone() + }; + assert_eq!( + diagnose(&vpn).cause, + Some(Cause::VpnBlocks { + adapter: Some("NordLynx".into()) + }) + ); + let wsl = Facts { + broadcast_egress: Some(Egress::Virtual { + name: "vEthernet (WSL)".into(), + }), + direct_sends: true, + ..alone() + }; + assert_eq!(diagnose(&wsl).cause, None); + // A sure cause comes before the guess: Public Wi-Fi. + assert_eq!( + diagnose(&Facts { + profile: Some(Profile::Public), + ..vpn.clone() + }) + .cause, + Some(Cause::PublicNetwork) + ); + // The missing machine hears this one: its sends get out, so not + // the VPN. + let heard = Facts { + broadcast_egress: vpn.broadcast_egress.clone(), + direct_sends: true, + ..compared(NetReport { + hears: vec!["desk".into()], + ..report() + }) + }; + assert_eq!(diagnose(&heard).cause, None); + // It reports from another network: not the VPN either. + let elsewhere = Facts { + broadcast_egress: vpn.broadcast_egress.clone(), + direct_sends: true, + ..compared(NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 9, 0, 7), 24).unwrap()], + ..report() + }) + }; + assert_eq!(diagnose(&elsewhere).cause, None); + // A machine heard here that can't hear this one: local traffic gets + // through the VPN, so it isn't named. + let deaf = Facts { + seen: vec![Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: true, + seen_at: NOW, + since: NOW - 600, + }], + ..heard.clone() + }; + let mut deaf = deaf; + deaf.reports.get_mut("laptop").unwrap().report.hears.clear(); + let d = diagnose(&deaf); + assert_eq!(d.deaf, vec!["Brian's Laptop".to_string()]); + assert_eq!(d.deaf_cause, None); + // The other side's report says the same of its own, after "another + // network". + let theirs = compared(NetReport { + vpn: Some(true), + direct: true, + ..report() + }); + assert_eq!(why_of(&theirs), Some(Why::TheirVpnOn)); + let theirs_elsewhere = compared(NetReport { + vpn: Some(true), + direct: true, + subnets: vec![Subnet::of(Ipv4Addr::new(10, 9, 0, 7), 24).unwrap()], + ..report() + }); + assert_eq!(why_of(&theirs_elsewhere), Some(Why::OtherNetwork)); + // It hears this machine with its VPN on: local traffic gets through + // it, so its VPN isn't the reason. + let theirs_hearing = compared(NetReport { + vpn: Some(true), + direct: true, + hears: vec!["desk".into()], + ..report() + }); + assert_eq!(why_of(&theirs_hearing), None); + // A virtual adapter on this machine's route is fine with direct + // sends: blocked can still be named. + let virt = Facts { + broadcast_egress: Some(Egress::Virtual { + name: "vEthernet (WSL)".into(), + }), + direct_sends: true, + ..compared(report()) + }; + assert_eq!(why_of(&virt), Some(Why::Blocked)); + let their_wsl = compared(NetReport { + virtual_adapter: Some("vEthernet (WSL)".into()), + direct: true, + ..report() + }); + assert_ne!( + why_of(&their_wsl), + Some(Why::TheirVirtual { + name: "vEthernet (WSL)".into() + }) + ); + let c = serde_json::to_value(Cause::VpnBlocks { adapter: None }).unwrap(); + assert_eq!(c, serde_json::json!({ "kind": "vpn_blocks" })); + } + #[test] fn subnets_come_from_wifi_and_ethernet_only() { let ifaces = vec![ diff --git a/crates/local/src/net_report.rs b/crates/local/src/net_report.rs index d7ee4aa..f4b9da1 100644 --- a/crates/local/src/net_report.rs +++ b/crates/local/src/net_report.rs @@ -65,6 +65,11 @@ pub struct NetReport { /// nothing it sends reaches anyone, whatever this machine does. #[serde(default, skip_serializing_if = "is_false")] pub send_error: bool, + /// It also sends its beacons straight onto each home-network subnet + /// (decision #125), so a VPN or virtual adapter on its broadcast route + /// no longer takes them all. + #[serde(default, skip_serializing_if = "is_false")] + pub direct: 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")] @@ -91,6 +96,7 @@ impl Default for NetReport { vpn: None, virtual_adapter: None, send_error: false, + direct: false, internet_vpn: None, hears: Vec::new(), public: None, @@ -216,6 +222,15 @@ impl Subnet { }) } + /// Its directed broadcast address (192.168.1.0/24 → 192.168.1.255): a + /// packet sent there leaves through the interface on this network, past + /// a VPN's default route. None for /31 and /32, which have none. + pub fn broadcast(&self) -> Option { + (self.prefix <= 30).then(|| { + Ipv4Addr::from(u32::from(self.net) | !(u32::MAX << (32 - u32::from(self.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 { @@ -292,6 +307,28 @@ mod tests { assert_eq!(Subnet::of(Ipv4Addr::LOCALHOST, 33), None); } + #[test] + fn a_subnet_knows_its_directed_broadcast_address() { + assert_eq!( + net(192, 168, 1, 20, 24).broadcast(), + Some(Ipv4Addr::new(192, 168, 1, 255)) + ); + assert_eq!( + net(10, 1, 2, 3, 8).broadcast(), + Some(Ipv4Addr::new(10, 255, 255, 255)) + ); + assert_eq!( + net(172, 21, 48, 1, 20).broadcast(), + Some(Ipv4Addr::new(172, 21, 63, 255)) + ); + assert_eq!( + net(10, 0, 0, 1, 30).broadcast(), + Some(Ipv4Addr::new(10, 0, 0, 3)) + ); + assert_eq!(net(10, 0, 0, 1, 31).broadcast(), None); + assert_eq!(net(10, 0, 0, 1, 32).broadcast(), None); + } + #[test] fn networks_match_even_with_different_prefixes() { let home = net(192, 168, 1, 20, 24); diff --git a/crates/local/src/netinfo.rs b/crates/local/src/netinfo.rs index 9fe9cff..f01fac5 100644 --- a/crates/local/src/netinfo.rs +++ b/crates/local/src/netinfo.rs @@ -154,6 +154,29 @@ mod tests { } } + /// Sends packets onto this machine's real networks, so not in CI: + /// `cargo test -p patchpilot-local directed -- --ignored --nocapture`. + #[test] + #[ignore = "sends broadcasts on the real network"] + fn directed_broadcasts_reach_a_listener_on_this_machine() { + let targets = crate::lan_diagnosis::direct_broadcasts(&interfaces(), route_source()); + println!("direct targets: {targets:?}"); + assert!(!targets.is_empty(), "no Wi-Fi or Ethernet subnet here"); + let rx = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap(); + rx.set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let port = rx.local_addr().unwrap().port(); + let tx = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap(); + tx.set_broadcast(true).unwrap(); + for t in &targets { + tx.send_to(b"pp-directed", (*t, port)).unwrap(); + let mut buf = [0u8; 32]; + let (n, from) = rx.recv_from(&mut buf).unwrap(); + println!("{t} -> received from {from}"); + assert_eq!(&buf[..n], b"pp-directed"); + } + } + #[test] fn adapter_info_reads_from_powershell_output() { assert_eq!( diff --git a/docs/decisions.md b/docs/decisions.md index 8aa76a4..138bea0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -129,3 +129,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 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 | +| 125 | 2026-11-06 | **Home-network beacons also go straight onto every home subnet** (amends #94; #123 and #124 deferred it; Brian: sync should work with a VPN on). Each beacon still goes to 255.255.255.255, and now also to the directed broadcast address of every Wi-Fi/Ethernet subnet (192.168.1.255; `lan_diagnosis::direct_broadcasts`, `Subnet::broadcast`). On Windows the limited broadcast leaves through the lowest-metric route, which with a VPN (or a Hyper-V/WSL adapter) on is usually that adapter, so machines on the same Wi-Fi never heard each other; a directed broadcast follows its subnet's own connected route out of the Wi-Fi/Ethernet adapter, past the VPN's default route (checked on Brian's laptop: 10.0.0.255 leaves on Wi-Fi and is received by a socket bound to 0.0.0.0). Older builds receive these as they are. Replies were already unicast and pulls TCP to the peer's address, which take the same subnet route. VPN adapters are left out, as are /31 and /32. On Windows only adapters Windows has described count (many VPNs name theirs "Ethernet 3" and say what they are only in the description), so no beacon goes into a work VPN: each home-looking adapter is described once per launch (PowerShell at background priority, one run at a time, retried 10 min after a failure); if PowerShell is blocked, nothing is sent directly and it works as before. The subnet list is one interface listing, worked out again at most every minute, when the internet route's source changes (another network), after looking at the network, and on picking Home network again; no new timer: it rides the existing 10 s beacon. A VPN that blocks local traffic (a kill switch, "block LAN") still stops it; nothing here can get past that. So the diagnosis changes: with direct sends, a VPN on the broadcast route is no longer certain to be the reason, only a guess (`Cause::VpnBlocks`: "Patch Pilot also sends straight onto your home network, so the VPN is most likely blocking local traffic"), ranked after the sure causes (Local Network off; a Public network, now read from the home adapters direct sends use, and only when Windows says Public of all of them) and left out while a missing machine may be elsewhere or hears this one, and never given as why a machine here can't hear this one (local traffic evidently gets through). A virtual adapter there is no reason at all, and doesn't stop "blocked"/"it hears this machine" being named. Direct sends that all fail count as a failed broadcast (macOS Local Network); one the OS accepted means the local network is usable, whatever happened to 255.255.255.255. A Hyper-V external switch with a VPN on gets no direct sends (the switch looks virtual without the internet route), as before. Reports (#124) say `direct`, so the other machine words its VPN the same way (`Why::TheirVpnOn`, after "another network", and not while it hears this machine) and doesn't name its virtual adapter. | The most common reason home-network sync failed for a linked pair was a VPN on one machine; most consumer VPNs and many work ones allow local traffic and only take the default route, which these beacons now step around. Two or three extra small UDP packets per 10 s beacon. | Proposed |