ts_dataplane/lib.rs
1#![doc = include_str!("../README.md")]
2
3use std::{collections::HashMap, sync::Arc, time::Instant};
4
5use ts_bart::RoutingTable;
6use ts_overlay_router as or;
7use ts_packet::PacketMut;
8use ts_packetfilter::{FilterExt, IpProto};
9use ts_time::{Handle, Scheduler};
10use ts_transport::{OverlayTransportId, PeerId, UnderlayTransportId};
11use ts_tunnel::{Endpoint, NodeKeyPair};
12use ts_underlay_router as ur;
13
14pub mod async_tokio;
15
16/// The single link-local destination Go's filter `pre()` exempts from the link-local drop: the
17/// cloud-metadata address `169.254.169.254` (Go `isAllowedLinkLocal`).
18const ALLOWED_LINK_LOCAL_V4: std::net::Ipv4Addr = std::net::Ipv4Addr::new(169, 254, 169, 254);
19
20/// Whether an inbound packet to destination `dst` must be dropped BEFORE consulting the ACL rules,
21/// mirroring Go's filter `pre()`: drop multicast destinations (`ReasonMulticast`) and link-local
22/// unicast destinations that are not the allowlisted cloud-metadata address (`ReasonLinkLocalUnicast`).
23/// Returning `true` means drop. This runs ahead of `can_access` so a permissive ACL cannot admit the
24/// multicast / link-local traffic Go rejects unconditionally.
25///
26/// Go's `isAllowedLinkLocal` is `dst == gcpDNSAddr || any(LinkLocalAllowHooks)`; only the static
27/// `gcpDNSAddr` arm is modeled here. The dynamic `LinkLocalAllowHooks` slice is empty in a plain
28/// engine/tsnet embedding (its only upstream producer is the GCP metadata path), so the omission is
29/// behaviorally equivalent for this fork; a feature that needs a dynamic link-local allowlist would
30/// have to extend this. Like Go's `netip.Addr` predicates, an IPv4-mapped-IPv6 destination (e.g.
31/// `::ffff:224.0.0.1`) matches NEITHER arm and falls through to the ACL — we deliberately do not
32/// canonicalize/unmap, to stay byte-faithful to Go (see the mapped-v6 test cases).
33fn drop_before_rules(dst: std::net::IpAddr) -> bool {
34 if dst.is_multicast() {
35 return true;
36 }
37 match dst {
38 // IPv4 link-local is 169.254.0.0/16; allow only the cloud-metadata address (Go parity).
39 std::net::IpAddr::V4(v4) => v4.is_link_local() && v4 != ALLOWED_LINK_LOCAL_V4,
40 // IPv6 unicast link-local is fe80::/10. (`Ipv6Addr::is_unicast_link_local` is unstable, so
41 // test the prefix directly.) This fork is IPv4-only by default, but match Go for any v6.
42 std::net::IpAddr::V6(v6) => (v6.segments()[0] & 0xffc0) == 0xfe80,
43 }
44}
45
46/// IPv4 fragment state read from the base header (Go `net/packet.decode4` reads `b[6:8]`): the
47/// fragment offset in 8-byte blocks and the more-fragments flag. A non-first fragment carries no L4
48/// header, so it needs its own verdict path rather than the (always-port-0) ACL match.
49#[derive(Debug, Clone, Copy)]
50struct Ipv4Fragment {
51 /// Fragment offset in 8-byte blocks (the 13-bit IPv4 field), 0 for the first/only fragment.
52 offset_blocks: u16,
53 /// The "more fragments" (MF) flag.
54 more_fragments: bool,
55}
56
57/// Minimum fragment offset (in 8-byte blocks) Go permits for a non-first fragment — Go
58/// `net/packet.minFragBlks = (60 + 20) / 8 = 10` (max IPv4 header + a basic TCP header). A later
59/// fragment starting before this could overlap a transport header (the RFC 1858 overlapping-fragment
60/// evasion), so Go demotes it to `unknown` and drops it; only fragments at or beyond this offset are
61/// allowed to "slide through".
62///
63/// Upstream reuses this one bound for IPv6 too (Go `net/packet` `26b2ed0a6` documents the reuse):
64/// it is sized for IPv4 and is therefore *conservative* for IPv6, whose fragments carry no
65/// per-fragment IP header — so on the v6 side it only ever rejects more later fragments as
66/// `unknown`, never fewer. Keep the single constant for both, exactly as Go does.
67const MIN_FRAG_BLKS: u16 = (60 + 20) / 8;
68
69/// Fixed IPv6 base header length (Go `net/packet.ip6HeaderLength`).
70const IP6_HEADER_LEN: usize = 40;
71
72/// IANA protocol number of the IPv6 Fragment extension header, "IPv6-Frag" (Go
73/// `net/packet.ip6FragHeader`). It appears as the **base** header's Next Header on a
74/// source-fragmented IPv6 packet, and is distinct from Go's internal `ipproto.Fragment` sentinel
75/// (0xff), which marks a non-first fragment whose sub-protocol header is not present.
76const IP6_FRAG_HEADER: u8 = 44;
77
78/// Length of the IPv6 Fragment extension header (Go `net/packet.ip6FragHeaderLength`): Next Header,
79/// Reserved, a 13-bit Fragment Offset in 8-byte blocks plus two reserved bits and the
80/// More-Fragments flag, then a 32-bit Identification.
81const IP6_FRAG_HEADER_LEN: usize = 8;
82
83/// How an IPv6 packet whose base header's Next Header is the Fragment extension header classifies —
84/// the port of Go `net/packet.Parsed.decode6Fragment` plus the sub-protocol switch `decode6` runs
85/// when it reports `continueDecode` (upstream `4c4ec3d46`, clarified by `26b2ed0a6`).
86///
87/// This is the IPv6 half of the RFC 1858 fragment rules [`Ipv4Fragment`] already carries. It only
88/// matters on the opt-in `Config::enable_ipv6` path — the tailnet is IPv4-only by default — but
89/// without it a source-fragmented IPv6 datagram reaches the ACL with no sub-protocol and port 0,
90/// so an allow-all rule admits the very low-offset fragments upstream drops, and a port-scoped rule
91/// blackholes the later fragments upstream passes through.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum Ipv6Fragment {
94 /// Go's `unknown`, which filter `pre()` drops outright: a Fragment header truncated by the
95 /// packet, a *first* fragment too short to hold its own transport header, a later fragment at
96 /// an offset small enough to overlap that transport header on reassembly (RFC 1858), the
97 /// on-the-wire use of Go's internal `ipproto.Fragment` sentinel, or a Fragment header reached
98 /// through a chained extension header rather than as the base header's immediate Next Header
99 /// ([`fragment_header_is_chained`]).
100 Unknown,
101 /// Go's `ipproto.Fragment`: a later fragment at a safe offset. It carries no sub-protocol
102 /// header, so there is nothing for a rule to match on and filter `pre()` passes it through
103 /// ahead of the ACL — statelessly, exactly as for IPv4. RFC 8200 §4.5 requires the receiver to
104 /// reassemble, and its kernel drops the pieces if the head fragment never arrives.
105 Later,
106 /// Go's `continueDecode == true`: the first fragment. `decode6` steps over the 8-byte Fragment
107 /// header and parses the real sub-protocol's header, so the ACL matches this datagram on the
108 /// same rule it would match unfragmented.
109 First {
110 /// The Fragment header's Next Header — the real sub-protocol (Go `q.IPProto = nextHdr`).
111 proto: IpProto,
112 /// The destination port read from that sub-protocol's header, 0 for a protocol Go does not
113 /// port-match (Go `withPort(q.Dst, ...)`).
114 dst_port: u16,
115 },
116}
117
118/// Classify a whole IPv6 packet `b` whose base header's Next Header is [`IP6_FRAG_HEADER`], as Go
119/// `net/packet.Parsed.decode6` does when it dispatches to `decode6Fragment`.
120///
121/// Callers must have already checked that immediate Next Header byte: Go parses the Fragment header
122/// **only** as the base header's immediate next header (upstream `26b2ed0a6` added a test locking
123/// that scoping in). No other extension header, and no IPSec AH/ESP header, is parsed here either —
124/// same as Go. A Fragment header reached through a chained extension header is *not* this
125/// function's business; it is [`fragment_header_is_chained`]'s, which classifies it
126/// [`Ipv6Fragment::Unknown`] so it is dropped.
127fn decode6_fragment(b: &[u8]) -> Ipv6Fragment {
128 // Go `q.length = BE16(b[4:6]) + ip6HeaderLength; if len(b) < q.length` — a packet cut off before
129 // its declared payload is `unknown`.
130 if b.len() < IP6_HEADER_LEN {
131 return Ipv6Fragment::Unknown;
132 }
133 let length = usize::from(u16::from_be_bytes([b[4], b[5]])) + IP6_HEADER_LEN;
134 if b.len() < length {
135 return Ipv6Fragment::Unknown;
136 }
137
138 // Go `if len(b) < q.subofs+ip6FragHeaderLength` with `q.subofs == 40`.
139 let Some(frag) = b.get(IP6_HEADER_LEN..) else {
140 return Ipv6Fragment::Unknown;
141 };
142 if frag.len() < IP6_FRAG_HEADER_LEN {
143 return Ipv6Fragment::Unknown;
144 }
145
146 let next_header = frag[0];
147 // Go `fragOfs := binary.BigEndian.Uint16(frag[2:4]) >> 3`: the top 13 bits are the offset in
148 // 8-byte blocks; the low 3 are two reserved bits and the More-Fragments flag. Go reads no MF
149 // flag here at all — unlike `decode4`, `decode6` has no more-fragments guard on the first
150 // fragment, so a first IPv6 fragment is decoded exactly like an unfragmented packet (TSMP
151 // included, where `decode4` instead demotes a fragmented first packet to `unknown`).
152 let frag_ofs = u16::from_be_bytes([frag[2], frag[3]]) >> 3;
153
154 // Go steps `q.subofs += ip6FragHeaderLength` before branching; `sub` is what follows.
155 let sub = &frag[IP6_FRAG_HEADER_LEN..];
156
157 if frag_ofs == 0 {
158 return decode6_first_fragment(IpProto::new(i64::from(next_header)), sub);
159 }
160 if frag_ofs < MIN_FRAG_BLKS {
161 // RFC 1858: this fragment's bytes could land on top of the transport header the ACL matched
162 // the head fragment on. Go `q.IPProto = unknown`, same guard as `decode4`.
163 return Ipv6Fragment::Unknown;
164 }
165 Ipv6Fragment::Later
166}
167
168/// The sub-protocol switch Go `decode6` runs on a first fragment once `decode6Fragment` has stepped
169/// over the Fragment header. `sub` is the buffer from the sub-protocol's header onwards (Go's
170/// `sub := b[q.subofs:]`, measured against the buffer, not the IPv6 length field).
171///
172/// Each arm's bounds check is Go's, and each failure is Go's `unknown`: a first fragment too short
173/// to hold the transport header must be **dropped**, never guessed at, or a follow-up fragment
174/// supplying the rest of that header would carry the flow past a rule the filter never really
175/// matched (RFC 1858, the same reason `decode4` rejects a short first fragment).
176fn decode6_first_fragment(proto: IpProto, sub: &[u8]) -> Ipv6Fragment {
177 /// Go `net/packet.icmp6HeaderLength`.
178 const ICMP6_HEADER_LEN: usize = 4;
179 /// Go `net/packet.tcpHeaderLength`.
180 const TCP_HEADER_LEN: usize = 20;
181 /// Go `net/packet.udpHeaderLength`.
182 const UDP_HEADER_LEN: usize = 8;
183 /// Go `net/packet.sctpHeaderLength`.
184 const SCTP_HEADER_LEN: usize = 12;
185 /// Go `net/packet.minTSMPSize` — the shortest TSMP body (a 7-byte rejected-connection message).
186 const MIN_TSMP_SIZE: usize = 7;
187 /// Go's internal `ipproto.Fragment` sentinel. Seeing it as a real Next Header is suspicious, so
188 /// Go maps it back to `unknown`.
189 const IPPROTO_FRAGMENT_SENTINEL: IpProto = IpProto::new(0xff);
190
191 // Go's port-ful arms: bounds-check, then read the destination port from `sub[2:4]`.
192 let ported = |min_len: usize| {
193 if sub.len() < min_len {
194 return Ipv6Fragment::Unknown;
195 }
196 Ipv6Fragment::First {
197 proto,
198 dst_port: u16::from_be_bytes([sub[2], sub[3]]),
199 }
200 };
201 // Go's portless arms: bounds-check only, both ports left at 0.
202 let portless = |min_len: usize| {
203 if sub.len() < min_len {
204 return Ipv6Fragment::Unknown;
205 }
206 Ipv6Fragment::First { proto, dst_port: 0 }
207 };
208
209 match proto {
210 IpProto::ICMPV6 => portless(ICMP6_HEADER_LEN),
211 IpProto::TCP => ported(TCP_HEADER_LEN),
212 IpProto::UDP => ported(UDP_HEADER_LEN),
213 IpProto::SCTP => ported(SCTP_HEADER_LEN),
214 IpProto::TSMP => portless(MIN_TSMP_SIZE),
215 IPPROTO_FRAGMENT_SENTINEL => Ipv6Fragment::Unknown,
216 // Go's switch has no default arm: any other protocol keeps its number and port 0, and the
217 // ACL matches it IPs-only (`IpProto::is_port_ful`).
218 _ => Ipv6Fragment::First { proto, dst_port: 0 },
219 }
220}
221
222/// Whether `ipv6` carries a Fragment extension header somewhere in its extension-header chain
223/// *other than* as the base header's immediate Next Header — the case [`decode6_fragment`] is
224/// deliberately not scoped to, and which must therefore fail closed here.
225///
226/// Callers must only ask this when the base header's Next Header is **not** [`IP6_FRAG_HEADER`];
227/// otherwise the leading Fragment header itself answers `true` and would shadow its own
228/// classification.
229///
230/// Why a drop and not a pass. Go's `decode6` sets `q.IPProto` from the base header's Next Header
231/// and steps over *only* a leading Fragment header, so a hop-by-hop-chained fragment leaves
232/// `q.IPProto == 0 == ipproto.Unknown` and filter `pre()` drops it before the ACL ever runs. This
233/// tree instead reads the sub-protocol out of etherparse's extension-header walk, which resolves
234/// straight through the chain to the real transport number — so without this check the packet
235/// reaches the ACL looking like an ordinary TCP/UDP datagram whose port merely happens to be 0
236/// (etherparse refuses to descend into a fragmenting payload), and a permissive "allow the whole
237/// tailnet" rule *admits* it. That re-opens the entire RFC 1858 hole this classification exists to
238/// close: prepend an 8-byte Hop-by-Hop Options header and a low-offset later fragment — the one
239/// whose bytes can land on top of the transport header the head fragment was matched on — slides
240/// past, as does a first fragment truncated before its own transport header. The fragment rules
241/// must not be defeatable by an extension header the attacker chooses to prepend, so anything
242/// carrying a chained Fragment header is [`Ipv6Fragment::Unknown`].
243///
244/// This drops a strict subset of what Go drops here (Go rejects the whole chained-extension-header
245/// class, fragmenting or not), so it cannot admit anything upstream refuses and cannot break a real
246/// Tailscale, `wireguard-go` or kernel-WireGuard peer: upstream would discard such a packet too, so
247/// no peer can already be relying on one being delivered.
248fn fragment_header_is_chained(ipv6: ðerparse::Ipv6Slice<'_>) -> bool {
249 ipv6.extensions()
250 .clone()
251 .into_iter()
252 .any(|ext| matches!(ext, etherparse::Ipv6ExtensionSlice::Fragment(_)))
253}
254
255/// Which address family's fragment rules apply to a packet, so [`inbound_filter_verdict`] can run
256/// Go's `decode4` and `decode6` fragment classifications on the packets each actually governs.
257#[derive(Debug, Clone, Copy)]
258enum Fragment {
259 /// IPv4: the offset and MF flag straight out of the base header (Go `decode4`).
260 V4(Ipv4Fragment),
261 /// IPv6: the already-resolved classification of a Fragment extension header (Go `decode6`).
262 V6(Ipv6Fragment),
263}
264
265/// The inbound packet-filter verdict for an already-parsed packet (`true` = admit). This is the
266/// proto-switch of Go's filter `runIn4`/`runIn6`, applied after `pre()` and after this fork's
267/// source-attribution and local-destination routing (the analogues of Go's `local4`/`local6`
268/// precondition) have run:
269///
270/// 1. `drop_before_rules` — Go `pre()`'s unconditional multicast / link-local-unicast drops.
271/// 2. **Fragment classification** (Go `net/packet.decode4`/`decode6` + filter `pre()`): a non-first
272/// fragment carries no L4 header, so it cannot be port-matched. Go classifies it by offset — a
273/// fragment at offset `>= MIN_FRAG_BLKS` is mapped to `ipproto.Fragment` and `pre()` **accepts**
274/// it (stateless pass-through; the receiver's kernel discards it if the head fragment was
275/// dropped), while a fragment at a smaller offset is dropped (RFC 1858). On IPv4 a *fragmented*
276/// TSMP is additionally disallowed (`moreFrags` on a first TSMP fragment → drop). Without this,
277/// etherparse leaves the transport `None` and the port reads as 0, so a normal ACL rule would
278/// silently drop every valid later fragment — breaking large/fragmented inbound traffic on the
279/// 1280-MTU overlay. The IPv6 half ([`Ipv6Fragment`], Go `decode6Fragment`) additionally folds in
280/// the sub-protocol decode of a *first* fragment, so `proto`/`dst_port` here are already the ones
281/// read past the Fragment extension header, and `Ipv6Fragment::Unknown` — a truncated or
282/// short-first fragment, or one whose Fragment header sits behind a chained extension header
283/// ([`fragment_header_is_chained`]) — is dropped where Go's `pre()` drops `ipproto.Unknown`.
284/// 3. TSMP (proto 99) is always admitted, bypassing the ACL — Go `case ipproto.TSMP: return Accept`.
285/// TSMP carries in-band control messages between nodes, so it must reach the local stack
286/// regardless of the ACL rules.
287/// 4. Everything else consults the control-derived ACL via `can_access` — Go's `matches4.match`.
288fn inbound_filter_verdict(
289 filter: &(dyn ts_packetfilter::Filter + Send + Sync),
290 proto: IpProto,
291 src: std::net::IpAddr,
292 dst: std::net::IpAddr,
293 dst_port: u16,
294 frag: Option<Fragment>,
295) -> bool {
296 if drop_before_rules(dst) {
297 tracing::trace!(?dst, "dropping multicast/link-local dst (pre-rule)");
298 return false;
299 }
300
301 match frag {
302 Some(Fragment::V4(frag)) => {
303 if frag.offset_blocks > 0 {
304 // A non-first fragment (Go `decode4`'s `fragOfs != 0` branch). It has no transport
305 // header to match, so the verdict is decided purely by offset:
306 if frag.offset_blocks < MIN_FRAG_BLKS {
307 // Potentially overlaps a transport header (RFC 1858); Go demotes to `unknown` → drop.
308 tracing::trace!(?dst, "dropping low-offset IPv4 fragment (RFC 1858)");
309 return false;
310 }
311 // A valid later fragment — Go maps it to `ipproto.Fragment`, which `pre()` accepts
312 // ahead of the ACL. Stateless: if the head fragment was filtered the receiver's kernel
313 // drops this on reassembly timeout. Accepting here is what large fragmented inbound
314 // traffic relies on.
315 tracing::trace!(
316 ?dst,
317 "accepting later IPv4 fragment (Go pre() pass-through)"
318 );
319 return true;
320 }
321 // `frag.offset_blocks == 0`: the first fragment (or an unfragmented packet). Go disallows a
322 // *fragmented* TSMP (a first fragment with MF set) — without the whole message it can't be a
323 // valid inter-node control packet. Fall through to the normal proto-switch for everything
324 // else; the first fragment of TCP/UDP carries its L4 header, so `dst_port` was parsed above.
325 if proto == IpProto::TSMP && frag.more_fragments {
326 tracing::trace!(?dst, "dropping fragmented TSMP (Go parity)");
327 return false;
328 }
329 }
330 // The IPv6 Fragment extension header (Go `decode6Fragment`, upstream `4c4ec3d46`). Only
331 // reachable on the opt-in `Config::enable_ipv6` path; the classification itself already ran
332 // Go's offset and bounds checks, so all that is left is Go's `pre()` disposition of the
333 // three protocol values `decode6` can end up with.
334 Some(Fragment::V6(Ipv6Fragment::Unknown)) => {
335 // Go `pre()`: `if q.IPProto == ipproto.Unknown { return Drop }`. This is the
336 // security-relevant arm — a short first fragment, an RFC 1858 low-offset later
337 // fragment, or a Fragment header hidden behind a chained extension header must never
338 // reach the ACL, where an allow-all rule would admit it.
339 tracing::trace!(
340 ?dst,
341 "dropping IPv6 fragment classified unknown (Go pre() drop)"
342 );
343 return false;
344 }
345 Some(Fragment::V6(Ipv6Fragment::Later)) => {
346 // Go `pre()`: `case ipproto.Fragment: return Accept`, same stateless pass-through as
347 // IPv4 — and required by RFC 8200 §4.5, which puts reassembly on the receiver.
348 tracing::trace!(
349 ?dst,
350 "accepting later IPv6 fragment (Go pre() pass-through)"
351 );
352 return true;
353 }
354 // A first IPv6 fragment: `proto` and `dst_port` were read past the Fragment header, so it
355 // takes the ordinary proto switch below and matches the rule an unfragmented datagram would.
356 // Note the deliberate asymmetry with IPv4: `decode6` has no more-fragments guard at all, so
357 // — unlike `decode4` — upstream does not demote a fragmented first TSMP packet to `unknown`.
358 Some(Fragment::V6(Ipv6Fragment::First { .. })) | None => {}
359 }
360
361 if proto == IpProto::TSMP {
362 tracing::trace!(?dst, "accepting TSMP inbound (bypasses ACL, Go parity)");
363 return true;
364 }
365
366 let info = ts_packetfilter::PacketInfo {
367 ip_proto: proto,
368 port: dst_port,
369 src,
370 dst,
371 };
372 // TODO(npry): wire in nodecaps
373 let caps = [];
374 let verdict = filter.can_access(&info, caps);
375 tracing::trace!(?info, ?caps, verdict);
376 verdict
377}
378
379/// Apply the inbound packet filter to one peer's already-source-attributed batch of decrypted
380/// packets, in place, and harvest any TSMP disco-key advertisements it carried.
381///
382/// This is the body of Go's `tstun.Wrapper.filterPacketInboundFromWireGuard`, in Go's order:
383///
384/// 1. **TSMP consumption.** Go inspects TSMP *before* running the ACL filter and returns
385/// `filter.DropSilently` for the messages it consumes itself. The one consumed here is the
386/// disco-key advertisement (Go `packet.TSMPDiscoKeyAdvertisement`, upstream capability version
387/// 144): a peer announces its disco public key right after an eligible WireGuard session comes
388/// up, so the receiver learns it without waiting for a netmap update or restarting WireGuard.
389/// A real Go peer sends this unprompted. Every *other* TSMP message (ping, pong,
390/// rejected-connection) is left in the batch and falls through to step 2, which admits it —
391/// exactly as Go's filter does for the TSMP types it does not consume.
392/// 2. **The ACL verdict**, [`inbound_filter_verdict`] (Go `runIn4`/`runIn6`).
393///
394/// `learned_disco_keys` is appended to, never cleared, so one batch can carry advertisements from
395/// several peers. A learned key is attributed to `peer_id` — the WireGuard peer whose session
396/// decrypted the packet, and whose source addresses the caller's source filter has already bound.
397/// Go reaches the same peer the long way round, looking the advertisement's source IP up in the
398/// netmap (`wgengine.userspaceEngine.peerForIP`). Either way a peer can only advertise a key for
399/// *itself*: it cannot speak for another peer.
400fn filter_inbound_from_peer(
401 filter: &(dyn ts_packetfilter::Filter + Send + Sync),
402 peer_id: PeerId,
403 packets: &mut Vec<PacketMut>,
404 learned_disco_keys: &mut Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
405) {
406 packets.retain(|packet| {
407 let bytes = packet.as_ref();
408 let Ok(pkt) = etherparse::SlicedPacket::from_ip(bytes) else {
409 tracing::trace!("does not look like ip packet");
410 return false;
411 };
412
413 let (proto, src, dst, frag) = match pkt.net {
414 Some(etherparse::NetSlice::Ipv4(ipv4)) => {
415 // IPv4 fragment state (Go `net/packet.decode4` reads `b[6:8]`): a
416 // non-first fragment carries no L4 header, so etherparse leaves
417 // `transport == None` and the port would read as 0 below — which a normal
418 // ACL rule never admits. Without classifying the fragment that silently
419 // drops valid later fragments Go *accepts* (breaking large/fragmented
420 // inbound traffic on the 1280-MTU overlay). Capture the offset (in 8-byte
421 // blocks) + the more-fragments bit so the verdict can mirror Go's
422 // `decode4`/`pre()` fragment handling.
423 let hdr = ipv4.header();
424 (
425 IpProto::new(ipv4.payload().ip_number.0 as _),
426 hdr.source_addr().into(),
427 hdr.destination_addr().into(),
428 Some(Fragment::V4(Ipv4Fragment {
429 offset_blocks: hdr.fragments_offset().value(),
430 more_fragments: hdr.more_fragments(),
431 })),
432 )
433 }
434 Some(etherparse::NetSlice::Ipv6(ipv6)) => {
435 let hdr = ipv6.header();
436 // IPv6 fragmentation is carried in a Fragment extension header, not the
437 // base header. Go `decode6` parses that header — and *only* when it is the
438 // base header's immediate Next Header. `next_header()` is exactly that
439 // immediate byte, so testing it here reproduces upstream's scoping. Only
440 // reachable under the opt-in `Config::enable_ipv6`; the tailnet is IPv4-only
441 // by default.
442 //
443 // A Fragment header reached through a *chained* hop-by-hop / routing /
444 // destination-options / AH header is outside that scope, and must fail
445 // closed rather than fall through to the ACL: Go drops it (the base Next
446 // Header leaves `q.IPProto` at `ipproto.Unknown`, which `pre()` refuses),
447 // whereas etherparse resolves the real sub-protocol through the chain, so an
448 // allow-all rule would otherwise admit exactly the RFC 1858 fragments this
449 // classification exists to reject. See `fragment_header_is_chained`.
450 let frag = if hdr.next_header().0 == IP6_FRAG_HEADER {
451 Some(decode6_fragment(bytes))
452 } else if fragment_header_is_chained(&ipv6) {
453 Some(Ipv6Fragment::Unknown)
454 } else {
455 None
456 };
457 let proto = match frag {
458 // Go `q.IPProto = nextHdr`: the first fragment's real sub-protocol, read
459 // past the 8-byte Fragment header.
460 Some(Ipv6Fragment::First { proto, .. }) => proto,
461 // A later or malformed fragment has no sub-protocol at all (Go's
462 // `ipproto.Fragment` / `unknown`); the verdict decides on the
463 // classification alone and never consults this.
464 Some(Ipv6Fragment::Later | Ipv6Fragment::Unknown) => IpProto::new(0),
465 None => IpProto::new(ipv6.payload().ip_number.0 as _),
466 };
467 (
468 proto,
469 hdr.source_addr().into(),
470 hdr.destination_addr().into(),
471 frag.map(Fragment::V6),
472 )
473 }
474 _ => {
475 // A packet that parsed as IP but is neither IPv4 nor IPv6 (e.g. a
476 // future/odd `NetSlice` shape). These bytes are attacker-controlled
477 // post-decrypt, so fail closed — drop it — rather than `unreachable!`,
478 // which would panic the single-threaded dataplane on a crafted packet.
479 // Go's filter `pre()` likewise returns Drop/"not-ip" here, never panics.
480 tracing::trace!("parsed packet is neither IPv4 nor IPv6; dropping");
481 return false;
482 }
483 };
484
485 // Go `decode6` reads a *first* IPv6 fragment's transport ports past the Fragment
486 // extension header, so a fragmented datagram matches the same rule as an
487 // unfragmented one. etherparse deliberately refuses to descend into a fragmenting
488 // payload and leaves `transport == None`, so that port comes from the
489 // classification above instead.
490 let dst_port = match frag {
491 Some(Fragment::V6(Ipv6Fragment::First { dst_port, .. })) => dst_port,
492 _ => match pkt.transport {
493 Some(etherparse::TransportSlice::Udp(udp)) => udp.destination_port(),
494 Some(etherparse::TransportSlice::Tcp(tcp)) => tcp.destination_port(),
495 _ => 0,
496 },
497 };
498
499 // TSMP disco-key advertisement (Go `packet.TSMPDiscoKeyAdvertisement`,
500 // upstream capability version 144). Go handles TSMP in
501 // `tstun.filterPacketInboundFromWireGuard` *before* the ACL filter runs, and
502 // returns `filter.DropSilently` for an advertisement: it is an inter-node
503 // control message consumed here, never delivered to the local stack. Mirror
504 // both the position (after source attribution, before the ACL) and the drop.
505 //
506 if proto == IpProto::TSMP
507 && let Some(advert) = ts_packet::tsmp::DiscoKeyAdvertisement::parse(bytes)
508 {
509 if advert.key_is_zero() {
510 // Go publishes only `if !discoKeyAdvert.Key.IsZero()`. Still a
511 // well-formed advertisement, so it is still dropped.
512 tracing::debug!(
513 ?peer_id,
514 "TSMP disco-key advertisement carried the zero key; ignoring"
515 );
516 } else {
517 tracing::debug!(?peer_id, %src, "learned peer disco key over TSMP");
518 learned_disco_keys.push((peer_id, advert));
519 }
520 return false;
521 }
522
523 // The inbound proto-switch (Go `runIn4`/`runIn6`): Go `pre()` multicast/link-local
524 // drops, then the fragment classification (Go `decode4` + `pre()`), then
525 // unconditional TSMP accept, then the control-derived ACL. The caller's source
526 // attribution and `or_in.route` bound this to attributable peers and local
527 // destinations (Go's `local4`/`local6` precondition).
528 inbound_filter_verdict(filter, proto, src, dst, dst_port, frag)
529 });
530}
531
532/// Where this node sends a TSMP disco-key advertisement, and what it puts in one.
533///
534/// The send half of Go's capability version 144 (`packet.TSMPDiscoKeyAdvertisement`): when a
535/// WireGuard session with a peer is established, this node announces its own disco public key to
536/// that peer over TSMP, so the peer can learn (or re-learn) the key without waiting for a netmap
537/// update from control. It is the mirror image of the receive half in
538/// [`filter_inbound_from_peer`], and both are unconditional — a real Go peer sends us one whether
539/// or not we send one back.
540///
541/// This is the netmap state Go's [`magicsock.Conn.PriorityMessageForPeer`] reads, snapshotted into
542/// the dataplane so building the message stays a cheap, synchronous, allocation-only step on the
543/// datapath. wireguard-go requires the same of its callback: "must be cheap and must not call back
544/// into the [`Device`]". The runtime refreshes the snapshot whenever the netmap changes.
545///
546/// [`magicsock.Conn.PriorityMessageForPeer`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
547/// [`Device`]: https://github.com/tailscale/wireguard-go/blob/main/device/device.go
548#[derive(Debug, Clone, Default)]
549pub struct DiscoAdvertisementState {
550 /// This node's own disco public key, raw (Go `Conn.DiscoPublicKey()`). The all-zero key means
551 /// "no disco key", and nothing is ever advertised — Go's first refusal.
552 pub disco_key: [u8; ts_packet::tsmp::DISCO_KEY_LEN],
553 /// This node's own tailnet addresses, in the order control sent them (Go `self.Addresses()`,
554 /// already narrowed to the single-IP prefixes `selfIPMatchingFamily` accepts). The
555 /// advertisement's source is the first entry matching the destination's family.
556 pub self_addrs: Vec<std::net::IpAddr>,
557 /// Where to send an advertisement, per peer. A peer absent from this map is never advertised
558 /// to — Go's `endpointForNodeKey` miss.
559 pub peers: HashMap<PeerId, AdvertisementTarget>,
560}
561
562/// One peer's advertisement destination, as [`DiscoAdvertisementState`] holds it.
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub struct AdvertisementTarget {
565 /// The peer's first tailnet address (Go `endpoint.nodeAddr`), which is the advertisement's
566 /// destination address.
567 pub node_addr: std::net::IpAddr,
568 /// Whether this is a plain WireGuard peer rather than a Tailscale node (Go
569 /// `endpoint.isWireguardOnly`). Such a peer speaks no TSMP, so Go never sends it one — and a
570 /// kernel-WireGuard or `wireguard-go` peer would hand the advertisement straight to its host
571 /// network stack as an unknown-protocol packet.
572 pub wireguard_only: bool,
573}
574
575impl DiscoAdvertisementState {
576 /// The marshalled TSMP disco-key advertisement to send `peer` on session establishment, or
577 /// `None` if this node must not advertise to it.
578 ///
579 /// Go [`magicsock.Conn.PriorityMessageForPeer`], refusal for refusal — every one of these is a
580 /// silent "send nothing", never a fallback to some other message:
581 ///
582 /// 1. **No disco key of our own** (`disco.IsZero()`): there is nothing to advertise.
583 /// 2. **Unknown peer** (`endpointForNodeKey` miss, or `!self.Valid()`): the netmap snapshot has
584 /// no destination address for this WireGuard peer, so any address we invented would be a
585 /// guess.
586 /// 3. **A WireGuard-only peer** (`ep.isWireguardOnly`): "Do not send TSMP messages to peers
587 /// that only speaks wireguard."
588 /// 4. **No source address in the destination's family** (`selfIPMatchingFamily` returning the
589 /// zero `Addr`): an IPv4-only node has nothing to put in the source field of a packet to a
590 /// peer's IPv6 address.
591 /// 5. A marshal refusal, which by construction of (4) cannot happen — see
592 /// [`ts_packet::tsmp::DiscoKeyAdvertisement::marshal`].
593 ///
594 /// [`magicsock.Conn.PriorityMessageForPeer`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
595 pub fn advertisement_for(&self, peer: PeerId) -> Option<Vec<u8>> {
596 if self.disco_key == [0u8; ts_packet::tsmp::DISCO_KEY_LEN] {
597 tracing::debug!(?peer, "no disco key of our own; not advertising");
598 return None;
599 }
600
601 let target = self.peers.get(&peer)?;
602
603 if target.wireguard_only {
604 return None;
605 }
606
607 let src = self_ip_matching_family(&self.self_addrs, target.node_addr)?;
608
609 ts_packet::tsmp::DiscoKeyAdvertisement {
610 src,
611 dst: target.node_addr,
612 key: self.disco_key,
613 }
614 .marshal()
615 .inspect_err(|e| tracing::debug!(?peer, error = %e, "not advertising our disco key"))
616 .ok()
617 }
618}
619
620/// This node's first tailnet address whose family matches `want`, or `None`.
621///
622/// Go `magicsock.selfIPMatchingFamily`, which walks `self.Addresses()` and returns the first
623/// single-IP prefix with `Addr().BitLen() == want.BitLen()`. `addrs` is already narrowed to
624/// single IPs by the caller that builds the snapshot, so only the family test remains.
625fn self_ip_matching_family(
626 addrs: &[std::net::IpAddr],
627 want: std::net::IpAddr,
628) -> Option<std::net::IpAddr> {
629 addrs
630 .iter()
631 .copied()
632 .find(|addr| addr.is_ipv4() == want.is_ipv4())
633}
634
635/// A data plane subsystem that can be the subject of timer events.
636pub enum Subsystem {
637 /// The wireguard component.
638 Wireguard,
639}
640
641/// The direction/path of a captured packet, mirroring Go Tailscale's `capture.Path`. The numeric
642/// values are the on-wire path codes written into each pcap record's Tailscale preamble.
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub enum CapturePath {
645 /// A packet from the local device, heading out to a peer (pre-encrypt).
646 FromLocal = 0,
647 /// A packet received from a peer, decrypted, heading to the local device.
648 FromPeer = 1,
649 /// A packet synthesized by us toward the local device. Retained for Go `capture.Path` on-wire
650 /// code parity (so captured pcap path codes match Go's, and a future synthesized-packet tee
651 /// point can emit it); not currently emitted — the tee only produces `FromLocal`/`FromPeer`.
652 SynthesizedToLocal = 2,
653 /// A packet synthesized by us toward a peer. Retained for Go `capture.Path` on-wire code parity
654 /// (see [`Self::SynthesizedToLocal`]); not currently emitted.
655 SynthesizedToPeer = 3,
656}
657
658impl CapturePath {
659 /// The on-wire path code (the `uint16` written into the pcap record preamble).
660 pub fn code(self) -> u16 {
661 self as u16
662 }
663}
664
665/// A debug packet-capture hook. When installed on a [`DataPlane`], it is invoked with the path and
666/// the raw IP packet bytes for every plaintext packet crossing the datapath. It must be cheap and
667/// non-blocking — it runs inline on the single-threaded dataplane step, so a slow hook backs up the
668/// datapath. Wrapped in `Arc` so it is cheap to clone and `Send + Sync` for the actor that installs
669/// it.
670pub type CaptureHook = std::sync::Arc<dyn Fn(CapturePath, &[u8]) + Send + Sync>;
671
672/// Transforms packets to make tailscale happen.
673pub struct DataPlane {
674 /// Wireguard encryption/decryption.
675 pub wireguard: Endpoint,
676
677 /// Outbound overlay router.
678 pub or_out: or::outbound::Router,
679 /// Outbound underlay router.
680 pub ur_out: ur::outbound::Router,
681
682 /// Inbound source filter.
683 pub src_filter_in: Arc<ts_bart::Table<PeerId>>,
684 /// Inbound overlay router.
685 pub or_in: or::inbound::Router,
686
687 /// The packet filter.
688 pub packet_filter: Arc<dyn ts_packetfilter::Filter + Send + Sync>,
689
690 /// Events queued for future processing.
691 pub events: Scheduler<Subsystem>,
692
693 /// Next event for the wireguard subsystem.
694 pub wg_next: Option<Handle<Subsystem>>,
695
696 /// Optional debug packet-capture hook (Go `tstun.Wrapper` capture hook). `None` (the default)
697 /// means no capture and zero datapath overhead. Installed/cleared at runtime by the dataplane
698 /// actor; see [`DataPlane::process_outbound`]/[`DataPlane::process_inbound`] for the tee points.
699 pub capture: Option<CaptureHook>,
700
701 /// Netmap snapshot for the TSMP disco-key advertisement this node sends on session
702 /// establishment (Go capability version 144). `None` (the default) advertises nothing at all,
703 /// which is what an embedder that never populates it gets — the same position this fork was in
704 /// before the send side existed, and still fully interoperable, since a peer's own
705 /// advertisement is unsolicited. Refreshed from the netmap by the runtime's dataplane actor.
706 pub disco_advertisement: Option<Arc<DiscoAdvertisementState>>,
707}
708
709impl DataPlane {
710 /// Creates a new data plane for a wireguard node key.
711 pub fn new(my_key: NodeKeyPair) -> Self {
712 DataPlane {
713 wireguard: Endpoint::new(my_key),
714 or_out: Default::default(),
715 ur_out: Default::default(),
716 src_filter_in: Default::default(),
717 or_in: Default::default(),
718 events: Default::default(),
719 packet_filter: Arc::new(ts_packetfilter::DropAllFilter),
720 wg_next: None,
721 capture: None,
722 disco_advertisement: None,
723 }
724 }
725
726 /// Processes packets originating from the local device.
727 #[tracing::instrument(skip_all, fields(n_packets = packets.len()))]
728 pub fn process_outbound(&mut self, packets: Vec<PacketMut>) -> OutboundResult {
729 if let Some(hook) = &self.capture {
730 for p in &packets {
731 hook(CapturePath::FromLocal, p.as_ref());
732 }
733 }
734
735 let or::outbound::Result {
736 to_wireguard,
737 loopback,
738 } = self.or_out.route(packets);
739
740 let to_wireguard = to_wireguard
741 .into_iter()
742 .map(|(k, v)| (ts_tunnel::PeerId(k.0), v))
743 .collect::<Vec<_>>();
744
745 let ts_tunnel::SendResult {
746 to_peers: encrypted,
747 } = self.wireguard.send(to_wireguard);
748
749 let to_peers = self
750 .ur_out
751 .route(encrypted.into_iter().map(|(k, v)| (PeerId(k.0), v)));
752
753 if let Some(next) = self.wireguard.next_event()
754 && let Some(prev) = self
755 .wg_next
756 .replace(self.events.add(next, Subsystem::Wireguard))
757 {
758 prev.cancel();
759 }
760
761 OutboundResult { to_peers, loopback }
762 }
763
764 /// Processes packets received from elsewhere, with no information about which peer sent them.
765 ///
766 /// Equivalent to [`DataPlane::process_inbound_from`] with no attribution; see there for what
767 /// the attribution buys.
768 pub fn process_inbound(
769 &mut self,
770 packets: impl IntoIterator<Item = PacketMut>,
771 ) -> InboundResult {
772 self.process_inbound_from(None, packets)
773 }
774
775 /// Processes packets an underlay transport received and attributed to peer `from`.
776 ///
777 /// The attribution is what lets the WireGuard layer answer a handshake initiation with a
778 /// cookie while it is under load: the reply has to go back where the initiation came from, and
779 /// in this stack that origin is a peer, not a source address. See
780 /// [`ts_tunnel::Endpoint::recv_from`].
781 pub fn process_inbound_from(
782 &mut self,
783 from: Option<PeerId>,
784 packets: impl IntoIterator<Item = PacketMut>,
785 ) -> InboundResult {
786 let ts_tunnel::RecvResult {
787 to_local,
788 to_peers,
789 sessions_established,
790 } = self
791 .wireguard
792 .recv_from(from.map(|p| ts_tunnel::PeerId(p.0)), packets);
793
794 if let Some(hook) = &self.capture {
795 for packets in to_local.values() {
796 for p in packets {
797 hook(CapturePath::FromPeer, p.as_ref());
798 }
799 }
800 }
801
802 // TSMP disco-key advertisements learned from this batch (Go `tstun.Wrapper`'s
803 // `discoKeyAdvertisementPub` publisher). Filled in by the packet-filter stage below, which
804 // is the point at which a packet has both been attributed to a peer and decoded far enough
805 // to know it is TSMP.
806 let mut learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)> =
807 Vec::new();
808
809 let to_local = to_local
810 .into_iter()
811 .map(|(peer_id, mut packets)| -> (PeerId, Vec<PacketMut>) {
812 let _span = tracing::trace_span!(
813 "src_filter_inbound",
814 peer_id = ?peer_id,
815 n_packet = packets.len(),
816 )
817 .entered();
818
819 packets.retain(|packet| {
820 let Some(src) = packet.get_src_addr() else {
821 tracing::trace!("does not look like ip packet");
822 return false;
823 };
824 let verdict = if let Some(allowed_peer) = self.src_filter_in.lookup(src) {
825 *allowed_peer == PeerId(peer_id.0)
826 } else {
827 tracing::trace!(remote_ip = %src, "unknown peer address");
828 false
829 };
830 tracing::trace!(?src, verdict);
831 verdict
832 });
833
834 (PeerId(peer_id.0), packets)
835 })
836 .map(|(peer_id, mut v)| {
837 let _span = tracing::trace_span!(
838 "packet_filter_inbound",
839 peer_id = ?peer_id,
840 n_packet = v.len()
841 )
842 .entered();
843
844 filter_inbound_from_peer(
845 self.packet_filter.as_ref(),
846 peer_id,
847 &mut v,
848 &mut learned_disco_keys,
849 );
850
851 v
852 });
853
854 // TSMP disco-key advertisement, send side (Go capability version 144). wireguard-go calls
855 // `peer.SendPriorityMessage()` the moment a keypair becomes current for forward
856 // transmission — on the initiator when the handshake response lands, and on the responder
857 // when the first transport packet authenticates on the new keypair (`device/receive.go`).
858 // `sessions_established` is exactly those two moments; the message is Go's
859 // `magicsock.Conn.PriorityMessageForPeer` return value. A peer we must not advertise to
860 // (see [`DiscoAdvertisementState::advertisement_for`]) simply gets nothing, and the fresh
861 // session is otherwise untouched.
862 let mut to_peers = to_peers;
863 if let Some(advert) = self.disco_advertisement.clone() {
864 // Held apart from what `recv` already queued for these peers so it can be spliced in
865 // FRONT of it below, rather than appended behind it.
866 let mut priority: HashMap<ts_tunnel::PeerId, Vec<PacketMut>> = HashMap::new();
867 for peer in sessions_established {
868 let Some(msg) = advert.advertisement_for(PeerId(peer.0)) else {
869 continue;
870 };
871 tracing::debug!(peer_id = ?peer, "advertising our disco key over TSMP");
872 for (peer, packets) in self.wireguard.send_priority_message(peer, &msg).to_peers {
873 priority.entry(peer).or_default().extend(packets);
874 }
875 }
876 // A priority message leads the traffic the same establishment released. wireguard-go
877 // hands it straight to the peer's *outbound* queue (`SendPriorityMessage` →
878 // `queueOutboundIfRunning`), never to the staged queue, and both call sites run it
879 // before the flush that follows — `peer.SendPriorityMessage()` ahead of
880 // `peer.SendKeepalive()` on the initiator and ahead of `peer.SendStagedPackets()` on
881 // the responder (`device/receive.go`). Here the flush has already happened inside
882 // [`Endpoint::recv`] (`activate` encrypts whatever was queued), so restoring Go's wire
883 // order means splicing the advertisement in front of it.
884 //
885 // Only the wire order is restored, not Go's nonce order: those flushed packets were
886 // sealed first and so hold the lower nonces, where Go would have numbered the priority
887 // message first. That is invisible to the peer. A WireGuard receiver accepts an
888 // earlier counter after a later one by construction, and the inversion is bounded by
889 // the send queue a session flushes on activation (`MAX_QUEUED_PER_PEER`, 32 packets) —
890 // two orders of magnitude inside the 8128-packet anti-replay window WireGuard
891 // receivers carry (`ts_tunnel`'s `ReplayWindow::WINDOW_SIZE`, wireguard-go parity).
892 for (peer, mut packets) in priority {
893 let queued = to_peers.entry(peer).or_default();
894 packets.append(queued);
895 *queued = packets;
896 }
897 }
898
899 let to_peers = to_peers
900 .into_iter()
901 .map(|(k, v)| (ts_transport::PeerId(k.0), v));
902
903 let to_local = self.or_in.route(to_local.flatten());
904 let to_peers = self.ur_out.route(to_peers);
905
906 if let Some(next) = self.wireguard.next_event()
907 && let Some(prev) = self
908 .wg_next
909 .replace(self.events.add(next, Subsystem::Wireguard))
910 {
911 prev.cancel();
912 }
913
914 InboundResult {
915 to_local,
916 to_peers,
917 learned_disco_keys,
918 }
919 }
920
921 /// Return the next time at which [`DataPlane::process_events`] must be called.
922 ///
923 /// [`DataPlane::process_outbound`], [`DataPlane::process_inbound`] and
924 /// [`DataPlane::process_events`] may all update the next event time. Callers should prefer
925 /// calling `next_event` as needed to get a correct result, rather than store the returned
926 /// value.
927 pub fn next_event(&self) -> Option<Instant> {
928 self.events.next_dispatch()
929 }
930
931 /// Process all queued events that are due for processing.
932 ///
933 /// Must be called at least as often as dictated by [`DataPlane::next_event`] for the
934 /// data plane to function correctly. It is harmless to call it more frequently.
935 pub fn process_events(&mut self) -> EventResult {
936 let mut to_peers = HashMap::new();
937 let now = Instant::now();
938 for event in self.events.dispatch(now) {
939 match event {
940 Subsystem::Wireguard => {
941 let res = self.wireguard.dispatch_events(now);
942 to_peers.extend(
943 res.to_peers
944 .into_iter()
945 .map(|(id, pkts)| (ts_transport::PeerId(id.0), pkts)),
946 );
947 }
948 }
949 }
950 let to_peers = self.ur_out.route(to_peers);
951
952 if let Some(next) = self.wireguard.next_event()
953 && let Some(prev) = self
954 .wg_next
955 .replace(self.events.add(next, Subsystem::Wireguard))
956 {
957 prev.cancel();
958 }
959
960 EventResult { to_peers }
961 }
962}
963
964/// The result of processing outbound packets.
965pub struct OutboundResult {
966 /// Packets to be sent into underlay transports for transmission.
967 pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
968 /// Packets to be looped back and delivered to overlay transports.
969 pub loopback: HashMap<OverlayTransportId, Vec<PacketMut>>,
970}
971
972/// The result of processing inbound packets.
973pub struct InboundResult {
974 /// Decrypted packets to be delivered to overlay transports.
975 pub to_local: HashMap<OverlayTransportId, Vec<PacketMut>>,
976 /// Encrypted packets to be sent to wireguard peers by the underlay.
977 pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
978 /// Disco keys peers advertised over TSMP in this batch, each paired with the WireGuard peer
979 /// whose session carried it (Go `tstun.Wrapper` publishing `events.PeerDiscoKeyUpdate`, which
980 /// `wgengine` turns into a `magicsock.Conn.HandleDiscoKeyAdvertisement` call).
981 ///
982 /// The advertisement packets themselves are dropped: they are inter-node control messages, not
983 /// traffic for the local stack. Zero keys are already filtered out. Empty for a batch that
984 /// carried none, which is the overwhelmingly common case.
985 pub learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
986}
987
988/// The result of processing an event.
989#[derive(Default)]
990pub struct EventResult {
991 /// Encrypted packets to be sent to wireguard peers by the underlay.
992 pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
993}
994
995#[cfg(test)]
996mod tests {
997 use std::sync::Mutex;
998
999 use super::*;
1000
1001 /// Records `(path, bytes)` for each capture-hook invocation in a test.
1002 type CaptureLog = Arc<Mutex<Vec<(CapturePath, Vec<u8>)>>>;
1003
1004 #[test]
1005 fn capture_path_codes() {
1006 assert_eq!(CapturePath::FromLocal.code(), 0);
1007 assert_eq!(CapturePath::FromPeer.code(), 1);
1008 assert_eq!(CapturePath::SynthesizedToLocal.code(), 2);
1009 assert_eq!(CapturePath::SynthesizedToPeer.code(), 3);
1010 }
1011
1012 /// The pre-rule destination screen (Go filter `pre()`): multicast and non-allowlisted link-local
1013 /// destinations are dropped before the ACL; ordinary unicast and the cloud-metadata link-local
1014 /// exception pass through to the rules.
1015 #[test]
1016 fn pre_rule_drop_matches_go() {
1017 let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1018 // Dropped pre-rules:
1019 assert!(drop_before_rules(ip("224.0.0.1")), "IPv4 multicast dropped");
1020 assert!(
1021 drop_before_rules(ip("239.255.255.250")),
1022 "IPv4 multicast (SSDP) dropped"
1023 );
1024 assert!(
1025 drop_before_rules(ip("169.254.1.1")),
1026 "IPv4 link-local dropped"
1027 );
1028 assert!(drop_before_rules(ip("ff02::1")), "IPv6 multicast dropped");
1029 assert!(drop_before_rules(ip("fe80::1")), "IPv6 link-local dropped");
1030 assert!(
1031 drop_before_rules(ip("febf:ffff::1")),
1032 "top of fe80::/10 dropped (locks the 0xffc0/0xfe80 mask)"
1033 );
1034 // Passed through to the rules:
1035 assert!(
1036 !drop_before_rules(ip("fec0::1")),
1037 "just past fe80::/10 passes (locks the 0xffc0/0xfe80 mask)"
1038 );
1039 // IPv4-mapped-IPv6 destinations match NEITHER arm and fall through to the ACL, exactly as
1040 // Go's `netip.Addr` predicates do (no unmap/canonicalize). Pinning this guards against a
1041 // future "canonicalize to be safe" refactor silently diverging from Go.
1042 assert!(
1043 !drop_before_rules(ip("::ffff:224.0.0.1")),
1044 "4in6-mapped multicast falls through to the ACL, matching Go"
1045 );
1046 assert!(
1047 !drop_before_rules(ip("::ffff:169.254.1.1")),
1048 "4in6-mapped link-local falls through to the ACL, matching Go"
1049 );
1050 assert!(
1051 !drop_before_rules(ip("100.64.0.5")),
1052 "ordinary tailnet unicast passes"
1053 );
1054 assert!(
1055 !drop_before_rules(ip("8.8.8.8")),
1056 "ordinary public unicast passes"
1057 );
1058 assert!(
1059 !drop_before_rules(ip("169.254.169.254")),
1060 "the cloud-metadata link-local address is the Go-allowlisted exception"
1061 );
1062 assert!(
1063 !drop_before_rules(ip("fd7a:115c:a1e0::1")),
1064 "IPv6 ULA (tailnet) passes"
1065 );
1066 }
1067
1068 /// A filter that drops everything (returns `None` for every packet). Lets a test prove that TSMP
1069 /// is admitted by bypassing the ACL — not by the ACL happening to allow it.
1070 struct DenyAll;
1071 impl ts_packetfilter::Filter for DenyAll {
1072 fn match_for(
1073 &self,
1074 _info: &ts_packetfilter::PacketInfo,
1075 _caps: ts_packetfilter::filter::CapIter,
1076 ) -> Option<&str> {
1077 None
1078 }
1079 }
1080
1081 /// The inbound proto-switch (Go `runIn4`/`runIn6`): TSMP is always admitted, bypassing the ACL;
1082 /// `pre()` drops still win over TSMP; non-TSMP defers to the ACL.
1083 #[test]
1084 fn tsmp_bypasses_acl_matches_go() {
1085 let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1086 let src = ip("100.64.0.9");
1087 let dst = ip("100.64.0.1");
1088 let tsmp = IpProto::new(99);
1089
1090 // TSMP is accepted even though the ACL denies everything — Go `case TSMP: return Accept`.
1091 assert!(
1092 inbound_filter_verdict(&DenyAll, tsmp, src, dst, 0, None),
1093 "TSMP admitted by bypassing the (deny-all) ACL"
1094 );
1095 // A non-TSMP proto under the same deny-all ACL is dropped — proves the bypass is TSMP-specific.
1096 assert!(
1097 !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443, None),
1098 "TCP still consults the ACL (deny-all → dropped)"
1099 );
1100 // `pre()` drops outrank the TSMP accept: TSMP to a multicast/link-local dst is still dropped,
1101 // exactly as Go runs `pre()` before the proto switch.
1102 assert!(
1103 !inbound_filter_verdict(&DenyAll, tsmp, src, ip("224.0.0.1"), 0, None),
1104 "TSMP to a multicast dst is still dropped (pre() before the switch)"
1105 );
1106 assert!(
1107 !inbound_filter_verdict(&DenyAll, tsmp, src, ip("169.254.1.1"), 0, None),
1108 "TSMP to a link-local dst is still dropped (pre() before the switch)"
1109 );
1110 // IpProto::TSMP is the named constant for proto 99.
1111 assert_eq!(IpProto::TSMP, tsmp, "IpProto::TSMP == 99");
1112 }
1113
1114 /// IPv4 fragment handling, mirroring Go `net/packet.decode4` + filter `pre()`:
1115 /// - a valid later fragment (offset ≥ `MIN_FRAG_BLKS`) is ACCEPTED ahead of the ACL (Go maps it
1116 /// to `ipproto.Fragment`, which `pre()` admits) — even under a deny-all ACL and even though its
1117 /// parsed port is 0, which a normal rule would never match;
1118 /// - a low-offset later fragment (offset < `MIN_FRAG_BLKS`) is DROPPED (RFC 1858);
1119 /// - a first fragment (offset 0) defers to the normal proto-switch/ACL on its real port;
1120 /// - a *fragmented* TSMP first fragment (offset 0, MF set) is DROPPED (Go disallows it), unlike a
1121 /// non-fragmented TSMP which bypasses the ACL.
1122 #[test]
1123 fn ipv4_fragment_handling_matches_go_decode4() {
1124 let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1125 let src = ip("100.64.0.9");
1126 let dst = ip("100.64.0.1");
1127 let frag = |offset_blocks: u16, more_fragments: bool| {
1128 Some(Fragment::V4(Ipv4Fragment {
1129 offset_blocks,
1130 more_fragments,
1131 }))
1132 };
1133
1134 // A valid later fragment is accepted under a DENY-ALL ACL with port 0 — proves the accept is
1135 // the Go `pre()` Fragment pass-through, not the ACL happening to allow it.
1136 assert!(
1137 inbound_filter_verdict(
1138 &DenyAll,
1139 IpProto::TCP,
1140 src,
1141 dst,
1142 0,
1143 frag(MIN_FRAG_BLKS, false)
1144 ),
1145 "a valid later fragment (offset >= MIN_FRAG_BLKS) is accepted ahead of the ACL"
1146 );
1147 assert!(
1148 inbound_filter_verdict(
1149 &DenyAll,
1150 IpProto::UDP,
1151 src,
1152 dst,
1153 0,
1154 frag(MIN_FRAG_BLKS + 50, true)
1155 ),
1156 "a later fragment well past the floor (MF set) is also accepted"
1157 );
1158
1159 // A low-offset later fragment (could overlap a transport header) is dropped — RFC 1858.
1160 assert!(
1161 !inbound_filter_verdict(
1162 &DenyAll,
1163 IpProto::TCP,
1164 src,
1165 dst,
1166 0,
1167 frag(MIN_FRAG_BLKS - 1, false)
1168 ),
1169 "a low-offset later fragment is dropped (RFC 1858)"
1170 );
1171 assert!(
1172 !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 0, frag(1, false)),
1173 "the smallest non-zero offset is dropped"
1174 );
1175
1176 // A first fragment (offset 0) defers to the normal ACL on its real port: deny-all drops a
1177 // TCP first fragment, exactly as it drops a non-fragmented TCP packet.
1178 assert!(
1179 !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443, frag(0, true)),
1180 "a first fragment defers to the ACL (deny-all -> dropped) on its parsed port"
1181 );
1182
1183 // A fragmented TSMP first fragment (offset 0, MF set) is dropped — Go disallows it — even
1184 // though a non-fragmented TSMP bypasses the ACL.
1185 assert!(
1186 !inbound_filter_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, true)),
1187 "a fragmented TSMP first fragment is dropped (Go parity)"
1188 );
1189 assert!(
1190 inbound_filter_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, false)),
1191 "a non-fragmented TSMP (offset 0, MF clear) still bypasses the ACL"
1192 );
1193
1194 // A *later* TSMP fragment (offset >= MIN_FRAG_BLKS) is accepted via the offset-based
1195 // fragment pass-through, NOT dropped by the fragmented-TSMP rule — that rule is offset-0
1196 // only (a first fragment with MF). This proves the later-fragment branch is proto-independent
1197 // and wins over the TSMP-specific logic (Go maps any offset>=minFragBlks to ipproto.Fragment
1198 // regardless of the L4 proto byte), locking the branch ordering against regression.
1199 assert!(
1200 inbound_filter_verdict(
1201 &DenyAll,
1202 IpProto::TSMP,
1203 src,
1204 dst,
1205 0,
1206 frag(MIN_FRAG_BLKS, true)
1207 ),
1208 "a later TSMP fragment is accepted via the fragment path (proto-independent)"
1209 );
1210 }
1211
1212 /// An ACL that admits everything, the shape a permissive "allow the whole tailnet" policy has.
1213 /// Under it, a DROP can only have come from a rule the filter applies *ahead* of the ACL — which
1214 /// is exactly what makes it the right control for the fragment classification's negative cases.
1215 struct AllowAll;
1216 impl ts_packetfilter::Filter for AllowAll {
1217 fn match_for(
1218 &self,
1219 _info: &ts_packetfilter::PacketInfo,
1220 _caps: ts_packetfilter::filter::CapIter,
1221 ) -> Option<&str> {
1222 Some("allow-all")
1223 }
1224 }
1225
1226 /// An ACL that admits exactly one destination port. An admitted packet therefore proves the
1227 /// filter read that port off the wire — the point of Go `decode6` reaching past the Fragment
1228 /// extension header to the first fragment's real transport header.
1229 struct AllowPort(u16);
1230 impl ts_packetfilter::Filter for AllowPort {
1231 fn match_for(
1232 &self,
1233 info: &ts_packetfilter::PacketInfo,
1234 _caps: ts_packetfilter::filter::CapIter,
1235 ) -> Option<&str> {
1236 (info.port == self.0).then_some("allow-port")
1237 }
1238 }
1239
1240 /// Source/destination for the IPv6 fixtures: RFC 3849 documentation addresses, standing in for
1241 /// the real ones upstream's `udp6*FragmentBuffer` fixtures use. Neither is multicast or
1242 /// link-local, so `drop_before_rules` never fires and every verdict below is the fragment
1243 /// classification's own.
1244 const IPV6_FIXTURE_SRC: std::net::Ipv6Addr =
1245 std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 5);
1246 const IPV6_FIXTURE_DST: std::net::Ipv6Addr =
1247 std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
1248
1249 /// The IPv6 packet a source-fragmenting host puts on the wire, in the shape of upstream's
1250 /// `udp6FirstFragmentBuffer` / `udp6NonFirstFragmentBuffer` fixtures (Go
1251 /// `net/packet/packet_test.go`): a 40-byte base header whose Next Header is the Fragment
1252 /// extension header (44), the 8-byte Fragment header itself, then `rest` — the real
1253 /// sub-protocol header on a first fragment, or continued payload on a later one.
1254 fn ipv6_fragment_packet(
1255 next_header: u8,
1256 offset_blocks: u16,
1257 more_fragments: bool,
1258 rest: &[u8],
1259 ) -> Vec<u8> {
1260 let mut buf = vec![0u8; IP6_HEADER_LEN + IP6_FRAG_HEADER_LEN + rest.len()];
1261 buf[0] = 0x60; // version 6, traffic class/flow label 0
1262 let payload_len = u16::try_from(IP6_FRAG_HEADER_LEN + rest.len()).unwrap();
1263 buf[4..6].copy_from_slice(&payload_len.to_be_bytes());
1264 buf[6] = IP6_FRAG_HEADER;
1265 buf[7] = 64; // hop limit
1266 buf[8..24].copy_from_slice(&IPV6_FIXTURE_SRC.octets());
1267 buf[24..40].copy_from_slice(&IPV6_FIXTURE_DST.octets());
1268 // Fragment extension header: Next Header, Reserved, offset<<3 | MF, Identification.
1269 buf[40] = next_header;
1270 let offset_field = (offset_blocks << 3) | u16::from(more_fragments);
1271 buf[42..44].copy_from_slice(&offset_field.to_be_bytes());
1272 buf[44..48].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
1273 buf[48..].copy_from_slice(rest);
1274 buf
1275 }
1276
1277 /// A plain, unfragmented IPv6/UDP packet: the same 40-byte base header the fragment fixtures
1278 /// use, but with UDP as its immediate Next Header. The control for the chained-extension-header
1279 /// fixtures below.
1280 fn ipv6_udp_packet(udp: &[u8]) -> Vec<u8> {
1281 let mut buf = vec![0u8; IP6_HEADER_LEN + udp.len()];
1282 buf[0] = 0x60; // version 6, traffic class/flow label 0
1283 buf[4..6].copy_from_slice(&u16::try_from(udp.len()).unwrap().to_be_bytes());
1284 buf[6] = 17; // Next Header = UDP
1285 buf[7] = 64; // hop limit
1286 buf[8..24].copy_from_slice(&IPV6_FIXTURE_SRC.octets());
1287 buf[24..40].copy_from_slice(&IPV6_FIXTURE_DST.octets());
1288 buf[IP6_HEADER_LEN..].copy_from_slice(udp);
1289 // Unlike a fragment fixture, this datagram is actually parsed as UDP, so its Length field
1290 // has to agree with the bytes present or etherparse rejects the packet outright.
1291 let udp_len = u16::try_from(udp.len()).unwrap();
1292 buf[IP6_HEADER_LEN + 4..IP6_HEADER_LEN + 6].copy_from_slice(&udp_len.to_be_bytes());
1293 buf
1294 }
1295
1296 /// Push one 8-byte extension header of protocol `ext_proto` in front of `inner`'s payload, so
1297 /// whatever `inner`'s base header pointed at directly is now reached through a *chain*. The
1298 /// generic Next-Header / Hdr-Ext-Len-0 / six-bytes-of-body shape is the on-the-wire layout of
1299 /// Hop-by-Hop Options (0), Routing (43) and Destination Options (60) alike.
1300 ///
1301 /// Those six body bytes are chosen so the header is well formed under *every* one of those
1302 /// three readings, not merely one etherparse happens not to look at:
1303 ///
1304 /// - as Options (0 / 60) they are a TLV stream — `1, 0` is a zero-length PadN, and the four
1305 /// trailing zeros are four Pad1s, filling the 8-byte header exactly;
1306 /// - as Routing (43) they are Routing Type 1, **Segments Left 0**, and four bytes of
1307 /// type-specific data. Segments Left must stay 0: `Hdr Ext Len` is 0, so there is no room
1308 /// for a single 16-byte segment, and RFC 8200 §4.4 has a receiver that meets a non-zero
1309 /// Segments Left on an unrecognized Routing Type discard the packet and answer ICMP
1310 /// Parameter Problem. etherparse walks a Routing header as a raw ext header and never reads
1311 /// the field, so a non-zero value parses here today — but a fixture that only survives
1312 /// because the parser is lenient is one parser release away from turning the negative
1313 /// assertions below into vacuous passes.
1314 fn ipv6_with_prepended_ext_header(ext_proto: u8, inner: &[u8]) -> Vec<u8> {
1315 let mut buf = Vec::with_capacity(inner.len() + 8);
1316 buf.extend_from_slice(&inner[..IP6_HEADER_LEN]);
1317 // The header we are displacing becomes the extension header's Next Header.
1318 let displaced = buf[6];
1319 buf[6] = ext_proto;
1320 let payload_len = u16::try_from(inner.len() - IP6_HEADER_LEN + 8).unwrap();
1321 buf[4..6].copy_from_slice(&payload_len.to_be_bytes());
1322 buf.extend_from_slice(&[displaced, 0, 1, 0, 0, 0, 0, 0]);
1323 buf.extend_from_slice(&inner[IP6_HEADER_LEN..]);
1324 buf
1325 }
1326
1327 /// An 8-byte UDP header carrying `dst_port`, as a first fragment's `rest`.
1328 fn udp_header(dst_port: u16) -> Vec<u8> {
1329 let mut hdr = vec![0u8; 8];
1330 hdr[0..2].copy_from_slice(&54276u16.to_be_bytes());
1331 hdr[2..4].copy_from_slice(&dst_port.to_be_bytes());
1332 hdr[4..6].copy_from_slice(&16u16.to_be_bytes());
1333 hdr
1334 }
1335
1336 /// The IPv6 Fragment extension-header classification, mirroring Go
1337 /// `net/packet.Parsed.decode6Fragment` plus the sub-protocol switch `decode6` runs when it
1338 /// reports `continueDecode` (upstream `4c4ec3d46`, clarified by `26b2ed0a6`). Cases are
1339 /// upstream's own `TestDecode` fixtures: `ipv6_frag_first`, `ipv6_frag_nonfirst`,
1340 /// `ipv6_frag_short_first` and `ipv6_frag_small_offset`.
1341 #[test]
1342 fn ipv6_fragment_classification_matches_go_decode6() {
1343 // `ipv6_frag_first`: offset 0 with MF set, and a whole UDP header behind the fragment
1344 // header — Go steps over the 8 bytes and reads the ports, so the ACL matches this datagram
1345 // on the same rule it would match unfragmented.
1346 assert_eq!(
1347 decode6_fragment(&ipv6_fragment_packet(17, 0, true, &udp_header(443))),
1348 Ipv6Fragment::First {
1349 proto: IpProto::UDP,
1350 dst_port: 443,
1351 },
1352 "a first fragment is decoded past the Fragment header, ports and all"
1353 );
1354
1355 // `ipv6_frag_nonfirst`: a later fragment at offset 185 blocks has no transport header at
1356 // all, so Go marks it `ipproto.Fragment` for `pre()` to pass through.
1357 assert_eq!(
1358 decode6_fragment(&ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1359 Ipv6Fragment::Later,
1360 "a later fragment at a safe offset classifies as a pass-through fragment"
1361 );
1362 // The floor itself is safe; one block below it is not. `MIN_FRAG_BLKS` is the IPv4-sized
1363 // bound upstream deliberately reuses for IPv6 (Go `26b2ed0a6`).
1364 assert_eq!(
1365 decode6_fragment(&ipv6_fragment_packet(17, MIN_FRAG_BLKS, false, &[0x61; 8])),
1366 Ipv6Fragment::Later,
1367 "offset == MIN_FRAG_BLKS is the first accepted later fragment"
1368 );
1369
1370 // `ipv6_frag_small_offset`: a later fragment whose bytes could land on top of the transport
1371 // header the head fragment was matched on — RFC 1858. Go rejects it as `unknown`.
1372 assert_eq!(
1373 decode6_fragment(&ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
1374 Ipv6Fragment::Unknown,
1375 "a later fragment at offset 1 block is rejected (RFC 1858)"
1376 );
1377 assert_eq!(
1378 decode6_fragment(&ipv6_fragment_packet(
1379 17,
1380 MIN_FRAG_BLKS - 1,
1381 false,
1382 &[0x61; 8]
1383 )),
1384 Ipv6Fragment::Unknown,
1385 "one block below the floor is still rejected (RFC 1858)"
1386 );
1387
1388 // `ipv6_frag_short_first`: a first fragment truncated before its full transport header. Go
1389 // refuses to guess at the ports, because a follow-up fragment supplying the rest of that
1390 // header would otherwise carry the flow past a rule the filter never really matched.
1391 assert_eq!(
1392 decode6_fragment(&ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])),
1393 Ipv6Fragment::Unknown,
1394 "a first fragment with only half a UDP header is rejected"
1395 );
1396 assert_eq!(
1397 decode6_fragment(&ipv6_fragment_packet(6, 0, true, &[0u8; 19])),
1398 Ipv6Fragment::Unknown,
1399 "a first fragment one byte short of a TCP header is rejected"
1400 );
1401 // ...and the same header one byte longer is accepted, so the rejection is the bounds check
1402 // and not the protocol.
1403 let mut tcp = vec![0u8; 20];
1404 tcp[2..4].copy_from_slice(&443u16.to_be_bytes());
1405 assert_eq!(
1406 decode6_fragment(&ipv6_fragment_packet(6, 0, true, &tcp)),
1407 Ipv6Fragment::First {
1408 proto: IpProto::TCP,
1409 dst_port: 443,
1410 },
1411 "a complete TCP header in the first fragment is read normally"
1412 );
1413
1414 // A Fragment header truncated by the packet itself (Go's `len(b) < q.subofs+8` guard).
1415 let mut short = ipv6_fragment_packet(17, 0, true, &[]);
1416 short.truncate(IP6_HEADER_LEN + 4);
1417 short[4..6].copy_from_slice(&4u16.to_be_bytes());
1418 assert_eq!(
1419 decode6_fragment(&short),
1420 Ipv6Fragment::Unknown,
1421 "a truncated Fragment extension header is rejected"
1422 );
1423 // A packet cut off before its declared payload length (Go `len(b) < q.length`).
1424 let mut cut = ipv6_fragment_packet(17, 0, true, &udp_header(443));
1425 cut.truncate(cut.len() - 1);
1426 assert_eq!(
1427 decode6_fragment(&cut),
1428 Ipv6Fragment::Unknown,
1429 "a packet cut off before its declared IPv6 length is rejected"
1430 );
1431
1432 // Go's portless arms bounds-check but leave the port at 0, and the on-the-wire use of Go's
1433 // internal `ipproto.Fragment` sentinel (0xff) maps back to `unknown`.
1434 assert_eq!(
1435 decode6_fragment(&ipv6_fragment_packet(58, 0, true, &[0u8; 4])),
1436 Ipv6Fragment::First {
1437 proto: IpProto::ICMPV6,
1438 dst_port: 0,
1439 },
1440 "a first ICMPv6 fragment keeps port 0 and is matched IPs-only"
1441 );
1442 assert_eq!(
1443 decode6_fragment(&ipv6_fragment_packet(58, 0, true, &[0u8; 3])),
1444 Ipv6Fragment::Unknown,
1445 "a first ICMPv6 fragment shorter than the ICMPv6 header is rejected"
1446 );
1447 assert_eq!(
1448 decode6_fragment(&ipv6_fragment_packet(0xff, 0, true, &[0u8; 8])),
1449 Ipv6Fragment::Unknown,
1450 "Go's internal Fragment sentinel seen on the wire maps back to unknown"
1451 );
1452 }
1453
1454 /// The verdict Go's filter `pre()` reaches for each IPv6 fragment classification, asserted
1455 /// against an ACL that would otherwise decide the packet the other way — so each assertion can
1456 /// only be the fragment rule, never the ACL:
1457 ///
1458 /// - `Unknown` is DROPPED under an ALLOW-ALL ACL (Go `pre()`: `IPProto == Unknown → Drop`).
1459 /// This is the security-relevant direction: an allow-all tailnet policy must not admit a
1460 /// short-first or RFC-1858 low-offset fragment.
1461 /// - `Later` is ACCEPTED under a DENY-ALL ACL (Go `pre()`: `case ipproto.Fragment: Accept`).
1462 /// - `First` consults the ACL normally on the port read past the Fragment header.
1463 #[test]
1464 fn ipv6_fragment_verdict_matches_go_pre() {
1465 let src = std::net::IpAddr::V6(IPV6_FIXTURE_SRC);
1466 let dst = std::net::IpAddr::V6(IPV6_FIXTURE_DST);
1467 let v6 = |class| Some(Fragment::V6(class));
1468
1469 // The negative case, stated explicitly: allow-all cannot rescue an `unknown` fragment.
1470 assert!(
1471 !inbound_filter_verdict(
1472 &AllowAll,
1473 IpProto::new(0),
1474 src,
1475 dst,
1476 0,
1477 v6(Ipv6Fragment::Unknown)
1478 ),
1479 "an unknown IPv6 fragment is dropped even under an allow-all ACL"
1480 );
1481 // The control: the same allow-all ACL admits an ordinary non-fragment packet, so the drop
1482 // above is the classification and not the harness.
1483 assert!(
1484 inbound_filter_verdict(&AllowAll, IpProto::UDP, src, dst, 443, None),
1485 "the allow-all ACL does admit an ordinary packet"
1486 );
1487
1488 // A safe later fragment slides through ahead of the ACL, with nothing but port 0 to match.
1489 assert!(
1490 inbound_filter_verdict(
1491 &DenyAll,
1492 IpProto::new(0),
1493 src,
1494 dst,
1495 0,
1496 v6(Ipv6Fragment::Later)
1497 ),
1498 "a later IPv6 fragment is accepted ahead of a deny-all ACL"
1499 );
1500
1501 // A first fragment is an ordinary packet again: admitted on the port the ACL allows,
1502 // dropped on one it does not.
1503 let first = |dst_port| {
1504 v6(Ipv6Fragment::First {
1505 proto: IpProto::UDP,
1506 dst_port,
1507 })
1508 };
1509 assert!(
1510 inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 443, first(443)),
1511 "a first IPv6 fragment is matched on the port behind the Fragment header"
1512 );
1513 assert!(
1514 !inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 444, first(444)),
1515 "a first IPv6 fragment on a disallowed port is dropped by the ACL"
1516 );
1517 // Control: the same ACL decides an unfragmented packet the same way, so the two results
1518 // above are the ACL being consulted on a real port and not a fragment-specific shortcut.
1519 assert!(
1520 inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 443, None),
1521 "control: the port-scoped ACL admits an unfragmented packet to 443"
1522 );
1523 assert!(
1524 !inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 0, None),
1525 "control: port 0 - what a v6 fragment used to read as - is not admitted"
1526 );
1527
1528 // `pre()`'s multicast/link-local drops still outrank the fragment pass-through, exactly as
1529 // Go runs them before `case ipproto.Fragment`.
1530 assert!(
1531 !inbound_filter_verdict(
1532 &AllowAll,
1533 IpProto::new(0),
1534 src,
1535 "ff02::1".parse().unwrap(),
1536 0,
1537 v6(Ipv6Fragment::Later)
1538 ),
1539 "a later fragment to a multicast dst is still dropped by pre()"
1540 );
1541 assert!(
1542 !inbound_filter_verdict(
1543 &AllowAll,
1544 IpProto::new(0),
1545 src,
1546 "fe80::1".parse().unwrap(),
1547 0,
1548 v6(Ipv6Fragment::Later)
1549 ),
1550 "a later fragment to a link-local dst is still dropped by pre()"
1551 );
1552 }
1553
1554 /// The whole inbound path on real IPv6 bytes — parse, classify, verdict — which is the shape
1555 /// the bypass had: before the Fragment extension header was classified, every source-fragmented
1556 /// IPv6 datagram reached the ACL with no sub-protocol and port 0, so an allow-all rule admitted
1557 /// the RFC 1858 fragments upstream drops and a port-scoped rule blackholed the later fragments
1558 /// upstream passes through.
1559 #[test]
1560 fn ipv6_fragments_are_filtered_end_to_end() {
1561 let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
1562 let mut packets = vec![PacketMut::from(packet)];
1563 let mut learned = Vec::new();
1564 filter_inbound_from_peer(filter, PeerId(3), &mut packets, &mut learned);
1565 assert!(
1566 learned.is_empty(),
1567 "no TSMP advertisement in these fixtures"
1568 );
1569 !packets.is_empty()
1570 };
1571
1572 // Under an ALLOW-ALL ACL — the permissive policy the bypass needs — the RFC 1858 fragment
1573 // must still be dropped, while the legitimate later fragment must still be delivered.
1574 assert!(
1575 !keep(&AllowAll, ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
1576 "a low-offset later IPv6 fragment is dropped even by an allow-all ACL (RFC 1858)"
1577 );
1578 assert!(
1579 !keep(
1580 &AllowAll,
1581 ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])
1582 ),
1583 "a first IPv6 fragment too short to hold its UDP header is dropped by an allow-all ACL"
1584 );
1585 assert!(
1586 keep(&AllowAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1587 "a legitimate later IPv6 fragment is delivered"
1588 );
1589
1590 // ...and the later fragment is delivered even under a DENY-ALL ACL, which is the Go
1591 // `pre()` pass-through and not the ACL agreeing.
1592 assert!(
1593 keep(&DenyAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1594 "a legitimate later IPv6 fragment slides through a deny-all ACL (Go pre())"
1595 );
1596 assert!(
1597 !keep(&DenyAll, ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
1598 "a low-offset later IPv6 fragment is dropped under a deny-all ACL too"
1599 );
1600
1601 // A first fragment is matched on the port that lives behind the Fragment extension header,
1602 // which is the whole point of stepping over it: 443 is admitted, 444 is not, under the same
1603 // port-scoped ACL. Before the port was read past the header both read as port 0 and both
1604 // were dropped.
1605 assert!(
1606 keep(
1607 &AllowPort(443),
1608 ipv6_fragment_packet(17, 0, true, &udp_header(443))
1609 ),
1610 "a first IPv6 fragment to an allowed port is delivered"
1611 );
1612 assert!(
1613 !keep(
1614 &AllowPort(443),
1615 ipv6_fragment_packet(17, 0, true, &udp_header(444))
1616 ),
1617 "a first IPv6 fragment to a disallowed port is dropped"
1618 );
1619
1620 // Scoping (Go `26b2ed0a6`): the Fragment header is parsed here ONLY as the base header's
1621 // immediate Next Header. What happens to one reached through a chained extension header —
1622 // it must fail closed, not fall through to the ACL — is
1623 // `chained_extension_header_cannot_bypass_the_ipv6_fragment_rules`.
1624 }
1625
1626 /// Prepending an extension header must not defeat the fragment rules.
1627 ///
1628 /// [`decode6_fragment`] is scoped exactly as Go scopes it: the Fragment header is parsed only
1629 /// as the base header's immediate Next Header. Go can afford that narrow scope because
1630 /// everything it does not parse *keeps the base header's Next Header* as `q.IPProto`, so a
1631 /// hop-by-hop-chained fragment is `ipproto.Unknown` and filter `pre()` drops it before the ACL
1632 /// ever runs. This tree reads the sub-protocol out of etherparse's extension-header walk
1633 /// instead, which resolves straight through the chain to the real transport number — so the
1634 /// same packet reached the ACL looking like an ordinary UDP datagram that merely happened to
1635 /// carry port 0, and a permissive "allow the whole tailnet" rule ADMITTED it. Eight bytes of
1636 /// Hop-by-Hop Options were enough to walk every RFC 1858 fragment straight past the rules the
1637 /// rest of this file exists to enforce.
1638 ///
1639 /// Every assertion is against an ALLOW-ALL ACL, so a drop can only be the fragment rule and
1640 /// never the ACL — and each extension type carries its own control that proves it: the same
1641 /// chain shape with no Fragment header in it is still delivered, on the port read past the
1642 /// extension header. That control is per-type rather than once at the end because `keep`
1643 /// cannot tell a fragment-rule drop from a parser rejection, so a fixture malformed for only
1644 /// one of the three protocols would otherwise turn that protocol's four drops into vacuous
1645 /// passes with the suite still green.
1646 #[test]
1647 fn chained_extension_header_cannot_bypass_the_ipv6_fragment_rules() {
1648 let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
1649 let mut packets = vec![PacketMut::from(packet)];
1650 let mut learned = Vec::new();
1651 filter_inbound_from_peer(filter, PeerId(4), &mut packets, &mut learned);
1652 assert!(
1653 learned.is_empty(),
1654 "no TSMP advertisement in these fixtures"
1655 );
1656 !packets.is_empty()
1657 };
1658
1659 // Hop-by-Hop Options (0), Routing (43) and Destination Options (60): the fragment rules
1660 // must not depend on which header the sender chose to hide behind.
1661 for ext in [0u8, 43, 60] {
1662 // The RFC 1858 evasion itself: a later fragment whose bytes can land on top of the
1663 // transport header the head fragment was matched on.
1664 assert!(
1665 !keep(
1666 &AllowAll,
1667 ipv6_with_prepended_ext_header(
1668 ext,
1669 &ipv6_fragment_packet(17, 1, false, &[0x61; 8])
1670 )
1671 ),
1672 "a low-offset later fragment behind extension header {ext} is dropped (RFC 1858)"
1673 );
1674 // A first fragment truncated before its own transport header, which a follow-up
1675 // fragment can then complete.
1676 assert!(
1677 !keep(
1678 &AllowAll,
1679 ipv6_with_prepended_ext_header(
1680 ext,
1681 &ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])
1682 )
1683 ),
1684 "a short first fragment behind extension header {ext} is dropped"
1685 );
1686 // A *well-formed* chained fragment is dropped too — Go drops this whole class, so
1687 // failing closed here can never admit something upstream refuses.
1688 assert!(
1689 !keep(
1690 &AllowAll,
1691 ipv6_with_prepended_ext_header(
1692 ext,
1693 &ipv6_fragment_packet(17, 185, false, &[0x61; 8])
1694 )
1695 ),
1696 "a chained later fragment behind extension header {ext} gets no pass-through"
1697 );
1698 assert!(
1699 !keep(
1700 &AllowAll,
1701 ipv6_with_prepended_ext_header(
1702 ext,
1703 &ipv6_fragment_packet(17, 0, true, &udp_header(443))
1704 )
1705 ),
1706 "a chained first fragment behind extension header {ext} is dropped"
1707 );
1708
1709 // Control for THIS extension type. Every assertion above is a `!keep`, and `keep`
1710 // reports a packet the parser rejected exactly as it reports a packet the fragment
1711 // rule dropped — so on its own the block above would also pass if this builder simply
1712 // produced eight bytes etherparse refuses to walk. The same chain shape with no
1713 // Fragment header behind it is still parsed, still admitted, and still matched on the
1714 // port read past the extension header, which pins the drops to the fragment rule.
1715 let plain = ipv6_with_prepended_ext_header(ext, &ipv6_udp_packet(&udp_header(443)));
1716 assert!(
1717 keep(&AllowAll, plain.clone()),
1718 "an unfragmented packet behind extension header {ext} is still delivered"
1719 );
1720 assert!(
1721 keep(&AllowPort(443), plain),
1722 "...and is still matched on the port read past extension header {ext}"
1723 );
1724 }
1725
1726 // Contrast: the very same later fragment, reached as the base header's immediate Next
1727 // Header, is still delivered. Only the 8 prepended bytes separate this from the third
1728 // assertion above, so the drops really are the chain and not the fragment fixtures.
1729 assert!(
1730 keep(&AllowAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1731 "an unchained later fragment is still delivered"
1732 );
1733 }
1734
1735 /// Build the IPv4 packet a Go peer puts on the wire for a TSMP message: a 20-byte IPv4
1736 /// header with proto 99 and `body` appended (Go `packet.Generate(IP4Header{...}, body)`,
1737 /// which is what `TSMPDiscoKeyAdvertisement.Marshal` calls). The header checksum is left
1738 /// zero — nothing on this path verifies it, and neither does Go's decoder.
1739 fn tsmp_packet4(src: [u8; 4], dst: [u8; 4], body: &[u8]) -> PacketMut {
1740 let mut buf = vec![0u8; 20 + body.len()];
1741 buf[20..].copy_from_slice(body);
1742 buf[0] = 0x45;
1743 let total_len = buf.len() as u16;
1744 buf[2..4].copy_from_slice(&total_len.to_be_bytes());
1745 buf[8] = 64;
1746 buf[9] = 99;
1747 buf[12..16].copy_from_slice(&src);
1748 buf[16..20].copy_from_slice(&dst);
1749 PacketMut::from(buf)
1750 }
1751
1752 /// A body a real Go peer sends: `'a'` then its 32-byte disco key.
1753 fn advertisement_body(key: [u8; 32]) -> Vec<u8> {
1754 let mut body = vec![ts_packet::tsmp::TSMP_TYPE_DISCO_ADVERTISEMENT];
1755 body.extend_from_slice(&key);
1756 body
1757 }
1758
1759 /// The receive side of the TSMP disco-key advertisement, at the point Go handles it: a
1760 /// well-formed advertisement is CONSUMED — the peer's key is learned and the packet is
1761 /// dropped rather than delivered to the local stack (Go `filter.DropSilently`) — while every
1762 /// other TSMP body is left alone and still admitted by the TSMP ACL bypass.
1763 ///
1764 /// The ACL here denies everything, so an admitted packet can only have come through the
1765 /// TSMP bypass, and a learned key can only have come from the advertisement path.
1766 #[test]
1767 fn tsmp_disco_key_advertisement_is_learned_and_dropped() {
1768 let peer = PeerId(7);
1769 let src = [100, 64, 0, 2];
1770 let dst = [100, 64, 0, 1];
1771 let key = [0xa5u8; 32];
1772
1773 let mut packets = vec![tsmp_packet4(src, dst, &advertisement_body(key))];
1774 let mut learned = Vec::new();
1775 filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1776
1777 assert!(
1778 packets.is_empty(),
1779 "a consumed advertisement must not be delivered to the local stack"
1780 );
1781 assert_eq!(learned.len(), 1, "the advertisement must be harvested");
1782 assert_eq!(
1783 learned[0].0, peer,
1784 "attributed to the sending wireguard peer"
1785 );
1786 assert_eq!(learned[0].1.key, key, "the advertised disco key is learned");
1787 assert_eq!(learned[0].1.src, std::net::IpAddr::from(src));
1788
1789 // A TSMP message that is NOT an advertisement stays in the batch (Go leaves the types it
1790 // does not consume to the filter, which accepts TSMP) and teaches us nothing.
1791 let mut ping = vec![ts_packet::tsmp::TSMP_TYPE_PING];
1792 ping.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
1793 let mut packets = vec![tsmp_packet4(src, dst, &ping)];
1794 let mut learned = Vec::new();
1795 filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1796 assert_eq!(packets.len(), 1, "a TSMP ping still bypasses the ACL");
1797 assert!(learned.is_empty(), "a ping advertises no disco key");
1798 }
1799
1800 /// The negative case, at the dataplane boundary: a TSMP body that is *nearly* an
1801 /// advertisement must not be half-parsed into a learned key. None of these may put anything
1802 /// in `learned` — a truncated key that was zero-padded, or a zero key that was accepted,
1803 /// would be a wrong disco key bound to a real peer.
1804 #[test]
1805 fn malformed_tsmp_disco_key_advertisements_teach_nothing() {
1806 let peer = PeerId(7);
1807 let src = [100, 64, 0, 2];
1808 let dst = [100, 64, 0, 1];
1809
1810 // A truncated advertisement: the type byte and only 31 of 32 key bytes.
1811 let mut truncated = advertisement_body([0xa5u8; 32]);
1812 truncated.truncate(32);
1813
1814 for (name, body, still_delivered) in [
1815 ("truncated advertisement", truncated, true),
1816 (
1817 "unknown TSMP type byte",
1818 {
1819 let mut b = advertisement_body([0xa5u8; 32]);
1820 b[0] = b'Z';
1821 b
1822 },
1823 true,
1824 ),
1825 // A well-formed advertisement of the zero key: Go parses it but publishes only
1826 // `if !discoKeyAdvert.Key.IsZero()`, so it teaches nothing — and it is still a TSMP
1827 // message we consumed, so it is still dropped.
1828 (
1829 "zero-key advertisement",
1830 advertisement_body([0u8; 32]),
1831 false,
1832 ),
1833 ] {
1834 let mut packets = vec![tsmp_packet4(src, dst, &body)];
1835 let mut learned = Vec::new();
1836 filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1837
1838 assert!(
1839 learned.is_empty(),
1840 "a {name} must not be half-parsed into a learned disco key"
1841 );
1842 assert_eq!(
1843 packets.len(),
1844 usize::from(still_delivered),
1845 "a {name} must {} be delivered",
1846 if still_delivered { "still" } else { "not" }
1847 );
1848 }
1849 }
1850
1851 /// Our own disco key, the one this node advertises. Asymmetric so a reversed or offset slice
1852 /// would be visible in the marshalled bytes.
1853 const SELF_DISCO_KEY: [u8; 32] = [
1854 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
1855 0x00, 0x9c, 0x5f, 0x3a, 0x01, 0x7d, 0xe2, 0x44, 0xb8, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a,
1856 0x69, 0x78,
1857 ];
1858
1859 /// An advertisement state with one peer, a v4 and a v6 address of our own, and a real disco key.
1860 fn advertisement_state(peer: PeerId, target: AdvertisementTarget) -> DiscoAdvertisementState {
1861 DiscoAdvertisementState {
1862 disco_key: SELF_DISCO_KEY,
1863 self_addrs: vec![
1864 std::net::IpAddr::from([100, 64, 0, 1]),
1865 std::net::IpAddr::from([
1866 0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1867 ]),
1868 ],
1869 peers: HashMap::from([(peer, target)]),
1870 }
1871 }
1872
1873 /// What this node advertises, and to whom (Go `magicsock.Conn.PriorityMessageForPeer`): the
1874 /// happy path emits the exact bytes `TSMPDiscoKeyAdvertisement.Marshal` emits, and each of Go's
1875 /// refusals emits nothing at all.
1876 #[test]
1877 fn disco_advertisement_matches_priority_message_for_peer() {
1878 let peer = PeerId(3);
1879 let peer_v4 = std::net::IpAddr::from([100, 64, 0, 2]);
1880 let target = AdvertisementTarget {
1881 node_addr: peer_v4,
1882 wireguard_only: false,
1883 };
1884 let state = advertisement_state(peer, target);
1885
1886 // Happy path: a v4 peer gets a v4 advertisement sourced from our v4 address — the first
1887 // self address in the destination's family (Go `selfIPMatchingFamily`).
1888 let msg = state
1889 .advertisement_for(peer)
1890 .expect("a Tailscale peer with a matching-family address must be advertised to");
1891 let parsed = ts_packet::tsmp::DiscoKeyAdvertisement::parse(&msg)
1892 .expect("what we emit must parse as an advertisement");
1893 assert_eq!(parsed.key, SELF_DISCO_KEY, "we advertise OUR disco key");
1894 assert_eq!(parsed.src, std::net::IpAddr::from([100, 64, 0, 1]));
1895 assert_eq!(parsed.dst, peer_v4);
1896 assert_eq!(
1897 msg,
1898 ts_packet::tsmp::DiscoKeyAdvertisement {
1899 src: std::net::IpAddr::from([100, 64, 0, 1]),
1900 dst: peer_v4,
1901 key: SELF_DISCO_KEY,
1902 }
1903 .marshal()
1904 .unwrap(),
1905 "the emitted bytes are exactly what Marshal produces"
1906 );
1907
1908 // A v6 peer is sourced from our v6 address, not our v4 one.
1909 let peer_v6 = std::net::IpAddr::from([
1910 0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
1911 ]);
1912 let v6_state = advertisement_state(
1913 peer,
1914 AdvertisementTarget {
1915 node_addr: peer_v6,
1916 wireguard_only: false,
1917 },
1918 );
1919 let parsed = v6_state
1920 .advertisement_for(peer)
1921 .and_then(|m| ts_packet::tsmp::DiscoKeyAdvertisement::parse(&m))
1922 .expect("a v6 peer must be advertised to over v6");
1923 assert!(parsed.src.is_ipv6(), "source must match the peer's family");
1924 assert_eq!(parsed.dst, peer_v6);
1925
1926 // Refusal 1 (Go `disco.IsZero()`): no disco key of our own, nothing to advertise.
1927 let mut no_key = advertisement_state(peer, target);
1928 no_key.disco_key = [0u8; 32];
1929 assert!(
1930 no_key.advertisement_for(peer).is_none(),
1931 "the zero disco key must never be advertised"
1932 );
1933
1934 // Refusal 2 (Go `endpointForNodeKey` miss / `!self.Valid()`): a peer the netmap snapshot
1935 // does not cover, and a node with no addresses of its own.
1936 assert!(
1937 state.advertisement_for(PeerId(0xbad)).is_none(),
1938 "an unknown peer must not be advertised to"
1939 );
1940 let mut no_self = advertisement_state(peer, target);
1941 no_self.self_addrs.clear();
1942 assert!(
1943 no_self.advertisement_for(peer).is_none(),
1944 "a node with no tailnet address of its own has no source to advertise from"
1945 );
1946
1947 // Refusal 3 (Go `ep.isWireguardOnly`): "Do not send TSMP messages to peers that only speaks
1948 // wireguard" — such a peer would hand it to its host stack as an unknown protocol.
1949 let wg_only = advertisement_state(
1950 peer,
1951 AdvertisementTarget {
1952 node_addr: peer_v4,
1953 wireguard_only: true,
1954 },
1955 );
1956 assert!(
1957 wg_only.advertisement_for(peer).is_none(),
1958 "a WireGuard-only peer must never be sent TSMP"
1959 );
1960
1961 // Refusal 4 (Go `selfIPMatchingFamily` returning the zero Addr): an IPv4-only node has no
1962 // source address for a packet to a peer's IPv6 address.
1963 let mut v4_only = advertisement_state(
1964 peer,
1965 AdvertisementTarget {
1966 node_addr: peer_v6,
1967 wireguard_only: false,
1968 },
1969 );
1970 v4_only.self_addrs = vec![std::net::IpAddr::from([100, 64, 0, 1])];
1971 assert!(
1972 v4_only.advertisement_for(peer).is_none(),
1973 "no self address in the peer's family means no advertisement"
1974 );
1975 }
1976
1977 /// End to end, over a real WireGuard handshake: when a session with a peer comes up, this
1978 /// node's dataplane emits its own TSMP disco-key advertisement to that peer — and the peer's
1979 /// dataplane learns the key from it and drops the packet.
1980 ///
1981 /// This is the send side (Go capability version 144) meeting the receive side already in this
1982 /// tree, so the assertion is not "some bytes went out" but "the far side learned exactly the
1983 /// disco key we hold". B is deliberately left with no advertisement state, which also pins the
1984 /// unconfigured case: it establishes the same session and sends nothing back.
1985 #[test]
1986 fn session_establishment_advertises_our_disco_key_to_the_peer() {
1987 let underlay: UnderlayTransportId = 0.into();
1988 let wg_peer = ts_tunnel::PeerId(1);
1989 let peer = PeerId(1);
1990 let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
1991 let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
1992
1993 let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
1994 let (mut a, mut b) = (
1995 DataPlane::new(a_static.clone()),
1996 DataPlane::new(b_static.clone()),
1997 );
1998
1999 for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
2000 dp.wireguard.upsert_peer(
2001 wg_peer,
2002 ts_tunnel::PeerConfig {
2003 key,
2004 psk: [0u8; 32].into(),
2005 persistent_keepalive_interval: None,
2006 },
2007 );
2008 dp.ur_out.table.insert(peer, underlay);
2009 }
2010
2011 // Only A knows how to advertise: its own disco key, its own address, and B's address.
2012 a.disco_advertisement = Some(Arc::new(advertisement_state(
2013 peer,
2014 AdvertisementTarget {
2015 node_addr: b_addr,
2016 wireguard_only: false,
2017 },
2018 )));
2019
2020 // B attributes A's tailnet address to the WireGuard peer that carries it, as the runtime's
2021 // source filter does — without that, B drops the advertisement before parsing it.
2022 let mut src_filter = ts_bart::Table::default();
2023 src_filter.insert(ipnet::IpNet::from(a_addr), peer);
2024 b.src_filter_in = Arc::new(src_filter);
2025
2026 // Drive the handshake. Only the initiation is kicked off directly (the dataplane starts one
2027 // from routed outbound traffic, which is not what this test is about); everything after it
2028 // goes through `process_inbound`, the path under test.
2029 let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
2030 out.into_values().flatten().collect::<Vec<_>>()
2031 };
2032 let init = a
2033 .wireguard
2034 .send([(wg_peer, vec![PacketMut::from(&b"hello"[..])])])
2035 .to_peers
2036 .remove(&wg_peer)
2037 .expect("handshake initiation");
2038
2039 let resp = take(b.process_inbound(init).to_peers);
2040 assert!(!resp.is_empty(), "B must answer the handshake initiation");
2041
2042 // A completes the handshake. Its session is now current, so alongside the queued data it
2043 // emits the advertisement.
2044 let from_a = take(a.process_inbound(resp).to_peers);
2045 assert_eq!(
2046 from_a.len(),
2047 2,
2048 "A must emit the queued data AND its disco-key advertisement"
2049 );
2050
2051 // B learns A's disco key from it, and the advertisement itself is consumed rather than
2052 // delivered to B's local stack.
2053 let inbound = b.process_inbound(from_a);
2054 assert_eq!(
2055 inbound
2056 .learned_disco_keys
2057 .iter()
2058 .map(|(peer, advert)| (*peer, advert.key))
2059 .collect::<Vec<_>>(),
2060 vec![(peer, SELF_DISCO_KEY)],
2061 "B must learn exactly the disco key A holds, attributed to A's wireguard peer"
2062 );
2063 assert!(
2064 inbound.to_peers.is_empty(),
2065 "B has no advertisement state, so it advertises nothing back"
2066 );
2067 }
2068
2069 /// Order regression: the advertisement must LEAD the traffic the same establishment released,
2070 /// not trail it.
2071 ///
2072 /// wireguard-go hands a priority message straight to the peer's *outbound* queue
2073 /// (`SendPriorityMessage` → `queueOutboundIfRunning`) and runs it before the flush that
2074 /// follows at both call sites — `peer.SendPriorityMessage()` ahead of `peer.SendKeepalive()`
2075 /// on the initiator and ahead of `peer.SendStagedPackets()` on the responder
2076 /// (`device/receive.go`) — so the advertisement is the first thing on the wire once a keypair
2077 /// becomes current. In this tree the flush has already happened inside `Endpoint::recv` by the
2078 /// time the advertisement exists, so `process_inbound` has to splice it in front; appending it
2079 /// would put it behind up to `MAX_QUEUED_PER_PEER` packets of queued traffic.
2080 ///
2081 /// The order is read off B's *decrypted* stream — its capture tee, which sees every inbound
2082 /// packet before any filtering — so what is pinned is the order the peer actually observes,
2083 /// not the order of a local vector.
2084 #[test]
2085 fn the_advertisement_leads_the_traffic_released_by_the_same_establishment() {
2086 let underlay: UnderlayTransportId = 0.into();
2087 let wg_peer = ts_tunnel::PeerId(1);
2088 let peer = PeerId(1);
2089 let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
2090 let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
2091
2092 let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
2093 let (mut a, mut b) = (
2094 DataPlane::new(a_static.clone()),
2095 DataPlane::new(b_static.clone()),
2096 );
2097
2098 for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
2099 dp.wireguard.upsert_peer(
2100 wg_peer,
2101 ts_tunnel::PeerConfig {
2102 key,
2103 psk: [0u8; 32].into(),
2104 persistent_keepalive_interval: None,
2105 },
2106 );
2107 dp.ur_out.table.insert(peer, underlay);
2108 }
2109
2110 a.disco_advertisement = Some(Arc::new(advertisement_state(
2111 peer,
2112 AdvertisementTarget {
2113 node_addr: b_addr,
2114 wireguard_only: false,
2115 },
2116 )));
2117
2118 let mut src_filter = ts_bart::Table::default();
2119 src_filter.insert(ipnet::IpNet::from(a_addr), peer);
2120 b.src_filter_in = Arc::new(src_filter);
2121
2122 // Everything B decrypts, in arrival order, before any filtering runs.
2123 let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
2124 let sink = recorded.clone();
2125 b.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
2126 sink.lock().unwrap().push((path, bytes.to_vec()));
2127 }));
2128
2129 let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
2130 out.into_values().flatten().collect::<Vec<_>>()
2131 };
2132
2133 // Traffic for a peer with no session yet: it stages, and a handshake starts.
2134 const QUEUED: &[u8] = b"staged while the session was still coming up";
2135 let init = a
2136 .wireguard
2137 .send([(wg_peer, vec![PacketMut::from(QUEUED)])])
2138 .to_peers
2139 .remove(&wg_peer)
2140 .expect("handshake initiation");
2141 let resp = take(b.process_inbound(init).to_peers);
2142
2143 // A's keypair becomes current here, which both flushes the staged packet and produces the
2144 // advertisement — the batch whose order is under test.
2145 let from_a = take(a.process_inbound(resp).to_peers);
2146 assert_eq!(
2147 from_a.len(),
2148 2,
2149 "A must emit the queued data AND its disco-key advertisement"
2150 );
2151
2152 // Hand them to B in exactly the order A produced them.
2153 let learned = b.process_inbound(from_a).learned_disco_keys;
2154 assert_eq!(
2155 learned
2156 .iter()
2157 .map(|(peer, advert)| (*peer, advert.key))
2158 .collect::<Vec<_>>(),
2159 vec![(peer, SELF_DISCO_KEY)],
2160 "B must still learn A's disco key"
2161 );
2162
2163 let advertisement = ts_packet::tsmp::DiscoKeyAdvertisement {
2164 src: a_addr,
2165 dst: b_addr,
2166 key: SELF_DISCO_KEY,
2167 }
2168 .marshal()
2169 .expect("a v4 advertisement between two v4 addresses marshals");
2170
2171 let captured = recorded.lock().unwrap();
2172 let from_peer = captured
2173 .iter()
2174 .filter(|(path, _)| *path == CapturePath::FromPeer)
2175 .map(|(_, bytes)| bytes.as_slice())
2176 .collect::<Vec<_>>();
2177 assert_eq!(from_peer.len(), 2, "B must decrypt both of A's packets");
2178 // The send path zero-pads each payload up to a 16-byte boundary and the receiver delivers
2179 // it with that padding intact (see `session::PADDING_MULTIPLE`), so compare on the leading
2180 // bytes rather than for equality.
2181 assert!(
2182 from_peer[0].starts_with(&advertisement),
2183 "the advertisement must reach the peer FIRST, ahead of the traffic the same \
2184 establishment released"
2185 );
2186 assert!(
2187 from_peer[1].starts_with(QUEUED),
2188 "the queued traffic follows the advertisement"
2189 );
2190 }
2191
2192 /// Behavioral guard: an installed capture hook MUST be invoked with `CapturePath::FromLocal`
2193 /// and the exact packet bytes for every outbound packet. The tee sits at the top of
2194 /// `process_outbound`, before `or_out.route` consumes the packets, so it fires regardless of
2195 /// whether a wireguard peer exists (an empty router just drops the routed packets afterward).
2196 /// This is the only end-to-end guard that the dataplane capture tee actually fires; a refactor
2197 /// that drops the tee would leave every byte-layout test green.
2198 #[test]
2199 fn capture_hook_fires_on_outbound() {
2200 let mut dp = DataPlane::new(NodeKeyPair::new());
2201
2202 let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
2203 let sink = recorded.clone();
2204 dp.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
2205 sink.lock().unwrap().push((path, bytes.to_vec()));
2206 }));
2207
2208 // The outbound tee passes `p.as_ref()` as-given; the bytes need not be a valid IP packet.
2209 let payload: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
2210 let packet = PacketMut::from(payload.clone());
2211
2212 drop(dp.process_outbound(vec![packet]));
2213
2214 let captured = recorded.lock().unwrap();
2215 assert_eq!(captured.len(), 1, "hook must fire exactly once per packet");
2216 assert_eq!(captured[0].0, CapturePath::FromLocal);
2217 assert_eq!(captured[0].1, payload);
2218 }
2219}