Skip to content

Commit 5b17ea8

Browse files
SSD DDDclaude
authored andcommitted
feat(nat): ICE session — gather, exchange candidates, connect (third NAT brick)
#42 gave each side its public address (STUN), #43 punched between two KNOWN ports. The missing glue: serialize the candidate LIST so the two sides can exchange it over a signaling/rendezvous channel, and orchestrate a real connect that probes ALL of the peer's candidates and nominates the one that answers (a NAT may silently drop some pairs). IceSession.swift does both, pure + standalone, reusing HolePunch's probe/ack codec and priority: * Ice.encode/decode: [count:2][kind:1][port:2][ipLen:1][ip] per candidate, bounds-checked on parse; * Ice.connect(localPort, remote[]): bind one socket, probe every remote for the whole window, ack observed sources, nominate the highest-priority remote that actually answered; report the bound port as the media socket to hand off. Verified in smoke/harness/ice_session.swift (10th verify.sh test, verify: 10 passed, 0 failed): serialization round-trips + rejects garbage; and TWO real in-process sessions exchange serialized blobs and connect over loopback UDP while correctly discarding a decoy candidate (192.0.2.2, RFC 5737 unroutable) that never answers. Ran 3x, 3/3 deterministic. Nominate by "did it ACK", never by priority alone — that is what makes a dead higher-priority candidate get skipped instead of selected. Do not early-exit on first success (strands the peer mid-handshake); run the full window, keep acking. Boundary: loopback proves serialize/exchange/connect/nominate over real UDP, NOT traversal of a real NAT (two separate NATs). The three bricks (STUN, punch, session) are harness-proven; wiring connect() into CallManager before the media socket, fed by the room's exchanged candidates, is the integration step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b468cd2 commit 5b17ea8

4 files changed

Lines changed: 226 additions & 1 deletion

File tree

.claude/skills/tri-net-development/SKILL.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2878,3 +2878,34 @@ Lessons:
28782878
NOT yet wired into the transport: exchanging candidate lists over a signaling/rendezvous
28792879
channel and running punch() before the media socket opens is the third brick. HolePunch.swift
28802880
is harness-proven but not in project.yml until used.
2881+
2882+
## WAVE 2026-07-23 #44 — ICE session: gather -> exchange -> connect (the third NAT brick)
2883+
#42 gave each side its public address, #43 punched between two KNOWN ports. The missing glue:
2884+
serialize the candidate LIST for the signaling channel, and orchestrate a real connect that
2885+
probes ALL of the peer's candidates and nominates the one that answers. IceSession.swift does
2886+
both (pure + standalone; reuses HolePunch's codec + priority):
2887+
* Ice.encode/decode: [count:2][kind:1][port:2][ipLen:1][ip utf8] per candidate, bounds-checked.
2888+
* Ice.connect(localPort, remote[]): bind one socket, probe every remote candidate for the
2889+
whole window, ack observed sources, nominate the highest-priority remote that answered.
2890+
2891+
Verified in smoke/harness/ice_session.swift (10th verify.sh test): serialization round-trips
2892+
+ rejects garbage; and TWO real in-process sessions exchange serialized blobs and CONNECT over
2893+
loopback UDP while correctly discarding a decoy candidate (192.0.2.2, RFC 5737 unroutable) that
2894+
never answers. Ran 3x -> 3/3.
2895+
2896+
Lessons:
2897+
- A decoy candidate must be genuinely dead AND not local: 127.0.0.1:<closed> triggers an ICMP
2898+
port-unreachable that can surface as ECONNREFUSED on the next recvfrom and disturb the loop.
2899+
Use an RFC 5737 unroutable address (192.0.2.0/24) so probes simply go nowhere.
2900+
- Nominate by "did it ACK", never by "is it highest priority": the whole point is that a NAT
2901+
may silently drop some pairs. connect() records which remote answered and picks the best
2902+
AMONG THOSE, so a dead higher-priority candidate is skipped, not selected.
2903+
- Don't early-exit connect() on first success — a peer that stops answering the instant it
2904+
succeeds strands the other side mid-handshake. Run the full window, keep acking, nominate at
2905+
the end. (Same lesson as #43's punch, now at the multi-candidate layer.)
2906+
- verify.sh now allows a multi-FILE source per harness (IceSession needs HolePunch too): $src
2907+
is left unquoted so each path is a separate swiftc arg (TriNetVideo paths have no spaces).
2908+
Boundary unchanged: loopback proves serialize/exchange/connect/nominate over real UDP, NOT
2909+
real-NAT traversal (two separate NATs). The three bricks (#42 STUN, #43 punch, #44 session) are
2910+
harness-proven but still not in project.yml / not wired into CallManager — that integration
2911+
(run connect() before the media socket opens, feed it the room's exchanged candidates) is next.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// IceSession.swift — the THIRD brick of NAT traversal: turn "I know my candidates" (host +
2+
// STUN server-reflexive) and "I can punch a known port" (HolePunch) into "connect to this
3+
// peer". Two things #43 lacked:
4+
// 1. a wire format for the candidate LIST, so the two sides can exchange candidates over a
5+
// signaling / rendezvous channel (a room code, the mesh, anything);
6+
// 2. orchestration: probe ALL of the peer's candidates at once and nominate the pair that
7+
// actually round-trips, ignoring the ones that never answer (a NAT may block some).
8+
// Pure + standalone; reuses HolePunch's probe/ack codec and priority. Verified by two
9+
// in-process sessions that exchange serialized candidate blobs and actually connect over
10+
// loopback UDP, discarding a decoy candidate. A single machine cannot prove real-NAT
11+
// traversal (needs two separate NATs); the check/serialize/nominate is what is proven.
12+
import Foundation
13+
14+
enum Ice {
15+
typealias Candidate = HolePunch.Candidate
16+
17+
// ---- candidate-list wire format (crosses the signaling channel) ----
18+
// [count:2 BE] then per candidate [kind:1][port:2 BE][ipLen:1][ip utf8].
19+
static func encode(_ cands: [Candidate]) -> Data {
20+
var d = Data()
21+
d.append(UInt8(cands.count >> 8)); d.append(UInt8(cands.count & 0xFF))
22+
for c in cands {
23+
let ip = Array(c.ip.utf8)
24+
d.append(UInt8(c.kind.rawValue))
25+
d.append(UInt8(c.port >> 8)); d.append(UInt8(c.port & 0xFF))
26+
d.append(UInt8(ip.count))
27+
d.append(contentsOf: ip)
28+
}
29+
return d
30+
}
31+
32+
static func decode(_ data: Data) -> [Candidate]? {
33+
let b = [UInt8](data)
34+
guard b.count >= 2 else { return nil }
35+
let count = Int(b[0]) << 8 | Int(b[1])
36+
var i = 2
37+
var out: [Candidate] = []
38+
for _ in 0..<count {
39+
guard i + 4 <= b.count, let kind = HolePunch.Kind(rawValue: Int(b[i])) else { return nil }
40+
let port = UInt16(b[i + 1]) << 8 | UInt16(b[i + 2])
41+
let ipLen = Int(b[i + 3])
42+
let ipStart = i + 4
43+
guard ipStart + ipLen <= b.count, let ip = String(bytes: b[ipStart ..< ipStart + ipLen], encoding: .utf8) else { return nil }
44+
out.append(Candidate(ip: ip, port: port, kind: kind))
45+
i = ipStart + ipLen
46+
}
47+
return out.count == count ? out : nil
48+
}
49+
50+
struct Connected: Equatable { let remote: Candidate; let localPort: UInt16 }
51+
52+
// Bind one UDP socket, then for the whole window: probe EVERY remote candidate, answer
53+
// every probe we receive (ack to the observed source — the pinhole), and note which
54+
// remote's ack echoes our txid. That remote is a working pair. We do not early-exit: a
55+
// peer that stopped answering the moment it succeeded would strand the other side, so we
56+
// keep answering and nominate the highest-priority pair that answered by the deadline.
57+
static func connect(localPort: UInt16, remote: [Candidate], timeoutMs: Int = 1500) -> Connected? {
58+
let fd = socket(AF_INET, SOCK_DGRAM, 0)
59+
guard fd >= 0 else { return nil }
60+
defer { close(fd) }
61+
var one: Int32 = 1
62+
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout<Int32>.size))
63+
64+
var me = sockaddr_in()
65+
me.sin_family = sa_family_t(AF_INET)
66+
me.sin_port = localPort.bigEndian
67+
me.sin_addr.s_addr = 0
68+
let bound = withUnsafePointer(to: &me) { p in
69+
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { Foundation.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) }
70+
}
71+
guard bound == 0 else { return nil }
72+
let boundPort = localBoundPort(fd) ?? localPort
73+
74+
var tv = timeval(tv_sec: 0, tv_usec: 50_000)
75+
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
76+
77+
// precompute a sockaddr for each remote candidate, ordered by priority
78+
let ordered = remote.sorted { HolePunch.priority($0) > HolePunch.priority($1) }
79+
let remoteAddrs: [(Candidate, sockaddr_in)] = ordered.map { c in
80+
var a = sockaddr_in()
81+
a.sin_family = sa_family_t(AF_INET)
82+
a.sin_port = c.port.bigEndian
83+
inet_pton(AF_INET, c.ip, &a.sin_addr)
84+
return (c, a)
85+
}
86+
87+
let myTxid = UInt64.random(in: 0 ... UInt64.max)
88+
let probe = HolePunch.probePacket(txid: myTxid)
89+
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
90+
var winners: Set<String> = [] // "ip:port" of remotes that acked us
91+
var buf = [UInt8](repeating: 0, count: 64)
92+
93+
while Date() < deadline {
94+
for (_, a) in remoteAddrs {
95+
var aa = a
96+
_ = probe.withUnsafeBytes { pb in
97+
withUnsafePointer(to: &aa) { pp in
98+
pp.withMemoryRebound(to: sockaddr.self, capacity: 1) { sp in
99+
sendto(fd, pb.baseAddress, probe.count, 0, sp, socklen_t(MemoryLayout<sockaddr_in>.size))
100+
}
101+
}
102+
}
103+
}
104+
var from = sockaddr_in()
105+
var fromLen = socklen_t(MemoryLayout<sockaddr_in>.size)
106+
let n = withUnsafeMutablePointer(to: &from) { fp in
107+
fp.withMemoryRebound(to: sockaddr.self, capacity: 1) { sp in
108+
recvfrom(fd, &buf, buf.count, 0, sp, &fromLen)
109+
}
110+
}
111+
guard n > 0 else { continue }
112+
let pkt = Data(buf.prefix(n))
113+
if let t = HolePunch.probeTxid(pkt) { // peer's probe -> ack the observed source
114+
let ack = HolePunch.ackPacket(txid: t)
115+
_ = ack.withUnsafeBytes { ab in
116+
withUnsafeMutablePointer(to: &from) { fp in
117+
fp.withMemoryRebound(to: sockaddr.self, capacity: 1) { sp in
118+
sendto(fd, ab.baseAddress, ack.count, 0, sp, fromLen)
119+
}
120+
}
121+
}
122+
} else if let t = HolePunch.ackTxid(pkt), t == myTxid { // our probe was answered
123+
var addr = from.sin_addr
124+
var ipbuf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
125+
inet_ntop(AF_INET, &addr, &ipbuf, socklen_t(INET_ADDRSTRLEN))
126+
winners.insert("\(String(cString: ipbuf)):\(UInt16(bigEndian: from.sin_port))")
127+
}
128+
}
129+
// nominate the highest-priority candidate that answered
130+
for (c, _) in remoteAddrs where winners.contains("\(c.ip):\(c.port)") {
131+
return Connected(remote: c, localPort: boundPort)
132+
}
133+
return nil
134+
}
135+
136+
private static func localBoundPort(_ fd: Int32) -> UInt16? {
137+
var a = sockaddr_in()
138+
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
139+
let ok = withUnsafeMutablePointer(to: &a) { p in
140+
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) }
141+
}
142+
return ok == 0 ? UInt16(bigEndian: a.sin_port) : nil
143+
}
144+
}

smoke/harness/ice_session.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Verifies the ICE session orchestration in the ACTUAL IceSession.swift (+ HolePunch.swift).
2+
// Two layers: (1) the candidate-list wire format round-trips bit-exact and rejects garbage;
3+
// (2) TWO real in-process sessions exchange serialized candidate blobs and actually connect
4+
// over loopback UDP, each correctly IGNORING a higher/equal-priority decoy candidate that
5+
// never answers (192.0.2.2, an RFC 5737 unroutable address) and nominating the real peer.
6+
// Deterministic: both sides retransmit for the whole window on lossless loopback.
7+
// swiftc HolePunch.swift IceSession.swift ice_session.swift -o /tmp/ice && /tmp/ice
8+
import Foundation
9+
10+
var fails = 0
11+
func check(_ c: Bool, _ l: String) { print("\(c ? "PASS" : "FAIL") \(l)"); if !c { fails += 1 } }
12+
13+
print("== candidate-list serialization ==")
14+
do {
15+
let cands = [Ice.Candidate(ip: "192.168.1.7", port: 5000, kind: .host),
16+
Ice.Candidate(ip: "203.0.113.7", port: 61234, kind: .srflx)]
17+
let blob = Ice.encode(cands)
18+
check(Ice.decode(blob) == cands, "encode -> decode round-trips a mixed host/srflx list")
19+
check(Ice.decode(Data([0x00, 0x02, 126])) == nil, "count says 2 but bytes run out -> nil")
20+
check(Ice.decode(Data()) == nil, "empty -> nil")
21+
check(Ice.decode(Ice.encode([])) == [], "empty list round-trips to empty")
22+
// a bad kind byte is rejected
23+
check(Ice.decode(Data([0x00, 0x01, 0x07, 0x13, 0x88, 0x01, 0x41])) == nil, "unknown kind byte -> nil")
24+
}
25+
26+
print("== two sessions connect over loopback, discarding a dead decoy candidate ==")
27+
do {
28+
let pA: UInt16 = 48311, pB: UInt16 = 48312
29+
let decoy = Ice.Candidate(ip: "192.0.2.2", port: 9, kind: .host) // RFC 5737 unroutable: never answers
30+
// each side advertises its real host candidate; the peer also sees the decoy first
31+
let aSeesB = [decoy] + (Ice.decode(Ice.encode([Ice.Candidate(ip: "127.0.0.1", port: pB, kind: .host)])) ?? [])
32+
let bSeesA = [decoy] + (Ice.decode(Ice.encode([Ice.Candidate(ip: "127.0.0.1", port: pA, kind: .host)])) ?? [])
33+
34+
var rA: Ice.Connected?, rB: Ice.Connected?
35+
let g = DispatchGroup()
36+
g.enter(); DispatchQueue.global().async { rA = Ice.connect(localPort: pA, remote: aSeesB, timeoutMs: 1500); g.leave() }
37+
g.enter(); DispatchQueue.global().async { rB = Ice.connect(localPort: pB, remote: bSeesA, timeoutMs: 1500); g.leave() }
38+
g.wait()
39+
40+
check(rA?.remote.ip == "127.0.0.1" && rA?.remote.port == pB, "A nominated the REAL peer 127.0.0.1:\(pB), not the decoy")
41+
check(rB?.remote.ip == "127.0.0.1" && rB?.remote.port == pA, "B nominated the REAL peer 127.0.0.1:\(pA), not the decoy")
42+
check(rA?.localPort == pA, "A reports its own bound port as the media socket")
43+
check(rB?.localPort == pB, "B reports its own bound port as the media socket")
44+
}
45+
46+
print("\n\(fails == 0 ? "ALL PASS" : "\(fails) FAILURE(S)")")
47+
exit(fails == 0 ? 0 : 1)

smoke/verify.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ SUITE=(
3434
"MeshCrypto keychain: $SRC/MeshCrypto.swift: smoke/harness/crypto_keychain.swift"
3535
"Stun RFC5769 : $SRC/StunClient.swift: smoke/harness/stun_vectors.swift"
3636
"HolePunch : $SRC/HolePunch.swift : smoke/harness/holepunch.swift"
37+
"IceSession : $SRC/HolePunch.swift $SRC/IceSession.swift: smoke/harness/ice_session.swift"
3738
)
3839

3940
pass=0; fail=0
@@ -46,7 +47,9 @@ run_harness() {
4647
local label="$1" src="$2" harness="$3"
4748
# top-level code must be in a file literally named main.swift
4849
cp "$harness" "$TMP/main.swift"
49-
if ! swiftc "$src" "$TMP/main.swift" -o "$TMP/bin" 2>"$TMP/err"; then
50+
# $src may name more than one source file (space-separated); leave it unquoted so each is
51+
# a separate swiftc argument. TriNetVideo paths contain no spaces, so word-splitting is safe.
52+
if ! swiftc $src "$TMP/main.swift" -o "$TMP/bin" 2>"$TMP/err"; then
5053
echo " FAIL $label (compile error)"; sed 's/^/ /' "$TMP/err" | head -8; fail=$((fail+1)); return
5154
fi
5255
"$TMP/bin" >"$TMP/out" 2>&1 &

0 commit comments

Comments
 (0)