1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
|
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Phase 4: Punching Through NATs — Tesseras</title>
<meta name="description" content="Tesseras nodes can now discover their NAT type via STUN, coordinate UDP hole punching through introducers, and fall back to transparent relay forwarding when direct connectivity fails.">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="Phase 4: Punching Through NATs">
<meta property="og:description" content="Tesseras nodes can now discover their NAT type via STUN, coordinate UDP hole punching through introducers, and fall back to transparent relay forwarding when direct connectivity fails.">
<meta property="og:image" content="https://tesseras.net/images/social.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Tesseras">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Phase 4: Punching Through NATs">
<meta name="twitter:description" content="Tesseras nodes can now discover their NAT type via STUN, coordinate UDP hole punching through introducers, and fall back to transparent relay forwarding when direct connectivity fails.">
<meta name="twitter:image" content="https://tesseras.net/images/social.jpg">
<link rel="stylesheet" href="https://tesseras.net/style.css?h=21f0f32121928ee5c690">
<link rel="alternate" type="application/atom+xml" title="Tesseras" href="https://tesseras.net/atom.xml">
<link rel="icon" type="image/png" sizes="32x32" href="https://tesseras.net/images/favicon.png?h=be4e123a23393b1a027d">
</head>
<body>
<header>
<h1>
<a href="https://tesseras.net/">
<img src="https://tesseras.net/images/logo-64.png?h=c1b8d0c4c5f93b49d40b" alt="Tesseras" width="40" height="40" class="logo">
Tesseras
</a>
</h1>
<nav>
<a href="https://tesseras.net/about/">About</a>
<a href="https://tesseras.net/news/">News</a>
<a href="https://tesseras.net/releases/">Releases</a>
<a href="https://tesseras.net/faq/">FAQ</a>
<a href="https://tesseras.net/subscriptions/">Subscriptions</a>
<a href="https://tesseras.net/contact/">Contact</a>
</nav>
<nav class="lang-switch">
<strong>English</strong> | <a href="/pt-br/news/phase4-nat-traversal/">Português</a>
</nav>
</header>
<main>
<article>
<h2>Phase 4: Punching Through NATs</h2>
<p class="news-date">2026-02-15</p>
<p>Most people's devices sit behind a NAT — a network address translator that lets
them reach the internet but prevents incoming connections. For a P2P network,
this is an existential problem: if two nodes behind NATs can't talk to each
other, the network fragments. Phase 4 continues with a full NAT traversal stack:
STUN-based discovery, coordinated hole punching, and relay fallback.</p>
<p>The approach follows the same pattern as most battle-tested P2P systems (WebRTC,
BitTorrent, IPFS): try the cheapest option first, escalate only when necessary.
Direct connectivity costs nothing. Hole punching costs a few coordinated
packets. Relaying costs sustained bandwidth from a third party. Tesseras tries
them in that order.</p>
<h2 id="what-was-built">What was built</h2>
<p><strong>NatType classification</strong> (<code>tesseras-core/src/network.rs</code>) — A new <code>NatType</code>
enum (Public, Cone, Symmetric, Unknown) added to the core domain layer. This
type is shared across the entire stack: the STUN client writes it, the DHT
advertises it in Pong messages, and the punch coordinator reads it to decide
whether hole punching is even worth attempting (Cone-to-Cone works ~80% of the
time; Symmetric-to-Symmetric almost never works).</p>
<p><strong>STUN client</strong> (<code>tesseras-net/src/stun.rs</code>) — A minimal STUN implementation
(RFC 5389 Binding Request/Response) that discovers a node's external address.
The codec encodes 20-byte binding requests with a random transaction ID and
decodes XOR-MAPPED-ADDRESS responses. The <code>discover_nat()</code> function queries
multiple STUN servers in parallel (Google, Cloudflare by default), compares the
mapped addresses, and classifies the NAT type:</p>
<ul>
<li>Same IP and port from all servers → <strong>Public</strong> (no NAT)</li>
<li>Same mapped address from all servers → <strong>Cone</strong> (hole punching works)</li>
<li>Different mapped addresses → <strong>Symmetric</strong> (hole punching unreliable)</li>
<li>No responses → <strong>Unknown</strong></li>
</ul>
<p>Retries with exponential backoff and configurable timeouts. 12 tests covering
codec roundtrips, all classification paths, and async loopback queries.</p>
<p><strong>Signed punch coordination</strong> (<code>tesseras-net/src/punch.rs</code>) — Ed25519 signing
and verification for <code>PunchIntro</code>, <code>RelayRequest</code>, and <code>RelayMigrate</code> messages.
Every introduction is signed by the initiator with a 30-second timestamp window,
preventing reflection attacks (where an attacker replays an old introduction to
redirect traffic). The payload format is <code>target || external_addr || timestamp</code>
— changing any field invalidates the signature. 6 unit tests plus 3
property-based tests with proptest (arbitrary node IDs, ports, and session
tokens).</p>
<p><strong>Relay session manager</strong> (<code>tesseras-net/src/relay.rs</code>) — Manages transparent
UDP relay sessions between NATed peers. Each session has a random 16-byte token;
peers prefix their packets with the token, the relay strips it and forwards.
Features:</p>
<ul>
<li>Bidirectional forwarding (A→R→B and B→R→A)</li>
<li>Rate limiting: 256 KB/s for reciprocal peers, 64 KB/s for non-reciprocal</li>
<li>10-minute maximum duration for bootstrap (non-reciprocal) sessions</li>
<li>Address migration: when a peer's IP changes (Wi-Fi to cellular), a signed
<code>RelayMigrate</code> updates the session without tearing it down</li>
<li>Idle cleanup with configurable timeout</li>
<li>8 unit tests plus 2 property-based tests</li>
</ul>
<p><strong>DHT message extensions</strong> (<code>tesseras-dht/src/message.rs</code>) — Seven new message
variants added to the DHT protocol:</p>
<table><thead><tr><th>Message</th><th>Purpose</th></tr></thead><tbody>
<tr><td><code>PunchIntro</code></td><td>"I want to connect to node X, here's my signed external address"</td></tr>
<tr><td><code>PunchRequest</code></td><td>Introducer forwards the request to the target</td></tr>
<tr><td><code>PunchReady</code></td><td>Target confirms readiness, sends its external address</td></tr>
<tr><td><code>RelayRequest</code></td><td>"Create a relay session to node X"</td></tr>
<tr><td><code>RelayOffer</code></td><td>Relay responds with its address and session token</td></tr>
<tr><td><code>RelayClose</code></td><td>Tear down a relay session</td></tr>
<tr><td><code>RelayMigrate</code></td><td>Update session after network change</td></tr>
</tbody></table>
<p>The <code>Pong</code> message was extended with NAT metadata: <code>nat_type</code>,
<code>relay_slots_available</code>, and <code>relay_bandwidth_used_kbps</code>. All new fields use
<code>#[serde(default)]</code> for backward compatibility — old nodes ignore what they
don't recognize, new nodes fall back to defaults. 9 new serialization roundtrip
tests.</p>
<p><strong>NatHandler trait and dispatch</strong> (<code>tesseras-dht/src/engine.rs</code>) — A new
<code>NatHandler</code> async trait (5 methods) injected into the DHT engine, following the
same dependency injection pattern as the existing <code>ReplicationHandler</code>. The
engine's message dispatch loop now routes all punch/relay messages to the
handler. This keeps the DHT engine protocol-agnostic while allowing the NAT
traversal logic to live in <code>tesseras-net</code>.</p>
<p><strong>Mobile reconnection types</strong> (<code>tesseras-embedded/src/reconnect.rs</code>) — A
three-phase reconnection state machine for mobile devices:</p>
<ol>
<li><strong>QuicMigration</strong> (0-2s) — try QUIC connection migration for all active peers</li>
<li><strong>ReStun</strong> (2-5s) — re-discover external address via STUN</li>
<li><strong>ReEstablish</strong> (5-10s) — reconnect peers that migration couldn't save</li>
</ol>
<p>Peers are reconnected in priority order: bootstrap nodes first, then nodes
holding our fragments, then nodes whose fragments we hold, then general DHT
neighbors. A new <code>NetworkChanged</code> event variant was added to the FFI event
stream so the Flutter app can show reconnection progress.</p>
<p><strong>Daemon NAT configuration</strong> (<code>tesd/src/config.rs</code>) — A new <code>[nat]</code> section in
the TOML config with STUN server list, relay toggle, max relay sessions,
bandwidth limits (reciprocal vs bootstrap), and idle timeout. All fields have
sensible defaults; relay is disabled by default.</p>
<p><strong>Prometheus metrics</strong> (<code>tesseras-net/src/metrics.rs</code>) — 16 metrics across four
subsystems:</p>
<ul>
<li><strong>STUN</strong>: requests, failures, latency histogram</li>
<li><strong>Punch</strong>: attempts/successes/failures (by NAT type pair), latency histogram</li>
<li><strong>Relay</strong>: active sessions, total sessions, bytes forwarded, idle timeouts,
rate limit hits</li>
<li><strong>Reconnect</strong>: network changes, attempts/successes by phase, duration
histogram</li>
</ul>
<p>6 tests verifying registration, increment, label cardinality, and
double-registration detection.</p>
<p><strong>Integration tests</strong> — Two end-to-end tests using <code>MemTransport</code> (in-memory
simulated network):</p>
<ul>
<li><code>punch_integration.rs</code> — Full 3-node hole-punch flow: A sends signed
<code>PunchIntro</code> to introducer I, I verifies and forwards <code>PunchRequest</code> to B, B
verifies the original signature and sends <code>PunchReady</code> back, A and B exchange
messages directly. Also tests that a bad signature is correctly rejected.</li>
<li><code>relay_integration.rs</code> — Full 3-node relay flow: A requests relay from R, R
creates session and sends <code>RelayOffer</code> to both peers, A and B exchange
token-prefixed packets through R, A migrates to a new address mid-session, A
closes the session, and the test verifies the session is torn down and further
forwarding fails.</li>
</ul>
<p><strong>Property tests</strong> — 7 proptest-based tests covering: signature round-trips for
all three signed message types (arbitrary node IDs, ports, tokens), NAT
classification determinism (same inputs always produce same output), STUN
binding request validity, session token uniqueness, and relay rejection of
too-short packets.</p>
<p><strong>Justfile targets</strong> — <code>just test-nat</code> runs all NAT traversal tests across
<code>tesseras-net</code> and <code>tesseras-dht</code>. <code>just test-chaos</code> is a placeholder for future
Docker Compose chaos tests with <code>tc netem</code>.</p>
<h2 id="architecture-decisions">Architecture decisions</h2>
<ul>
<li><strong>STUN over TURN</strong>: we implement STUN (discovery) and custom relay rather than
full TURN. TURN requires authenticated allocation and is designed for media
relay; our relay is simpler — token-prefixed UDP forwarding with rate limits.
This keeps the protocol minimal and avoids depending on external TURN servers.</li>
<li><strong>Signatures on introductions</strong>: every <code>PunchIntro</code> is signed by the
initiator. Without this, an attacker could send forged introductions to
redirect a node's hole-punch attempts to an attacker-controlled address (a
reflection attack). The 30-second timestamp window limits replay.</li>
<li><strong>Reciprocal bandwidth tiers</strong>: relay nodes give 4x more bandwidth (256 vs 64
KB/s) to peers with good reciprocity scores. This incentivizes nodes to store
fragments for others — if you contribute, you get better relay service when
you need it.</li>
<li><strong>Backward-compatible Pong extension</strong>: new NAT fields in <code>Pong</code> use
<code>#[serde(default)]</code> and <code>Option<T></code>. Old nodes that don't understand these
fields simply skip them during deserialization. No protocol version bump
needed.</li>
<li><strong>NatHandler as async trait</strong>: the NAT traversal logic is injected into the
DHT engine via a trait, just like <code>ReplicationHandler</code>. This keeps the DHT
engine focused on routing and peer management, and allows the NAT
implementation to be swapped or disabled without touching core DHT code.</li>
</ul>
<h2 id="what-comes-next">What comes next</h2>
<ul>
<li><strong>Phase 4 continued</strong> — performance tuning (connection pooling, fragment
caching, SQLite WAL), security audits, institutional node onboarding, OS
packaging</li>
<li><strong>Phase 5: Exploration and Culture</strong> — public tessera browser by
era/location/theme/language, institutional curation, genealogy integration,
physical media export (M-DISC, microfilm, acid-free paper with QR)</li>
</ul>
<p>With NAT traversal, Tesseras can connect nodes regardless of their network
topology. Public nodes talk directly. Cone-NATed nodes punch through with an
introducer's help. Symmetric-NATed or firewalled nodes relay through willing
peers. The network adapts to the real world, where most devices are behind a NAT
and network conditions change constantly.</p>
</article>
</main>
<footer>
<p>© 2026 Tesseras Project. <a href="/atom.xml">News Feed</a> · <a href="https://git.sr.ht/~ijanc/tesseras">Source</a></p>
</footer>
</body>
</html>
|