Skip to content

Commit 2547c0a

Browse files
joelteplyclaude
andcommitted
feat(discovery): the daemon derives a peer's identity port itself — the by-hand dial, encoded (#1301 caller side)
Measured live 2026-08-15, BigMama -> M5: the peer was enrolled, its Mac answered mDNS, its daemon was LISTENING on its identity-derived stable port — and every STORED endpoint for it carried a stale ephemeral port from the pre-#1369 churn. Discovery dialed corpses and reported "0 routes, 96 enrolled peers". The connection that finally worked was a human doing the derivation by hand: arp/mDNS -> the IP (192.168.1.249) stable_lan_port(id) -> the port (57958, from peer 2f0aed7f) airc dial --expected-peer ... -> CONNECTED, authenticated A user without a terminal cannot do any of that. Joel: "There is no one to manually do shit... all of this must work automatically for users." So the sequence becomes a dial rung. `identity_port_candidates` pairs every IP already known for a peer (stored endpoints; learned IPs ride in via the existing #9 rung) with the ONE port knowable WITHOUT any advertisement: stable_lan_port of the peer's transport host. Derived from the dial PIN, not the record peer — the listener binds the wire-owning scope's identity, so a record answered by a declared host must derive from that host (machine-vs-scope, same rule as the TLS pin). Appended LAST, not prepended: post-#1369 the advertised port IS the stable port, so stored endpoints normally win and this rung costs nothing. It pays exactly when the stored set is stale — churn-era records, or a peer whose bind fell back — and failure-counted eviction retires the corpses so the rung stops paying their timeouts. When a stored endpoint already carries the derived port, no candidate is added (pinned by test). With #1369 (bind keeps the stable port across restarts) this closes the loop from both ends: the listener stays where identity says it should be, and the dialer can now find it there with nothing but the trust store. Verified: cargo test -p airc-lib --lib — 336 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
1 parent f04a59e commit 2547c0a

1 file changed

Lines changed: 104 additions & 0 deletions

File tree

crates/airc-lib/src/route/discovery.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,49 @@ fn learned_lan_candidates(
9898
candidates
9999
}
100100

101+
/// #1301 (caller side): pair every IP we know for a peer with the port its
102+
/// transport host's IDENTITY derives — [`crate::lan::stable_lan_port`] —
103+
/// because that is the one port knowable WITHOUT any advertisement.
104+
///
105+
/// Why this rung exists, measured live 2026-08-15 (BigMama -> M5): the peer
106+
/// was enrolled, its Mac answered mDNS, its daemon was LISTENING on its
107+
/// stable port — and every STORED endpoint for it carried a stale ephemeral
108+
/// port from the pre-#1369 churn, so discovery dialed corpses and reported
109+
/// "0 routes, 96 enrolled". The connection that finally worked was a human
110+
/// running the derivation by hand: arp for the IP, `stable_lan_port(peer)`
111+
/// for the port, `airc dial --expected-peer`. A user without a terminal
112+
/// cannot do that, so the daemon must.
113+
///
114+
/// The port derives from the TRANSPORT HOST (the dial pin), not the record
115+
/// peer: the listener binds the wire-owning scope's identity port, and a
116+
/// record whose endpoints are answered by a declared host must derive from
117+
/// that host (machine-vs-scope, same rule as the TLS pin).
118+
///
119+
/// Appended AFTER stored/learned candidates, not prepended: post-#1369 the
120+
/// advertised port IS the stable port, so stored endpoints usually win and
121+
/// this rung costs nothing. It pays exactly when the stored set is stale —
122+
/// churn-era records, or a peer whose bind fell back — and failure-counted
123+
/// eviction retires the corpses so this rung stops paying their timeouts.
124+
fn identity_port_candidates(
125+
existing: &[RouteEndpoint],
126+
transport_host: PeerId,
127+
) -> Vec<RouteEndpoint> {
128+
let derived = crate::lan::stable_lan_port(transport_host);
129+
let mut candidates = Vec::new();
130+
for endpoint in existing {
131+
// Same non-wildcard match discipline as `learned_lan_candidates`.
132+
if let RouteEndpoint::LanTcp { addr } | RouteEndpoint::TailscaleTcp { addr } = endpoint {
133+
let candidate = RouteEndpoint::LanTcp {
134+
addr: std::net::SocketAddr::from((addr.ip(), derived)),
135+
};
136+
if !existing.contains(&candidate) && !candidates.contains(&candidate) {
137+
candidates.push(candidate);
138+
}
139+
}
140+
}
141+
candidates
142+
}
143+
101144
/// Card 625abe6d slice 1 — a stored peer endpoint that could not be
102145
/// dialed during discovery. Surfaced on the snapshot (and printed by
103146
/// `airc transport health`) instead of being swallowed: an offline
@@ -515,6 +558,11 @@ impl Airc {
515558
);
516559
}
517560
}
561+
// #1301 caller side: the identity-derived port rides as
562+
// the LAST candidate for every known IP — the recovery
563+
// rung when every stored endpoint is a churn-era corpse.
564+
let mut eps = eps;
565+
eps.extend(identity_port_candidates(&eps, pin));
518566
(peer_id, pin, eps, stamp, alive)
519567
})
520568
})
@@ -1197,6 +1245,62 @@ mod tests {
11971245
);
11981246
}
11991247

1248+
/// what this catches: the by-hand dial that connected BigMama->M5 on
1249+
/// 2026-08-15, encoded so no human ever runs it again. Every STORED
1250+
/// endpoint carried a churn-era ephemeral port; the peer was listening on
1251+
/// its identity-derived port at a known IP. The candidate set must
1252+
/// therefore include (known_ip, stable_lan_port(transport_host)) — and
1253+
/// derive from the TRANSPORT HOST, not the record peer, because the
1254+
/// listener binds the wire-owning scope's identity (machine-vs-scope,
1255+
/// same rule as the TLS pin).
1256+
#[test]
1257+
fn identity_port_candidates_pair_known_ips_with_the_derived_port() {
1258+
let record_peer = PeerId::from_u128(0x2f0aed7f_3359_4dd5_9f9b_aaa0b07c6266);
1259+
let host = PeerId::from_u128(0x7711fe60_a19f_4f41_9ab6_24c884757338);
1260+
let stale = RouteEndpoint::LanTcp {
1261+
addr: "192.168.1.249:65209".parse().unwrap(),
1262+
};
1263+
1264+
// Derives from the record peer when it is its own transport host…
1265+
let own = identity_port_candidates(std::slice::from_ref(&stale), record_peer);
1266+
assert_eq!(
1267+
own,
1268+
vec![RouteEndpoint::LanTcp {
1269+
addr: std::net::SocketAddr::from((
1270+
"192.168.1.249".parse::<std::net::IpAddr>().unwrap(),
1271+
crate::lan::stable_lan_port(record_peer),
1272+
)),
1273+
}],
1274+
"a stale stored port must still yield (same ip, identity-derived port)"
1275+
);
1276+
1277+
// …and from the DECLARED HOST when one answers the endpoints.
1278+
let hosted = identity_port_candidates(std::slice::from_ref(&stale), host);
1279+
assert_eq!(
1280+
hosted[0],
1281+
RouteEndpoint::LanTcp {
1282+
addr: std::net::SocketAddr::from((
1283+
"192.168.1.249".parse::<std::net::IpAddr>().unwrap(),
1284+
crate::lan::stable_lan_port(host),
1285+
)),
1286+
},
1287+
"the port must derive from the transport host that owns the listener"
1288+
);
1289+
1290+
// A stored endpoint ALREADY at the derived port adds nothing — the
1291+
// rung is free exactly when #1369 has done its job.
1292+
let current = RouteEndpoint::LanTcp {
1293+
addr: std::net::SocketAddr::from((
1294+
"192.168.1.249".parse::<std::net::IpAddr>().unwrap(),
1295+
crate::lan::stable_lan_port(record_peer),
1296+
)),
1297+
};
1298+
assert!(
1299+
identity_port_candidates(std::slice::from_ref(&current), record_peer).is_empty(),
1300+
"no duplicate candidate when the stored endpoint already carries the derived port"
1301+
);
1302+
}
1303+
12001304
// what this catches (#9): the learn-live-address candidate builder. A peer
12011305
// that connected to us teaches its real IP; we must produce
12021306
// (learned_ip, advertised_port) as a NEW candidate so a peer whose

0 commit comments

Comments
 (0)