|
| 1 | +// HolePunch.swift — the SECOND brick of NAT traversal, on top of StunClient. Once each |
| 2 | +// side knows its candidates (host addresses + the STUN server-reflexive address), the two |
| 3 | +// peers send probes to each other's candidates AT THE SAME TIME. That simultaneous open is |
| 4 | +// what punches a pinhole through each NAT: NAT B lets A's probe in because B just sent one |
| 5 | +// out toward A, and vice-versa. The pair that completes a probe/ack round-trip is the one |
| 6 | +// the media call then uses. |
| 7 | +// |
| 8 | +// Pure + standalone (like StunClient / MeshCrypto): the probe/ack wire format and the |
| 9 | +// ICE-style pair-priority / nomination are proven deterministically, and two in-process |
| 10 | +// agents actually hole-punch each other over loopback UDP in the harness. What a single |
| 11 | +// machine canNOT prove is traversal of a REAL NAT (needs two separate NATs); that is the |
| 12 | +// integration step. Not yet wired into the transport. |
| 13 | +import Foundation |
| 14 | + |
| 15 | +enum HolePunch { |
| 16 | + // 0xFD is the control family; 0x1C/0x1D are free (0x11/0x4E/0x4F/0xAD/0xBE/0xC0 are taken). |
| 17 | + private static let probeTag: [UInt8] = [0xFD, 0x1C] |
| 18 | + private static let ackTag: [UInt8] = [0xFD, 0x1D] |
| 19 | + |
| 20 | + // ---- candidates + ICE-style priority (RFC 8445 §5.1.2 / §6.1.2) ---- |
| 21 | + enum Kind: Int { case host = 126, srflx = 100 } // type preferences |
| 22 | + struct Candidate: Equatable { let ip: String; let port: UInt16; let kind: Kind } |
| 23 | + |
| 24 | + static func priority(_ c: Candidate) -> UInt32 { |
| 25 | + (UInt32(c.kind.rawValue) << 24) | 255 // component 1 -> (256 - 1) |
| 26 | + } |
| 27 | + // Pair priority, RFC 8445 §6.1.2.3: G is the controlling agent's candidate priority, |
| 28 | + // D the controlled agent's. min/max make the value symmetric across the two agents. |
| 29 | + static func pairPriority(local: Candidate, remote: Candidate, controlling: Bool) -> UInt64 { |
| 30 | + let g = UInt64(priority(controlling ? local : remote)) |
| 31 | + let d = UInt64(priority(controlling ? remote : local)) |
| 32 | + return (UInt64(1) << 32) * min(g, d) + 2 * max(g, d) + (g > d ? 1 : 0) |
| 33 | + } |
| 34 | + // All candidate pairs, highest priority first — the check order. |
| 35 | + static func orderedPairs(local: [Candidate], remote: [Candidate], controlling: Bool) -> [(Candidate, Candidate)] { |
| 36 | + var pairs: [(Candidate, Candidate)] = [] |
| 37 | + for l in local { for r in remote { pairs.append((l, r)) } } |
| 38 | + return pairs.sorted { pairPriority(local: $0.0, remote: $0.1, controlling: controlling) |
| 39 | + > pairPriority(local: $1.0, remote: $1.1, controlling: controlling) } |
| 40 | + } |
| 41 | + // Nominate: the controlling agent picks the highest-priority pair that PASSED its check. |
| 42 | + static func nominate(ordered: [(Candidate, Candidate)], succeeded: Set<Int>) -> (Candidate, Candidate)? { |
| 43 | + for (i, pair) in ordered.enumerated() where succeeded.contains(i) { return pair } |
| 44 | + return nil |
| 45 | + } |
| 46 | + |
| 47 | + // ---- probe / ack wire codec ---- |
| 48 | + static func probePacket(txid: UInt64) -> Data { Data(probeTag + be(txid)) } |
| 49 | + static func ackPacket(txid: UInt64) -> Data { Data(ackTag + be(txid)) } |
| 50 | + static func probeTxid(_ d: Data) -> UInt64? { txid(d, tag: probeTag) } |
| 51 | + static func ackTxid(_ d: Data) -> UInt64? { txid(d, tag: ackTag) } |
| 52 | + |
| 53 | + private static func be(_ x: UInt64) -> [UInt8] { (0..<8).map { UInt8((x >> (56 - 8 * $0)) & 0xFF) } } |
| 54 | + private static func txid(_ d: Data, tag: [UInt8]) -> UInt64? { |
| 55 | + let b = [UInt8](d) |
| 56 | + guard b.count == 10, b[0] == tag[0], b[1] == tag[1] else { return nil } |
| 57 | + return b[2..<10].reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } |
| 58 | + } |
| 59 | + |
| 60 | + // ---- the actual hole-punch over one UDP socket ---- |
| 61 | + // Bind boundPort, then for the whole window: retransmit our probe toward the peer, answer |
| 62 | + // every probe we receive with an ack (replying to the OBSERVED source, which is what a |
| 63 | + // symmetric NAT rewrites the port to), and watch for an ack that echoes OUR txid. Success |
| 64 | + // = we heard an ack for our own probe, i.e. this pair round-tripped. Retransmission is |
| 65 | + // what makes the simultaneous open robust to which side sends first. |
| 66 | + static func punch(boundPort: UInt16, peerHost: String, peerPort: UInt16, timeoutMs: Int = 1200) -> Bool { |
| 67 | + let fd = socket(AF_INET, SOCK_DGRAM, 0) |
| 68 | + guard fd >= 0 else { return false } |
| 69 | + defer { close(fd) } |
| 70 | + var one: Int32 = 1 |
| 71 | + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout<Int32>.size)) |
| 72 | + |
| 73 | + var me = sockaddr_in() |
| 74 | + me.sin_family = sa_family_t(AF_INET) |
| 75 | + me.sin_port = boundPort.bigEndian |
| 76 | + me.sin_addr.s_addr = 0 // INADDR_ANY |
| 77 | + let bound = withUnsafePointer(to: &me) { p in |
| 78 | + p.withMemoryRebound(to: sockaddr.self, capacity: 1) { Foundation.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) } |
| 79 | + } |
| 80 | + guard bound == 0 else { return false } |
| 81 | + |
| 82 | + var tv = timeval(tv_sec: 0, tv_usec: 50_000) // 50ms: re-probe ~20x/sec |
| 83 | + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size)) |
| 84 | + |
| 85 | + var peer = sockaddr_in() |
| 86 | + peer.sin_family = sa_family_t(AF_INET) |
| 87 | + peer.sin_port = peerPort.bigEndian |
| 88 | + inet_pton(AF_INET, peerHost, &peer.sin_addr) |
| 89 | + |
| 90 | + let myTxid = UInt64.random(in: 0 ... UInt64.max) |
| 91 | + let probe = probePacket(txid: myTxid) |
| 92 | + let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0) |
| 93 | + var gotAck = false |
| 94 | + var buf = [UInt8](repeating: 0, count: 64) |
| 95 | + |
| 96 | + while Date() < deadline { |
| 97 | + _ = probe.withUnsafeBytes { pb in |
| 98 | + withUnsafePointer(to: &peer) { pp in |
| 99 | + pp.withMemoryRebound(to: sockaddr.self, capacity: 1) { sp in |
| 100 | + sendto(fd, pb.baseAddress, probe.count, 0, sp, socklen_t(MemoryLayout<sockaddr_in>.size)) |
| 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 } // timeout -> re-probe |
| 112 | + let pkt = Data(buf.prefix(n)) |
| 113 | + if let t = probeTxid(pkt) { // peer's probe -> ack the observed source |
| 114 | + let ack = 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 = ackTxid(pkt), t == myTxid { // ack for OUR probe -> this pair works |
| 123 | + gotAck = true |
| 124 | + } |
| 125 | + } |
| 126 | + return gotAck |
| 127 | + } |
| 128 | +} |
0 commit comments