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