Skip to main content

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: &etherparse::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.
765    pub fn process_inbound(
766        &mut self,
767        packets: impl IntoIterator<Item = PacketMut>,
768    ) -> InboundResult {
769        let ts_tunnel::RecvResult {
770            to_local,
771            to_peers,
772            sessions_established,
773        } = self.wireguard.recv(packets);
774
775        if let Some(hook) = &self.capture {
776            for packets in to_local.values() {
777                for p in packets {
778                    hook(CapturePath::FromPeer, p.as_ref());
779                }
780            }
781        }
782
783        // TSMP disco-key advertisements learned from this batch (Go `tstun.Wrapper`'s
784        // `discoKeyAdvertisementPub` publisher). Filled in by the packet-filter stage below, which
785        // is the point at which a packet has both been attributed to a peer and decoded far enough
786        // to know it is TSMP.
787        let mut learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)> =
788            Vec::new();
789
790        let to_local = to_local
791            .into_iter()
792            .map(|(peer_id, mut packets)| -> (PeerId, Vec<PacketMut>) {
793                let _span = tracing::trace_span!(
794                    "src_filter_inbound",
795                    peer_id = ?peer_id,
796                    n_packet = packets.len(),
797                )
798                .entered();
799
800                packets.retain(|packet| {
801                    let Some(src) = packet.get_src_addr() else {
802                        tracing::trace!("does not look like ip packet");
803                        return false;
804                    };
805                    let verdict = if let Some(allowed_peer) = self.src_filter_in.lookup(src) {
806                        *allowed_peer == PeerId(peer_id.0)
807                    } else {
808                        tracing::trace!(remote_ip = %src, "unknown peer address");
809                        false
810                    };
811                    tracing::trace!(?src, verdict);
812                    verdict
813                });
814
815                (PeerId(peer_id.0), packets)
816            })
817            .map(|(peer_id, mut v)| {
818                let _span = tracing::trace_span!(
819                    "packet_filter_inbound",
820                    peer_id = ?peer_id,
821                    n_packet = v.len()
822                )
823                .entered();
824
825                filter_inbound_from_peer(
826                    self.packet_filter.as_ref(),
827                    peer_id,
828                    &mut v,
829                    &mut learned_disco_keys,
830                );
831
832                v
833            });
834
835        // TSMP disco-key advertisement, send side (Go capability version 144). wireguard-go calls
836        // `peer.SendPriorityMessage()` the moment a keypair becomes current for forward
837        // transmission — on the initiator when the handshake response lands, and on the responder
838        // when the first transport packet authenticates on the new keypair (`device/receive.go`).
839        // `sessions_established` is exactly those two moments; the message is Go's
840        // `magicsock.Conn.PriorityMessageForPeer` return value. A peer we must not advertise to
841        // (see [`DiscoAdvertisementState::advertisement_for`]) simply gets nothing, and the fresh
842        // session is otherwise untouched.
843        let mut to_peers = to_peers;
844        if let Some(advert) = self.disco_advertisement.clone() {
845            // Held apart from what `recv` already queued for these peers so it can be spliced in
846            // FRONT of it below, rather than appended behind it.
847            let mut priority: HashMap<ts_tunnel::PeerId, Vec<PacketMut>> = HashMap::new();
848            for peer in sessions_established {
849                let Some(msg) = advert.advertisement_for(PeerId(peer.0)) else {
850                    continue;
851                };
852                tracing::debug!(peer_id = ?peer, "advertising our disco key over TSMP");
853                for (peer, packets) in self.wireguard.send_priority_message(peer, &msg).to_peers {
854                    priority.entry(peer).or_default().extend(packets);
855                }
856            }
857            // A priority message leads the traffic the same establishment released. wireguard-go
858            // hands it straight to the peer's *outbound* queue (`SendPriorityMessage` →
859            // `queueOutboundIfRunning`), never to the staged queue, and both call sites run it
860            // before the flush that follows — `peer.SendPriorityMessage()` ahead of
861            // `peer.SendKeepalive()` on the initiator and ahead of `peer.SendStagedPackets()` on
862            // the responder (`device/receive.go`). Here the flush has already happened inside
863            // [`Endpoint::recv`] (`activate` encrypts whatever was queued), so restoring Go's wire
864            // order means splicing the advertisement in front of it.
865            //
866            // Only the wire order is restored, not Go's nonce order: those flushed packets were
867            // sealed first and so hold the lower nonces, where Go would have numbered the priority
868            // message first. That is invisible to the peer. A WireGuard receiver accepts an
869            // earlier counter after a later one by construction, and the inversion is bounded by
870            // the send queue a session flushes on activation (`MAX_QUEUED_PER_PEER`, 32 packets) —
871            // two orders of magnitude inside the 8128-packet anti-replay window WireGuard
872            // receivers carry (`ts_tunnel`'s `ReplayWindow::WINDOW_SIZE`, wireguard-go parity).
873            for (peer, mut packets) in priority {
874                let queued = to_peers.entry(peer).or_default();
875                packets.append(queued);
876                *queued = packets;
877            }
878        }
879
880        let to_peers = to_peers
881            .into_iter()
882            .map(|(k, v)| (ts_transport::PeerId(k.0), v));
883
884        let to_local = self.or_in.route(to_local.flatten());
885        let to_peers = self.ur_out.route(to_peers);
886
887        if let Some(next) = self.wireguard.next_event()
888            && let Some(prev) = self
889                .wg_next
890                .replace(self.events.add(next, Subsystem::Wireguard))
891        {
892            prev.cancel();
893        }
894
895        InboundResult {
896            to_local,
897            to_peers,
898            learned_disco_keys,
899        }
900    }
901
902    /// Return the next time at which [`DataPlane::process_events`] must be called.
903    ///
904    /// [`DataPlane::process_outbound`], [`DataPlane::process_inbound`] and
905    /// [`DataPlane::process_events`] may all update the next event time. Callers should prefer
906    /// calling `next_event` as needed to get a correct result, rather than store the returned
907    /// value.
908    pub fn next_event(&self) -> Option<Instant> {
909        self.events.next_dispatch()
910    }
911
912    /// Process all queued events that are due for processing.
913    ///
914    /// Must be called at least as often as dictated by [`DataPlane::next_event`] for the
915    /// data plane to function correctly. It is harmless to call it more frequently.
916    pub fn process_events(&mut self) -> EventResult {
917        let mut to_peers = HashMap::new();
918        let now = Instant::now();
919        for event in self.events.dispatch(now) {
920            match event {
921                Subsystem::Wireguard => {
922                    let res = self.wireguard.dispatch_events(now);
923                    to_peers.extend(
924                        res.to_peers
925                            .into_iter()
926                            .map(|(id, pkts)| (ts_transport::PeerId(id.0), pkts)),
927                    );
928                }
929            }
930        }
931        let to_peers = self.ur_out.route(to_peers);
932
933        if let Some(next) = self.wireguard.next_event()
934            && let Some(prev) = self
935                .wg_next
936                .replace(self.events.add(next, Subsystem::Wireguard))
937        {
938            prev.cancel();
939        }
940
941        EventResult { to_peers }
942    }
943}
944
945/// The result of processing outbound packets.
946pub struct OutboundResult {
947    /// Packets to be sent into underlay transports for transmission.
948    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
949    /// Packets to be looped back and delivered to overlay transports.
950    pub loopback: HashMap<OverlayTransportId, Vec<PacketMut>>,
951}
952
953/// The result of processing inbound packets.
954pub struct InboundResult {
955    /// Decrypted packets to be delivered to overlay transports.
956    pub to_local: HashMap<OverlayTransportId, Vec<PacketMut>>,
957    /// Encrypted packets to be sent to wireguard peers by the underlay.
958    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
959    /// Disco keys peers advertised over TSMP in this batch, each paired with the WireGuard peer
960    /// whose session carried it (Go `tstun.Wrapper` publishing `events.PeerDiscoKeyUpdate`, which
961    /// `wgengine` turns into a `magicsock.Conn.HandleDiscoKeyAdvertisement` call).
962    ///
963    /// The advertisement packets themselves are dropped: they are inter-node control messages, not
964    /// traffic for the local stack. Zero keys are already filtered out. Empty for a batch that
965    /// carried none, which is the overwhelmingly common case.
966    pub learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
967}
968
969/// The result of processing an event.
970#[derive(Default)]
971pub struct EventResult {
972    /// Encrypted packets to be sent to wireguard peers by the underlay.
973    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
974}
975
976#[cfg(test)]
977mod tests {
978    use std::sync::Mutex;
979
980    use super::*;
981
982    /// Records `(path, bytes)` for each capture-hook invocation in a test.
983    type CaptureLog = Arc<Mutex<Vec<(CapturePath, Vec<u8>)>>>;
984
985    #[test]
986    fn capture_path_codes() {
987        assert_eq!(CapturePath::FromLocal.code(), 0);
988        assert_eq!(CapturePath::FromPeer.code(), 1);
989        assert_eq!(CapturePath::SynthesizedToLocal.code(), 2);
990        assert_eq!(CapturePath::SynthesizedToPeer.code(), 3);
991    }
992
993    /// The pre-rule destination screen (Go filter `pre()`): multicast and non-allowlisted link-local
994    /// destinations are dropped before the ACL; ordinary unicast and the cloud-metadata link-local
995    /// exception pass through to the rules.
996    #[test]
997    fn pre_rule_drop_matches_go() {
998        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
999        // Dropped pre-rules:
1000        assert!(drop_before_rules(ip("224.0.0.1")), "IPv4 multicast dropped");
1001        assert!(
1002            drop_before_rules(ip("239.255.255.250")),
1003            "IPv4 multicast (SSDP) dropped"
1004        );
1005        assert!(
1006            drop_before_rules(ip("169.254.1.1")),
1007            "IPv4 link-local dropped"
1008        );
1009        assert!(drop_before_rules(ip("ff02::1")), "IPv6 multicast dropped");
1010        assert!(drop_before_rules(ip("fe80::1")), "IPv6 link-local dropped");
1011        assert!(
1012            drop_before_rules(ip("febf:ffff::1")),
1013            "top of fe80::/10 dropped (locks the 0xffc0/0xfe80 mask)"
1014        );
1015        // Passed through to the rules:
1016        assert!(
1017            !drop_before_rules(ip("fec0::1")),
1018            "just past fe80::/10 passes (locks the 0xffc0/0xfe80 mask)"
1019        );
1020        // IPv4-mapped-IPv6 destinations match NEITHER arm and fall through to the ACL, exactly as
1021        // Go's `netip.Addr` predicates do (no unmap/canonicalize). Pinning this guards against a
1022        // future "canonicalize to be safe" refactor silently diverging from Go.
1023        assert!(
1024            !drop_before_rules(ip("::ffff:224.0.0.1")),
1025            "4in6-mapped multicast falls through to the ACL, matching Go"
1026        );
1027        assert!(
1028            !drop_before_rules(ip("::ffff:169.254.1.1")),
1029            "4in6-mapped link-local falls through to the ACL, matching Go"
1030        );
1031        assert!(
1032            !drop_before_rules(ip("100.64.0.5")),
1033            "ordinary tailnet unicast passes"
1034        );
1035        assert!(
1036            !drop_before_rules(ip("8.8.8.8")),
1037            "ordinary public unicast passes"
1038        );
1039        assert!(
1040            !drop_before_rules(ip("169.254.169.254")),
1041            "the cloud-metadata link-local address is the Go-allowlisted exception"
1042        );
1043        assert!(
1044            !drop_before_rules(ip("fd7a:115c:a1e0::1")),
1045            "IPv6 ULA (tailnet) passes"
1046        );
1047    }
1048
1049    /// A filter that drops everything (returns `None` for every packet). Lets a test prove that TSMP
1050    /// is admitted by bypassing the ACL — not by the ACL happening to allow it.
1051    struct DenyAll;
1052    impl ts_packetfilter::Filter for DenyAll {
1053        fn match_for(
1054            &self,
1055            _info: &ts_packetfilter::PacketInfo,
1056            _caps: ts_packetfilter::filter::CapIter,
1057        ) -> Option<&str> {
1058            None
1059        }
1060    }
1061
1062    /// The inbound proto-switch (Go `runIn4`/`runIn6`): TSMP is always admitted, bypassing the ACL;
1063    /// `pre()` drops still win over TSMP; non-TSMP defers to the ACL.
1064    #[test]
1065    fn tsmp_bypasses_acl_matches_go() {
1066        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1067        let src = ip("100.64.0.9");
1068        let dst = ip("100.64.0.1");
1069        let tsmp = IpProto::new(99);
1070
1071        // TSMP is accepted even though the ACL denies everything — Go `case TSMP: return Accept`.
1072        assert!(
1073            inbound_filter_verdict(&DenyAll, tsmp, src, dst, 0, None),
1074            "TSMP admitted by bypassing the (deny-all) ACL"
1075        );
1076        // A non-TSMP proto under the same deny-all ACL is dropped — proves the bypass is TSMP-specific.
1077        assert!(
1078            !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443, None),
1079            "TCP still consults the ACL (deny-all → dropped)"
1080        );
1081        // `pre()` drops outrank the TSMP accept: TSMP to a multicast/link-local dst is still dropped,
1082        // exactly as Go runs `pre()` before the proto switch.
1083        assert!(
1084            !inbound_filter_verdict(&DenyAll, tsmp, src, ip("224.0.0.1"), 0, None),
1085            "TSMP to a multicast dst is still dropped (pre() before the switch)"
1086        );
1087        assert!(
1088            !inbound_filter_verdict(&DenyAll, tsmp, src, ip("169.254.1.1"), 0, None),
1089            "TSMP to a link-local dst is still dropped (pre() before the switch)"
1090        );
1091        // IpProto::TSMP is the named constant for proto 99.
1092        assert_eq!(IpProto::TSMP, tsmp, "IpProto::TSMP == 99");
1093    }
1094
1095    /// IPv4 fragment handling, mirroring Go `net/packet.decode4` + filter `pre()`:
1096    /// - a valid later fragment (offset ≥ `MIN_FRAG_BLKS`) is ACCEPTED ahead of the ACL (Go maps it
1097    ///   to `ipproto.Fragment`, which `pre()` admits) — even under a deny-all ACL and even though its
1098    ///   parsed port is 0, which a normal rule would never match;
1099    /// - a low-offset later fragment (offset < `MIN_FRAG_BLKS`) is DROPPED (RFC 1858);
1100    /// - a first fragment (offset 0) defers to the normal proto-switch/ACL on its real port;
1101    /// - a *fragmented* TSMP first fragment (offset 0, MF set) is DROPPED (Go disallows it), unlike a
1102    ///   non-fragmented TSMP which bypasses the ACL.
1103    #[test]
1104    fn ipv4_fragment_handling_matches_go_decode4() {
1105        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1106        let src = ip("100.64.0.9");
1107        let dst = ip("100.64.0.1");
1108        let frag = |offset_blocks: u16, more_fragments: bool| {
1109            Some(Fragment::V4(Ipv4Fragment {
1110                offset_blocks,
1111                more_fragments,
1112            }))
1113        };
1114
1115        // A valid later fragment is accepted under a DENY-ALL ACL with port 0 — proves the accept is
1116        // the Go `pre()` Fragment pass-through, not the ACL happening to allow it.
1117        assert!(
1118            inbound_filter_verdict(
1119                &DenyAll,
1120                IpProto::TCP,
1121                src,
1122                dst,
1123                0,
1124                frag(MIN_FRAG_BLKS, false)
1125            ),
1126            "a valid later fragment (offset >= MIN_FRAG_BLKS) is accepted ahead of the ACL"
1127        );
1128        assert!(
1129            inbound_filter_verdict(
1130                &DenyAll,
1131                IpProto::UDP,
1132                src,
1133                dst,
1134                0,
1135                frag(MIN_FRAG_BLKS + 50, true)
1136            ),
1137            "a later fragment well past the floor (MF set) is also accepted"
1138        );
1139
1140        // A low-offset later fragment (could overlap a transport header) is dropped — RFC 1858.
1141        assert!(
1142            !inbound_filter_verdict(
1143                &DenyAll,
1144                IpProto::TCP,
1145                src,
1146                dst,
1147                0,
1148                frag(MIN_FRAG_BLKS - 1, false)
1149            ),
1150            "a low-offset later fragment is dropped (RFC 1858)"
1151        );
1152        assert!(
1153            !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 0, frag(1, false)),
1154            "the smallest non-zero offset is dropped"
1155        );
1156
1157        // A first fragment (offset 0) defers to the normal ACL on its real port: deny-all drops a
1158        // TCP first fragment, exactly as it drops a non-fragmented TCP packet.
1159        assert!(
1160            !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443, frag(0, true)),
1161            "a first fragment defers to the ACL (deny-all -> dropped) on its parsed port"
1162        );
1163
1164        // A fragmented TSMP first fragment (offset 0, MF set) is dropped — Go disallows it — even
1165        // though a non-fragmented TSMP bypasses the ACL.
1166        assert!(
1167            !inbound_filter_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, true)),
1168            "a fragmented TSMP first fragment is dropped (Go parity)"
1169        );
1170        assert!(
1171            inbound_filter_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, false)),
1172            "a non-fragmented TSMP (offset 0, MF clear) still bypasses the ACL"
1173        );
1174
1175        // A *later* TSMP fragment (offset >= MIN_FRAG_BLKS) is accepted via the offset-based
1176        // fragment pass-through, NOT dropped by the fragmented-TSMP rule — that rule is offset-0
1177        // only (a first fragment with MF). This proves the later-fragment branch is proto-independent
1178        // and wins over the TSMP-specific logic (Go maps any offset>=minFragBlks to ipproto.Fragment
1179        // regardless of the L4 proto byte), locking the branch ordering against regression.
1180        assert!(
1181            inbound_filter_verdict(
1182                &DenyAll,
1183                IpProto::TSMP,
1184                src,
1185                dst,
1186                0,
1187                frag(MIN_FRAG_BLKS, true)
1188            ),
1189            "a later TSMP fragment is accepted via the fragment path (proto-independent)"
1190        );
1191    }
1192
1193    /// An ACL that admits everything, the shape a permissive "allow the whole tailnet" policy has.
1194    /// Under it, a DROP can only have come from a rule the filter applies *ahead* of the ACL — which
1195    /// is exactly what makes it the right control for the fragment classification's negative cases.
1196    struct AllowAll;
1197    impl ts_packetfilter::Filter for AllowAll {
1198        fn match_for(
1199            &self,
1200            _info: &ts_packetfilter::PacketInfo,
1201            _caps: ts_packetfilter::filter::CapIter,
1202        ) -> Option<&str> {
1203            Some("allow-all")
1204        }
1205    }
1206
1207    /// An ACL that admits exactly one destination port. An admitted packet therefore proves the
1208    /// filter read that port off the wire — the point of Go `decode6` reaching past the Fragment
1209    /// extension header to the first fragment's real transport header.
1210    struct AllowPort(u16);
1211    impl ts_packetfilter::Filter for AllowPort {
1212        fn match_for(
1213            &self,
1214            info: &ts_packetfilter::PacketInfo,
1215            _caps: ts_packetfilter::filter::CapIter,
1216        ) -> Option<&str> {
1217            (info.port == self.0).then_some("allow-port")
1218        }
1219    }
1220
1221    /// Source/destination for the IPv6 fixtures: RFC 3849 documentation addresses, standing in for
1222    /// the real ones upstream's `udp6*FragmentBuffer` fixtures use. Neither is multicast or
1223    /// link-local, so `drop_before_rules` never fires and every verdict below is the fragment
1224    /// classification's own.
1225    const IPV6_FIXTURE_SRC: std::net::Ipv6Addr =
1226        std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 5);
1227    const IPV6_FIXTURE_DST: std::net::Ipv6Addr =
1228        std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
1229
1230    /// The IPv6 packet a source-fragmenting host puts on the wire, in the shape of upstream's
1231    /// `udp6FirstFragmentBuffer` / `udp6NonFirstFragmentBuffer` fixtures (Go
1232    /// `net/packet/packet_test.go`): a 40-byte base header whose Next Header is the Fragment
1233    /// extension header (44), the 8-byte Fragment header itself, then `rest` — the real
1234    /// sub-protocol header on a first fragment, or continued payload on a later one.
1235    fn ipv6_fragment_packet(
1236        next_header: u8,
1237        offset_blocks: u16,
1238        more_fragments: bool,
1239        rest: &[u8],
1240    ) -> Vec<u8> {
1241        let mut buf = vec![0u8; IP6_HEADER_LEN + IP6_FRAG_HEADER_LEN + rest.len()];
1242        buf[0] = 0x60; // version 6, traffic class/flow label 0
1243        let payload_len = u16::try_from(IP6_FRAG_HEADER_LEN + rest.len()).unwrap();
1244        buf[4..6].copy_from_slice(&payload_len.to_be_bytes());
1245        buf[6] = IP6_FRAG_HEADER;
1246        buf[7] = 64; // hop limit
1247        buf[8..24].copy_from_slice(&IPV6_FIXTURE_SRC.octets());
1248        buf[24..40].copy_from_slice(&IPV6_FIXTURE_DST.octets());
1249        // Fragment extension header: Next Header, Reserved, offset<<3 | MF, Identification.
1250        buf[40] = next_header;
1251        let offset_field = (offset_blocks << 3) | u16::from(more_fragments);
1252        buf[42..44].copy_from_slice(&offset_field.to_be_bytes());
1253        buf[44..48].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
1254        buf[48..].copy_from_slice(rest);
1255        buf
1256    }
1257
1258    /// A plain, unfragmented IPv6/UDP packet: the same 40-byte base header the fragment fixtures
1259    /// use, but with UDP as its immediate Next Header. The control for the chained-extension-header
1260    /// fixtures below.
1261    fn ipv6_udp_packet(udp: &[u8]) -> Vec<u8> {
1262        let mut buf = vec![0u8; IP6_HEADER_LEN + udp.len()];
1263        buf[0] = 0x60; // version 6, traffic class/flow label 0
1264        buf[4..6].copy_from_slice(&u16::try_from(udp.len()).unwrap().to_be_bytes());
1265        buf[6] = 17; // Next Header = UDP
1266        buf[7] = 64; // hop limit
1267        buf[8..24].copy_from_slice(&IPV6_FIXTURE_SRC.octets());
1268        buf[24..40].copy_from_slice(&IPV6_FIXTURE_DST.octets());
1269        buf[IP6_HEADER_LEN..].copy_from_slice(udp);
1270        // Unlike a fragment fixture, this datagram is actually parsed as UDP, so its Length field
1271        // has to agree with the bytes present or etherparse rejects the packet outright.
1272        let udp_len = u16::try_from(udp.len()).unwrap();
1273        buf[IP6_HEADER_LEN + 4..IP6_HEADER_LEN + 6].copy_from_slice(&udp_len.to_be_bytes());
1274        buf
1275    }
1276
1277    /// Push one 8-byte extension header of protocol `ext_proto` in front of `inner`'s payload, so
1278    /// whatever `inner`'s base header pointed at directly is now reached through a *chain*. The
1279    /// generic Next-Header / Hdr-Ext-Len-0 / six-bytes-of-body shape is the on-the-wire layout of
1280    /// Hop-by-Hop Options (0), Routing (43) and Destination Options (60) alike.
1281    ///
1282    /// Those six body bytes are chosen so the header is well formed under *every* one of those
1283    /// three readings, not merely one etherparse happens not to look at:
1284    ///
1285    /// - as Options (0 / 60) they are a TLV stream — `1, 0` is a zero-length PadN, and the four
1286    ///   trailing zeros are four Pad1s, filling the 8-byte header exactly;
1287    /// - as Routing (43) they are Routing Type 1, **Segments Left 0**, and four bytes of
1288    ///   type-specific data. Segments Left must stay 0: `Hdr Ext Len` is 0, so there is no room
1289    ///   for a single 16-byte segment, and RFC 8200 §4.4 has a receiver that meets a non-zero
1290    ///   Segments Left on an unrecognized Routing Type discard the packet and answer ICMP
1291    ///   Parameter Problem. etherparse walks a Routing header as a raw ext header and never reads
1292    ///   the field, so a non-zero value parses here today — but a fixture that only survives
1293    ///   because the parser is lenient is one parser release away from turning the negative
1294    ///   assertions below into vacuous passes.
1295    fn ipv6_with_prepended_ext_header(ext_proto: u8, inner: &[u8]) -> Vec<u8> {
1296        let mut buf = Vec::with_capacity(inner.len() + 8);
1297        buf.extend_from_slice(&inner[..IP6_HEADER_LEN]);
1298        // The header we are displacing becomes the extension header's Next Header.
1299        let displaced = buf[6];
1300        buf[6] = ext_proto;
1301        let payload_len = u16::try_from(inner.len() - IP6_HEADER_LEN + 8).unwrap();
1302        buf[4..6].copy_from_slice(&payload_len.to_be_bytes());
1303        buf.extend_from_slice(&[displaced, 0, 1, 0, 0, 0, 0, 0]);
1304        buf.extend_from_slice(&inner[IP6_HEADER_LEN..]);
1305        buf
1306    }
1307
1308    /// An 8-byte UDP header carrying `dst_port`, as a first fragment's `rest`.
1309    fn udp_header(dst_port: u16) -> Vec<u8> {
1310        let mut hdr = vec![0u8; 8];
1311        hdr[0..2].copy_from_slice(&54276u16.to_be_bytes());
1312        hdr[2..4].copy_from_slice(&dst_port.to_be_bytes());
1313        hdr[4..6].copy_from_slice(&16u16.to_be_bytes());
1314        hdr
1315    }
1316
1317    /// The IPv6 Fragment extension-header classification, mirroring Go
1318    /// `net/packet.Parsed.decode6Fragment` plus the sub-protocol switch `decode6` runs when it
1319    /// reports `continueDecode` (upstream `4c4ec3d46`, clarified by `26b2ed0a6`). Cases are
1320    /// upstream's own `TestDecode` fixtures: `ipv6_frag_first`, `ipv6_frag_nonfirst`,
1321    /// `ipv6_frag_short_first` and `ipv6_frag_small_offset`.
1322    #[test]
1323    fn ipv6_fragment_classification_matches_go_decode6() {
1324        // `ipv6_frag_first`: offset 0 with MF set, and a whole UDP header behind the fragment
1325        // header — Go steps over the 8 bytes and reads the ports, so the ACL matches this datagram
1326        // on the same rule it would match unfragmented.
1327        assert_eq!(
1328            decode6_fragment(&ipv6_fragment_packet(17, 0, true, &udp_header(443))),
1329            Ipv6Fragment::First {
1330                proto: IpProto::UDP,
1331                dst_port: 443,
1332            },
1333            "a first fragment is decoded past the Fragment header, ports and all"
1334        );
1335
1336        // `ipv6_frag_nonfirst`: a later fragment at offset 185 blocks has no transport header at
1337        // all, so Go marks it `ipproto.Fragment` for `pre()` to pass through.
1338        assert_eq!(
1339            decode6_fragment(&ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1340            Ipv6Fragment::Later,
1341            "a later fragment at a safe offset classifies as a pass-through fragment"
1342        );
1343        // The floor itself is safe; one block below it is not. `MIN_FRAG_BLKS` is the IPv4-sized
1344        // bound upstream deliberately reuses for IPv6 (Go `26b2ed0a6`).
1345        assert_eq!(
1346            decode6_fragment(&ipv6_fragment_packet(17, MIN_FRAG_BLKS, false, &[0x61; 8])),
1347            Ipv6Fragment::Later,
1348            "offset == MIN_FRAG_BLKS is the first accepted later fragment"
1349        );
1350
1351        // `ipv6_frag_small_offset`: a later fragment whose bytes could land on top of the transport
1352        // header the head fragment was matched on — RFC 1858. Go rejects it as `unknown`.
1353        assert_eq!(
1354            decode6_fragment(&ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
1355            Ipv6Fragment::Unknown,
1356            "a later fragment at offset 1 block is rejected (RFC 1858)"
1357        );
1358        assert_eq!(
1359            decode6_fragment(&ipv6_fragment_packet(
1360                17,
1361                MIN_FRAG_BLKS - 1,
1362                false,
1363                &[0x61; 8]
1364            )),
1365            Ipv6Fragment::Unknown,
1366            "one block below the floor is still rejected (RFC 1858)"
1367        );
1368
1369        // `ipv6_frag_short_first`: a first fragment truncated before its full transport header. Go
1370        // refuses to guess at the ports, because a follow-up fragment supplying the rest of that
1371        // header would otherwise carry the flow past a rule the filter never really matched.
1372        assert_eq!(
1373            decode6_fragment(&ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])),
1374            Ipv6Fragment::Unknown,
1375            "a first fragment with only half a UDP header is rejected"
1376        );
1377        assert_eq!(
1378            decode6_fragment(&ipv6_fragment_packet(6, 0, true, &[0u8; 19])),
1379            Ipv6Fragment::Unknown,
1380            "a first fragment one byte short of a TCP header is rejected"
1381        );
1382        // ...and the same header one byte longer is accepted, so the rejection is the bounds check
1383        // and not the protocol.
1384        let mut tcp = vec![0u8; 20];
1385        tcp[2..4].copy_from_slice(&443u16.to_be_bytes());
1386        assert_eq!(
1387            decode6_fragment(&ipv6_fragment_packet(6, 0, true, &tcp)),
1388            Ipv6Fragment::First {
1389                proto: IpProto::TCP,
1390                dst_port: 443,
1391            },
1392            "a complete TCP header in the first fragment is read normally"
1393        );
1394
1395        // A Fragment header truncated by the packet itself (Go's `len(b) < q.subofs+8` guard).
1396        let mut short = ipv6_fragment_packet(17, 0, true, &[]);
1397        short.truncate(IP6_HEADER_LEN + 4);
1398        short[4..6].copy_from_slice(&4u16.to_be_bytes());
1399        assert_eq!(
1400            decode6_fragment(&short),
1401            Ipv6Fragment::Unknown,
1402            "a truncated Fragment extension header is rejected"
1403        );
1404        // A packet cut off before its declared payload length (Go `len(b) < q.length`).
1405        let mut cut = ipv6_fragment_packet(17, 0, true, &udp_header(443));
1406        cut.truncate(cut.len() - 1);
1407        assert_eq!(
1408            decode6_fragment(&cut),
1409            Ipv6Fragment::Unknown,
1410            "a packet cut off before its declared IPv6 length is rejected"
1411        );
1412
1413        // Go's portless arms bounds-check but leave the port at 0, and the on-the-wire use of Go's
1414        // internal `ipproto.Fragment` sentinel (0xff) maps back to `unknown`.
1415        assert_eq!(
1416            decode6_fragment(&ipv6_fragment_packet(58, 0, true, &[0u8; 4])),
1417            Ipv6Fragment::First {
1418                proto: IpProto::ICMPV6,
1419                dst_port: 0,
1420            },
1421            "a first ICMPv6 fragment keeps port 0 and is matched IPs-only"
1422        );
1423        assert_eq!(
1424            decode6_fragment(&ipv6_fragment_packet(58, 0, true, &[0u8; 3])),
1425            Ipv6Fragment::Unknown,
1426            "a first ICMPv6 fragment shorter than the ICMPv6 header is rejected"
1427        );
1428        assert_eq!(
1429            decode6_fragment(&ipv6_fragment_packet(0xff, 0, true, &[0u8; 8])),
1430            Ipv6Fragment::Unknown,
1431            "Go's internal Fragment sentinel seen on the wire maps back to unknown"
1432        );
1433    }
1434
1435    /// The verdict Go's filter `pre()` reaches for each IPv6 fragment classification, asserted
1436    /// against an ACL that would otherwise decide the packet the other way — so each assertion can
1437    /// only be the fragment rule, never the ACL:
1438    ///
1439    /// - `Unknown` is DROPPED under an ALLOW-ALL ACL (Go `pre()`: `IPProto == Unknown → Drop`).
1440    ///   This is the security-relevant direction: an allow-all tailnet policy must not admit a
1441    ///   short-first or RFC-1858 low-offset fragment.
1442    /// - `Later` is ACCEPTED under a DENY-ALL ACL (Go `pre()`: `case ipproto.Fragment: Accept`).
1443    /// - `First` consults the ACL normally on the port read past the Fragment header.
1444    #[test]
1445    fn ipv6_fragment_verdict_matches_go_pre() {
1446        let src = std::net::IpAddr::V6(IPV6_FIXTURE_SRC);
1447        let dst = std::net::IpAddr::V6(IPV6_FIXTURE_DST);
1448        let v6 = |class| Some(Fragment::V6(class));
1449
1450        // The negative case, stated explicitly: allow-all cannot rescue an `unknown` fragment.
1451        assert!(
1452            !inbound_filter_verdict(
1453                &AllowAll,
1454                IpProto::new(0),
1455                src,
1456                dst,
1457                0,
1458                v6(Ipv6Fragment::Unknown)
1459            ),
1460            "an unknown IPv6 fragment is dropped even under an allow-all ACL"
1461        );
1462        // The control: the same allow-all ACL admits an ordinary non-fragment packet, so the drop
1463        // above is the classification and not the harness.
1464        assert!(
1465            inbound_filter_verdict(&AllowAll, IpProto::UDP, src, dst, 443, None),
1466            "the allow-all ACL does admit an ordinary packet"
1467        );
1468
1469        // A safe later fragment slides through ahead of the ACL, with nothing but port 0 to match.
1470        assert!(
1471            inbound_filter_verdict(
1472                &DenyAll,
1473                IpProto::new(0),
1474                src,
1475                dst,
1476                0,
1477                v6(Ipv6Fragment::Later)
1478            ),
1479            "a later IPv6 fragment is accepted ahead of a deny-all ACL"
1480        );
1481
1482        // A first fragment is an ordinary packet again: admitted on the port the ACL allows,
1483        // dropped on one it does not.
1484        let first = |dst_port| {
1485            v6(Ipv6Fragment::First {
1486                proto: IpProto::UDP,
1487                dst_port,
1488            })
1489        };
1490        assert!(
1491            inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 443, first(443)),
1492            "a first IPv6 fragment is matched on the port behind the Fragment header"
1493        );
1494        assert!(
1495            !inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 444, first(444)),
1496            "a first IPv6 fragment on a disallowed port is dropped by the ACL"
1497        );
1498        // Control: the same ACL decides an unfragmented packet the same way, so the two results
1499        // above are the ACL being consulted on a real port and not a fragment-specific shortcut.
1500        assert!(
1501            inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 443, None),
1502            "control: the port-scoped ACL admits an unfragmented packet to 443"
1503        );
1504        assert!(
1505            !inbound_filter_verdict(&AllowPort(443), IpProto::UDP, src, dst, 0, None),
1506            "control: port 0 - what a v6 fragment used to read as - is not admitted"
1507        );
1508
1509        // `pre()`'s multicast/link-local drops still outrank the fragment pass-through, exactly as
1510        // Go runs them before `case ipproto.Fragment`.
1511        assert!(
1512            !inbound_filter_verdict(
1513                &AllowAll,
1514                IpProto::new(0),
1515                src,
1516                "ff02::1".parse().unwrap(),
1517                0,
1518                v6(Ipv6Fragment::Later)
1519            ),
1520            "a later fragment to a multicast dst is still dropped by pre()"
1521        );
1522        assert!(
1523            !inbound_filter_verdict(
1524                &AllowAll,
1525                IpProto::new(0),
1526                src,
1527                "fe80::1".parse().unwrap(),
1528                0,
1529                v6(Ipv6Fragment::Later)
1530            ),
1531            "a later fragment to a link-local dst is still dropped by pre()"
1532        );
1533    }
1534
1535    /// The whole inbound path on real IPv6 bytes — parse, classify, verdict — which is the shape
1536    /// the bypass had: before the Fragment extension header was classified, every source-fragmented
1537    /// IPv6 datagram reached the ACL with no sub-protocol and port 0, so an allow-all rule admitted
1538    /// the RFC 1858 fragments upstream drops and a port-scoped rule blackholed the later fragments
1539    /// upstream passes through.
1540    #[test]
1541    fn ipv6_fragments_are_filtered_end_to_end() {
1542        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
1543            let mut packets = vec![PacketMut::from(packet)];
1544            let mut learned = Vec::new();
1545            filter_inbound_from_peer(filter, PeerId(3), &mut packets, &mut learned);
1546            assert!(
1547                learned.is_empty(),
1548                "no TSMP advertisement in these fixtures"
1549            );
1550            !packets.is_empty()
1551        };
1552
1553        // Under an ALLOW-ALL ACL — the permissive policy the bypass needs — the RFC 1858 fragment
1554        // must still be dropped, while the legitimate later fragment must still be delivered.
1555        assert!(
1556            !keep(&AllowAll, ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
1557            "a low-offset later IPv6 fragment is dropped even by an allow-all ACL (RFC 1858)"
1558        );
1559        assert!(
1560            !keep(
1561                &AllowAll,
1562                ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])
1563            ),
1564            "a first IPv6 fragment too short to hold its UDP header is dropped by an allow-all ACL"
1565        );
1566        assert!(
1567            keep(&AllowAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1568            "a legitimate later IPv6 fragment is delivered"
1569        );
1570
1571        // ...and the later fragment is delivered even under a DENY-ALL ACL, which is the Go
1572        // `pre()` pass-through and not the ACL agreeing.
1573        assert!(
1574            keep(&DenyAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1575            "a legitimate later IPv6 fragment slides through a deny-all ACL (Go pre())"
1576        );
1577        assert!(
1578            !keep(&DenyAll, ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
1579            "a low-offset later IPv6 fragment is dropped under a deny-all ACL too"
1580        );
1581
1582        // A first fragment is matched on the port that lives behind the Fragment extension header,
1583        // which is the whole point of stepping over it: 443 is admitted, 444 is not, under the same
1584        // port-scoped ACL. Before the port was read past the header both read as port 0 and both
1585        // were dropped.
1586        assert!(
1587            keep(
1588                &AllowPort(443),
1589                ipv6_fragment_packet(17, 0, true, &udp_header(443))
1590            ),
1591            "a first IPv6 fragment to an allowed port is delivered"
1592        );
1593        assert!(
1594            !keep(
1595                &AllowPort(443),
1596                ipv6_fragment_packet(17, 0, true, &udp_header(444))
1597            ),
1598            "a first IPv6 fragment to a disallowed port is dropped"
1599        );
1600
1601        // Scoping (Go `26b2ed0a6`): the Fragment header is parsed here ONLY as the base header's
1602        // immediate Next Header. What happens to one reached through a chained extension header —
1603        // it must fail closed, not fall through to the ACL — is
1604        // `chained_extension_header_cannot_bypass_the_ipv6_fragment_rules`.
1605    }
1606
1607    /// Prepending an extension header must not defeat the fragment rules.
1608    ///
1609    /// [`decode6_fragment`] is scoped exactly as Go scopes it: the Fragment header is parsed only
1610    /// as the base header's immediate Next Header. Go can afford that narrow scope because
1611    /// everything it does not parse *keeps the base header's Next Header* as `q.IPProto`, so a
1612    /// hop-by-hop-chained fragment is `ipproto.Unknown` and filter `pre()` drops it before the ACL
1613    /// ever runs. This tree reads the sub-protocol out of etherparse's extension-header walk
1614    /// instead, which resolves straight through the chain to the real transport number — so the
1615    /// same packet reached the ACL looking like an ordinary UDP datagram that merely happened to
1616    /// carry port 0, and a permissive "allow the whole tailnet" rule ADMITTED it. Eight bytes of
1617    /// Hop-by-Hop Options were enough to walk every RFC 1858 fragment straight past the rules the
1618    /// rest of this file exists to enforce.
1619    ///
1620    /// Every assertion is against an ALLOW-ALL ACL, so a drop can only be the fragment rule and
1621    /// never the ACL — and each extension type carries its own control that proves it: the same
1622    /// chain shape with no Fragment header in it is still delivered, on the port read past the
1623    /// extension header. That control is per-type rather than once at the end because `keep`
1624    /// cannot tell a fragment-rule drop from a parser rejection, so a fixture malformed for only
1625    /// one of the three protocols would otherwise turn that protocol's four drops into vacuous
1626    /// passes with the suite still green.
1627    #[test]
1628    fn chained_extension_header_cannot_bypass_the_ipv6_fragment_rules() {
1629        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
1630            let mut packets = vec![PacketMut::from(packet)];
1631            let mut learned = Vec::new();
1632            filter_inbound_from_peer(filter, PeerId(4), &mut packets, &mut learned);
1633            assert!(
1634                learned.is_empty(),
1635                "no TSMP advertisement in these fixtures"
1636            );
1637            !packets.is_empty()
1638        };
1639
1640        // Hop-by-Hop Options (0), Routing (43) and Destination Options (60): the fragment rules
1641        // must not depend on which header the sender chose to hide behind.
1642        for ext in [0u8, 43, 60] {
1643            // The RFC 1858 evasion itself: a later fragment whose bytes can land on top of the
1644            // transport header the head fragment was matched on.
1645            assert!(
1646                !keep(
1647                    &AllowAll,
1648                    ipv6_with_prepended_ext_header(
1649                        ext,
1650                        &ipv6_fragment_packet(17, 1, false, &[0x61; 8])
1651                    )
1652                ),
1653                "a low-offset later fragment behind extension header {ext} is dropped (RFC 1858)"
1654            );
1655            // A first fragment truncated before its own transport header, which a follow-up
1656            // fragment can then complete.
1657            assert!(
1658                !keep(
1659                    &AllowAll,
1660                    ipv6_with_prepended_ext_header(
1661                        ext,
1662                        &ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])
1663                    )
1664                ),
1665                "a short first fragment behind extension header {ext} is dropped"
1666            );
1667            // A *well-formed* chained fragment is dropped too — Go drops this whole class, so
1668            // failing closed here can never admit something upstream refuses.
1669            assert!(
1670                !keep(
1671                    &AllowAll,
1672                    ipv6_with_prepended_ext_header(
1673                        ext,
1674                        &ipv6_fragment_packet(17, 185, false, &[0x61; 8])
1675                    )
1676                ),
1677                "a chained later fragment behind extension header {ext} gets no pass-through"
1678            );
1679            assert!(
1680                !keep(
1681                    &AllowAll,
1682                    ipv6_with_prepended_ext_header(
1683                        ext,
1684                        &ipv6_fragment_packet(17, 0, true, &udp_header(443))
1685                    )
1686                ),
1687                "a chained first fragment behind extension header {ext} is dropped"
1688            );
1689
1690            // Control for THIS extension type. Every assertion above is a `!keep`, and `keep`
1691            // reports a packet the parser rejected exactly as it reports a packet the fragment
1692            // rule dropped — so on its own the block above would also pass if this builder simply
1693            // produced eight bytes etherparse refuses to walk. The same chain shape with no
1694            // Fragment header behind it is still parsed, still admitted, and still matched on the
1695            // port read past the extension header, which pins the drops to the fragment rule.
1696            let plain = ipv6_with_prepended_ext_header(ext, &ipv6_udp_packet(&udp_header(443)));
1697            assert!(
1698                keep(&AllowAll, plain.clone()),
1699                "an unfragmented packet behind extension header {ext} is still delivered"
1700            );
1701            assert!(
1702                keep(&AllowPort(443), plain),
1703                "...and is still matched on the port read past extension header {ext}"
1704            );
1705        }
1706
1707        // Contrast: the very same later fragment, reached as the base header's immediate Next
1708        // Header, is still delivered. Only the 8 prepended bytes separate this from the third
1709        // assertion above, so the drops really are the chain and not the fragment fixtures.
1710        assert!(
1711            keep(&AllowAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
1712            "an unchained later fragment is still delivered"
1713        );
1714    }
1715
1716    /// Build the IPv4 packet a Go peer puts on the wire for a TSMP message: a 20-byte IPv4
1717    /// header with proto 99 and `body` appended (Go `packet.Generate(IP4Header{...}, body)`,
1718    /// which is what `TSMPDiscoKeyAdvertisement.Marshal` calls). The header checksum is left
1719    /// zero — nothing on this path verifies it, and neither does Go's decoder.
1720    fn tsmp_packet4(src: [u8; 4], dst: [u8; 4], body: &[u8]) -> PacketMut {
1721        let mut buf = vec![0u8; 20 + body.len()];
1722        buf[20..].copy_from_slice(body);
1723        buf[0] = 0x45;
1724        let total_len = buf.len() as u16;
1725        buf[2..4].copy_from_slice(&total_len.to_be_bytes());
1726        buf[8] = 64;
1727        buf[9] = 99;
1728        buf[12..16].copy_from_slice(&src);
1729        buf[16..20].copy_from_slice(&dst);
1730        PacketMut::from(buf)
1731    }
1732
1733    /// A body a real Go peer sends: `'a'` then its 32-byte disco key.
1734    fn advertisement_body(key: [u8; 32]) -> Vec<u8> {
1735        let mut body = vec![ts_packet::tsmp::TSMP_TYPE_DISCO_ADVERTISEMENT];
1736        body.extend_from_slice(&key);
1737        body
1738    }
1739
1740    /// The receive side of the TSMP disco-key advertisement, at the point Go handles it: a
1741    /// well-formed advertisement is CONSUMED — the peer's key is learned and the packet is
1742    /// dropped rather than delivered to the local stack (Go `filter.DropSilently`) — while every
1743    /// other TSMP body is left alone and still admitted by the TSMP ACL bypass.
1744    ///
1745    /// The ACL here denies everything, so an admitted packet can only have come through the
1746    /// TSMP bypass, and a learned key can only have come from the advertisement path.
1747    #[test]
1748    fn tsmp_disco_key_advertisement_is_learned_and_dropped() {
1749        let peer = PeerId(7);
1750        let src = [100, 64, 0, 2];
1751        let dst = [100, 64, 0, 1];
1752        let key = [0xa5u8; 32];
1753
1754        let mut packets = vec![tsmp_packet4(src, dst, &advertisement_body(key))];
1755        let mut learned = Vec::new();
1756        filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1757
1758        assert!(
1759            packets.is_empty(),
1760            "a consumed advertisement must not be delivered to the local stack"
1761        );
1762        assert_eq!(learned.len(), 1, "the advertisement must be harvested");
1763        assert_eq!(
1764            learned[0].0, peer,
1765            "attributed to the sending wireguard peer"
1766        );
1767        assert_eq!(learned[0].1.key, key, "the advertised disco key is learned");
1768        assert_eq!(learned[0].1.src, std::net::IpAddr::from(src));
1769
1770        // A TSMP message that is NOT an advertisement stays in the batch (Go leaves the types it
1771        // does not consume to the filter, which accepts TSMP) and teaches us nothing.
1772        let mut ping = vec![ts_packet::tsmp::TSMP_TYPE_PING];
1773        ping.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
1774        let mut packets = vec![tsmp_packet4(src, dst, &ping)];
1775        let mut learned = Vec::new();
1776        filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1777        assert_eq!(packets.len(), 1, "a TSMP ping still bypasses the ACL");
1778        assert!(learned.is_empty(), "a ping advertises no disco key");
1779    }
1780
1781    /// The negative case, at the dataplane boundary: a TSMP body that is *nearly* an
1782    /// advertisement must not be half-parsed into a learned key. None of these may put anything
1783    /// in `learned` — a truncated key that was zero-padded, or a zero key that was accepted,
1784    /// would be a wrong disco key bound to a real peer.
1785    #[test]
1786    fn malformed_tsmp_disco_key_advertisements_teach_nothing() {
1787        let peer = PeerId(7);
1788        let src = [100, 64, 0, 2];
1789        let dst = [100, 64, 0, 1];
1790
1791        // A truncated advertisement: the type byte and only 31 of 32 key bytes.
1792        let mut truncated = advertisement_body([0xa5u8; 32]);
1793        truncated.truncate(32);
1794
1795        for (name, body, still_delivered) in [
1796            ("truncated advertisement", truncated, true),
1797            (
1798                "unknown TSMP type byte",
1799                {
1800                    let mut b = advertisement_body([0xa5u8; 32]);
1801                    b[0] = b'Z';
1802                    b
1803                },
1804                true,
1805            ),
1806            // A well-formed advertisement of the zero key: Go parses it but publishes only
1807            // `if !discoKeyAdvert.Key.IsZero()`, so it teaches nothing — and it is still a TSMP
1808            // message we consumed, so it is still dropped.
1809            (
1810                "zero-key advertisement",
1811                advertisement_body([0u8; 32]),
1812                false,
1813            ),
1814        ] {
1815            let mut packets = vec![tsmp_packet4(src, dst, &body)];
1816            let mut learned = Vec::new();
1817            filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1818
1819            assert!(
1820                learned.is_empty(),
1821                "a {name} must not be half-parsed into a learned disco key"
1822            );
1823            assert_eq!(
1824                packets.len(),
1825                usize::from(still_delivered),
1826                "a {name} must {} be delivered",
1827                if still_delivered { "still" } else { "not" }
1828            );
1829        }
1830    }
1831
1832    /// Our own disco key, the one this node advertises. Asymmetric so a reversed or offset slice
1833    /// would be visible in the marshalled bytes.
1834    const SELF_DISCO_KEY: [u8; 32] = [
1835        0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
1836        0x00, 0x9c, 0x5f, 0x3a, 0x01, 0x7d, 0xe2, 0x44, 0xb8, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a,
1837        0x69, 0x78,
1838    ];
1839
1840    /// An advertisement state with one peer, a v4 and a v6 address of our own, and a real disco key.
1841    fn advertisement_state(peer: PeerId, target: AdvertisementTarget) -> DiscoAdvertisementState {
1842        DiscoAdvertisementState {
1843            disco_key: SELF_DISCO_KEY,
1844            self_addrs: vec![
1845                std::net::IpAddr::from([100, 64, 0, 1]),
1846                std::net::IpAddr::from([
1847                    0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1848                ]),
1849            ],
1850            peers: HashMap::from([(peer, target)]),
1851        }
1852    }
1853
1854    /// What this node advertises, and to whom (Go `magicsock.Conn.PriorityMessageForPeer`): the
1855    /// happy path emits the exact bytes `TSMPDiscoKeyAdvertisement.Marshal` emits, and each of Go's
1856    /// refusals emits nothing at all.
1857    #[test]
1858    fn disco_advertisement_matches_priority_message_for_peer() {
1859        let peer = PeerId(3);
1860        let peer_v4 = std::net::IpAddr::from([100, 64, 0, 2]);
1861        let target = AdvertisementTarget {
1862            node_addr: peer_v4,
1863            wireguard_only: false,
1864        };
1865        let state = advertisement_state(peer, target);
1866
1867        // Happy path: a v4 peer gets a v4 advertisement sourced from our v4 address — the first
1868        // self address in the destination's family (Go `selfIPMatchingFamily`).
1869        let msg = state
1870            .advertisement_for(peer)
1871            .expect("a Tailscale peer with a matching-family address must be advertised to");
1872        let parsed = ts_packet::tsmp::DiscoKeyAdvertisement::parse(&msg)
1873            .expect("what we emit must parse as an advertisement");
1874        assert_eq!(parsed.key, SELF_DISCO_KEY, "we advertise OUR disco key");
1875        assert_eq!(parsed.src, std::net::IpAddr::from([100, 64, 0, 1]));
1876        assert_eq!(parsed.dst, peer_v4);
1877        assert_eq!(
1878            msg,
1879            ts_packet::tsmp::DiscoKeyAdvertisement {
1880                src: std::net::IpAddr::from([100, 64, 0, 1]),
1881                dst: peer_v4,
1882                key: SELF_DISCO_KEY,
1883            }
1884            .marshal()
1885            .unwrap(),
1886            "the emitted bytes are exactly what Marshal produces"
1887        );
1888
1889        // A v6 peer is sourced from our v6 address, not our v4 one.
1890        let peer_v6 = std::net::IpAddr::from([
1891            0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
1892        ]);
1893        let v6_state = advertisement_state(
1894            peer,
1895            AdvertisementTarget {
1896                node_addr: peer_v6,
1897                wireguard_only: false,
1898            },
1899        );
1900        let parsed = v6_state
1901            .advertisement_for(peer)
1902            .and_then(|m| ts_packet::tsmp::DiscoKeyAdvertisement::parse(&m))
1903            .expect("a v6 peer must be advertised to over v6");
1904        assert!(parsed.src.is_ipv6(), "source must match the peer's family");
1905        assert_eq!(parsed.dst, peer_v6);
1906
1907        // Refusal 1 (Go `disco.IsZero()`): no disco key of our own, nothing to advertise.
1908        let mut no_key = advertisement_state(peer, target);
1909        no_key.disco_key = [0u8; 32];
1910        assert!(
1911            no_key.advertisement_for(peer).is_none(),
1912            "the zero disco key must never be advertised"
1913        );
1914
1915        // Refusal 2 (Go `endpointForNodeKey` miss / `!self.Valid()`): a peer the netmap snapshot
1916        // does not cover, and a node with no addresses of its own.
1917        assert!(
1918            state.advertisement_for(PeerId(0xbad)).is_none(),
1919            "an unknown peer must not be advertised to"
1920        );
1921        let mut no_self = advertisement_state(peer, target);
1922        no_self.self_addrs.clear();
1923        assert!(
1924            no_self.advertisement_for(peer).is_none(),
1925            "a node with no tailnet address of its own has no source to advertise from"
1926        );
1927
1928        // Refusal 3 (Go `ep.isWireguardOnly`): "Do not send TSMP messages to peers that only speaks
1929        // wireguard" — such a peer would hand it to its host stack as an unknown protocol.
1930        let wg_only = advertisement_state(
1931            peer,
1932            AdvertisementTarget {
1933                node_addr: peer_v4,
1934                wireguard_only: true,
1935            },
1936        );
1937        assert!(
1938            wg_only.advertisement_for(peer).is_none(),
1939            "a WireGuard-only peer must never be sent TSMP"
1940        );
1941
1942        // Refusal 4 (Go `selfIPMatchingFamily` returning the zero Addr): an IPv4-only node has no
1943        // source address for a packet to a peer's IPv6 address.
1944        let mut v4_only = advertisement_state(
1945            peer,
1946            AdvertisementTarget {
1947                node_addr: peer_v6,
1948                wireguard_only: false,
1949            },
1950        );
1951        v4_only.self_addrs = vec![std::net::IpAddr::from([100, 64, 0, 1])];
1952        assert!(
1953            v4_only.advertisement_for(peer).is_none(),
1954            "no self address in the peer's family means no advertisement"
1955        );
1956    }
1957
1958    /// End to end, over a real WireGuard handshake: when a session with a peer comes up, this
1959    /// node's dataplane emits its own TSMP disco-key advertisement to that peer — and the peer's
1960    /// dataplane learns the key from it and drops the packet.
1961    ///
1962    /// This is the send side (Go capability version 144) meeting the receive side already in this
1963    /// tree, so the assertion is not "some bytes went out" but "the far side learned exactly the
1964    /// disco key we hold". B is deliberately left with no advertisement state, which also pins the
1965    /// unconfigured case: it establishes the same session and sends nothing back.
1966    #[test]
1967    fn session_establishment_advertises_our_disco_key_to_the_peer() {
1968        let underlay: UnderlayTransportId = 0.into();
1969        let wg_peer = ts_tunnel::PeerId(1);
1970        let peer = PeerId(1);
1971        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
1972        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
1973
1974        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
1975        let (mut a, mut b) = (
1976            DataPlane::new(a_static.clone()),
1977            DataPlane::new(b_static.clone()),
1978        );
1979
1980        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
1981            dp.wireguard.upsert_peer(
1982                wg_peer,
1983                ts_tunnel::PeerConfig {
1984                    key,
1985                    psk: [0u8; 32].into(),
1986                    persistent_keepalive_interval: None,
1987                },
1988            );
1989            dp.ur_out.table.insert(peer, underlay);
1990        }
1991
1992        // Only A knows how to advertise: its own disco key, its own address, and B's address.
1993        a.disco_advertisement = Some(Arc::new(advertisement_state(
1994            peer,
1995            AdvertisementTarget {
1996                node_addr: b_addr,
1997                wireguard_only: false,
1998            },
1999        )));
2000
2001        // B attributes A's tailnet address to the WireGuard peer that carries it, as the runtime's
2002        // source filter does — without that, B drops the advertisement before parsing it.
2003        let mut src_filter = ts_bart::Table::default();
2004        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
2005        b.src_filter_in = Arc::new(src_filter);
2006
2007        // Drive the handshake. Only the initiation is kicked off directly (the dataplane starts one
2008        // from routed outbound traffic, which is not what this test is about); everything after it
2009        // goes through `process_inbound`, the path under test.
2010        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
2011            out.into_values().flatten().collect::<Vec<_>>()
2012        };
2013        let init = a
2014            .wireguard
2015            .send([(wg_peer, vec![PacketMut::from(&b"hello"[..])])])
2016            .to_peers
2017            .remove(&wg_peer)
2018            .expect("handshake initiation");
2019
2020        let resp = take(b.process_inbound(init).to_peers);
2021        assert!(!resp.is_empty(), "B must answer the handshake initiation");
2022
2023        // A completes the handshake. Its session is now current, so alongside the queued data it
2024        // emits the advertisement.
2025        let from_a = take(a.process_inbound(resp).to_peers);
2026        assert_eq!(
2027            from_a.len(),
2028            2,
2029            "A must emit the queued data AND its disco-key advertisement"
2030        );
2031
2032        // B learns A's disco key from it, and the advertisement itself is consumed rather than
2033        // delivered to B's local stack.
2034        let inbound = b.process_inbound(from_a);
2035        assert_eq!(
2036            inbound
2037                .learned_disco_keys
2038                .iter()
2039                .map(|(peer, advert)| (*peer, advert.key))
2040                .collect::<Vec<_>>(),
2041            vec![(peer, SELF_DISCO_KEY)],
2042            "B must learn exactly the disco key A holds, attributed to A's wireguard peer"
2043        );
2044        assert!(
2045            inbound.to_peers.is_empty(),
2046            "B has no advertisement state, so it advertises nothing back"
2047        );
2048    }
2049
2050    /// Order regression: the advertisement must LEAD the traffic the same establishment released,
2051    /// not trail it.
2052    ///
2053    /// wireguard-go hands a priority message straight to the peer's *outbound* queue
2054    /// (`SendPriorityMessage` → `queueOutboundIfRunning`) and runs it before the flush that
2055    /// follows at both call sites — `peer.SendPriorityMessage()` ahead of `peer.SendKeepalive()`
2056    /// on the initiator and ahead of `peer.SendStagedPackets()` on the responder
2057    /// (`device/receive.go`) — so the advertisement is the first thing on the wire once a keypair
2058    /// becomes current. In this tree the flush has already happened inside `Endpoint::recv` by the
2059    /// time the advertisement exists, so `process_inbound` has to splice it in front; appending it
2060    /// would put it behind up to `MAX_QUEUED_PER_PEER` packets of queued traffic.
2061    ///
2062    /// The order is read off B's *decrypted* stream — its capture tee, which sees every inbound
2063    /// packet before any filtering — so what is pinned is the order the peer actually observes,
2064    /// not the order of a local vector.
2065    #[test]
2066    fn the_advertisement_leads_the_traffic_released_by_the_same_establishment() {
2067        let underlay: UnderlayTransportId = 0.into();
2068        let wg_peer = ts_tunnel::PeerId(1);
2069        let peer = PeerId(1);
2070        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
2071        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
2072
2073        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
2074        let (mut a, mut b) = (
2075            DataPlane::new(a_static.clone()),
2076            DataPlane::new(b_static.clone()),
2077        );
2078
2079        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
2080            dp.wireguard.upsert_peer(
2081                wg_peer,
2082                ts_tunnel::PeerConfig {
2083                    key,
2084                    psk: [0u8; 32].into(),
2085                    persistent_keepalive_interval: None,
2086                },
2087            );
2088            dp.ur_out.table.insert(peer, underlay);
2089        }
2090
2091        a.disco_advertisement = Some(Arc::new(advertisement_state(
2092            peer,
2093            AdvertisementTarget {
2094                node_addr: b_addr,
2095                wireguard_only: false,
2096            },
2097        )));
2098
2099        let mut src_filter = ts_bart::Table::default();
2100        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
2101        b.src_filter_in = Arc::new(src_filter);
2102
2103        // Everything B decrypts, in arrival order, before any filtering runs.
2104        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
2105        let sink = recorded.clone();
2106        b.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
2107            sink.lock().unwrap().push((path, bytes.to_vec()));
2108        }));
2109
2110        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
2111            out.into_values().flatten().collect::<Vec<_>>()
2112        };
2113
2114        // Traffic for a peer with no session yet: it stages, and a handshake starts.
2115        const QUEUED: &[u8] = b"staged while the session was still coming up";
2116        let init = a
2117            .wireguard
2118            .send([(wg_peer, vec![PacketMut::from(QUEUED)])])
2119            .to_peers
2120            .remove(&wg_peer)
2121            .expect("handshake initiation");
2122        let resp = take(b.process_inbound(init).to_peers);
2123
2124        // A's keypair becomes current here, which both flushes the staged packet and produces the
2125        // advertisement — the batch whose order is under test.
2126        let from_a = take(a.process_inbound(resp).to_peers);
2127        assert_eq!(
2128            from_a.len(),
2129            2,
2130            "A must emit the queued data AND its disco-key advertisement"
2131        );
2132
2133        // Hand them to B in exactly the order A produced them.
2134        let learned = b.process_inbound(from_a).learned_disco_keys;
2135        assert_eq!(
2136            learned
2137                .iter()
2138                .map(|(peer, advert)| (*peer, advert.key))
2139                .collect::<Vec<_>>(),
2140            vec![(peer, SELF_DISCO_KEY)],
2141            "B must still learn A's disco key"
2142        );
2143
2144        let advertisement = ts_packet::tsmp::DiscoKeyAdvertisement {
2145            src: a_addr,
2146            dst: b_addr,
2147            key: SELF_DISCO_KEY,
2148        }
2149        .marshal()
2150        .expect("a v4 advertisement between two v4 addresses marshals");
2151
2152        let captured = recorded.lock().unwrap();
2153        let from_peer = captured
2154            .iter()
2155            .filter(|(path, _)| *path == CapturePath::FromPeer)
2156            .map(|(_, bytes)| bytes.as_slice())
2157            .collect::<Vec<_>>();
2158        assert_eq!(from_peer.len(), 2, "B must decrypt both of A's packets");
2159        // The send path zero-pads each payload up to a 16-byte boundary and the receiver delivers
2160        // it with that padding intact (see `session::PADDING_MULTIPLE`), so compare on the leading
2161        // bytes rather than for equality.
2162        assert!(
2163            from_peer[0].starts_with(&advertisement),
2164            "the advertisement must reach the peer FIRST, ahead of the traffic the same \
2165             establishment released"
2166        );
2167        assert!(
2168            from_peer[1].starts_with(QUEUED),
2169            "the queued traffic follows the advertisement"
2170        );
2171    }
2172
2173    /// Behavioral guard: an installed capture hook MUST be invoked with `CapturePath::FromLocal`
2174    /// and the exact packet bytes for every outbound packet. The tee sits at the top of
2175    /// `process_outbound`, before `or_out.route` consumes the packets, so it fires regardless of
2176    /// whether a wireguard peer exists (an empty router just drops the routed packets afterward).
2177    /// This is the only end-to-end guard that the dataplane capture tee actually fires; a refactor
2178    /// that drops the tee would leave every byte-layout test green.
2179    #[test]
2180    fn capture_hook_fires_on_outbound() {
2181        let mut dp = DataPlane::new(NodeKeyPair::new());
2182
2183        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
2184        let sink = recorded.clone();
2185        dp.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
2186            sink.lock().unwrap().push((path, bytes.to_vec()));
2187        }));
2188
2189        // The outbound tee passes `p.as_ref()` as-given; the bytes need not be a valid IP packet.
2190        let payload: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
2191        let packet = PacketMut::from(payload.clone());
2192
2193        drop(dp.process_outbound(vec![packet]));
2194
2195        let captured = recorded.lock().unwrap();
2196        assert_eq!(captured.len(), 1, "hook must fire exactly once per packet");
2197        assert_eq!(captured[0].0, CapturePath::FromLocal);
2198        assert_eq!(captured[0].1, payload);
2199    }
2200}