|
| 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 | +} |
0 commit comments