{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(),