Punch
A Punch does two things: it keeps a live channel open for each peer assigned to it, and it relays signalling between a peer that wants to connect and one that is bound here.
It does not parse the SDP it carries — an SDP is an opaque string to it. It does not issue connection credentials; peers sign those themselves. And it does not ask Track anything while serving a request, so a Track outage does not stop signalling.
QUIC, and why
The control channel is QUIC rather than TCP, for two reasons:
- Connection migration. Peers are phones and home connections. Switching between Wi-Fi and cellular normally kills a TCP connection; QUIC is identified by a connection id rather than by address, so the same connection survives.
- It is UDP already. Half of this server's job is helping a peer keep its NAT mapping alive, which only periodic UDP packets can do. QUIC is UDP, so no second path is needed.
The price is that networks blocking UDP are out of luck. The transport is kept behind a small interface so a TCP fallback can be added where that matters.
Heartbeats ride QUIC datagrams — small, frequent, and a lost one is simply followed by another. Everything else (join, exit, and all signalling) uses a bidirectional stream, because it must arrive.
Bindings
A peer becomes reachable by joining: it presents a self-signed ticket naming this server, and gets back a binding_id and a binding_key. From then on:
sequenceDiagram
participant P as Peer
participant PS as Punch
P->>PS: join (ticket)
PS-->>P: binding_id, binding_key
loop every few seconds
P->>PS: heartbeat (seq, timestamp, MAC)
end
P->>PS: exit (seq, MAC)
Every message after join is authenticated with HMAC(binding_key, binding_id || seq || timestamp || generation), plus a sequence number that must move forward and a timestamp that must be close to the server's clock. A generation counter handles network changes: it only ever increases, so a rejoin after switching networks cannot be confused with a replay.
The channel itself is the credential. When it drops, the binding is gone — which is why the list of "reachable" peers is exactly the set of peers with a live channel, not a separate table.
Signalling
Setting up a connection has two phases, and their order is deliberate.
sequenceDiagram participant A as Peer A participant PS as Punch participant B as Peer B A->>PS: connect (ticket naming B) PS->>B: connect B->>PS: accept or reject PS->>A: accept or reject Note over A,B: only now does A build an offer A->>PS: offer (SDP, candidates) PS->>B: offer B->>PS: answer (SDP, candidates) PS->>A: answer A->>PS: candidate (trickle) PS->>B: candidate
Consent comes first. A is not allowed to send an offer until B has accepted. A refusal therefore costs no ICE gathering, no STUN query, and no relay allocation — B just says no. In a large swarm most attempts are refused, so this saves real work.
connect requires a ticket every time (it is what authorises waking someone else). After that the channel is the credential: answer, candidate and cancel are checked against the connection record rather than re-verifying a ticket each time, which is also why relayed candidates are not constrained by the ticket's expiry.
Server authentication
A client must be able to tell whether it is talking to the real Punch. The anchor is the public key Track publishes alongside the address — the client checks that the certificate in front of it carries exactly that key.
Address and key are tracked separately on purpose: an address changes whenever the deployment changes, while the key is the server's long-lived identity. Which is why the keypair has to be persisted — a server that mints a new one on every start is a server no client will trust.
Signalling channels
Two kinds, because they have different lifetimes:
- Standing — the peer bound here, opened at
join. If it closes, the peer is unreachable and connect requests get a distinguishable "target offline" error. - Ephemeral — the asker's, opened with a connection and closed with it. The response to the first offer carries a
signal_key; if the channel breaks, the asker reconnects and presents the connection id plus that key to resume. Anything that arrived while it was away is replayed.
Note that heartbeats are a separate path from the standing channel: keeping the channel alive is the transport's job, while heartbeats exist to keep the NAT mapping alive and refresh the TTL. Conflating them would make it impossible to tell "the channel dropped but the mapping is still there" from "both are gone".
Running it
See Deploy. A Punch needs:
- a
punch_idthat matches what Track lists - a persisted identity keypair
- inbound UDP on its advertised port
cluster_sizeset to the number of instances, so the rate limit is split rather than multiplied