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
16mod flowtrack;
17
18/// The single link-local destination Go's filter `pre()` exempts from the link-local drop: the
19/// cloud-metadata address `169.254.169.254` (Go `isAllowedLinkLocal`).
20const ALLOWED_LINK_LOCAL_V4: std::net::Ipv4Addr = std::net::Ipv4Addr::new(169, 254, 169, 254);
21
22/// Whether an inbound packet to destination `dst` must be dropped BEFORE consulting the ACL rules,
23/// mirroring Go's filter `pre()`: drop multicast destinations (`ReasonMulticast`) and link-local
24/// unicast destinations that are not the allowlisted cloud-metadata address (`ReasonLinkLocalUnicast`).
25/// Returning `true` means drop. This runs ahead of `can_access` so a permissive ACL cannot admit the
26/// multicast / link-local traffic Go rejects unconditionally.
27///
28/// Go's `isAllowedLinkLocal` is `dst == gcpDNSAddr || any(LinkLocalAllowHooks)`; only the static
29/// `gcpDNSAddr` arm is modeled here. The dynamic `LinkLocalAllowHooks` slice is empty in a plain
30/// engine/tsnet embedding (its only upstream producer is the GCP metadata path), so the omission is
31/// behaviorally equivalent for this fork; a feature that needs a dynamic link-local allowlist would
32/// have to extend this. Like Go's `netip.Addr` predicates, an IPv4-mapped-IPv6 destination (e.g.
33/// `::ffff:224.0.0.1`) matches NEITHER arm and falls through to the ACL — we deliberately do not
34/// canonicalize/unmap, to stay byte-faithful to Go (see the mapped-v6 test cases).
35fn drop_before_rules(dst: std::net::IpAddr) -> bool {
36    if dst.is_multicast() {
37        return true;
38    }
39    match dst {
40        // IPv4 link-local is 169.254.0.0/16; allow only the cloud-metadata address (Go parity).
41        std::net::IpAddr::V4(v4) => v4.is_link_local() && v4 != ALLOWED_LINK_LOCAL_V4,
42        // IPv6 unicast link-local is fe80::/10. (`Ipv6Addr::is_unicast_link_local` is unstable, so
43        // test the prefix directly.) This fork is IPv4-only by default, but match Go for any v6.
44        std::net::IpAddr::V6(v6) => (v6.segments()[0] & 0xffc0) == 0xfe80,
45    }
46}
47
48/// IPv4 fragment state read from the base header (Go `net/packet.decode4` reads `b[6:8]`): the
49/// fragment offset in 8-byte blocks and the more-fragments flag. A non-first fragment carries no L4
50/// header, so it needs its own verdict path rather than the (always-port-0) ACL match.
51#[derive(Debug, Clone, Copy)]
52struct Ipv4Fragment {
53    /// Fragment offset in 8-byte blocks (the 13-bit IPv4 field), 0 for the first/only fragment.
54    offset_blocks: u16,
55    /// The "more fragments" (MF) flag.
56    more_fragments: bool,
57}
58
59/// Minimum fragment offset (in 8-byte blocks) Go permits for a non-first fragment — Go
60/// `net/packet.minFragBlks = (60 + 20) / 8 = 10` (max IPv4 header + a basic TCP header). A later
61/// fragment starting before this could overlap a transport header (the RFC 1858 overlapping-fragment
62/// evasion), so Go demotes it to `unknown` and drops it; only fragments at or beyond this offset are
63/// allowed to "slide through".
64///
65/// Upstream reuses this one bound for IPv6 too (Go `net/packet` `26b2ed0a6` documents the reuse):
66/// it is sized for IPv4 and is therefore *conservative* for IPv6, whose fragments carry no
67/// per-fragment IP header — so on the v6 side it only ever rejects more later fragments as
68/// `unknown`, never fewer. Keep the single constant for both, exactly as Go does.
69const MIN_FRAG_BLKS: u16 = (60 + 20) / 8;
70
71/// Minimum IPv4 base header length (Go `net/packet.ip4HeaderLength`). A buffer shorter than this
72/// is not a decodable IPv4 packet at all (Go `decode4` returns `unknown`).
73const IP4_HEADER_LEN: usize = 20;
74
75/// Fixed IPv6 base header length (Go `net/packet.ip6HeaderLength`).
76const IP6_HEADER_LEN: usize = 40;
77
78/// IANA protocol number of the IPv6 Fragment extension header, "IPv6-Frag" (Go
79/// `net/packet.ip6FragHeader`). It appears as the **base** header's Next Header on a
80/// source-fragmented IPv6 packet, and is distinct from Go's internal `ipproto.Fragment` sentinel
81/// (0xff), which marks a non-first fragment whose sub-protocol header is not present.
82const IP6_FRAG_HEADER: u8 = 44;
83
84/// Go's `ipproto.Unknown` (0). Go's decoders assign it to every packet they refuse to classify, and
85/// filter `pre()` drops it — `if q.IPProto == ipproto.Unknown { return Drop }` — before the ACL can
86/// see the packet. It is also the real IANA number of the IPv6 Hop-by-Hop Options extension header,
87/// which is why an IPv6 packet that leads with Hop-by-Hop is dropped by upstream: `decode6` reads
88/// the base header's Next Header byte straight into `q.IPProto`, and 0 *is* "unknown".
89const IPPROTO_UNKNOWN: IpProto = IpProto::new(0);
90
91/// TCP's IP protocol number as a single byte, for the one place it is written onto the wire rather
92/// than matched against: the `Proto` field of a TSMP rejected-connection message, which is a byte
93/// (Go `ipproto.Proto`) where this fork's [`IpProto`] is `i64`-wide.
94const IPPROTO_TCP_BYTE: u8 = 6;
95
96/// Go's internal `ipproto.Fragment` sentinel (0xff), which `decode6Fragment` assigns to a later
97/// fragment. Seeing it as a real Next Header on the wire is suspicious, so Go's `decode6` switch
98/// maps it back to [`IPPROTO_UNKNOWN`] (`case ipproto.Fragment: q.IPProto = unknown`) — whether it
99/// arrived as the base header's Next Header or as a Fragment header's.
100const IPPROTO_FRAGMENT_SENTINEL: IpProto = IpProto::new(0xff);
101
102/// Length of the IPv6 Fragment extension header (Go `net/packet.ip6FragHeaderLength`): Next Header,
103/// Reserved, a 13-bit Fragment Offset in 8-byte blocks plus two reserved bits and the
104/// More-Fragments flag, then a 32-bit Identification.
105const IP6_FRAG_HEADER_LEN: usize = 8;
106
107/// Length of the SCTP common header (Go `net/packet.sctpHeaderLength`): source port, destination
108/// port, verification tag, checksum. Go's `decode4`/`decode6` refuse an SCTP packet shorter than
109/// this rather than guess at its ports.
110const SCTP_HEADER_LEN: usize = 12;
111
112/// How an IPv6 packet whose base header's Next Header is the Fragment extension header classifies —
113/// the port of Go `net/packet.Parsed.decode6Fragment` plus the sub-protocol switch `decode6` runs
114/// when it reports `continueDecode` (upstream `4c4ec3d46`, clarified by `26b2ed0a6`).
115///
116/// This is the IPv6 half of the RFC 1858 fragment rules [`Ipv4Fragment`] already carries. It only
117/// matters on the opt-in `Config::enable_ipv6` path — the tailnet is IPv4-only by default — but
118/// without it a source-fragmented IPv6 datagram reaches the ACL with no sub-protocol and port 0,
119/// so an allow-all rule admits the very low-offset fragments upstream drops, and a port-scoped rule
120/// blackholes the later fragments upstream passes through.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum Ipv6Fragment {
123    /// Go's `unknown`, which filter `pre()` drops outright: a Fragment header truncated by the
124    /// packet, a *first* fragment too short to hold its own transport header, a later fragment at
125    /// an offset small enough to overlap that transport header on reassembly (RFC 1858), the
126    /// on-the-wire use of Go's internal `ipproto.Fragment` sentinel, or a Fragment header reached
127    /// through a chained extension header rather than as the base header's immediate Next Header
128    /// ([`fragment_header_is_chained`]).
129    Unknown,
130    /// Go's `ipproto.Fragment`: a later fragment at a safe offset. It carries no sub-protocol
131    /// header, so there is nothing for a rule to match on and filter `pre()` passes it through
132    /// ahead of the ACL — statelessly, exactly as for IPv4. RFC 8200 §4.5 requires the receiver to
133    /// reassemble, and its kernel drops the pieces if the head fragment never arrives.
134    Later,
135    /// Go's `continueDecode == true`: the first fragment. `decode6` steps over the 8-byte Fragment
136    /// header and parses the real sub-protocol's header, so the ACL matches this datagram on the
137    /// same rule it would match unfragmented.
138    First {
139        /// The Fragment header's Next Header — the real sub-protocol (Go `q.IPProto = nextHdr`).
140        proto: IpProto,
141        /// The source port read from that sub-protocol's header, 0 for a protocol Go does not
142        /// port-match (Go `withPort(q.Src, ...)`). Only the reverse-flow cache
143        /// ([`flowtrack::FlowCache`]) reads it — the ACL matches on the destination port alone —
144        /// but Go's `flowtrack.Tuple` keys on both, so both have to be carried.
145        src_port: u16,
146        /// The destination port read from that sub-protocol's header, 0 for a protocol Go does not
147        /// port-match (Go `withPort(q.Dst, ...)`).
148        dst_port: u16,
149        /// The rest of what Go's `decode6` reads out of that same sub-protocol header: the TCP
150        /// flags byte (`q.TCPFlags = TCPFlag(sub[13])`) or the ICMPv6 type/code. The inbound
151        /// filter's reply carve-outs consult it, so a first fragment carrying the SYN-ACK for this
152        /// node's own connection is admitted on exactly the terms an unfragmented one is.
153        l4: ts_packetfilter::L4Header,
154    },
155}
156
157/// Classify a whole IPv6 packet `b` whose base header's Next Header is [`IP6_FRAG_HEADER`], as Go
158/// `net/packet.Parsed.decode6` does when it dispatches to `decode6Fragment`.
159///
160/// Callers must have already checked that immediate Next Header byte: Go parses the Fragment header
161/// **only** as the base header's immediate next header (upstream `26b2ed0a6` added a test locking
162/// that scoping in). No other extension header, and no IPSec AH/ESP header, is parsed here either —
163/// same as Go. A Fragment header reached through a chained extension header is *not* this
164/// function's business; it is [`fragment_header_is_chained`]'s, which classifies it
165/// [`Ipv6Fragment::Unknown`] so it is dropped.
166fn decode6_fragment(b: &[u8]) -> Ipv6Fragment {
167    // Go `q.length = BE16(b[4:6]) + ip6HeaderLength; if len(b) < q.length` — a packet cut off before
168    // its declared payload is `unknown`.
169    if b.len() < IP6_HEADER_LEN {
170        return Ipv6Fragment::Unknown;
171    }
172    let length = usize::from(u16::from_be_bytes([b[4], b[5]])) + IP6_HEADER_LEN;
173    if b.len() < length {
174        return Ipv6Fragment::Unknown;
175    }
176
177    // Go `if len(b) < q.subofs+ip6FragHeaderLength` with `q.subofs == 40`.
178    let Some(frag) = b.get(IP6_HEADER_LEN..) else {
179        return Ipv6Fragment::Unknown;
180    };
181    if frag.len() < IP6_FRAG_HEADER_LEN {
182        return Ipv6Fragment::Unknown;
183    }
184
185    let next_header = frag[0];
186    // Go `fragOfs := binary.BigEndian.Uint16(frag[2:4]) >> 3`: the top 13 bits are the offset in
187    // 8-byte blocks; the low 3 are two reserved bits and the More-Fragments flag. Go reads no MF
188    // flag here at all — unlike `decode4`, `decode6` has no more-fragments guard on the first
189    // fragment, so a first IPv6 fragment is decoded exactly like an unfragmented packet (TSMP
190    // included, where `decode4` instead demotes a fragmented first packet to `unknown`).
191    let frag_ofs = u16::from_be_bytes([frag[2], frag[3]]) >> 3;
192
193    // Go steps `q.subofs += ip6FragHeaderLength` before branching; `sub` is what follows.
194    let sub = &frag[IP6_FRAG_HEADER_LEN..];
195
196    if frag_ofs == 0 {
197        return decode6_first_fragment(IpProto::new(i64::from(next_header)), sub);
198    }
199    if frag_ofs < MIN_FRAG_BLKS {
200        // RFC 1858: this fragment's bytes could land on top of the transport header the ACL matched
201        // the head fragment on. Go `q.IPProto = unknown`, same guard as `decode4`.
202        return Ipv6Fragment::Unknown;
203    }
204    Ipv6Fragment::Later
205}
206
207/// The sub-protocol switch Go `decode6` runs on a first fragment once `decode6Fragment` has stepped
208/// over the Fragment header. `sub` is the buffer from the sub-protocol's header onwards (Go's
209/// `sub := b[q.subofs:]`, measured against the buffer, not the IPv6 length field).
210///
211/// Each arm's bounds check is Go's, and each failure is Go's `unknown`: a first fragment too short
212/// to hold the transport header must be **dropped**, never guessed at, or a follow-up fragment
213/// supplying the rest of that header would carry the flow past a rule the filter never really
214/// matched (RFC 1858, the same reason `decode4` rejects a short first fragment).
215fn decode6_first_fragment(proto: IpProto, sub: &[u8]) -> Ipv6Fragment {
216    /// Go `net/packet.icmp6HeaderLength`.
217    const ICMP6_HEADER_LEN: usize = 4;
218    /// Go `net/packet.tcpHeaderLength`.
219    const TCP_HEADER_LEN: usize = 20;
220    /// Go `net/packet.udpHeaderLength`.
221    const UDP_HEADER_LEN: usize = 8;
222    /// Go `net/packet.minTSMPSize` — the shortest TSMP body (a 7-byte rejected-connection message).
223    const MIN_TSMP_SIZE: usize = 7;
224
225    // Go's port-ful arms: bounds-check, then read the source port from `sub[0:2]` and the
226    // destination port from `sub[2:4]`. `l4` is whatever else Go reads out of that same header —
227    // only the TCP arm reads anything (`q.TCPFlags = TCPFlag(sub[13])`).
228    let ported = |min_len: usize, l4: ts_packetfilter::L4Header| {
229        if sub.len() < min_len {
230            return Ipv6Fragment::Unknown;
231        }
232        Ipv6Fragment::First {
233            proto,
234            src_port: u16::from_be_bytes([sub[0], sub[1]]),
235            dst_port: u16::from_be_bytes([sub[2], sub[3]]),
236            l4,
237        }
238    };
239    // Go's portless arms: bounds-check only, both ports left at 0.
240    let portless = |min_len: usize, l4: ts_packetfilter::L4Header| {
241        if sub.len() < min_len {
242            return Ipv6Fragment::Unknown;
243        }
244        Ipv6Fragment::First {
245            proto,
246            src_port: 0,
247            dst_port: 0,
248            l4,
249        }
250    };
251    // Go's `IsEchoResponse`/`IsError` read `q.b[q.subofs]` and `q.b[q.subofs+1]` behind
252    // `len(q.b) >= q.subofs+8` — eight bytes, not the four `decode6`'s ICMPv6 arm bounds-checks.
253    // A shorter first fragment is decoded (and matched IPs-only) but is no "response" to upstream
254    // either, so it carries no ICMP header here.
255    let icmp6 = || match (sub.first(), sub.get(1)) {
256        (Some(&icmp_type), Some(&icmp_code)) if sub.len() >= 8 => ts_packetfilter::L4Header::Icmp {
257            icmp_type,
258            icmp_code,
259        },
260        _ => ts_packetfilter::L4Header::Unknown,
261    };
262    // Go `q.TCPFlags = TCPFlag(sub[13])`, guarded by the same 20-byte bounds check `ported` runs.
263    let tcp_flags = || match sub.get(13) {
264        Some(&flags) => ts_packetfilter::L4Header::Tcp { flags },
265        None => ts_packetfilter::L4Header::Unknown,
266    };
267
268    match proto {
269        IpProto::ICMPV6 => portless(ICMP6_HEADER_LEN, icmp6()),
270        IpProto::TCP => ported(TCP_HEADER_LEN, tcp_flags()),
271        IpProto::UDP => ported(UDP_HEADER_LEN, ts_packetfilter::L4Header::Unknown),
272        IpProto::SCTP => ported(SCTP_HEADER_LEN, ts_packetfilter::L4Header::Unknown),
273        IpProto::TSMP => portless(MIN_TSMP_SIZE, ts_packetfilter::L4Header::Unknown),
274        IPPROTO_FRAGMENT_SENTINEL => Ipv6Fragment::Unknown,
275        // Go's switch has no default arm: any other protocol keeps its number and port 0, and the
276        // ACL matches it IPs-only (`IpProto::is_port_ful`).
277        //
278        // Protocol 0 is carried here like any other, which is Go's `q.IPProto = nextHdr` followed
279        // by a switch with no case for it. It is not an admission: 0 is `ipproto.Unknown`, so the
280        // packet dies on `inbound_filter_verdict`'s [`IPPROTO_UNKNOWN`] arm before a rule sees it,
281        // exactly where Go's `pre()` kills it. Pinned by
282        // `first_ipv6_fragment_with_unknown_next_header_is_dropped_before_the_acl`.
283        _ => Ipv6Fragment::First {
284            proto,
285            src_port: 0,
286            dst_port: 0,
287            l4: ts_packetfilter::L4Header::Unknown,
288        },
289    }
290}
291
292/// The `(source, destination)` ports of the SCTP packet whose common header starts at `sub` — Go's
293/// `case ipproto.SCTP` arm, which both `decode4` and `decode6` carry verbatim: bounds-check the
294/// 12-byte common header, then read `sub[0:2]` and `sub[2:4]`.
295///
296/// `None` is Go's refusal in that same arm (`q.IPProto = unknown`), which filter `pre()` turns into
297/// a drop. It must never be read as "port 0": a truncated SCTP header carries no port for a rule to
298/// match, and admitting it as port 0 would let an all-ports rule pass the packet Go throws away.
299///
300/// This exists because etherparse's `TransportSlice` has arms for ICMPv4/ICMPv6/TCP/UDP and nothing
301/// else, so an SCTP packet leaves `SlicedPacket::transport` empty and its ports have to be read the
302/// way Go reads them.
303fn sctp_ports(sub: &[u8]) -> Option<(u16, u16)> {
304    if sub.len() < SCTP_HEADER_LEN {
305        return None;
306    }
307    Some((
308        u16::from_be_bytes([sub[0], sub[1]]),
309        u16::from_be_bytes([sub[2], sub[3]]),
310    ))
311}
312
313/// Whether `ipv6` carries a Fragment extension header somewhere in its extension-header chain
314/// *other than* as the base header's immediate Next Header — the case [`decode6_fragment`] is
315/// deliberately not scoped to, and which must therefore fail closed here.
316///
317/// Callers must only ask this when the base header's Next Header is **not** [`IP6_FRAG_HEADER`];
318/// otherwise the leading Fragment header itself answers `true` and would shadow its own
319/// classification.
320///
321/// Why a drop and not a pass. Go's `decode6` steps over *only* a leading Fragment header, so a
322/// chained one is never classified at all: the packet is filtered as whatever extension header the
323/// base Next Header names, and its fragment offset is never read. Anything this tree said about
324/// such a packet would therefore be its own invention, so it says the one thing that cannot be an
325/// invention in the permissive direction — [`Ipv6Fragment::Unknown`], a drop.
326///
327/// This never admits what upstream refuses. Where the chain leads with Hop-by-Hop Options, Go's
328/// `q.IPProto` is 0 == `ipproto.Unknown` and `pre()` drops it too. Where it leads with Routing (43)
329/// or Destination Options (60), Go carries that number to `runIn6`'s `default` arm, so it can be
330/// admitted only by an all-ports rule that names protocol 43 or 60 IPs-only
331/// (`matchProtoAndIPsOnlyIfAllPorts`) — an ACL nobody writes by accident, and the sole case where
332/// this drop is stricter than upstream. Refusing it cannot break a real Tailscale, `wireguard-go`
333/// or kernel-WireGuard peer: none of them source-fragments behind a chained extension header, and
334/// no peer can be relying on delivery of a packet whose fragment offset upstream never looked at.
335fn fragment_header_is_chained(ipv6: &etherparse::Ipv6Slice<'_>) -> bool {
336    ipv6.extensions()
337        .clone()
338        .into_iter()
339        .any(|ext| matches!(ext, etherparse::Ipv6ExtensionSlice::Fragment(_)))
340}
341
342/// Which address family's fragment rules apply to a packet, so [`inbound_filter_verdict`] can run
343/// Go's `decode4` and `decode6` fragment classifications on the packets each actually governs.
344#[derive(Debug, Clone, Copy)]
345enum Fragment {
346    /// IPv4: the offset and MF flag straight out of the base header (Go `decode4`).
347    V4(Ipv4Fragment),
348    /// IPv6: the already-resolved classification of a Fragment extension header (Go `decode6`).
349    V6(Ipv6Fragment),
350}
351
352/// The inbound packet-filter verdict for an already-parsed packet (`true` = admit). This is the
353/// proto-switch of Go's filter `runIn4`/`runIn6`, applied after `pre()` and after this fork's
354/// source-attribution and local-destination routing (the analogues of Go's `local4`/`local6`
355/// precondition) have run:
356///
357/// 1. `drop_before_rules` — Go `pre()`'s unconditional multicast / link-local-unicast drops.
358/// 2. **Fragment classification** (Go `net/packet.decode4`/`decode6` + filter `pre()`): a non-first
359///    fragment carries no L4 header, so it cannot be port-matched. Go classifies it by offset — a
360///    fragment at offset `>= MIN_FRAG_BLKS` is mapped to `ipproto.Fragment` and `pre()` **accepts**
361///    it (stateless pass-through; the receiver's kernel discards it if the head fragment was
362///    dropped), while a fragment at a smaller offset is dropped (RFC 1858). On IPv4 a *fragmented*
363///    TSMP is additionally disallowed (`moreFrags` on a first TSMP fragment → drop). Without this,
364///    etherparse leaves the transport `None` and the port reads as 0, so a normal ACL rule would
365///    silently drop every valid later fragment — breaking large/fragmented inbound traffic on the
366///    1280-MTU overlay. The IPv6 half ([`Ipv6Fragment`], Go `decode6Fragment`) additionally folds in
367///    the sub-protocol decode of a *first* fragment, so `proto`/`dst_port` here are already the ones
368///    read past the Fragment extension header, and `Ipv6Fragment::Unknown` — a truncated or
369///    short-first fragment, or one whose Fragment header sits behind a chained extension header
370///    ([`fragment_header_is_chained`]) — is dropped where Go's `pre()` drops `ipproto.Unknown`.
371/// 3. **Unknown protocol** ([`IPPROTO_UNKNOWN`]) — Go `pre()`'s `if q.IPProto == ipproto.Unknown`
372///    drop. `proto` is whatever the *base* header declared (Go `decode4`'s `b[9]`, `decode6`'s
373///    `b[6]`), so this is the arm that refuses an IPv6 packet leading with Hop-by-Hop Options,
374///    which is literally protocol 0.
375/// 4. TSMP (proto 99) is always admitted, bypassing the ACL — Go `case ipproto.TSMP: return Accept`.
376///    TSMP carries in-band control messages between nodes, so it must reach the local stack
377///    regardless of the ACL rules. The two TSMP messages this fork understands never get here:
378///    [`filter_inbound_from_peer`] consumes them ahead of this call, as Go does.
379/// 5. **A UDP or SCTP reply to a flow this node started** is admitted from `flows` — Go's
380///    `case ipproto.UDP, ipproto.SCTP` arm, which consults the `flowtrack` LRU and returns
381///    `Accept, "cached"` *before* the rule match. See [`flowtrack`].
382/// 6. **A TCP segment that is not a SYN**, and **an ICMP echo response or ICMP error**, are
383///    admitted with no rule consulted — Go's `return Accept, "tcp non-syn"` and
384///    `return Accept, "icmp response ok"`, the other two thirds of the reply admission step 5
385///    starts. All three exist for the same reason: the answer to a connection *this node* opened
386///    arrives at an ephemeral port, so no ACL control can write will ever name it. Without them a
387///    policy that grants this node outbound access to a peer without granting that peer inbound
388///    access back hangs every TCP connection on its SYN-ACK and silently eats every ping reply.
389///    The predicates are [`ts_packetfilter::PacketInfo`]'s, and each fails closed when the L4
390///    header was not decoded, so a **SYN** — or any packet whose flags this fork never read —
391///    still faces the rules.
392/// 7. Everything else consults the control-derived ACL via `can_access` — Go's `matches4.match`.
393///    A protocol Go's `runIn4`/`runIn6` switch has no arm for (an IPv6 Routing or
394///    Destination-Options header, say) lands in its `default`, which admits IPs-only and only
395///    under an all-ports rule naming that protocol (`matchProtoAndIPsOnlyIfAllPorts`); that
396///    per-protocol port semantics lives in [`ts_packetfilter::Rule`]. ICMP that is *not* a
397///    response reaches the same call and is matched IPs-only there, which is Go's
398///    `else if f.matches4.matchIPsOnly(q, …)`.
399///
400/// `src` and `dst` carry ports because Go's `packet.Parsed` does (`q.Src`/`q.Dst` are
401/// `netip.AddrPort`) and because the flow cache in step 5 keys on all four fields. The ACL itself
402/// still sees only the destination port, which is all Go's `matches4.match` reads. `l4` is the rest
403/// of what Go's `packet.Parsed` holds — `TCPFlags` and the ICMP type/code — and only step 6 reads
404/// it.
405fn inbound_filter_verdict(
406    filter: &(dyn ts_packetfilter::Filter + Send + Sync),
407    flows: &mut flowtrack::FlowCache,
408    proto: IpProto,
409    src: std::net::SocketAddr,
410    dst: std::net::SocketAddr,
411    l4: ts_packetfilter::L4Header,
412    frag: Option<Fragment>,
413) -> bool {
414    if drop_before_rules(dst.ip()) {
415        tracing::trace!(?dst, "dropping multicast/link-local dst (pre-rule)");
416        return false;
417    }
418
419    match frag {
420        Some(Fragment::V4(frag)) => {
421            if frag.offset_blocks > 0 {
422                // A non-first fragment (Go `decode4`'s `fragOfs != 0` branch). It has no transport
423                // header to match, so the verdict is decided purely by offset:
424                if frag.offset_blocks < MIN_FRAG_BLKS {
425                    // Potentially overlaps a transport header (RFC 1858); Go demotes to `unknown` → drop.
426                    tracing::trace!(?dst, "dropping low-offset IPv4 fragment (RFC 1858)");
427                    return false;
428                }
429                // A valid later fragment — Go maps it to `ipproto.Fragment`, which `pre()` accepts
430                // ahead of the ACL. Stateless: if the head fragment was filtered the receiver's kernel
431                // drops this on reassembly timeout. Accepting here is what large fragmented inbound
432                // traffic relies on.
433                tracing::trace!(
434                    ?dst,
435                    "accepting later IPv4 fragment (Go pre() pass-through)"
436                );
437                return true;
438            }
439            // `frag.offset_blocks == 0`: the first fragment (or an unfragmented packet). Go disallows a
440            // *fragmented* TSMP (a first fragment with MF set) — without the whole message it can't be a
441            // valid inter-node control packet. Fall through to the normal proto-switch for everything
442            // else; the first fragment of TCP/UDP carries its L4 header, so `dst_port` was parsed above.
443            if proto == IpProto::TSMP && frag.more_fragments {
444                tracing::trace!(?dst, "dropping fragmented TSMP (Go parity)");
445                return false;
446            }
447        }
448        // The IPv6 Fragment extension header (Go `decode6Fragment`, upstream `4c4ec3d46`). Only
449        // reachable on the opt-in `Config::enable_ipv6` path; the classification itself already ran
450        // Go's offset and bounds checks, so all that is left is Go's `pre()` disposition of the
451        // three protocol values `decode6` can end up with.
452        Some(Fragment::V6(Ipv6Fragment::Unknown)) => {
453            // Go `pre()`: `if q.IPProto == ipproto.Unknown { return Drop }`. This is the
454            // security-relevant arm — a short first fragment, an RFC 1858 low-offset later
455            // fragment, or a Fragment header hidden behind a chained extension header must never
456            // reach the ACL, where an allow-all rule would admit it.
457            tracing::trace!(
458                ?dst,
459                "dropping IPv6 fragment classified unknown (Go pre() drop)"
460            );
461            return false;
462        }
463        Some(Fragment::V6(Ipv6Fragment::Later)) => {
464            // Go `pre()`: `case ipproto.Fragment: return Accept`, same stateless pass-through as
465            // IPv4 — and required by RFC 8200 §4.5, which puts reassembly on the receiver.
466            tracing::trace!(
467                ?dst,
468                "accepting later IPv6 fragment (Go pre() pass-through)"
469            );
470            return true;
471        }
472        // A first IPv6 fragment: `proto` and `dst_port` were read past the Fragment header, so it
473        // takes the ordinary proto switch below and matches the rule an unfragmented datagram would.
474        // Note the deliberate asymmetry with IPv4: `decode6` has no more-fragments guard at all, so
475        // — unlike `decode4` — upstream does not demote a fragmented first TSMP packet to `unknown`.
476        // Falling through is also what refuses a first fragment whose Fragment header names
477        // protocol 0: it arrives here as `proto == IPPROTO_UNKNOWN` and the shared arm below drops
478        // it pre-rules, which is the same fall-through Go gets from a switch with no case for 0.
479        Some(Fragment::V6(Ipv6Fragment::First { .. })) | None => {}
480    }
481
482    // Go filter `pre()`: `if q.IPProto == ipproto.Unknown { return Drop }`. A protocol number
483    // upstream's decoder refused to classify never reaches the ACL, so no rule — however
484    // permissive — can admit it. The check sits after the fragment arms above rather than at the
485    // top of the function only because those arms use `IPPROTO_UNKNOWN` as their own "no
486    // sub-protocol here" placeholder; in Go the two are distinct values (`ipproto.Fragment` is
487    // 0xff) and `pre()` tests them in either order to the same effect.
488    //
489    // The common way to land here is an IPv6 packet whose base Next Header is Hop-by-Hop Options,
490    // which *is* protocol 0: `decode6` copies it into `q.IPProto` and never looks past it.
491    if proto == IPPROTO_UNKNOWN {
492        tracing::trace!(?dst, "dropping unknown-proto packet (Go pre() drop)");
493        return false;
494    }
495
496    if proto == IpProto::TSMP {
497        tracing::trace!(?dst, "accepting TSMP inbound (bypasses ACL, Go parity)");
498        return true;
499    }
500
501    // Go `runIn4`/`runIn6`, at the top of the UDP/SCTP arm and ahead of the rule match:
502    //
503    //     case ipproto.UDP, ipproto.SCTP:
504    //         t := flowtrack.MakeTuple(q.IPProto, q.Src, q.Dst)
505    //         f.state.mu.Lock()
506    //         _, ok := f.state.lru.Get(t)
507    //         f.state.mu.Unlock()
508    //         if ok {
509    //             return Accept, "cached"
510    //         }
511    //
512    // This is the reply to a datagram `process_outbound` sent, so no ACL rule can be expected to
513    // name it: our source port was ephemeral. A miss falls straight through to the rule match
514    // below, which is exactly what Go does — the cache only ever admits, it never denies.
515    if flows.admits_inbound(proto, src, dst) {
516        tracing::trace!(
517            ?src,
518            ?dst,
519            "accepting reply to a tracked outbound flow (cached)"
520        );
521        return true;
522    }
523
524    let info = ts_packetfilter::PacketInfo {
525        ip_proto: proto,
526        port: dst.port(),
527        src: src.ip(),
528        dst: dst.ip(),
529        l4,
530    };
531
532    // Go `runIn4`/`runIn6`, `case ipproto.TCP`, ahead of the rule match:
533    //
534    //     // For TCP, we want to allow *outgoing* connections, which means we want to allow
535    //     // return packets on those connections. To make this restriction work, we need to
536    //     // allow non-SYN packets (continuation of an existing session) to arrive. This
537    //     // should be okay since a new incoming session can't be initiated without first
538    //     // sending a SYN.
539    //     if !q.IsTCPSyn() {
540    //         return Accept, "tcp non-syn"
541    //     }
542    //
543    // The SYN-ACK answering a connection `process_outbound` opened is a non-SYN, and it lands on
544    // our ephemeral source port, so no rule from control can name it. A SYN is the one segment this
545    // must never admit: it is the only way to *start* an inbound session, and admitting it would
546    // turn the carve-out into an open door. See `PacketInfo::is_tcp_non_syn`, which demands a
547    // decoded flags byte rather than treating "no flags" as "not a SYN".
548    if info.is_tcp_non_syn() {
549        tracing::trace!(
550            ?src,
551            ?dst,
552            "accepting inbound TCP non-SYN (Go 'tcp non-syn')"
553        );
554        return true;
555    }
556
557    // Go `runIn4`'s `case ipproto.ICMPv4` and `runIn6`'s `case ipproto.ICMPv6`, ahead of the same
558    // rule match:
559    //
560    //     if q.IsEchoResponse() || q.IsError() {
561    //         // ICMP responses are allowed.
562    //         return Accept, "icmp response ok"
563    //     } else if f.matches4.matchIPsOnly(q, f.srcIPHasCap) {
564    //         // If any port is open to an IP, allow ICMP to it.
565    //         return Accept, "icmp ok"
566    //     }
567    //
568    // The `else if` is already in place: `ts_packetfilter::Rule` matches ICMP IPs-only, so an echo
569    // *request* — and anything else that is not a response — falls through to `can_access` below
570    // and is admitted only if a rule opens some port to this destination. Only the unconditional
571    // arm is added here.
572    if info.is_icmp_echo_response() || info.is_icmp_error() {
573        tracing::trace!(
574            ?src,
575            ?dst,
576            "accepting inbound ICMP response (Go 'icmp response ok')"
577        );
578        return true;
579    }
580
581    // TODO(npry): wire in nodecaps
582    let caps = [];
583    let verdict = filter.can_access(&info, caps);
584    tracing::trace!(?info, ?caps, verdict);
585    verdict
586}
587
588/// The knobs [`filter_inbound_from_peer`] reads that come from this node's configuration rather
589/// than from the packet in front of it — everything Go's `tstun.Wrapper` holds on itself for the
590/// TSMP rejected-connection reply.
591#[derive(Debug, Clone, Copy, Default)]
592struct RejectConfig {
593    /// Go `tstun.Wrapper.disableTSMPRejected`: when set, this node tells a peer nothing about an
594    /// ACL drop, exactly as before this existed.
595    disabled: bool,
596    /// Go `tstun.Wrapper.PeerAPIPort`: the TCP port this node's peerAPI listens on, if it runs one.
597    /// A SYN to it never produces a reject — see [`tsmp_reject_for_drop`].
598    peerapi_port: Option<u16>,
599}
600
601/// Everything a filtered inbound batch produces besides the packets it keeps.
602///
603/// Grouped into one out-parameter because all three are appended to across a whole
604/// [`DataPlane::process_inbound_from`] call, never per packet, and because every one of them is a
605/// TSMP message either consumed or synthesized by the filter step.
606#[derive(Debug, Default)]
607struct InboundHarvest {
608    /// Disco keys peers advertised (Go `packet.TSMPDiscoKeyAdvertisement`). See
609    /// [`InboundResult::learned_disco_keys`].
610    learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
611    /// Rejected-connection messages peers sent us. See [`InboundResult::rejected_flows`].
612    rejected_flows: Vec<(PeerId, ts_packet::tsmp::TailscaleRejectedHeader)>,
613    /// Rejected-connection messages *this* node is sending back, already marshalled into complete
614    /// IP packets, each addressed to the peer whose packet was dropped. The caller encrypts and
615    /// queues them (Go `tstun.Wrapper.InjectOutbound`).
616    rejects_to_send: Vec<(PeerId, PacketMut)>,
617}
618
619/// The TSMP rejected-connection message to send back for an inbound packet the filter just
620/// dropped, marshalled into a complete IP packet — or `None` when this drop must stay silent.
621///
622/// Go `tstun.Wrapper.filterPacketInboundFromWireGuard`, the block that runs on `outcome !=
623/// filter.Accept`:
624///
625/// ```text
626/// // Tell them, via TSMP, we're dropping them due to the ACL.
627/// // Their host networking stack can translate this into ICMP
628/// // or whatnot as required. But notably, their GUI or tailscale CLI
629/// // can show them a rejection history with reasons.
630/// if p.IPVersion == 4 && p.IPProto == ipproto.TCP && p.TCPFlags&packet.TCPSyn != 0 && !t.disableTSMPRejected {
631/// ```
632///
633/// Every clause of that guard is a refusal that has to come with the message, because each one is a
634/// packet upstream stays **silent** about:
635///
636/// 1. **`disableTSMPRejected`** ([`RejectConfig::disabled`]) — the node-wide off switch.
637/// 2. **IPv4 only.** An IPv6 packet dropped by the ACL produces nothing. Upstream's guard is
638///    `p.IPVersion == 4` and the message itself is family-agnostic, so this is a deliberate
639///    upstream scoping and not a limitation of the encoding.
640/// 3. **TCP only, and only a segment with the SYN bit set.** A dropped UDP datagram, a dropped
641///    non-SYN segment and a dropped ICMP packet are all silent: the message names a *connection*
642///    being refused, and only a SYN is a connection being opened. See
643///    [`ts_packetfilter::L4Header::tcp_syn_flag_set`] for why this is not `is_tcp_non_syn`'s
644///    negation.
645/// 4. **Never for the peerAPI port.** Go's carve-out immediately above — "Let peerapi through the
646///    filter; its ACLs are handled at L7, not at the packet level" — flips an ACL-refused peerAPI
647///    SYN back to `Accept` *before* this block runs, so upstream never emits a reject for one. This
648///    fork has no such carve-out (its peerAPI is reachable only through the ordinary rules), so the
649///    same outcome is reached by suppressing the message here rather than by admitting the packet:
650///    the verdict on the packet is untouched, and, as upstream, the peer is told nothing.
651///
652/// The reason is [`RejectReason::SHIELDS_UP`] when the live filter has shields up (Go
653/// `if t.filter.ShieldsUp() { rj.Reason = packet.RejectedDueToShieldsUp }`) and
654/// [`RejectReason::ACLS`] otherwise. `maybe_broken` stays false: both reasons are terminal
655/// verdicts about this node's own policy, and Go sets neither of the non-terminal reasons here.
656///
657/// [`RejectReason::SHIELDS_UP`]: ts_packet::tsmp::RejectReason::SHIELDS_UP
658/// [`RejectReason::ACLS`]: ts_packet::tsmp::RejectReason::ACLS
659fn tsmp_reject_for_drop(
660    proto: IpProto,
661    src: std::net::SocketAddr,
662    dst: std::net::SocketAddr,
663    l4: ts_packetfilter::L4Header,
664    shields_up: bool,
665    cfg: RejectConfig,
666) -> Option<Vec<u8>> {
667    if cfg.disabled {
668        return None;
669    }
670    // Go `p.IPVersion == 4`. `src` and `dst` come from one IP header, so testing either is testing
671    // the header; both are spelled out because the marshal below refuses a mixed pair anyway.
672    if !src.is_ipv4() || !dst.is_ipv4() {
673        return None;
674    }
675    if proto != IpProto::TCP || !l4.tcp_syn_flag_set() {
676        return None;
677    }
678    if cfg.peerapi_port == Some(dst.port()) {
679        tracing::trace!(?dst, "not rejecting a peerAPI SYN (Go's peerapi carve-out)");
680        return None;
681    }
682
683    let reason = if shields_up {
684        ts_packet::tsmp::RejectReason::SHIELDS_UP
685    } else {
686        ts_packet::tsmp::RejectReason::ACLS
687    };
688
689    // Go: `IPSrc: p.Dst.Addr(), IPDst: p.Src.Addr(), Src: p.Src, Dst: p.Dst`. The IP pair is
690    // reversed — the reject travels back the way the packet came — while the four-tuple keeps the
691    // *rejected connection's* own direction, so the dialer can match it against the flow it opened.
692    ts_packet::tsmp::TailscaleRejectedHeader {
693        ip_src: dst.ip(),
694        ip_dst: src.ip(),
695        src,
696        dst,
697        // Go `Proto: p.IPProto`, which the guard above has already pinned to TCP.
698        proto: IPPROTO_TCP_BYTE,
699        reason,
700        maybe_broken: false,
701    }
702    .marshal()
703    .inspect_err(|e| tracing::debug!(?dst, error = %e, "not sending a TSMP reject"))
704    .ok()
705}
706
707/// Apply the inbound packet filter to one peer's already-source-attributed batch of decrypted
708/// packets, in place, and harvest any TSMP disco-key advertisements it carried.
709///
710/// This is the body of Go's `tstun.Wrapper.filterPacketInboundFromWireGuard`, in Go's order:
711///
712/// 1. **TSMP consumption.** Go inspects TSMP *before* running the ACL filter and returns
713///    `filter.DropSilently` for the messages it consumes itself. The one consumed here is the
714///    disco-key advertisement (Go `packet.TSMPDiscoKeyAdvertisement`, upstream capability version
715///    144): a peer announces its disco public key right after an eligible WireGuard session comes
716///    up, so the receiver learns it without waiting for a netmap update or restarting WireGuard.
717///    A real Go peer sends this unprompted. The other message consumed here is the
718///    **rejected-connection** message (Go `packet.TailscaleRejectedHeader`), which Go consumes in
719///    `wgengine.userspaceEngine.trackOpenPreFilterIn` — also ahead of the ACL, also with
720///    `filter.DropSilently`. Every *remaining* TSMP message (ping, pong, a type this fork does not
721///    know) is left in the batch and falls through to step 2, which admits it — exactly as Go's
722///    filter does for the TSMP types it does not consume.
723/// 2. **The ACL verdict**, [`inbound_filter_verdict`] (Go `runIn4`/`runIn6`).
724/// 3. **The rejected-connection reply**, on a drop: Go answers a dropped IPv4 TCP SYN by injecting
725///    a `packet.TailscaleRejectedHeader` back to the peer, so the far side learns *why* its dial
726///    failed instead of waiting out a TCP timeout. See [`tsmp_reject_for_drop`], which carries the
727///    whole of upstream's guard — including the drops that must stay silent.
728///
729/// `harvest` is appended to, never cleared, so one batch can carry messages from several peers. A
730/// learned key is attributed to `peer_id` — the WireGuard peer whose session
731/// decrypted the packet, and whose source addresses the caller's source filter has already bound.
732/// Go reaches the same peer the long way round, looking the advertisement's source IP up in the
733/// netmap (`wgengine.userspaceEngine.peerForIP`). Either way a peer can only advertise a key for
734/// *itself*: it cannot speak for another peer.
735fn filter_inbound_from_peer(
736    filter: &(dyn ts_packetfilter::Filter + Send + Sync),
737    flows: &mut flowtrack::FlowCache,
738    peer_id: PeerId,
739    packets: &mut Vec<PacketMut>,
740    reject_cfg: RejectConfig,
741    harvest: &mut InboundHarvest,
742) {
743    packets.retain(|packet| {
744        let bytes = packet.as_ref();
745        let Ok(pkt) = etherparse::SlicedPacket::from_ip(bytes) else {
746            tracing::trace!("does not look like ip packet");
747            return false;
748        };
749
750        // Go's `sub` in `decode4`/`decode6`: the packet from the sub-protocol's header onwards
751        // (`b[q.subofs:]`, the bytes after the IPv4 header or after the IPv6 base header and any
752        // extension headers). Taken here because the classification below consumes `pkt.net`; only
753        // the SCTP arm of `dst_port` reads it, for the ports etherparse does not parse itself.
754        let sub = match &pkt.net {
755            Some(etherparse::NetSlice::Ipv4(ipv4)) => ipv4.payload().payload,
756            Some(etherparse::NetSlice::Ipv6(ipv6)) => ipv6.payload().payload,
757            _ => &[][..],
758        };
759
760        let (proto, src, dst, frag) = match pkt.net {
761            Some(etherparse::NetSlice::Ipv4(ipv4)) => {
762                // IPv4 fragment state (Go `net/packet.decode4` reads `b[6:8]`): a
763                // non-first fragment carries no L4 header, so etherparse leaves
764                // `transport == None` and the port would read as 0 below — which a normal
765                // ACL rule never admits. Without classifying the fragment that silently
766                // drops valid later fragments Go *accepts* (breaking large/fragmented
767                // inbound traffic on the 1280-MTU overlay). Capture the offset (in 8-byte
768                // blocks) + the more-fragments bit so the verdict can mirror Go's
769                // `decode4`/`pre()` fragment handling.
770                let hdr = ipv4.header();
771                (
772                    IpProto::new(ipv4.payload().ip_number.0 as _),
773                    hdr.source_addr().into(),
774                    hdr.destination_addr().into(),
775                    Some(Fragment::V4(Ipv4Fragment {
776                        offset_blocks: hdr.fragments_offset().value(),
777                        more_fragments: hdr.more_fragments(),
778                    })),
779                )
780            }
781            Some(etherparse::NetSlice::Ipv6(ipv6)) => {
782                let hdr = ipv6.header();
783                // Go `decode6` reads the protocol out of the base header and only the base
784                // header (`q.IPProto = ipproto.Proto(b[6])`). `next_header()` is that byte.
785                // Its one remapping is `decode6`'s switch arm `case ipproto.Fragment:
786                // q.IPProto = unknown` — Go's internal later-fragment sentinel has no business
787                // being on the wire, and `decode6_first_fragment` already refuses it in the
788                // other place it can appear.
789                let base_proto = match IpProto::new(i64::from(hdr.next_header().0)) {
790                    IPPROTO_FRAGMENT_SENTINEL => IPPROTO_UNKNOWN,
791                    other => other,
792                };
793                // IPv6 fragmentation is carried in a Fragment extension header, not the
794                // base header. Go `decode6` parses that header — and *only* when it is the
795                // base header's immediate Next Header. `next_header()` is exactly that
796                // immediate byte, so testing it here reproduces upstream's scoping. Only
797                // reachable under the opt-in `Config::enable_ipv6`; the tailnet is IPv4-only
798                // by default.
799                //
800                // A Fragment header reached through a *chained* hop-by-hop / routing /
801                // destination-options / AH header is outside that scope, and fails closed
802                // rather than falling through to the ACL as a fragment Go never classified.
803                // See `fragment_header_is_chained`.
804                let frag = if hdr.next_header().0 == IP6_FRAG_HEADER {
805                    Some(decode6_fragment(bytes))
806                } else if fragment_header_is_chained(&ipv6) {
807                    Some(Ipv6Fragment::Unknown)
808                } else {
809                    None
810                };
811                let proto = match frag {
812                    // Go `q.IPProto = nextHdr`: the first fragment's real sub-protocol, read
813                    // past the 8-byte Fragment header.
814                    Some(Ipv6Fragment::First { proto, .. }) => proto,
815                    // A later or malformed fragment has no sub-protocol at all (Go's
816                    // `ipproto.Fragment` / `unknown`); the verdict decides on the
817                    // classification alone and never consults this.
818                    Some(Ipv6Fragment::Later | Ipv6Fragment::Unknown) => IPPROTO_UNKNOWN,
819                    // Go `decode6`: `q.IPProto = ipproto.Proto(b[6])` — the **base** header's
820                    // Next Header byte, and nothing after that line resolves it any further.
821                    // `decode6` steps over exactly one header, the leading Fragment header
822                    // handled above; every other extension header is left unparsed, so the
823                    // protocol Go matches on is the extension header's own number. Reading
824                    // `ipv6.payload().ip_number` instead would take etherparse's walk *through*
825                    // the whole chain to the real transport number, which is a different packet
826                    // than the one upstream filters: a chain that leads with Hop-by-Hop (0) is
827                    // `ipproto.Unknown` and `pre()` drops it, and one that leads with Routing
828                    // (43) or Destination Options (60) reaches the ACL as protocol 43/60 —
829                    // never matched against a TCP or UDP rule, and admitted only by an
830                    // all-ports rule naming that protocol (Go `matchProtoAndIPsOnlyIfAllPorts`).
831                    None => base_proto,
832                };
833                (
834                    proto,
835                    hdr.source_addr().into(),
836                    hdr.destination_addr().into(),
837                    frag.map(Fragment::V6),
838                )
839            }
840            _ => {
841                // A packet that parsed as IP but is neither IPv4 nor IPv6 (e.g. a
842                // future/odd `NetSlice` shape). These bytes are attacker-controlled
843                // post-decrypt, so fail closed — drop it — rather than `unreachable!`,
844                // which would panic the single-threaded dataplane on a crafted packet.
845                // Go's filter `pre()` likewise returns Drop/"not-ip" here, never panics.
846                tracing::trace!("parsed packet is neither IPv4 nor IPv6; dropping");
847                return false;
848            }
849        };
850
851        // The rest of what Go's `packet.Parsed` holds about the L4 header — `q.TCPFlags`, and the
852        // ICMP type/code its `IsEchoResponse`/`IsError` read — for the reply carve-outs
853        // `inbound_filter_verdict` applies ahead of the rules. Taken before the port match below,
854        // which consumes `pkt.transport`.
855        //
856        // Every arm is guarded on `proto`, the number the *base* IP header declared. That is what
857        // Go's `runIn4`/`runIn6` switch on, and it is load-bearing here: etherparse walks an IPv6
858        // extension-header chain through to the real transport, so a packet leading with a Routing
859        // (43) or Destination Options (60) header can hand us a `TransportSlice::Tcp` for a
860        // protocol upstream never treats as TCP. Without the guard, burying a non-SYN segment under
861        // an extension header would be enough to skip the rules entirely.
862        let l4 = match frag {
863            // Go `decode6` parses a first fragment's real sub-protocol header past the Fragment
864            // extension header, TCP flags and ICMPv6 type included.
865            Some(Fragment::V6(Ipv6Fragment::First { l4, .. })) => l4,
866            // A later or malformed fragment has no L4 header to read at all, and the verdict
867            // decides on the classification alone.
868            Some(Fragment::V6(Ipv6Fragment::Later | Ipv6Fragment::Unknown)) => {
869                ts_packetfilter::L4Header::Unknown
870            }
871            // Any IPv4 packet, and any IPv6 packet with no Fragment header. etherparse refuses to
872            // descend into a *fragmenting* payload, so a first IPv4 fragment arrives here with no
873            // transport slice and falls to the `_` arm — no carve-out, ordinary rule match. Go does
874            // read that header; the same etherparse limit already leaves such a packet's ports at
875            // 0, so closing the gap is a wider change than this one and both halves of it fail
876            // closed.
877            Some(Fragment::V4(_)) | None => match &pkt.transport {
878                // Go `decode4`/`decode6`: `q.TCPFlags = TCPFlag(sub[13])`. `TcpSlice::from_slice`
879                // has already bounds-checked a 20-byte header, so the `None` is unreachable — but
880                // it is spelled out rather than indexed, because these bytes are attacker
881                // controlled post-decrypt and `L4Header::Unknown` fails closed where a panic would
882                // take down the dataplane.
883                Some(etherparse::TransportSlice::Tcp(tcp)) if proto == IpProto::TCP => {
884                    match tcp.header_slice().get(13) {
885                        Some(&flags) => ts_packetfilter::L4Header::Tcp { flags },
886                        None => ts_packetfilter::L4Header::Unknown,
887                    }
888                }
889                // Go's `IsEchoResponse`/`IsError` read `q.b[q.subofs]` and `q.b[q.subofs+1]` behind
890                // `len(q.b) >= q.subofs+8`; etherparse's ICMP slices refuse anything shorter than
891                // those same 8 bytes, so reaching this arm at all satisfies upstream's guard.
892                Some(etherparse::TransportSlice::Icmpv4(icmp)) if proto == IpProto::ICMP => {
893                    ts_packetfilter::L4Header::Icmp {
894                        icmp_type: icmp.type_u8(),
895                        icmp_code: icmp.code_u8(),
896                    }
897                }
898                Some(etherparse::TransportSlice::Icmpv6(icmp)) if proto == IpProto::ICMPV6 => {
899                    ts_packetfilter::L4Header::Icmp {
900                        icmp_type: icmp.type_u8(),
901                        icmp_code: icmp.code_u8(),
902                    }
903                }
904                _ => ts_packetfilter::L4Header::Unknown,
905            },
906        };
907
908        // Go `decode6` reads a *first* IPv6 fragment's transport ports past the Fragment
909        // extension header, so a fragmented datagram matches the same rule as an
910        // unfragmented one. etherparse deliberately refuses to descend into a fragmenting
911        // payload and leaves `transport == None`, so that port comes from the
912        // classification above instead.
913        let (src_port, dst_port) = match frag {
914            Some(Fragment::V6(Ipv6Fragment::First {
915                src_port, dst_port, ..
916            })) => (src_port, dst_port),
917            // Go reads a destination port in exactly three arms of `decode4`/`decode6` — TCP,
918            // UDP and SCTP — and which arm runs is decided by the protocol number the *base*
919            // header declared, not by what a header walk can reach. So an IPv6 packet that
920            // leads with an extension header takes the switch's `default` (in `decode6`, no
921            // arm at all) and keeps port 0 even though a transport header does sit further
922            // down its chain. Reading that buried port here is what let a chained packet be
923            // matched against a port-scoped TCP/UDP rule it is not upstream's to match.
924            _ if !proto.is_port_ful() => (0, 0),
925            // A later IPv4 fragment carries no transport header at all: Go `decode4` leaves both
926            // ports 0 and classifies it `ipproto.Fragment`, and the verdict below decides on the
927            // offset alone. `sub` is continued payload here, not a header, so the SCTP arm must
928            // not read it — that would invent a port, and would drop a short later fragment Go
929            // passes through.
930            Some(Fragment::V4(v4)) if v4.offset_blocks > 0 => (0, 0),
931            // SCTP. etherparse's `TransportSlice` parses ICMPv4, ICMPv6, TCP and UDP and nothing
932            // else, so `pkt.transport` is `None` for SCTP and the arm below would report port 0
933            // for every SCTP packet on the wire — a match Go never makes. Go has an SCTP arm in
934            // both `decode4` and `decode6` that reads `sub[2:4]`, so read it there too, from the
935            // same bytes Go calls `sub`. (An IPv6 *first fragment* carrying SCTP is already
936            // handled by the first arm, out of `decode6_first_fragment`'s own SCTP arm.)
937            _ if proto == IpProto::SCTP => {
938                let Some(ports) = sctp_ports(sub) else {
939                    // Go's `q.IPProto = unknown` for a header too short to hold the ports, which
940                    // `pre()` drops before any rule is consulted. Falling back to port 0 instead
941                    // would hand the packet to an all-ports SCTP rule.
942                    tracing::trace!(?dst, "dropping SCTP packet shorter than its own header");
943                    return false;
944                };
945                ports
946            }
947            _ => match pkt.transport {
948                Some(etherparse::TransportSlice::Udp(udp)) => {
949                    (udp.source_port(), udp.destination_port())
950                }
951                Some(etherparse::TransportSlice::Tcp(tcp)) => {
952                    (tcp.source_port(), tcp.destination_port())
953                }
954                _ => (0, 0),
955            },
956        };
957
958        // TSMP disco-key advertisement (Go `packet.TSMPDiscoKeyAdvertisement`,
959        // upstream capability version 144). Go handles TSMP in
960        // `tstun.filterPacketInboundFromWireGuard` *before* the ACL filter runs, and
961        // returns `filter.DropSilently` for an advertisement: it is an inter-node
962        // control message consumed here, never delivered to the local stack. Mirror
963        // both the position (after source attribution, before the ACL) and the drop.
964        //
965        if proto == IpProto::TSMP
966            && let Some(advert) = ts_packet::tsmp::DiscoKeyAdvertisement::parse(bytes)
967        {
968            if advert.key_is_zero() {
969                // Go publishes only `if !discoKeyAdvert.Key.IsZero()`. Still a
970                // well-formed advertisement, so it is still dropped.
971                tracing::debug!(
972                    ?peer_id,
973                    "TSMP disco-key advertisement carried the zero key; ignoring"
974                );
975            } else {
976                tracing::debug!(?peer_id, %src, "learned peer disco key over TSMP");
977                harvest.learned_disco_keys.push((peer_id, advert));
978            }
979            return false;
980        }
981
982        // TSMP rejected-connection message (Go `packet.TailscaleRejectedHeader`): a peer telling us
983        // it refused a connection *we* opened, and why. Go consumes it in
984        // `wgengine.userspaceEngine.trackOpenPreFilterIn`, a pre-filter hook that runs ahead of the
985        // ACL and returns `filter.DropSilently` — the message is an inter-node control message
986        // addressed to the engine, never traffic for the local stack. Consumed in the same position
987        // and dropped the same way here; without this it reaches `smoltcp` as an IP-proto-99
988        // datagram it has no handler for.
989        //
990        // Narrower than Go's hook, which drops *every* TSMP packet it sees: only a well-formed
991        // rejected-connection message is consumed here, so the TSMP types this fork does not
992        // implement keep the unconditional accept `inbound_filter_verdict` has always given them.
993        if proto == IpProto::TSMP
994            && let Some(reject) = ts_packet::tsmp::TailscaleRejectedHeader::parse(bytes)
995        {
996            // Go `wgengine/pendopen.go`:
997            //     e.logf("open-conn-track: flow %v %v > %v rejected due to %v", ...)
998            // Go looks the flow up in its pending-open table first and logs only for a flow it was
999            // actually waiting on (or, on `MaybeBroken`, marks that flow problematic instead of
1000            // removing it). This fork keeps no pending-open table, so the message is logged
1001            // unconditionally and handed to the caller with `MaybeBroken` intact, for the embedder
1002            // to match against whatever it has open.
1003            //
1004            // `debug!`, not the `info!` Go's `logf` amounts to, precisely *because* the
1005            // pending-open lookup is missing: Go's log volume is bounded by the flows this node
1006            // opened, and an unconditional log's is bounded only by what a peer chooses to send.
1007            // Every field below is peer-supplied, and reaching here needs nothing but a WireGuard
1008            // session and a well-formed TSMP body — no pending-flow match, no rate limit — so at
1009            // `info!` any authenticated peer can drive an operator's default-level log as fast as
1010            // the link allows and bury real events under it. The same reasoning already puts the
1011            // sibling TSMP branches (the disco-key advertisement above, the reject *send* below)
1012            // at `debug!`. Nothing is lost: `rejected_flows` still carries the whole record, and
1013            // the embedder is the layer that *can* do Go's match, so it is the layer that gets to
1014            // decide a rejection is worth an `info!`.
1015            tracing::debug!(
1016                ?peer_id,
1017                maybe_broken = reject.maybe_broken,
1018                "open-conn-track: flow {} {} > {} rejected due to {}",
1019                reject.proto,
1020                reject.src,
1021                reject.dst,
1022                reject.reason,
1023            );
1024            harvest.rejected_flows.push((peer_id, reject));
1025            return false;
1026        }
1027
1028        // The inbound proto-switch (Go `runIn4`/`runIn6`): Go `pre()` multicast/link-local
1029        // drops, then the fragment classification (Go `decode4` + `pre()`), then
1030        // unconditional TSMP accept, then the control-derived ACL. The caller's source
1031        // attribution and `or_in.route` bound this to attributable peers and local
1032        // destinations (Go's `local4`/`local6` precondition).
1033        let src = std::net::SocketAddr::new(src, src_port);
1034        let dst = std::net::SocketAddr::new(dst, dst_port);
1035
1036        let verdict = inbound_filter_verdict(filter, flows, proto, src, dst, l4, frag);
1037
1038        // Go, on any non-`Accept` outcome: "Tell them, via TSMP, we're dropping them due to the
1039        // ACL. Their host networking stack can translate this into ICMP or whatnot as required. But
1040        // notably, their GUI or tailscale CLI can show them a rejection history with reasons."
1041        // Almost every drop stays silent — see `tsmp_reject_for_drop` for the guard, and for the
1042        // one shape (a peerAPI SYN) upstream silences by admitting rather than by suppressing.
1043        if !verdict
1044            && let Some(reject) =
1045                tsmp_reject_for_drop(proto, src, dst, l4, filter.shields_up(), reject_cfg)
1046        {
1047            tracing::debug!(
1048                ?peer_id,
1049                ?src,
1050                ?dst,
1051                "sending a TSMP reject for a dropped SYN"
1052            );
1053            harvest.rejects_to_send.push((peer_id, reject.into()));
1054        }
1055
1056        verdict
1057    });
1058}
1059
1060/// Where this node sends a TSMP disco-key advertisement, and what it puts in one.
1061///
1062/// The send half of Go's capability version 144 (`packet.TSMPDiscoKeyAdvertisement`): when a
1063/// WireGuard session with a peer is established, this node announces its own disco public key to
1064/// that peer over TSMP, so the peer can learn (or re-learn) the key without waiting for a netmap
1065/// update from control. It is the mirror image of the receive half in
1066/// `filter_inbound_from_peer`, and both are unconditional — a real Go peer sends us one whether
1067/// or not we send one back.
1068///
1069/// This is the netmap state Go's [`magicsock.Conn.PriorityMessageForPeer`] reads, snapshotted into
1070/// the dataplane so building the message stays a cheap, synchronous, allocation-only step on the
1071/// datapath. wireguard-go requires the same of its callback: "must be cheap and must not call back
1072/// into the [`Device`]". The runtime refreshes the snapshot whenever the netmap changes.
1073///
1074/// [`magicsock.Conn.PriorityMessageForPeer`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
1075/// [`Device`]: https://github.com/tailscale/wireguard-go/blob/main/device/device.go
1076#[derive(Debug, Clone, Default)]
1077pub struct DiscoAdvertisementState {
1078    /// This node's own disco public key, raw (Go `Conn.DiscoPublicKey()`). The all-zero key means
1079    /// "no disco key", and nothing is ever advertised — Go's first refusal.
1080    pub disco_key: [u8; ts_packet::tsmp::DISCO_KEY_LEN],
1081    /// This node's own tailnet addresses, in the order control sent them (Go `self.Addresses()`,
1082    /// already narrowed to the single-IP prefixes `selfIPMatchingFamily` accepts). The
1083    /// advertisement's source is the first entry matching the destination's family.
1084    pub self_addrs: Vec<std::net::IpAddr>,
1085    /// Where to send an advertisement, per peer. A peer absent from this map is never advertised
1086    /// to — Go's `endpointForNodeKey` miss.
1087    pub peers: HashMap<PeerId, AdvertisementTarget>,
1088}
1089
1090/// One peer's advertisement destination, as [`DiscoAdvertisementState`] holds it.
1091#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1092pub struct AdvertisementTarget {
1093    /// The peer's first tailnet address (Go `endpoint.nodeAddr`), which is the advertisement's
1094    /// destination address.
1095    pub node_addr: std::net::IpAddr,
1096    /// Whether this is a plain WireGuard peer rather than a Tailscale node (Go
1097    /// `endpoint.isWireguardOnly`). Such a peer speaks no TSMP, so Go never sends it one — and a
1098    /// kernel-WireGuard or `wireguard-go` peer would hand the advertisement straight to its host
1099    /// network stack as an unknown-protocol packet.
1100    pub wireguard_only: bool,
1101}
1102
1103impl DiscoAdvertisementState {
1104    /// The marshalled TSMP disco-key advertisement to send `peer` on session establishment, or
1105    /// `None` if this node must not advertise to it.
1106    ///
1107    /// Go [`magicsock.Conn.PriorityMessageForPeer`], refusal for refusal — every one of these is a
1108    /// silent "send nothing", never a fallback to some other message:
1109    ///
1110    /// 1. **No disco key of our own** (`disco.IsZero()`): there is nothing to advertise.
1111    /// 2. **Unknown peer** (`endpointForNodeKey` miss, or `!self.Valid()`): the netmap snapshot has
1112    ///    no destination address for this WireGuard peer, so any address we invented would be a
1113    ///    guess.
1114    /// 3. **A WireGuard-only peer** (`ep.isWireguardOnly`): "Do not send TSMP messages to peers
1115    ///    that only speaks wireguard."
1116    /// 4. **No source address in the destination's family** (`selfIPMatchingFamily` returning the
1117    ///    zero `Addr`): an IPv4-only node has nothing to put in the source field of a packet to a
1118    ///    peer's IPv6 address.
1119    /// 5. A marshal refusal, which by construction of (4) cannot happen — see
1120    ///    [`ts_packet::tsmp::DiscoKeyAdvertisement::marshal`].
1121    ///
1122    /// [`magicsock.Conn.PriorityMessageForPeer`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
1123    pub fn advertisement_for(&self, peer: PeerId) -> Option<Vec<u8>> {
1124        if self.disco_key == [0u8; ts_packet::tsmp::DISCO_KEY_LEN] {
1125            tracing::debug!(?peer, "no disco key of our own; not advertising");
1126            return None;
1127        }
1128
1129        let target = self.peers.get(&peer)?;
1130
1131        if target.wireguard_only {
1132            return None;
1133        }
1134
1135        let src = self_ip_matching_family(&self.self_addrs, target.node_addr)?;
1136
1137        ts_packet::tsmp::DiscoKeyAdvertisement {
1138            src,
1139            dst: target.node_addr,
1140            key: self.disco_key,
1141        }
1142        .marshal()
1143        .inspect_err(|e| tracing::debug!(?peer, error = %e, "not advertising our disco key"))
1144        .ok()
1145    }
1146}
1147
1148/// This node's first tailnet address whose family matches `want`, or `None`.
1149///
1150/// Go `magicsock.selfIPMatchingFamily`, which walks `self.Addresses()` and returns the first
1151/// single-IP prefix with `Addr().BitLen() == want.BitLen()`. `addrs` is already narrowed to
1152/// single IPs by the caller that builds the snapshot, so only the family test remains.
1153fn self_ip_matching_family(
1154    addrs: &[std::net::IpAddr],
1155    want: std::net::IpAddr,
1156) -> Option<std::net::IpAddr> {
1157    addrs
1158        .iter()
1159        .copied()
1160        .find(|addr| addr.is_ipv4() == want.is_ipv4())
1161}
1162
1163/// The `tstun_out_to_wg_drop_tsmp` counter (Go `metricPacketOutDropTSMP`), registered into the
1164/// process-global registry on first use and exported by `ts_metrics::write_prometheus`. This is the
1165/// durable signal for [`outbound_packet_carries_tsmp`] firing: the datapath log below it is
1166/// `debug!`, because a local process can write these as fast as it likes and this tree has no
1167/// rate-limited logger to put behind Go's `limitedLogf`.
1168fn metric_out_to_wg_drop_tsmp() -> &'static ts_metrics::Metric {
1169    static M: std::sync::OnceLock<&'static ts_metrics::Metric> = std::sync::OnceLock::new();
1170    M.get_or_init(|| ts_metrics::Metric::new_counter("tstun_out_to_wg_drop_tsmp"))
1171}
1172
1173/// Whether the IP packet `b`, written into the TUN by a local host process, carries TSMP and must
1174/// therefore be dropped before it reaches WireGuard.
1175///
1176/// Go `tstun.filterPacketOutboundToWireGuard`: "TSMP traffic should only originate from tailscaled,
1177/// not from the host itself." TSMP is the inter-node control channel — capability version 144's
1178/// disco-key advertisement rides it — so a TSMP packet the host writes is either a confused
1179/// networking stack or a local process forging a control message in this node's name. A peer cannot
1180/// tell a forged advertisement from one this node meant to send: both arrive inside this node's
1181/// WireGuard session, from this node's tailnet address. It would bind whatever disco key the forger
1182/// chose.
1183///
1184/// The advertisements this node legitimately sends never pass through here. They are built in
1185/// [`DiscoAdvertisementState::advertisement_for`] and injected straight into the WireGuard session
1186/// by [`DataPlane::process_inbound`] (the priority-message path), which is *below* this check —
1187/// the same relationship Go has, where `injectedRead` bypasses the outbound filter entirely.
1188///
1189/// # Where this is a superset of Go's classification, and why
1190///
1191/// Go tests the decoded `p.IPProto`, so a *malformed* proto-99 packet decodes to `ipproto.Unknown`
1192/// rather than TSMP and slips past this particular check — only to be dropped one step later by the
1193/// outbound ACL, whose `pre()` refuses `ipproto.Unknown` outright. This tree has no outbound ACL at
1194/// all, so there is no second refusal to fall through to; testing the header's protocol byte
1195/// reaches Go's *net* verdict (nothing carrying proto 99 leaves the host) in one step instead of
1196/// two. Concretely, three shapes are dropped here that Go's TSMP arm alone would not:
1197///
1198/// - an IPv4 TSMP packet that is fragmented, truncated, or shorter than `minTSMPSize`;
1199/// - an IPv6 packet whose Fragment extension header names TSMP but whose first fragment is too
1200///   short to hold a TSMP body;
1201/// - a *later* IPv6 fragment of a TSMP datagram (Go classifies it `ipproto.Fragment` and does put
1202///   it on the wire). This is the one shape Go sends and we do not, and it is unreachable in
1203///   practice: its head fragment is dropped by Go and by us alike, so no peer could ever reassemble
1204///   the datagram, and nothing in a Tailscale node ever emits a fragmented TSMP message in the
1205///   first place. No real peer can be relying on one arriving.
1206///
1207/// A Fragment header reached through a *chained* extension header (hop-by-hop, routing, destination
1208/// options) is deliberately not chased: Go's `decode6` only steps over a Fragment header that is the
1209/// base header's immediate Next Header, so such a packet decodes to `ipproto.Unknown` at every
1210/// Tailscale receiver — including [`ts_packet::tsmp::DiscoKeyAdvertisement::parse`] here — and is
1211/// discarded rather than read as a control message. It is not a forgery vector.
1212fn outbound_packet_carries_tsmp(b: &[u8]) -> bool {
1213    match b.first().map(|first| first >> 4) {
1214        // Go `decode4`: `q.IPProto = ipproto.Proto(b[9])`.
1215        Some(4) => b.len() >= IP4_HEADER_LEN && b[9] == ts_packet::tsmp::IP_PROTO_TSMP,
1216        Some(6) => {
1217            if b.len() < IP6_HEADER_LEN {
1218                return false;
1219            }
1220            // Go `decode6`: `q.IPProto = ipproto.Proto(b[6])`, then step over a leading Fragment
1221            // extension header and take its Next Header instead. Every fragment of one datagram
1222            // repeats that Next Header, so this catches the head fragment (which is what Go's TSMP
1223            // arm catches) and its followers alike.
1224            match b[6] {
1225                ts_packet::tsmp::IP_PROTO_TSMP => true,
1226                IP6_FRAG_HEADER => b
1227                    .get(IP6_HEADER_LEN)
1228                    .is_some_and(|next| *next == ts_packet::tsmp::IP_PROTO_TSMP),
1229                _ => false,
1230            }
1231        }
1232        // Not an IP packet at all: `or_out.route` drops it a moment later for want of a
1233        // destination address. Nothing to classify.
1234        _ => false,
1235    }
1236}
1237
1238/// The UDP or SCTP flow an outbound packet belongs to — `(proto, src, dst)` with the packet's own
1239/// source and destination — or `None` for anything Go's `UpdateOutboundFlowState` switch would not
1240/// record. The caller hands it to [`flowtrack::FlowCache::record_outbound`], which stores the
1241/// reverse.
1242///
1243/// This is Go's `net/packet.Parsed.decode4`/`decode6` narrowed to the two protocols that switch has
1244/// arms for, and it keeps that decoder's refusals rather than guessing:
1245///
1246/// - **The protocol comes from the base header only** — `decode4`'s `b[9]`, `decode6`'s `b[6]` —
1247///   so an IPv6 packet behind a chained extension header is not recorded even though etherparse can
1248///   walk to its UDP header. Go never reaches that header either, so recording it would be this
1249///   tree inventing a flow upstream does not track.
1250/// - **A non-first IPv4 fragment is not a flow.** `decode4` classifies it `ipproto.Fragment`, which
1251///   matches neither arm of Go's switch; it also has no transport header, so its ports would be
1252///   invented.
1253/// - **A leading IPv6 Fragment header is stepped over**, exactly as `decode6` does, so the *first*
1254///   fragment of an outbound datagram records the same tuple an unfragmented one would.
1255/// - **A truncated SCTP common header records nothing** (Go's `q.IPProto = unknown`), rather than
1256///   recording a flow on ports read as 0 — an entry keyed on port 0 would admit inbound SCTP that
1257///   no outbound packet ever justified.
1258fn outbound_udp_or_sctp_flow(
1259    b: &[u8],
1260) -> Option<(IpProto, std::net::SocketAddr, std::net::SocketAddr)> {
1261    let pkt = etherparse::SlicedPacket::from_ip(b).ok()?;
1262
1263    // Go's `sub`: the bytes from the sub-protocol header onwards. Only the SCTP arm reads it,
1264    // because etherparse's `TransportSlice` has no SCTP variant.
1265    let sub = match &pkt.net {
1266        Some(etherparse::NetSlice::Ipv4(ipv4)) => ipv4.payload().payload,
1267        Some(etherparse::NetSlice::Ipv6(ipv6)) => ipv6.payload().payload,
1268        _ => &[][..],
1269    };
1270
1271    let (proto, src_ip, dst_ip, v6_first_fragment_ports) = match &pkt.net {
1272        Some(etherparse::NetSlice::Ipv4(ipv4)) => {
1273            let hdr = ipv4.header();
1274            if hdr.fragments_offset().value() > 0 {
1275                return None;
1276            }
1277            (
1278                IpProto::new(i64::from(ipv4.payload().ip_number.0)),
1279                std::net::IpAddr::from(hdr.source_addr()),
1280                std::net::IpAddr::from(hdr.destination_addr()),
1281                None,
1282            )
1283        }
1284        Some(etherparse::NetSlice::Ipv6(ipv6)) => {
1285            let hdr = ipv6.header();
1286            let src_ip = std::net::IpAddr::from(hdr.source_addr());
1287            let dst_ip = std::net::IpAddr::from(hdr.destination_addr());
1288            if hdr.next_header().0 == IP6_FRAG_HEADER {
1289                // Go `decode6Fragment`: a first fragment yields the real sub-protocol and its
1290                // ports; a later or malformed one yields no flow at all.
1291                let Ipv6Fragment::First {
1292                    proto,
1293                    src_port,
1294                    dst_port,
1295                    // The flow cache keys on Go's `flowtrack.Tuple` — proto and both address/port
1296                    // pairs — and nothing else, so the L4 header is not part of an outbound flow.
1297                    l4: _,
1298                } = decode6_fragment(b)
1299                else {
1300                    return None;
1301                };
1302                (proto, src_ip, dst_ip, Some((src_port, dst_port)))
1303            } else {
1304                (
1305                    IpProto::new(i64::from(hdr.next_header().0)),
1306                    src_ip,
1307                    dst_ip,
1308                    None,
1309                )
1310            }
1311        }
1312        // Not an IP packet at all; `or_out.route` drops it a moment later for want of a
1313        // destination address.
1314        _ => return None,
1315    };
1316
1317    /// Go `net/packet.udpHeaderLength`.
1318    const UDP_HEADER_LEN: usize = 8;
1319
1320    // Go's `decode4`/`decode6` read both ports straight out of `sub` — `sub[0:2]` and `sub[2:4]` —
1321    // after a bounds check, for exactly the protocols below. Reading them here rather than from
1322    // etherparse's `TransportSlice` is not a shortcut: etherparse deliberately refuses to descend
1323    // into a fragmenting payload, so a *first* IPv4 fragment would otherwise surface no ports at
1324    // all, where Go reads them and records the flow like an unfragmented datagram's.
1325    let (src_port, dst_port) = match (proto, v6_first_fragment_ports) {
1326        (IpProto::UDP | IpProto::SCTP, Some(ports)) => ports,
1327        (IpProto::UDP, None) => {
1328            if sub.len() < UDP_HEADER_LEN {
1329                return None;
1330            }
1331            (
1332                u16::from_be_bytes([sub[0], sub[1]]),
1333                u16::from_be_bytes([sub[2], sub[3]]),
1334            )
1335        }
1336        (IpProto::SCTP, None) => sctp_ports(sub)?,
1337        // Every other protocol: Go's switch has no arm for it, so nothing is recorded.
1338        _ => return None,
1339    };
1340
1341    Some((
1342        proto,
1343        std::net::SocketAddr::new(src_ip, src_port),
1344        std::net::SocketAddr::new(dst_ip, dst_port),
1345    ))
1346}
1347
1348/// A data plane subsystem that can be the subject of timer events.
1349pub enum Subsystem {
1350    /// The wireguard component.
1351    Wireguard,
1352}
1353
1354/// The direction/path of a captured packet, mirroring Go Tailscale's `capture.Path`. The numeric
1355/// values are the on-wire path codes written into each pcap record's Tailscale preamble.
1356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1357pub enum CapturePath {
1358    /// A packet from the local device, heading out to a peer (pre-encrypt).
1359    FromLocal = 0,
1360    /// A packet received from a peer, decrypted, heading to the local device.
1361    FromPeer = 1,
1362    /// A packet synthesized by us toward the local device. Retained for Go `capture.Path` on-wire
1363    /// code parity (so captured pcap path codes match Go's, and a future synthesized-packet tee
1364    /// point can emit it); not currently emitted — the tee only produces `FromLocal`/`FromPeer`.
1365    SynthesizedToLocal = 2,
1366    /// A packet synthesized by us toward a peer. Retained for Go `capture.Path` on-wire code parity
1367    /// (see [`Self::SynthesizedToLocal`]); not currently emitted.
1368    SynthesizedToPeer = 3,
1369}
1370
1371impl CapturePath {
1372    /// The on-wire path code (the `uint16` written into the pcap record preamble).
1373    pub fn code(self) -> u16 {
1374        self as u16
1375    }
1376}
1377
1378/// A debug packet-capture hook. When installed on a [`DataPlane`], it is invoked with the path and
1379/// the raw IP packet bytes for every plaintext packet crossing the datapath. It must be cheap and
1380/// non-blocking — it runs inline on the single-threaded dataplane step, so a slow hook backs up the
1381/// datapath. Wrapped in `Arc` so it is cheap to clone and `Send + Sync` for the actor that installs
1382/// it.
1383pub type CaptureHook = std::sync::Arc<dyn Fn(CapturePath, &[u8]) + Send + Sync>;
1384
1385/// Transforms packets to make tailscale happen.
1386pub struct DataPlane {
1387    /// Wireguard encryption/decryption.
1388    pub wireguard: Endpoint,
1389
1390    /// Outbound overlay router.
1391    pub or_out: or::outbound::Router,
1392    /// Outbound underlay router.
1393    pub ur_out: ur::outbound::Router,
1394
1395    /// Inbound source filter.
1396    pub src_filter_in: Arc<ts_bart::Table<PeerId>>,
1397    /// Inbound overlay router.
1398    pub or_in: or::inbound::Router,
1399
1400    /// The packet filter.
1401    pub packet_filter: Arc<dyn ts_packetfilter::Filter + Send + Sync>,
1402
1403    /// Events queued for future processing.
1404    pub events: Scheduler<Subsystem>,
1405
1406    /// Next event for the wireguard subsystem.
1407    pub wg_next: Option<Handle<Subsystem>>,
1408
1409    /// Optional debug packet-capture hook (Go `tstun.Wrapper` capture hook). `None` (the default)
1410    /// means no capture and zero datapath overhead. Installed/cleared at runtime by the dataplane
1411    /// actor; see [`DataPlane::process_outbound`]/[`DataPlane::process_inbound`] for the tee points.
1412    pub capture: Option<CaptureHook>,
1413
1414    /// Reverse-flow connection tracking for outbound UDP and SCTP, so a peer's reply to a
1415    /// datagram this node sent is admitted without an ACL rule naming our ephemeral source port
1416    /// (Go `filter.Filter.state`). Filled by [`DataPlane::process_outbound`] and consulted by the
1417    /// inbound filter; bounded at Go's `lruMax`. Private because it is derived datapath state, not
1418    /// configuration — nothing outside this crate has anything to set it to.
1419    flows: flowtrack::FlowCache,
1420
1421    /// Netmap snapshot for the TSMP disco-key advertisement this node sends on session
1422    /// establishment (Go capability version 144). `None` (the default) advertises nothing at all,
1423    /// which is what an embedder that never populates it gets — the same position this fork was in
1424    /// before the send side existed, and still fully interoperable, since a peer's own
1425    /// advertisement is unsolicited. Refreshed from the netmap by the runtime's dataplane actor.
1426    pub disco_advertisement: Option<Arc<DiscoAdvertisementState>>,
1427
1428    /// The TCP port this node's peerAPI listens on, if it runs one (Go `tstun.Wrapper.PeerAPIPort`).
1429    /// Read only when a dropped packet is about to be answered with a TSMP rejected-connection
1430    /// message: a SYN to the peerAPI port never produces one, because upstream's peerAPI carve-out
1431    /// has already flipped it back to `Accept` by that point. See `tsmp_reject_for_drop`.
1432    ///
1433    /// `None` (the default) means no peerAPI, and is also what an embedder that never sets it gets
1434    /// — the same position this fork was in before the message existed. Refreshed by the runtime's
1435    /// dataplane actor.
1436    pub peerapi_port: Option<u16>,
1437
1438    /// Go `tstun.Wrapper.disableTSMPRejected`: when `true`, an ACL drop tells the peer nothing.
1439    /// `false` (the default) is upstream's default and the interoperable one — a Go peer expects
1440    /// to be told.
1441    pub disable_tsmp_rejected: bool,
1442}
1443
1444impl DataPlane {
1445    /// Creates a new data plane for a wireguard node key.
1446    pub fn new(my_key: NodeKeyPair) -> Self {
1447        DataPlane {
1448            wireguard: Endpoint::new(my_key),
1449            or_out: Default::default(),
1450            ur_out: Default::default(),
1451            src_filter_in: Default::default(),
1452            or_in: Default::default(),
1453            events: Default::default(),
1454            packet_filter: Arc::new(ts_packetfilter::DropAllFilter),
1455            wg_next: None,
1456            capture: None,
1457            flows: flowtrack::FlowCache::default(),
1458            disco_advertisement: None,
1459            peerapi_port: None,
1460            disable_tsmp_rejected: false,
1461        }
1462    }
1463
1464    /// Processes packets originating from the local device.
1465    ///
1466    /// Packets carrying TSMP are refused here (Go `tstun.filterPacketOutboundToWireGuard`): the
1467    /// inter-node control channel must only ever carry messages this node built, never bytes a host
1468    /// process handed us. See `outbound_packet_carries_tsmp` for why, and for the one shape Go
1469    /// forwards that this refuses.
1470    #[tracing::instrument(skip_all, fields(n_packets = packets.len()))]
1471    pub fn process_outbound(&mut self, mut packets: Vec<PacketMut>) -> OutboundResult {
1472        // The capture tee runs first, and so still sees the packets dropped just below — Go tees to
1473        // its capture hook in `Wrapper.Read` before calling the outbound filter, so a pcap taken on
1474        // either implementation shows the refused packet.
1475        if let Some(hook) = &self.capture {
1476            for p in &packets {
1477                hook(CapturePath::FromLocal, p.as_ref());
1478            }
1479        }
1480
1481        packets.retain(|p| {
1482            if outbound_packet_carries_tsmp(p.as_ref()) {
1483                tracing::debug!("[unexpected] TSMP packet written into the tun; dropping");
1484                metric_out_to_wg_drop_tsmp().inc();
1485                return false;
1486            }
1487            true
1488        });
1489
1490        // Go `filter.Filter.UpdateOutboundFlowState`, called from `RunOut` for every packet read
1491        // off the TUN and — since upstream `e0677ccc7` — from `net/tstun`'s injected path too,
1492        // because packets produced by netstack never pass `RunOut` and "a netstack-side dial of UDP
1493        // would send fine but the reply would be dropped as `no matching rule`". Every outbound
1494        // packet in this engine comes from the netstack, so this is that call site. It runs after
1495        // the TSMP refusal above, matching Go's order in `filterPacketOutboundToWireGuard`, and
1496        // before `or_out.route` consumes the batch.
1497        for p in &packets {
1498            if let Some((proto, src, dst)) = outbound_udp_or_sctp_flow(p.as_ref()) {
1499                self.flows.record_outbound(proto, src, dst);
1500            }
1501        }
1502
1503        let or::outbound::Result {
1504            to_wireguard,
1505            loopback,
1506        } = self.or_out.route(packets);
1507
1508        let to_wireguard = to_wireguard
1509            .into_iter()
1510            .map(|(k, v)| (ts_tunnel::PeerId(k.0), v))
1511            .collect::<Vec<_>>();
1512
1513        let ts_tunnel::SendResult {
1514            to_peers: encrypted,
1515        } = self.wireguard.send(to_wireguard);
1516
1517        let to_peers = self
1518            .ur_out
1519            .route(encrypted.into_iter().map(|(k, v)| (PeerId(k.0), v)));
1520
1521        if let Some(next) = self.wireguard.next_event()
1522            && let Some(prev) = self
1523                .wg_next
1524                .replace(self.events.add(next, Subsystem::Wireguard))
1525        {
1526            prev.cancel();
1527        }
1528
1529        OutboundResult { to_peers, loopback }
1530    }
1531
1532    /// Processes packets received from elsewhere, with no information about which peer sent them.
1533    ///
1534    /// Equivalent to [`DataPlane::process_inbound_from`] with no attribution; see there for what
1535    /// the attribution buys.
1536    pub fn process_inbound(
1537        &mut self,
1538        packets: impl IntoIterator<Item = PacketMut>,
1539    ) -> InboundResult {
1540        self.process_inbound_from(None, packets)
1541    }
1542
1543    /// Processes packets an underlay transport received and attributed to peer `from`.
1544    ///
1545    /// The attribution is what lets the WireGuard layer answer a handshake initiation with a
1546    /// cookie while it is under load: the reply has to go back where the initiation came from, and
1547    /// in this stack that origin is a peer, not a source address. See
1548    /// [`ts_tunnel::Endpoint::recv_from`].
1549    pub fn process_inbound_from(
1550        &mut self,
1551        from: Option<PeerId>,
1552        packets: impl IntoIterator<Item = PacketMut>,
1553    ) -> InboundResult {
1554        let ts_tunnel::RecvResult {
1555            to_local,
1556            to_peers,
1557            sessions_established,
1558        } = self
1559            .wireguard
1560            .recv_from(from.map(|p| ts_tunnel::PeerId(p.0)), packets);
1561
1562        if let Some(hook) = &self.capture {
1563            for packets in to_local.values() {
1564                for p in packets {
1565                    hook(CapturePath::FromPeer, p.as_ref());
1566                }
1567            }
1568        }
1569
1570        // The TSMP messages this batch consumes or synthesizes (Go `tstun.Wrapper`'s
1571        // `discoKeyAdvertisementPub` publisher, its `trackOpenPreFilterIn` hook, and its
1572        // `InjectOutbound` of a rejected-connection reply). Filled in by the packet-filter stage
1573        // below, which is the point at which a packet has both been attributed to a peer and been
1574        // decoded far enough to know it is TSMP.
1575        let mut harvest = InboundHarvest::default();
1576
1577        // Hoisted out of the filter closure below, which borrows `self.flows` mutably.
1578        let reject_cfg = RejectConfig {
1579            disabled: self.disable_tsmp_rejected,
1580            peerapi_port: self.peerapi_port,
1581        };
1582
1583        let to_local = to_local
1584            .into_iter()
1585            .map(|(peer_id, mut packets)| -> (PeerId, Vec<PacketMut>) {
1586                let _span = tracing::trace_span!(
1587                    "src_filter_inbound",
1588                    peer_id = ?peer_id,
1589                    n_packet = packets.len(),
1590                )
1591                .entered();
1592
1593                packets.retain(|packet| {
1594                    let Some(src) = packet.get_src_addr() else {
1595                        tracing::trace!("does not look like ip packet");
1596                        return false;
1597                    };
1598                    let verdict = if let Some(allowed_peer) = self.src_filter_in.lookup(src) {
1599                        *allowed_peer == PeerId(peer_id.0)
1600                    } else {
1601                        tracing::trace!(remote_ip = %src, "unknown peer address");
1602                        false
1603                    };
1604                    tracing::trace!(?src, verdict);
1605                    verdict
1606                });
1607
1608                (PeerId(peer_id.0), packets)
1609            })
1610            .map(|(peer_id, mut v)| {
1611                let _span = tracing::trace_span!(
1612                    "packet_filter_inbound",
1613                    peer_id = ?peer_id,
1614                    n_packet = v.len()
1615                )
1616                .entered();
1617
1618                filter_inbound_from_peer(
1619                    self.packet_filter.as_ref(),
1620                    &mut self.flows,
1621                    peer_id,
1622                    &mut v,
1623                    reject_cfg,
1624                    &mut harvest,
1625                );
1626
1627                v
1628            })
1629            // Forced here rather than left lazy: the TSMP rejected-connection replies the filter
1630            // stage synthesizes have to be encrypted and merged into `to_peers` below, and that
1631            // cannot happen while the iterator that produces them is still unconsumed.
1632            .collect::<Vec<_>>();
1633
1634        // TSMP disco-key advertisement, send side (Go capability version 144). wireguard-go calls
1635        // `peer.SendPriorityMessage()` the moment a keypair becomes current for forward
1636        // transmission — on the initiator when the handshake response lands, and on the responder
1637        // when the first transport packet authenticates on the new keypair (`device/receive.go`).
1638        // `sessions_established` is exactly those two moments; the message is Go's
1639        // `magicsock.Conn.PriorityMessageForPeer` return value. A peer we must not advertise to
1640        // (see [`DiscoAdvertisementState::advertisement_for`]) simply gets nothing, and the fresh
1641        // session is otherwise untouched.
1642        let mut to_peers = to_peers;
1643
1644        // TSMP rejected-connection replies, send side (Go `tstun.Wrapper.InjectOutbound`, called
1645        // from `filterPacketInboundFromWireGuard` on an ACL drop). Each goes to the peer whose own
1646        // packet was dropped — the peer whose session decrypted it, which the source filter has
1647        // already bound to that packet's source address, so this is the same node Go reaches by
1648        // routing the injected packet on its destination IP.
1649        //
1650        // These deliberately bypass `process_outbound`'s TSMP refusal: that refusal exists to stop
1651        // a *host process* writing TSMP into the tun, and upstream's injected packets skip the
1652        // outbound filter for the same reason.
1653        if !harvest.rejects_to_send.is_empty() {
1654            let mut by_peer: HashMap<ts_tunnel::PeerId, Vec<PacketMut>> = HashMap::new();
1655            for (peer, packet) in std::mem::take(&mut harvest.rejects_to_send) {
1656                by_peer
1657                    .entry(ts_tunnel::PeerId(peer.0))
1658                    .or_default()
1659                    .push(packet);
1660            }
1661            let ts_tunnel::SendResult { to_peers: sealed } = self.wireguard.send(by_peer);
1662            for (peer, packets) in sealed {
1663                to_peers.entry(peer).or_default().extend(packets);
1664            }
1665        }
1666
1667        if let Some(advert) = self.disco_advertisement.clone() {
1668            // Held apart from what `recv` already queued for these peers so it can be spliced in
1669            // FRONT of it below, rather than appended behind it.
1670            let mut priority: HashMap<ts_tunnel::PeerId, Vec<PacketMut>> = HashMap::new();
1671            for peer in sessions_established {
1672                let Some(msg) = advert.advertisement_for(PeerId(peer.0)) else {
1673                    continue;
1674                };
1675                tracing::debug!(peer_id = ?peer, "advertising our disco key over TSMP");
1676                for (peer, packets) in self.wireguard.send_priority_message(peer, &msg).to_peers {
1677                    priority.entry(peer).or_default().extend(packets);
1678                }
1679            }
1680            // A priority message leads the traffic the same establishment released. wireguard-go
1681            // hands it straight to the peer's *outbound* queue (`SendPriorityMessage` →
1682            // `queueOutboundIfRunning`), never to the staged queue, and both call sites run it
1683            // before the flush that follows — `peer.SendPriorityMessage()` ahead of
1684            // `peer.SendKeepalive()` on the initiator and ahead of `peer.SendStagedPackets()` on
1685            // the responder (`device/receive.go`). Here the flush has already happened inside
1686            // [`Endpoint::recv`] (`activate` encrypts whatever was queued), so restoring Go's wire
1687            // order means splicing the advertisement in front of it.
1688            //
1689            // Only the wire order is restored, not Go's nonce order: those flushed packets were
1690            // sealed first and so hold the lower nonces, where Go would have numbered the priority
1691            // message first. That is invisible to the peer. A WireGuard receiver accepts an
1692            // earlier counter after a later one by construction, and the inversion is bounded by
1693            // the send queue a session flushes on activation (`MAX_QUEUED_PER_PEER`, 32 packets) —
1694            // two orders of magnitude inside the 8128-packet anti-replay window WireGuard
1695            // receivers carry (`ts_tunnel`'s `ReplayWindow::WINDOW_SIZE`, wireguard-go parity).
1696            for (peer, mut packets) in priority {
1697                let queued = to_peers.entry(peer).or_default();
1698                packets.append(queued);
1699                *queued = packets;
1700            }
1701        }
1702
1703        let to_peers = to_peers
1704            .into_iter()
1705            .map(|(k, v)| (ts_transport::PeerId(k.0), v));
1706
1707        let to_local = self.or_in.route(to_local.into_iter().flatten());
1708        let to_peers = self.ur_out.route(to_peers);
1709
1710        if let Some(next) = self.wireguard.next_event()
1711            && let Some(prev) = self
1712                .wg_next
1713                .replace(self.events.add(next, Subsystem::Wireguard))
1714        {
1715            prev.cancel();
1716        }
1717
1718        InboundResult {
1719            to_local,
1720            to_peers,
1721            learned_disco_keys: harvest.learned_disco_keys,
1722            rejected_flows: harvest.rejected_flows,
1723        }
1724    }
1725
1726    /// Return the next time at which [`DataPlane::process_events`] must be called.
1727    ///
1728    /// [`DataPlane::process_outbound`], [`DataPlane::process_inbound`] and
1729    /// [`DataPlane::process_events`] may all update the next event time. Callers should prefer
1730    /// calling `next_event` as needed to get a correct result, rather than store the returned
1731    /// value.
1732    pub fn next_event(&self) -> Option<Instant> {
1733        self.events.next_dispatch()
1734    }
1735
1736    /// Process all queued events that are due for processing.
1737    ///
1738    /// Must be called at least as often as dictated by [`DataPlane::next_event`] for the
1739    /// data plane to function correctly. It is harmless to call it more frequently.
1740    pub fn process_events(&mut self) -> EventResult {
1741        let mut to_peers = HashMap::new();
1742        let now = Instant::now();
1743        for event in self.events.dispatch(now) {
1744            match event {
1745                Subsystem::Wireguard => {
1746                    let res = self.wireguard.dispatch_events(now);
1747                    to_peers.extend(
1748                        res.to_peers
1749                            .into_iter()
1750                            .map(|(id, pkts)| (ts_transport::PeerId(id.0), pkts)),
1751                    );
1752                }
1753            }
1754        }
1755        let to_peers = self.ur_out.route(to_peers);
1756
1757        if let Some(next) = self.wireguard.next_event()
1758            && let Some(prev) = self
1759                .wg_next
1760                .replace(self.events.add(next, Subsystem::Wireguard))
1761        {
1762            prev.cancel();
1763        }
1764
1765        EventResult { to_peers }
1766    }
1767}
1768
1769/// The result of processing outbound packets.
1770pub struct OutboundResult {
1771    /// Packets to be sent into underlay transports for transmission.
1772    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
1773    /// Packets to be looped back and delivered to overlay transports.
1774    pub loopback: HashMap<OverlayTransportId, Vec<PacketMut>>,
1775}
1776
1777/// The result of processing inbound packets.
1778pub struct InboundResult {
1779    /// Decrypted packets to be delivered to overlay transports.
1780    pub to_local: HashMap<OverlayTransportId, Vec<PacketMut>>,
1781    /// Encrypted packets to be sent to wireguard peers by the underlay.
1782    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
1783    /// Disco keys peers advertised over TSMP in this batch, each paired with the WireGuard peer
1784    /// whose session carried it (Go `tstun.Wrapper` publishing `events.PeerDiscoKeyUpdate`, which
1785    /// `wgengine` turns into a `magicsock.Conn.HandleDiscoKeyAdvertisement` call).
1786    ///
1787    /// The advertisement packets themselves are dropped: they are inter-node control messages, not
1788    /// traffic for the local stack. Zero keys are already filtered out. Empty for a batch that
1789    /// carried none, which is the overwhelmingly common case.
1790    pub learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
1791    /// Connections peers refused, as they told us over TSMP (Go `packet.TailscaleRejectedHeader`,
1792    /// which `wgengine.userspaceEngine.trackOpenPreFilterIn` matches against its pending-open
1793    /// flows), each paired with the WireGuard peer whose session carried it.
1794    ///
1795    /// This engine is always the dialing client, so this is the answer to a connection *this node*
1796    /// opened: without it a dial into a peer's ACL drop waits out a TCP timeout with no reason
1797    /// available anywhere. `src` is this node's own address and ephemeral port and `dst` is the
1798    /// peer's, so an embedder can match the message against the flow it opened;
1799    /// [`maybe_broken`](ts_packet::tsmp::TailscaleRejectedHeader::maybe_broken) says whether the
1800    /// refusal is terminal.
1801    ///
1802    /// The messages themselves are dropped rather than delivered — they are inter-node control
1803    /// messages, and the local stack has no handler for IP protocol 99. Empty for a batch that
1804    /// carried none, which is the overwhelmingly common case.
1805    pub rejected_flows: Vec<(PeerId, ts_packet::tsmp::TailscaleRejectedHeader)>,
1806}
1807
1808/// The result of processing an event.
1809#[derive(Default)]
1810pub struct EventResult {
1811    /// Encrypted packets to be sent to wireguard peers by the underlay.
1812    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817    use std::sync::Mutex;
1818
1819    use super::*;
1820
1821    /// Records `(path, bytes)` for each capture-hook invocation in a test.
1822    type CaptureLog = Arc<Mutex<Vec<(CapturePath, Vec<u8>)>>>;
1823
1824    /// [`inbound_filter_verdict`] against an **empty** flow cache and with **no decoded L4 header**,
1825    /// which is the verdict for a packet that is not a reply to anything this node sent. Every case
1826    /// that predates the reverse-flow cache is exactly that case, so they all go through here and
1827    /// stay pinned to the stateless decision; the tests that do record an outbound flow call
1828    /// `inbound_filter_verdict` directly.
1829    ///
1830    /// [`ts_packetfilter::L4Header::Unknown`] is also the assertion that the reply carve-outs fail
1831    /// closed: a TCP packet whose flags were never read must still face the rules here, and it does.
1832    fn stateless_verdict(
1833        filter: &(dyn ts_packetfilter::Filter + Send + Sync),
1834        proto: IpProto,
1835        src: std::net::IpAddr,
1836        dst: std::net::IpAddr,
1837        dst_port: u16,
1838        frag: Option<Fragment>,
1839    ) -> bool {
1840        inbound_filter_verdict(
1841            filter,
1842            &mut flowtrack::FlowCache::default(),
1843            proto,
1844            std::net::SocketAddr::new(src, 0),
1845            std::net::SocketAddr::new(dst, dst_port),
1846            ts_packetfilter::L4Header::Unknown,
1847            frag,
1848        )
1849    }
1850
1851    #[test]
1852    fn capture_path_codes() {
1853        assert_eq!(CapturePath::FromLocal.code(), 0);
1854        assert_eq!(CapturePath::FromPeer.code(), 1);
1855        assert_eq!(CapturePath::SynthesizedToLocal.code(), 2);
1856        assert_eq!(CapturePath::SynthesizedToPeer.code(), 3);
1857    }
1858
1859    /// The pre-rule destination screen (Go filter `pre()`): multicast and non-allowlisted link-local
1860    /// destinations are dropped before the ACL; ordinary unicast and the cloud-metadata link-local
1861    /// exception pass through to the rules.
1862    #[test]
1863    fn pre_rule_drop_matches_go() {
1864        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1865        // Dropped pre-rules:
1866        assert!(drop_before_rules(ip("224.0.0.1")), "IPv4 multicast dropped");
1867        assert!(
1868            drop_before_rules(ip("239.255.255.250")),
1869            "IPv4 multicast (SSDP) dropped"
1870        );
1871        assert!(
1872            drop_before_rules(ip("169.254.1.1")),
1873            "IPv4 link-local dropped"
1874        );
1875        assert!(drop_before_rules(ip("ff02::1")), "IPv6 multicast dropped");
1876        assert!(drop_before_rules(ip("fe80::1")), "IPv6 link-local dropped");
1877        assert!(
1878            drop_before_rules(ip("febf:ffff::1")),
1879            "top of fe80::/10 dropped (locks the 0xffc0/0xfe80 mask)"
1880        );
1881        // Passed through to the rules:
1882        assert!(
1883            !drop_before_rules(ip("fec0::1")),
1884            "just past fe80::/10 passes (locks the 0xffc0/0xfe80 mask)"
1885        );
1886        // IPv4-mapped-IPv6 destinations match NEITHER arm and fall through to the ACL, exactly as
1887        // Go's `netip.Addr` predicates do (no unmap/canonicalize). Pinning this guards against a
1888        // future "canonicalize to be safe" refactor silently diverging from Go.
1889        assert!(
1890            !drop_before_rules(ip("::ffff:224.0.0.1")),
1891            "4in6-mapped multicast falls through to the ACL, matching Go"
1892        );
1893        assert!(
1894            !drop_before_rules(ip("::ffff:169.254.1.1")),
1895            "4in6-mapped link-local falls through to the ACL, matching Go"
1896        );
1897        assert!(
1898            !drop_before_rules(ip("100.64.0.5")),
1899            "ordinary tailnet unicast passes"
1900        );
1901        assert!(
1902            !drop_before_rules(ip("8.8.8.8")),
1903            "ordinary public unicast passes"
1904        );
1905        assert!(
1906            !drop_before_rules(ip("169.254.169.254")),
1907            "the cloud-metadata link-local address is the Go-allowlisted exception"
1908        );
1909        assert!(
1910            !drop_before_rules(ip("fd7a:115c:a1e0::1")),
1911            "IPv6 ULA (tailnet) passes"
1912        );
1913    }
1914
1915    /// A filter that drops everything (returns `None` for every packet). Lets a test prove that TSMP
1916    /// is admitted by bypassing the ACL — not by the ACL happening to allow it.
1917    struct DenyAll;
1918    impl ts_packetfilter::Filter for DenyAll {
1919        fn match_for(
1920            &self,
1921            _info: &ts_packetfilter::PacketInfo,
1922            _caps: ts_packetfilter::filter::CapIter,
1923        ) -> Option<&str> {
1924            None
1925        }
1926    }
1927
1928    /// The inbound proto-switch (Go `runIn4`/`runIn6`): TSMP is always admitted, bypassing the ACL;
1929    /// `pre()` drops still win over TSMP; non-TSMP defers to the ACL.
1930    #[test]
1931    fn tsmp_bypasses_acl_matches_go() {
1932        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1933        let src = ip("100.64.0.9");
1934        let dst = ip("100.64.0.1");
1935        let tsmp = IpProto::new(99);
1936
1937        // TSMP is accepted even though the ACL denies everything — Go `case TSMP: return Accept`.
1938        assert!(
1939            stateless_verdict(&DenyAll, tsmp, src, dst, 0, None),
1940            "TSMP admitted by bypassing the (deny-all) ACL"
1941        );
1942        // A non-TSMP proto under the same deny-all ACL is dropped — proves the bypass is TSMP-specific.
1943        assert!(
1944            !stateless_verdict(&DenyAll, IpProto::TCP, src, dst, 443, None),
1945            "TCP still consults the ACL (deny-all → dropped)"
1946        );
1947        // `pre()` drops outrank the TSMP accept: TSMP to a multicast/link-local dst is still dropped,
1948        // exactly as Go runs `pre()` before the proto switch.
1949        assert!(
1950            !stateless_verdict(&DenyAll, tsmp, src, ip("224.0.0.1"), 0, None),
1951            "TSMP to a multicast dst is still dropped (pre() before the switch)"
1952        );
1953        assert!(
1954            !stateless_verdict(&DenyAll, tsmp, src, ip("169.254.1.1"), 0, None),
1955            "TSMP to a link-local dst is still dropped (pre() before the switch)"
1956        );
1957        // IpProto::TSMP is the named constant for proto 99.
1958        assert_eq!(IpProto::TSMP, tsmp, "IpProto::TSMP == 99");
1959    }
1960
1961    /// IPv4 fragment handling, mirroring Go `net/packet.decode4` + filter `pre()`:
1962    /// - a valid later fragment (offset ≥ `MIN_FRAG_BLKS`) is ACCEPTED ahead of the ACL (Go maps it
1963    ///   to `ipproto.Fragment`, which `pre()` admits) — even under a deny-all ACL and even though its
1964    ///   parsed port is 0, which a normal rule would never match;
1965    /// - a low-offset later fragment (offset < `MIN_FRAG_BLKS`) is DROPPED (RFC 1858);
1966    /// - a first fragment (offset 0) defers to the normal proto-switch/ACL on its real port;
1967    /// - a *fragmented* TSMP first fragment (offset 0, MF set) is DROPPED (Go disallows it), unlike a
1968    ///   non-fragmented TSMP which bypasses the ACL.
1969    #[test]
1970    fn ipv4_fragment_handling_matches_go_decode4() {
1971        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
1972        let src = ip("100.64.0.9");
1973        let dst = ip("100.64.0.1");
1974        let frag = |offset_blocks: u16, more_fragments: bool| {
1975            Some(Fragment::V4(Ipv4Fragment {
1976                offset_blocks,
1977                more_fragments,
1978            }))
1979        };
1980
1981        // A valid later fragment is accepted under a DENY-ALL ACL with port 0 — proves the accept is
1982        // the Go `pre()` Fragment pass-through, not the ACL happening to allow it.
1983        assert!(
1984            stateless_verdict(
1985                &DenyAll,
1986                IpProto::TCP,
1987                src,
1988                dst,
1989                0,
1990                frag(MIN_FRAG_BLKS, false)
1991            ),
1992            "a valid later fragment (offset >= MIN_FRAG_BLKS) is accepted ahead of the ACL"
1993        );
1994        assert!(
1995            stateless_verdict(
1996                &DenyAll,
1997                IpProto::UDP,
1998                src,
1999                dst,
2000                0,
2001                frag(MIN_FRAG_BLKS + 50, true)
2002            ),
2003            "a later fragment well past the floor (MF set) is also accepted"
2004        );
2005
2006        // A low-offset later fragment (could overlap a transport header) is dropped — RFC 1858.
2007        assert!(
2008            !stateless_verdict(
2009                &DenyAll,
2010                IpProto::TCP,
2011                src,
2012                dst,
2013                0,
2014                frag(MIN_FRAG_BLKS - 1, false)
2015            ),
2016            "a low-offset later fragment is dropped (RFC 1858)"
2017        );
2018        assert!(
2019            !stateless_verdict(&DenyAll, IpProto::TCP, src, dst, 0, frag(1, false)),
2020            "the smallest non-zero offset is dropped"
2021        );
2022
2023        // A first fragment (offset 0) defers to the normal ACL on its real port: deny-all drops a
2024        // TCP first fragment, exactly as it drops a non-fragmented TCP packet.
2025        assert!(
2026            !stateless_verdict(&DenyAll, IpProto::TCP, src, dst, 443, frag(0, true)),
2027            "a first fragment defers to the ACL (deny-all -> dropped) on its parsed port"
2028        );
2029
2030        // A fragmented TSMP first fragment (offset 0, MF set) is dropped — Go disallows it — even
2031        // though a non-fragmented TSMP bypasses the ACL.
2032        assert!(
2033            !stateless_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, true)),
2034            "a fragmented TSMP first fragment is dropped (Go parity)"
2035        );
2036        assert!(
2037            stateless_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, false)),
2038            "a non-fragmented TSMP (offset 0, MF clear) still bypasses the ACL"
2039        );
2040
2041        // A *later* TSMP fragment (offset >= MIN_FRAG_BLKS) is accepted via the offset-based
2042        // fragment pass-through, NOT dropped by the fragmented-TSMP rule — that rule is offset-0
2043        // only (a first fragment with MF). This proves the later-fragment branch is proto-independent
2044        // and wins over the TSMP-specific logic (Go maps any offset>=minFragBlks to ipproto.Fragment
2045        // regardless of the L4 proto byte), locking the branch ordering against regression.
2046        assert!(
2047            stateless_verdict(
2048                &DenyAll,
2049                IpProto::TSMP,
2050                src,
2051                dst,
2052                0,
2053                frag(MIN_FRAG_BLKS, true)
2054            ),
2055            "a later TSMP fragment is accepted via the fragment path (proto-independent)"
2056        );
2057    }
2058
2059    /// An ACL that admits everything, the shape a permissive "allow the whole tailnet" policy has.
2060    /// Under it, a DROP can only have come from a rule the filter applies *ahead* of the ACL — which
2061    /// is exactly what makes it the right control for the fragment classification's negative cases.
2062    struct AllowAll;
2063    impl ts_packetfilter::Filter for AllowAll {
2064        fn match_for(
2065            &self,
2066            _info: &ts_packetfilter::PacketInfo,
2067            _caps: ts_packetfilter::filter::CapIter,
2068        ) -> Option<&str> {
2069            Some("allow-all")
2070        }
2071    }
2072
2073    /// An ACL that admits exactly one destination port. An admitted packet therefore proves the
2074    /// filter read that port off the wire — the point of Go `decode6` reaching past the Fragment
2075    /// extension header to the first fragment's real transport header.
2076    struct AllowPort(u16);
2077    impl ts_packetfilter::Filter for AllowPort {
2078        fn match_for(
2079            &self,
2080            info: &ts_packetfilter::PacketInfo,
2081            _caps: ts_packetfilter::filter::CapIter,
2082        ) -> Option<&str> {
2083            (info.port == self.0).then_some("allow-port")
2084        }
2085    }
2086
2087    /// Source/destination for the IPv6 fixtures: RFC 3849 documentation addresses, standing in for
2088    /// the real ones upstream's `udp6*FragmentBuffer` fixtures use. Neither is multicast or
2089    /// link-local, so `drop_before_rules` never fires and every verdict below is the fragment
2090    /// classification's own.
2091    const IPV6_FIXTURE_SRC: std::net::Ipv6Addr =
2092        std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 5);
2093    const IPV6_FIXTURE_DST: std::net::Ipv6Addr =
2094        std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
2095
2096    /// The IPv6 packet a source-fragmenting host puts on the wire, in the shape of upstream's
2097    /// `udp6FirstFragmentBuffer` / `udp6NonFirstFragmentBuffer` fixtures (Go
2098    /// `net/packet/packet_test.go`): a 40-byte base header whose Next Header is the Fragment
2099    /// extension header (44), the 8-byte Fragment header itself, then `rest` — the real
2100    /// sub-protocol header on a first fragment, or continued payload on a later one.
2101    fn ipv6_fragment_packet(
2102        next_header: u8,
2103        offset_blocks: u16,
2104        more_fragments: bool,
2105        rest: &[u8],
2106    ) -> Vec<u8> {
2107        let mut buf = vec![0u8; IP6_HEADER_LEN + IP6_FRAG_HEADER_LEN + rest.len()];
2108        buf[0] = 0x60; // version 6, traffic class/flow label 0
2109        let payload_len = u16::try_from(IP6_FRAG_HEADER_LEN + rest.len()).unwrap();
2110        buf[4..6].copy_from_slice(&payload_len.to_be_bytes());
2111        buf[6] = IP6_FRAG_HEADER;
2112        buf[7] = 64; // hop limit
2113        buf[8..24].copy_from_slice(&IPV6_FIXTURE_SRC.octets());
2114        buf[24..40].copy_from_slice(&IPV6_FIXTURE_DST.octets());
2115        // Fragment extension header: Next Header, Reserved, offset<<3 | MF, Identification.
2116        buf[40] = next_header;
2117        let offset_field = (offset_blocks << 3) | u16::from(more_fragments);
2118        buf[42..44].copy_from_slice(&offset_field.to_be_bytes());
2119        buf[44..48].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
2120        buf[48..].copy_from_slice(rest);
2121        buf
2122    }
2123
2124    /// A plain, unfragmented IPv6 packet: the same 40-byte base header the fragment fixtures use,
2125    /// with `payload` sitting directly behind it as the protocol `next_header` names.
2126    fn ipv6_packet(next_header: u8, payload: &[u8]) -> Vec<u8> {
2127        let mut buf = vec![0u8; IP6_HEADER_LEN + payload.len()];
2128        buf[0] = 0x60; // version 6, traffic class/flow label 0
2129        buf[4..6].copy_from_slice(&u16::try_from(payload.len()).unwrap().to_be_bytes());
2130        buf[6] = next_header;
2131        buf[7] = 64; // hop limit
2132        buf[8..24].copy_from_slice(&IPV6_FIXTURE_SRC.octets());
2133        buf[24..40].copy_from_slice(&IPV6_FIXTURE_DST.octets());
2134        buf[IP6_HEADER_LEN..].copy_from_slice(payload);
2135        buf
2136    }
2137
2138    /// A plain, unfragmented IPv6/UDP packet: [`ipv6_packet`] with UDP as its immediate Next
2139    /// Header. The control for the chained-extension-header fixtures below.
2140    fn ipv6_udp_packet(udp: &[u8]) -> Vec<u8> {
2141        let mut buf = ipv6_packet(17, udp);
2142        // Unlike a fragment fixture, this datagram is actually parsed as UDP, so its Length field
2143        // has to agree with the bytes present or etherparse rejects the packet outright.
2144        let udp_len = u16::try_from(udp.len()).unwrap();
2145        buf[IP6_HEADER_LEN + 4..IP6_HEADER_LEN + 6].copy_from_slice(&udp_len.to_be_bytes());
2146        buf
2147    }
2148
2149    /// Push one 8-byte extension header of protocol `ext_proto` in front of `inner`'s payload, so
2150    /// whatever `inner`'s base header pointed at directly is now reached through a *chain*. The
2151    /// generic Next-Header / Hdr-Ext-Len-0 / six-bytes-of-body shape is the on-the-wire layout of
2152    /// Hop-by-Hop Options (0), Routing (43) and Destination Options (60) alike.
2153    ///
2154    /// Those six body bytes are chosen so the header is well formed under *every* one of those
2155    /// three readings, not merely one etherparse happens not to look at:
2156    ///
2157    /// - as Options (0 / 60) they are a TLV stream — `1, 0` is a zero-length PadN, and the four
2158    ///   trailing zeros are four Pad1s, filling the 8-byte header exactly;
2159    /// - as Routing (43) they are Routing Type 1, **Segments Left 0**, and four bytes of
2160    ///   type-specific data. Segments Left must stay 0: `Hdr Ext Len` is 0, so there is no room
2161    ///   for a single 16-byte segment, and RFC 8200 §4.4 has a receiver that meets a non-zero
2162    ///   Segments Left on an unrecognized Routing Type discard the packet and answer ICMP
2163    ///   Parameter Problem. etherparse walks a Routing header as a raw ext header and never reads
2164    ///   the field, so a non-zero value parses here today — but a fixture that only survives
2165    ///   because the parser is lenient is one parser release away from turning the negative
2166    ///   assertions below into vacuous passes.
2167    fn ipv6_with_prepended_ext_header(ext_proto: u8, inner: &[u8]) -> Vec<u8> {
2168        let mut buf = Vec::with_capacity(inner.len() + 8);
2169        buf.extend_from_slice(&inner[..IP6_HEADER_LEN]);
2170        // The header we are displacing becomes the extension header's Next Header.
2171        let displaced = buf[6];
2172        buf[6] = ext_proto;
2173        let payload_len = u16::try_from(inner.len() - IP6_HEADER_LEN + 8).unwrap();
2174        buf[4..6].copy_from_slice(&payload_len.to_be_bytes());
2175        buf.extend_from_slice(&[displaced, 0, 1, 0, 0, 0, 0, 0]);
2176        buf.extend_from_slice(&inner[IP6_HEADER_LEN..]);
2177        buf
2178    }
2179
2180    /// An 8-byte UDP header carrying `dst_port`, as a first fragment's `rest`.
2181    fn udp_header(dst_port: u16) -> Vec<u8> {
2182        let mut hdr = vec![0u8; 8];
2183        hdr[0..2].copy_from_slice(&54276u16.to_be_bytes());
2184        hdr[2..4].copy_from_slice(&dst_port.to_be_bytes());
2185        hdr[4..6].copy_from_slice(&16u16.to_be_bytes());
2186        hdr
2187    }
2188
2189    /// The IPv6 Fragment extension-header classification, mirroring Go
2190    /// `net/packet.Parsed.decode6Fragment` plus the sub-protocol switch `decode6` runs when it
2191    /// reports `continueDecode` (upstream `4c4ec3d46`, clarified by `26b2ed0a6`). Cases are
2192    /// upstream's own `TestDecode` fixtures: `ipv6_frag_first`, `ipv6_frag_nonfirst`,
2193    /// `ipv6_frag_short_first` and `ipv6_frag_small_offset`.
2194    #[test]
2195    fn ipv6_fragment_classification_matches_go_decode6() {
2196        // `ipv6_frag_first`: offset 0 with MF set, and a whole UDP header behind the fragment
2197        // header — Go steps over the 8 bytes and reads the ports, so the ACL matches this datagram
2198        // on the same rule it would match unfragmented.
2199        assert_eq!(
2200            decode6_fragment(&ipv6_fragment_packet(17, 0, true, &udp_header(443))),
2201            Ipv6Fragment::First {
2202                proto: IpProto::UDP,
2203                // `udp_header` writes 54276 as its source port; Go reads `sub[0:2]` for it, and the
2204                // reverse-flow cache is the only thing that consults it.
2205                src_port: 54276,
2206                dst_port: 443,
2207                // UDP is one of Go's port-ful arms and nothing else is read out of its header.
2208                l4: ts_packetfilter::L4Header::Unknown,
2209            },
2210            "a first fragment is decoded past the Fragment header, ports and all"
2211        );
2212
2213        // `ipv6_frag_nonfirst`: a later fragment at offset 185 blocks has no transport header at
2214        // all, so Go marks it `ipproto.Fragment` for `pre()` to pass through.
2215        assert_eq!(
2216            decode6_fragment(&ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
2217            Ipv6Fragment::Later,
2218            "a later fragment at a safe offset classifies as a pass-through fragment"
2219        );
2220        // The floor itself is safe; one block below it is not. `MIN_FRAG_BLKS` is the IPv4-sized
2221        // bound upstream deliberately reuses for IPv6 (Go `26b2ed0a6`).
2222        assert_eq!(
2223            decode6_fragment(&ipv6_fragment_packet(17, MIN_FRAG_BLKS, false, &[0x61; 8])),
2224            Ipv6Fragment::Later,
2225            "offset == MIN_FRAG_BLKS is the first accepted later fragment"
2226        );
2227
2228        // `ipv6_frag_small_offset`: a later fragment whose bytes could land on top of the transport
2229        // header the head fragment was matched on — RFC 1858. Go rejects it as `unknown`.
2230        assert_eq!(
2231            decode6_fragment(&ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
2232            Ipv6Fragment::Unknown,
2233            "a later fragment at offset 1 block is rejected (RFC 1858)"
2234        );
2235        assert_eq!(
2236            decode6_fragment(&ipv6_fragment_packet(
2237                17,
2238                MIN_FRAG_BLKS - 1,
2239                false,
2240                &[0x61; 8]
2241            )),
2242            Ipv6Fragment::Unknown,
2243            "one block below the floor is still rejected (RFC 1858)"
2244        );
2245
2246        // `ipv6_frag_short_first`: a first fragment truncated before its full transport header. Go
2247        // refuses to guess at the ports, because a follow-up fragment supplying the rest of that
2248        // header would otherwise carry the flow past a rule the filter never really matched.
2249        assert_eq!(
2250            decode6_fragment(&ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])),
2251            Ipv6Fragment::Unknown,
2252            "a first fragment with only half a UDP header is rejected"
2253        );
2254        assert_eq!(
2255            decode6_fragment(&ipv6_fragment_packet(6, 0, true, &[0u8; 19])),
2256            Ipv6Fragment::Unknown,
2257            "a first fragment one byte short of a TCP header is rejected"
2258        );
2259        // ...and the same header one byte longer is accepted, so the rejection is the bounds check
2260        // and not the protocol.
2261        let mut tcp = vec![0u8; 20];
2262        tcp[2..4].copy_from_slice(&443u16.to_be_bytes());
2263        assert_eq!(
2264            decode6_fragment(&ipv6_fragment_packet(6, 0, true, &tcp)),
2265            Ipv6Fragment::First {
2266                proto: IpProto::TCP,
2267                src_port: 0,
2268                dst_port: 443,
2269                // Go `q.TCPFlags = TCPFlag(sub[13])`, which is 0 in this all-zero header. A
2270                // fragment carrying real flags is exercised by
2271                // `ipv6_first_fragment_carries_its_tcp_flags_to_the_reply_carve_out`.
2272                l4: ts_packetfilter::L4Header::Tcp { flags: 0 },
2273            },
2274            "a complete TCP header in the first fragment is read normally"
2275        );
2276
2277        // A Fragment header truncated by the packet itself (Go's `len(b) < q.subofs+8` guard).
2278        let mut short = ipv6_fragment_packet(17, 0, true, &[]);
2279        short.truncate(IP6_HEADER_LEN + 4);
2280        short[4..6].copy_from_slice(&4u16.to_be_bytes());
2281        assert_eq!(
2282            decode6_fragment(&short),
2283            Ipv6Fragment::Unknown,
2284            "a truncated Fragment extension header is rejected"
2285        );
2286        // A packet cut off before its declared payload length (Go `len(b) < q.length`).
2287        let mut cut = ipv6_fragment_packet(17, 0, true, &udp_header(443));
2288        cut.truncate(cut.len() - 1);
2289        assert_eq!(
2290            decode6_fragment(&cut),
2291            Ipv6Fragment::Unknown,
2292            "a packet cut off before its declared IPv6 length is rejected"
2293        );
2294
2295        // Go's portless arms bounds-check but leave the port at 0, and the on-the-wire use of Go's
2296        // internal `ipproto.Fragment` sentinel (0xff) maps back to `unknown`.
2297        assert_eq!(
2298            decode6_fragment(&ipv6_fragment_packet(58, 0, true, &[0u8; 4])),
2299            Ipv6Fragment::First {
2300                proto: IpProto::ICMPV6,
2301                src_port: 0,
2302                dst_port: 0,
2303                // Go bounds-checks 4 bytes here but reads the type/code behind `len(q.b) >=
2304                // q.subofs+8`, so a 4-byte first fragment is no "response" upstream either.
2305                l4: ts_packetfilter::L4Header::Unknown,
2306            },
2307            "a first ICMPv6 fragment keeps port 0 and is matched IPs-only"
2308        );
2309        assert_eq!(
2310            decode6_fragment(&ipv6_fragment_packet(58, 0, true, &[0u8; 3])),
2311            Ipv6Fragment::Unknown,
2312            "a first ICMPv6 fragment shorter than the ICMPv6 header is rejected"
2313        );
2314        assert_eq!(
2315            decode6_fragment(&ipv6_fragment_packet(0xff, 0, true, &[0u8; 8])),
2316            Ipv6Fragment::Unknown,
2317            "Go's internal Fragment sentinel seen on the wire maps back to unknown"
2318        );
2319    }
2320
2321    /// The verdict Go's filter `pre()` reaches for each IPv6 fragment classification, asserted
2322    /// against an ACL that would otherwise decide the packet the other way — so each assertion can
2323    /// only be the fragment rule, never the ACL:
2324    ///
2325    /// - `Unknown` is DROPPED under an ALLOW-ALL ACL (Go `pre()`: `IPProto == Unknown → Drop`).
2326    ///   This is the security-relevant direction: an allow-all tailnet policy must not admit a
2327    ///   short-first or RFC-1858 low-offset fragment.
2328    /// - `Later` is ACCEPTED under a DENY-ALL ACL (Go `pre()`: `case ipproto.Fragment: Accept`).
2329    /// - `First` consults the ACL normally on the port read past the Fragment header.
2330    #[test]
2331    fn ipv6_fragment_verdict_matches_go_pre() {
2332        let src = std::net::IpAddr::V6(IPV6_FIXTURE_SRC);
2333        let dst = std::net::IpAddr::V6(IPV6_FIXTURE_DST);
2334        let v6 = |class| Some(Fragment::V6(class));
2335
2336        // The negative case, stated explicitly: allow-all cannot rescue an `unknown` fragment.
2337        assert!(
2338            !stateless_verdict(
2339                &AllowAll,
2340                IpProto::new(0),
2341                src,
2342                dst,
2343                0,
2344                v6(Ipv6Fragment::Unknown)
2345            ),
2346            "an unknown IPv6 fragment is dropped even under an allow-all ACL"
2347        );
2348        // The control: the same allow-all ACL admits an ordinary non-fragment packet, so the drop
2349        // above is the classification and not the harness.
2350        assert!(
2351            stateless_verdict(&AllowAll, IpProto::UDP, src, dst, 443, None),
2352            "the allow-all ACL does admit an ordinary packet"
2353        );
2354
2355        // A safe later fragment slides through ahead of the ACL, with nothing but port 0 to match.
2356        assert!(
2357            stateless_verdict(
2358                &DenyAll,
2359                IpProto::new(0),
2360                src,
2361                dst,
2362                0,
2363                v6(Ipv6Fragment::Later)
2364            ),
2365            "a later IPv6 fragment is accepted ahead of a deny-all ACL"
2366        );
2367
2368        // A first fragment is an ordinary packet again: admitted on the port the ACL allows,
2369        // dropped on one it does not.
2370        let first = |dst_port| {
2371            v6(Ipv6Fragment::First {
2372                proto: IpProto::UDP,
2373                src_port: 0,
2374                dst_port,
2375                l4: ts_packetfilter::L4Header::Unknown,
2376            })
2377        };
2378        assert!(
2379            stateless_verdict(&AllowPort(443), IpProto::UDP, src, dst, 443, first(443)),
2380            "a first IPv6 fragment is matched on the port behind the Fragment header"
2381        );
2382        assert!(
2383            !stateless_verdict(&AllowPort(443), IpProto::UDP, src, dst, 444, first(444)),
2384            "a first IPv6 fragment on a disallowed port is dropped by the ACL"
2385        );
2386        // Control: the same ACL decides an unfragmented packet the same way, so the two results
2387        // above are the ACL being consulted on a real port and not a fragment-specific shortcut.
2388        assert!(
2389            stateless_verdict(&AllowPort(443), IpProto::UDP, src, dst, 443, None),
2390            "control: the port-scoped ACL admits an unfragmented packet to 443"
2391        );
2392        assert!(
2393            !stateless_verdict(&AllowPort(443), IpProto::UDP, src, dst, 0, None),
2394            "control: port 0 - what a v6 fragment used to read as - is not admitted"
2395        );
2396
2397        // `pre()`'s multicast/link-local drops still outrank the fragment pass-through, exactly as
2398        // Go runs them before `case ipproto.Fragment`.
2399        assert!(
2400            !stateless_verdict(
2401                &AllowAll,
2402                IpProto::new(0),
2403                src,
2404                "ff02::1".parse().unwrap(),
2405                0,
2406                v6(Ipv6Fragment::Later)
2407            ),
2408            "a later fragment to a multicast dst is still dropped by pre()"
2409        );
2410        assert!(
2411            !stateless_verdict(
2412                &AllowAll,
2413                IpProto::new(0),
2414                src,
2415                "fe80::1".parse().unwrap(),
2416                0,
2417                v6(Ipv6Fragment::Later)
2418            ),
2419            "a later fragment to a link-local dst is still dropped by pre()"
2420        );
2421    }
2422
2423    /// The whole inbound path on real IPv6 bytes — parse, classify, verdict — which is the shape
2424    /// the bypass had: before the Fragment extension header was classified, every source-fragmented
2425    /// IPv6 datagram reached the ACL with no sub-protocol and port 0, so an allow-all rule admitted
2426    /// the RFC 1858 fragments upstream drops and a port-scoped rule blackholed the later fragments
2427    /// upstream passes through.
2428    #[test]
2429    fn ipv6_fragments_are_filtered_end_to_end() {
2430        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
2431            let mut packets = vec![PacketMut::from(packet)];
2432            let mut harvest = InboundHarvest::default();
2433            filter_inbound_from_peer(
2434                filter,
2435                &mut flowtrack::FlowCache::default(),
2436                PeerId(3),
2437                &mut packets,
2438                RejectConfig::default(),
2439                &mut harvest,
2440            );
2441            assert!(
2442                harvest.learned_disco_keys.is_empty(),
2443                "no TSMP advertisement in these fixtures"
2444            );
2445            !packets.is_empty()
2446        };
2447
2448        // Under an ALLOW-ALL ACL — the permissive policy the bypass needs — the RFC 1858 fragment
2449        // must still be dropped, while the legitimate later fragment must still be delivered.
2450        assert!(
2451            !keep(&AllowAll, ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
2452            "a low-offset later IPv6 fragment is dropped even by an allow-all ACL (RFC 1858)"
2453        );
2454        assert!(
2455            !keep(
2456                &AllowAll,
2457                ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])
2458            ),
2459            "a first IPv6 fragment too short to hold its UDP header is dropped by an allow-all ACL"
2460        );
2461        assert!(
2462            keep(&AllowAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
2463            "a legitimate later IPv6 fragment is delivered"
2464        );
2465
2466        // ...and the later fragment is delivered even under a DENY-ALL ACL, which is the Go
2467        // `pre()` pass-through and not the ACL agreeing.
2468        assert!(
2469            keep(&DenyAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
2470            "a legitimate later IPv6 fragment slides through a deny-all ACL (Go pre())"
2471        );
2472        assert!(
2473            !keep(&DenyAll, ipv6_fragment_packet(17, 1, false, &[0x61; 8])),
2474            "a low-offset later IPv6 fragment is dropped under a deny-all ACL too"
2475        );
2476
2477        // A first fragment is matched on the port that lives behind the Fragment extension header,
2478        // which is the whole point of stepping over it: 443 is admitted, 444 is not, under the same
2479        // port-scoped ACL. Before the port was read past the header both read as port 0 and both
2480        // were dropped.
2481        assert!(
2482            keep(
2483                &AllowPort(443),
2484                ipv6_fragment_packet(17, 0, true, &udp_header(443))
2485            ),
2486            "a first IPv6 fragment to an allowed port is delivered"
2487        );
2488        assert!(
2489            !keep(
2490                &AllowPort(443),
2491                ipv6_fragment_packet(17, 0, true, &udp_header(444))
2492            ),
2493            "a first IPv6 fragment to a disallowed port is dropped"
2494        );
2495
2496        // Scoping (Go `26b2ed0a6`): the Fragment header is parsed here ONLY as the base header's
2497        // immediate Next Header. What happens to one reached through a chained extension header —
2498        // it must fail closed, not fall through to the ACL — is
2499        // `chained_extension_header_cannot_bypass_the_ipv6_fragment_rules`.
2500    }
2501
2502    /// An allow-all ACL that also records whether it was consulted at all.
2503    ///
2504    /// [`AllowAll`] alone can show that a packet was dropped; it cannot show *where*. Under an ACL
2505    /// that admits everything, "dropped AND never consulted" is the signature of a `pre()` drop and
2506    /// of nothing else — which is the guarantee the fragment classification exists to keep, so it
2507    /// is worth asserting directly rather than inferring from the verdict.
2508    #[derive(Default)]
2509    struct RecordingAllowAll(std::sync::atomic::AtomicBool);
2510
2511    impl RecordingAllowAll {
2512        /// Whether the ACL was asked about any packet since this filter was made.
2513        fn consulted(&self) -> bool {
2514            self.0.load(std::sync::atomic::Ordering::Relaxed)
2515        }
2516    }
2517
2518    impl ts_packetfilter::Filter for RecordingAllowAll {
2519        fn match_for(
2520            &self,
2521            _info: &ts_packetfilter::PacketInfo,
2522            _caps: ts_packetfilter::filter::CapIter,
2523        ) -> Option<&str> {
2524            self.0.store(true, std::sync::atomic::Ordering::Relaxed);
2525            Some("allow-all")
2526        }
2527    }
2528
2529    /// A *first* IPv6 fragment whose Fragment header's Next Header is 0 is dropped ahead of the
2530    /// rules, never matched by them.
2531    ///
2532    /// Go `net/packet.decode6Fragment` copies that byte into `q.IPProto` (`q.IPProto = nextHdr`)
2533    /// and reports `continueDecode`, so the packet goes back through `decode6`'s sub-protocol
2534    /// switch — which has no case for 0. `ipproto.Unknown` *is* 0, so `q.IPProto` is left at
2535    /// Unknown and filter `pre()`'s `if q.IPProto == ipproto.Unknown { return Drop }` fires before
2536    /// any rule is consulted. (Protocol 0 on the wire is Hop-by-Hop Options; an IPv6 packet whose
2537    /// *base* header declares it is refused by that same arm, and always has been.)
2538    ///
2539    /// This tree reaches the same drop by the same route: `decode6_first_fragment`'s catch-all arm
2540    /// keeps the number, exactly as Go's absent switch case does, and the drop comes from
2541    /// `inbound_filter_verdict`'s shared [`IPPROTO_UNKNOWN`] arm — which a first fragment falls
2542    /// through to for the same reason an unfragmented packet does. Nothing about that is specific
2543    /// to fragments, which is why there is no fragment-specific arm for it; this test is what pins
2544    /// the fall-through, at each of the three levels the packet passes through.
2545    #[test]
2546    fn first_ipv6_fragment_with_unknown_next_header_is_dropped_before_the_acl() {
2547        // 1. Classification. Go's `q.IPProto = nextHdr` on a first fragment, verbatim: the 0 is
2548        //    carried, not translated. `dst_port` is 0 because Go reads a port in the TCP/UDP/SCTP
2549        //    arms only, and protocol 0 is in none of them.
2550        assert_eq!(
2551            decode6_fragment(&ipv6_fragment_packet(0, 0, true, &udp_header(443))),
2552            Ipv6Fragment::First {
2553                proto: IPPROTO_UNKNOWN,
2554                src_port: 0,
2555                dst_port: 0,
2556                l4: ts_packetfilter::L4Header::Unknown,
2557            },
2558            "a first fragment carries its Fragment header's Next Header, 0 included"
2559        );
2560
2561        // 2. Verdict. The allow-all ACL is the control that makes this a pre-rule drop and not a
2562        //    rule saying no.
2563        let src = std::net::IpAddr::V6(IPV6_FIXTURE_SRC);
2564        let dst = std::net::IpAddr::V6(IPV6_FIXTURE_DST);
2565        assert!(
2566            !stateless_verdict(
2567                &AllowAll,
2568                IPPROTO_UNKNOWN,
2569                src,
2570                dst,
2571                0,
2572                Some(Fragment::V6(Ipv6Fragment::First {
2573                    proto: IPPROTO_UNKNOWN,
2574                    src_port: 0,
2575                    dst_port: 0,
2576                    l4: ts_packetfilter::L4Header::Unknown,
2577                })),
2578            ),
2579            "a first IPv6 fragment declaring protocol 0 is dropped under an allow-all ACL"
2580        );
2581
2582        // 3. The whole inbound path on real bytes, and the part the ACL never sees. The two
2583        //    fixtures differ in exactly one byte — the Fragment header's Next Header — so the
2584        //    control proves the drop is the protocol number and not the packet shape: the same
2585        //    fragment naming UDP is parsed, matched and delivered.
2586        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
2587            let mut packets = vec![PacketMut::from(packet)];
2588            let mut harvest = InboundHarvest::default();
2589            filter_inbound_from_peer(
2590                filter,
2591                &mut flowtrack::FlowCache::default(),
2592                PeerId(5),
2593                &mut packets,
2594                RejectConfig::default(),
2595                &mut harvest,
2596            );
2597            assert!(
2598                harvest.learned_disco_keys.is_empty(),
2599                "no TSMP advertisement in these fixtures"
2600            );
2601            !packets.is_empty()
2602        };
2603
2604        let acl = RecordingAllowAll::default();
2605        assert!(
2606            !keep(&acl, ipv6_fragment_packet(0, 0, true, &udp_header(443))),
2607            "a crafted first IPv6 fragment naming protocol 0 is dropped by an allow-all ACL"
2608        );
2609        assert!(
2610            !acl.consulted(),
2611            "and it is dropped ahead of the rules: the ACL is never asked about it"
2612        );
2613
2614        let control = RecordingAllowAll::default();
2615        assert!(
2616            keep(
2617                &control,
2618                ipv6_fragment_packet(17, 0, true, &udp_header(443))
2619            ),
2620            "control: the same fragment naming UDP is delivered"
2621        );
2622        assert!(
2623            control.consulted(),
2624            "control: and it got there by being matched against the rules"
2625        );
2626    }
2627
2628    /// Prepending an extension header must not defeat the fragment rules.
2629    ///
2630    /// [`decode6_fragment`] is scoped exactly as Go scopes it: the Fragment header is parsed only
2631    /// as the base header's immediate Next Header. Go can afford that narrow scope because
2632    /// everything it does not parse *keeps the base header's Next Header* as `q.IPProto`, so a
2633    /// chained fragment is filtered as the extension header it leads with and its fragment offset
2634    /// is never read at all. This tree does classify the chain
2635    /// ([`fragment_header_is_chained`]), and the only classification that cannot be an invention
2636    /// in the permissive direction is [`Ipv6Fragment::Unknown`] — a drop. Without it, eight bytes
2637    /// of Hop-by-Hop Options were enough to walk every RFC 1858 fragment straight past the rules
2638    /// the rest of this file exists to enforce.
2639    ///
2640    /// Every assertion is against an ALLOW-ALL ACL, so a drop can only be the fragment rule and
2641    /// never the ACL — and each extension type carries its own control that proves it: the same
2642    /// chain shape with no Fragment header in it is still walked to its UDP header by the parser.
2643    /// That control is per-type rather than once at the end because `keep` cannot tell a
2644    /// fragment-rule drop from a parser rejection, so a fixture malformed for only one of the
2645    /// three protocols would otherwise turn that protocol's four drops into vacuous passes with
2646    /// the suite still green.
2647    #[test]
2648    fn chained_extension_header_cannot_bypass_the_ipv6_fragment_rules() {
2649        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
2650            let mut packets = vec![PacketMut::from(packet)];
2651            let mut harvest = InboundHarvest::default();
2652            filter_inbound_from_peer(
2653                filter,
2654                &mut flowtrack::FlowCache::default(),
2655                PeerId(4),
2656                &mut packets,
2657                RejectConfig::default(),
2658                &mut harvest,
2659            );
2660            assert!(
2661                harvest.learned_disco_keys.is_empty(),
2662                "no TSMP advertisement in these fixtures"
2663            );
2664            !packets.is_empty()
2665        };
2666
2667        // Hop-by-Hop Options (0), Routing (43) and Destination Options (60): the fragment rules
2668        // must not depend on which header the sender chose to hide behind.
2669        for ext in [0u8, 43, 60] {
2670            // The RFC 1858 evasion itself: a later fragment whose bytes can land on top of the
2671            // transport header the head fragment was matched on.
2672            assert!(
2673                !keep(
2674                    &AllowAll,
2675                    ipv6_with_prepended_ext_header(
2676                        ext,
2677                        &ipv6_fragment_packet(17, 1, false, &[0x61; 8])
2678                    )
2679                ),
2680                "a low-offset later fragment behind extension header {ext} is dropped (RFC 1858)"
2681            );
2682            // A first fragment truncated before its own transport header, which a follow-up
2683            // fragment can then complete.
2684            assert!(
2685                !keep(
2686                    &AllowAll,
2687                    ipv6_with_prepended_ext_header(
2688                        ext,
2689                        &ipv6_fragment_packet(17, 0, true, &udp_header(443)[..4])
2690                    )
2691                ),
2692                "a short first fragment behind extension header {ext} is dropped"
2693            );
2694            // A *well-formed* chained fragment is dropped too — Go drops this whole class, so
2695            // failing closed here can never admit something upstream refuses.
2696            assert!(
2697                !keep(
2698                    &AllowAll,
2699                    ipv6_with_prepended_ext_header(
2700                        ext,
2701                        &ipv6_fragment_packet(17, 185, false, &[0x61; 8])
2702                    )
2703                ),
2704                "a chained later fragment behind extension header {ext} gets no pass-through"
2705            );
2706            assert!(
2707                !keep(
2708                    &AllowAll,
2709                    ipv6_with_prepended_ext_header(
2710                        ext,
2711                        &ipv6_fragment_packet(17, 0, true, &udp_header(443))
2712                    )
2713                ),
2714                "a chained first fragment behind extension header {ext} is dropped"
2715            );
2716
2717            // Control for THIS extension type. Every assertion above is a `!keep`, and `keep`
2718            // reports a packet the parser rejected exactly as it reports a packet the fragment
2719            // rule dropped — so on its own the block above would also pass if this builder simply
2720            // produced eight bytes etherparse refuses to walk. It does not: the same chain shape
2721            // with no Fragment header behind it is walked all the way to its UDP header. The drops
2722            // above are therefore this file refusing a packet it could perfectly well have read,
2723            // which is the whole claim.
2724            //
2725            // The control is a parser assertion and not a `keep`, because what the *filter* does
2726            // with a chained non-fragment is no longer "deliver it on the port behind the chain" —
2727            // it is Go's base-Next-Header disposition, which
2728            // `ipv6_extension_header_chain_is_matched_on_the_base_next_header` covers in full.
2729            let plain = ipv6_with_prepended_ext_header(ext, &ipv6_udp_packet(&udp_header(443)));
2730            let parsed = etherparse::SlicedPacket::from_ip(&plain)
2731                .unwrap_or_else(|e| panic!("extension header {ext} fixture must parse: {e:?}"));
2732            assert!(
2733                matches!(parsed.transport, Some(etherparse::TransportSlice::Udp(_))),
2734                "extension header {ext} fixture must chain to a UDP header the parser can reach"
2735            );
2736        }
2737
2738        // Contrast: the very same later fragment, reached as the base header's immediate Next
2739        // Header, is still delivered. Only the 8 prepended bytes separate this from the third
2740        // assertion above, so the drops really are the chain and not the fragment fixtures.
2741        assert!(
2742            keep(&AllowAll, ipv6_fragment_packet(17, 185, false, &[0x61; 8])),
2743            "an unchained later fragment is still delivered"
2744        );
2745    }
2746
2747    /// A filter that admits everything and records the [`ts_packetfilter::PacketInfo`] it was asked
2748    /// about, so a test can assert on the protocol and port the dataplane actually derived — and on
2749    /// a packet never reaching the ACL at all.
2750    #[derive(Default)]
2751    struct Recording(Mutex<Vec<ts_packetfilter::PacketInfo>>);
2752    impl ts_packetfilter::Filter for Recording {
2753        fn match_for(
2754            &self,
2755            info: &ts_packetfilter::PacketInfo,
2756            _caps: ts_packetfilter::filter::CapIter,
2757        ) -> Option<&str> {
2758            self.0.lock().unwrap().push(*info);
2759            Some("recording")
2760        }
2761    }
2762
2763    /// A real control-derived ACL — one rule built out of [`ts_packetfilter::Rule`] itself rather
2764    /// than a hand-written stub, so the assertions run through the same per-protocol port semantics
2765    /// as production: TCP/UDP/SCTP are port-matched, and any other protocol matches IPs-only and
2766    /// only under an all-ports rule (Go `matchProtoAndIPsOnlyIfAllPorts`).
2767    fn ipv6_acl(
2768        protos: &[i64],
2769        ports: std::ops::RangeInclusive<u16>,
2770    ) -> std::collections::BTreeMap<String, ts_packetfilter::Ruleset> {
2771        acl("2001:db8::/32", protos, ports)
2772    }
2773
2774    /// [`ipv6_acl`] for either family: one rule whose source and destination are both `net`.
2775    fn acl(
2776        net: &str,
2777        protos: &[i64],
2778        ports: std::ops::RangeInclusive<u16>,
2779    ) -> std::collections::BTreeMap<String, ts_packetfilter::Ruleset> {
2780        let net: ipnet::IpNet = net.parse().unwrap();
2781        std::collections::BTreeMap::from([(
2782            ts_packetfilter::DEFAULT_RULESET_NAME.to_string(),
2783            vec![ts_packetfilter::Rule {
2784                src: ts_packetfilter::SrcMatch {
2785                    pfxs: vec![net],
2786                    caps: Vec::new(),
2787                },
2788                protos: protos.iter().copied().map(IpProto::new).collect(),
2789                dst: vec![ts_packetfilter::DstMatch {
2790                    ports,
2791                    ips: vec![net],
2792                }],
2793            }],
2794        )])
2795    }
2796
2797    /// An IPv6 extension-header chain is filtered on the **base** header's Next Header, never on
2798    /// the transport the chain resolves to.
2799    ///
2800    /// Go `net/packet.decode6` assigns `q.IPProto = ipproto.Proto(b[6])` and — apart from a leading
2801    /// Fragment header — parses nothing further, so the number that reaches `wgengine/filter` is
2802    /// the extension header's own. Two consequences, both asserted here:
2803    ///
2804    /// * Hop-by-Hop Options **is** protocol 0, which is `ipproto.Unknown`, so filter `pre()` drops
2805    ///   the packet outright before any rule is consulted.
2806    /// * Routing (43) and Destination Options (60) reach `runIn6`'s `default` arm, where the only
2807    ///   way in is `matchProtoAndIPsOnlyIfAllPorts` — an all-ports rule naming protocol 43 or 60.
2808    ///   Such a packet is never matched against a TCP or UDP rule and its transport port is never
2809    ///   read.
2810    ///
2811    /// Reading the protocol out of etherparse's extension-header walk instead resolves straight
2812    /// through the chain to the real transport number and reads that transport's destination port,
2813    /// which is a strictly more permissive filter than upstream's: an ordinary `udp:443` ACL
2814    /// admitted a packet Go matches IPs-only, and would admit it for any protocol an attacker
2815    /// chose to bury the chain under.
2816    ///
2817    /// Ported from github.com/tailscale/tailscale `net/packet/packet.go` (`decode6`) and
2818    /// `wgengine/filter/filter.go` (`pre`, `runIn6`) at
2819    /// `9ea7cba44591e0cd840c6c94d23274dd222059bf`.
2820    #[test]
2821    fn ipv6_extension_header_chain_is_matched_on_the_base_next_header() {
2822        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
2823            let mut packets = vec![PacketMut::from(packet)];
2824            let mut harvest = InboundHarvest::default();
2825            filter_inbound_from_peer(
2826                filter,
2827                &mut flowtrack::FlowCache::default(),
2828                PeerId(5),
2829                &mut packets,
2830                RejectConfig::default(),
2831                &mut harvest,
2832            );
2833            assert!(
2834                harvest.learned_disco_keys.is_empty(),
2835                "no TSMP advertisement in these fixtures"
2836            );
2837            !packets.is_empty()
2838        };
2839        // What the ACL was asked about, or `None` if the packet never got that far.
2840        let seen = |packet: Vec<u8>| {
2841            let recording = Recording::default();
2842            keep(&recording, packet);
2843            let seen = recording.0.into_inner().unwrap();
2844            assert!(seen.len() <= 1, "one packet in, at most one ACL question");
2845            seen.into_iter().next()
2846        };
2847
2848        let unchained = ipv6_udp_packet(&udp_header(443));
2849
2850        // The baseline this is all measured against: with UDP as the base header's Next Header,
2851        // `decode6` takes its UDP arm, so the ACL sees protocol 17 on port 443.
2852        let info = seen(unchained.clone()).expect("an unchained UDP datagram reaches the ACL");
2853        assert_eq!(info.ip_proto, IpProto::UDP, "unchained: protocol is UDP");
2854        assert_eq!(
2855            info.port, 443,
2856            "unchained: the UDP destination port is read"
2857        );
2858
2859        // Routing (43) and Destination Options (60): the base header now says "extension header",
2860        // so that is the protocol the ACL is asked about — and no port is read, even though the
2861        // very same UDP header still sits 8 bytes further down the chain.
2862        for ext in [43u8, 60] {
2863            let chained = ipv6_with_prepended_ext_header(ext, &unchained);
2864            let info = seen(chained.clone()).unwrap_or_else(|| {
2865                panic!("a packet behind extension header {ext} reaches the ACL")
2866            });
2867            assert_eq!(
2868                info.ip_proto,
2869                IpProto::new(i64::from(ext)),
2870                "behind extension header {ext}: the ACL sees the base Next Header, not the transport"
2871            );
2872            assert_eq!(
2873                info.port, 0,
2874                "behind extension header {ext}: no port is read past the chain"
2875            );
2876
2877            // And what that means for a real ACL. An ordinary `udp:443` rule admits the unchained
2878            // datagram and refuses the chained one, because protocol 43/60 is not UDP...
2879            let udp443 = ipv6_acl(&[i64::from(IpProto::UDP)], 443..=443);
2880            assert!(
2881                keep(&udp443, unchained.clone()),
2882                "a udp:443 rule admits the unchained datagram"
2883            );
2884            assert!(
2885                !keep(&udp443, chained.clone()),
2886                "a udp:443 rule does not admit a packet behind extension header {ext}"
2887            );
2888
2889            // ...and the one rule that does admit it is Go's `matchProtoAndIPsOnlyIfAllPorts`:
2890            // the protocol named, IPs-only, all ports open. A narrower port range on the same
2891            // protocol opens nothing, because a portless protocol carries no port to match.
2892            assert!(
2893                keep(&ipv6_acl(&[i64::from(ext)], 0..=u16::MAX), chained.clone()),
2894                "an all-ports rule naming protocol {ext} admits it IPs-only"
2895            );
2896            assert!(
2897                !keep(&ipv6_acl(&[i64::from(ext)], 443..=443), chained),
2898                "a port-scoped rule naming protocol {ext} opens nothing (matchProtoAndIPsOnlyIfAllPorts)"
2899            );
2900        }
2901
2902        // Hop-by-Hop Options is protocol 0, and protocol 0 is `ipproto.Unknown`: Go's `pre()`
2903        // drops it before the ACL exists, so not even an allow-everything filter is consulted.
2904        let hop_by_hop = ipv6_with_prepended_ext_header(0, &unchained);
2905        assert!(
2906            seen(hop_by_hop.clone()).is_none(),
2907            "a hop-by-hop-led packet never reaches the ACL"
2908        );
2909        assert!(
2910            !keep(&AllowAll, hop_by_hop),
2911            "a hop-by-hop-led packet is dropped by an allow-all ACL (Go pre() unknown-proto drop)"
2912        );
2913
2914        // The same drop for Go's internal later-fragment sentinel used as a real Next Header:
2915        // `decode6`'s `case ipproto.Fragment: q.IPProto = unknown`.
2916        let mut sentinel = unchained.clone();
2917        sentinel[6] = 0xff;
2918        assert!(
2919            seen(sentinel.clone()).is_none(),
2920            "a packet whose base Next Header is the 0xff sentinel never reaches the ACL"
2921        );
2922        assert!(
2923            !keep(&AllowAll, sentinel),
2924            "...and is dropped by an allow-all ACL"
2925        );
2926    }
2927
2928    /// Source/destination for the IPv4 fixtures: ordinary tailnet unicast, so `drop_before_rules`
2929    /// never fires and every verdict below is the decode's own.
2930    const IPV4_FIXTURE_SRC: std::net::Ipv4Addr = std::net::Ipv4Addr::new(100, 64, 0, 9);
2931    const IPV4_FIXTURE_DST: std::net::Ipv4Addr = std::net::Ipv4Addr::new(100, 64, 0, 1);
2932    /// The tailnet range both IPv4 fixture addresses sit in, for [`acl`].
2933    const IPV4_FIXTURE_NET: &str = "100.64.0.0/10";
2934
2935    /// A minimal IPv4 packet: a 20-byte header carrying protocol `proto`, the fragment offset (in
2936    /// 8-byte blocks) and More-Fragments flag asked for, and `payload` behind it. The header
2937    /// checksum is left zero — nothing on this path verifies it, and neither does Go's decoder.
2938    fn v4_packet(proto: u8, offset_blocks: u16, more_fragments: bool, payload: &[u8]) -> Vec<u8> {
2939        let total_len = u16::try_from(IP4_HEADER_LEN + payload.len()).unwrap();
2940        let mut buf = vec![0u8; usize::from(total_len)];
2941        buf[0] = 0x45; // version 4, IHL 5 (no options)
2942        buf[2..4].copy_from_slice(&total_len.to_be_bytes());
2943        let frag_field = (offset_blocks & 0x1fff) | if more_fragments { 0x2000 } else { 0 };
2944        buf[6..8].copy_from_slice(&frag_field.to_be_bytes());
2945        buf[8] = 64; // TTL
2946        buf[9] = proto;
2947        buf[12..16].copy_from_slice(&IPV4_FIXTURE_SRC.octets());
2948        buf[16..20].copy_from_slice(&IPV4_FIXTURE_DST.octets());
2949        buf[IP4_HEADER_LEN..].copy_from_slice(payload);
2950        buf
2951    }
2952
2953    /// An SCTP common header carrying `dst_port`, truncated to `len` bytes so a test can hand the
2954    /// decoder the short header Go refuses.
2955    fn sctp_header(dst_port: u16, len: usize) -> Vec<u8> {
2956        let mut hdr = vec![0u8; SCTP_HEADER_LEN];
2957        hdr[0..2].copy_from_slice(&54276u16.to_be_bytes()); // source port
2958        hdr[2..4].copy_from_slice(&dst_port.to_be_bytes());
2959        hdr[4..8].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); // verification tag
2960        hdr.truncate(len);
2961        hdr
2962    }
2963
2964    /// An SCTP packet is filtered on its real destination port, on both families — Go
2965    /// `net/packet.decode4` and `decode6` each carry a `case ipproto.SCTP` arm that bounds-checks
2966    /// the 12-byte common header and reads `sub[2:4]`, exactly as their TCP and UDP arms do.
2967    ///
2968    /// etherparse parses no SCTP header of its own (its `TransportSlice` has ICMPv4, ICMPv6, TCP
2969    /// and UDP arms and nothing else), so leaving the port to `SlicedPacket::transport` reported
2970    /// port 0 for every SCTP packet on the wire. That is wrong in both directions: an `sctp:443`
2971    /// rule blackholed the SCTP traffic it was written to admit, and any rule whose port range
2972    /// happens to contain 0 admitted SCTP to *every* port. Both are asserted below through a real
2973    /// control-derived rule, not just through the recorded `PacketInfo`.
2974    ///
2975    /// The refusals come with it. A header too short to hold the ports is Go's
2976    /// `q.IPProto = unknown`, which filter `pre()` drops before any rule is consulted — never a
2977    /// fallback to port 0, which an all-ports rule would admit. And a *later* fragment is not an
2978    /// SCTP header at all: Go leaves its ports 0 and passes it through on its offset alone.
2979    ///
2980    /// Ported from github.com/tailscale/tailscale `net/packet/packet.go` (`decode4`, `decode6`) and
2981    /// `wgengine/filter/filter.go` (`pre`, `runIn4`, `runIn6`) at
2982    /// `9ea7cba44591e0cd840c6c94d23274dd222059bf`.
2983    #[test]
2984    fn sctp_destination_port_is_read_before_the_acl() {
2985        let keep = |filter: &(dyn ts_packetfilter::Filter + Send + Sync), packet: Vec<u8>| {
2986            let mut packets = vec![PacketMut::from(packet)];
2987            let mut harvest = InboundHarvest::default();
2988            filter_inbound_from_peer(
2989                filter,
2990                &mut flowtrack::FlowCache::default(),
2991                PeerId(11),
2992                &mut packets,
2993                RejectConfig::default(),
2994                &mut harvest,
2995            );
2996            assert!(
2997                harvest.learned_disco_keys.is_empty(),
2998                "no TSMP advertisement in these fixtures"
2999            );
3000            !packets.is_empty()
3001        };
3002        // What the ACL was asked about, or `None` if the packet never got that far.
3003        let seen = |packet: Vec<u8>| {
3004            let recording = Recording::default();
3005            keep(&recording, packet);
3006            let seen = recording.0.into_inner().unwrap();
3007            assert!(seen.len() <= 1, "one packet in, at most one ACL question");
3008            seen.into_iter().next()
3009        };
3010
3011        let sctp = i64::from(IpProto::SCTP);
3012        let whole = sctp_header(443, SCTP_HEADER_LEN);
3013        let v4 = v4_packet(132, 0, false, &whole);
3014        let v6 = ipv6_packet(132, &whole);
3015
3016        for (family, packet) in [("IPv4", &v4), ("IPv6", &v6)] {
3017            let info = seen(packet.clone())
3018                .unwrap_or_else(|| panic!("{family}: an SCTP packet reaches the ACL"));
3019            assert_eq!(
3020                info.ip_proto,
3021                IpProto::SCTP,
3022                "{family}: the protocol is SCTP"
3023            );
3024            assert_eq!(
3025                info.port, 443,
3026                "{family}: the SCTP destination port is read off the wire"
3027            );
3028        }
3029
3030        // And what that means for a real control-derived rule. An `sctp:443` rule admits the
3031        // packet; a rule whose range covers port 0 but not 443 does not — the ACL bypass a
3032        // hard-coded port 0 would have opened.
3033        assert!(
3034            keep(&acl(IPV4_FIXTURE_NET, &[sctp], 443..=443), v4.clone()),
3035            "IPv4: an sctp:443 rule admits an SCTP packet to port 443"
3036        );
3037        assert!(
3038            !keep(&acl(IPV4_FIXTURE_NET, &[sctp], 0..=442), v4.clone()),
3039            "IPv4: an sctp:0-442 rule does not admit an SCTP packet to port 443"
3040        );
3041        assert!(
3042            keep(&ipv6_acl(&[sctp], 443..=443), v6.clone()),
3043            "IPv6: an sctp:443 rule admits an SCTP packet to port 443"
3044        );
3045        assert!(
3046            !keep(&ipv6_acl(&[sctp], 0..=442), v6),
3047            "IPv6: an sctp:0-442 rule does not admit an SCTP packet to port 443"
3048        );
3049
3050        // A *first* fragment carries the whole common header, so Go reads its ports like an
3051        // unfragmented packet's (`decode4` only skips the transport header when `fragOfs != 0`).
3052        let info = seen(v4_packet(132, 0, true, &whole))
3053            .expect("IPv4: a first SCTP fragment reaches the ACL");
3054        assert_eq!(
3055            info.port, 443,
3056            "IPv4: a first fragment's SCTP port is read, as decode4 does"
3057        );
3058
3059        // A later fragment is continued payload, not a header: Go leaves its ports 0 and `pre()`
3060        // passes it through on its offset alone. Reading `sub[2:4]` here would invent a port, and
3061        // the short-header refusal would drop a fragment upstream delivers — so a deny-all ACL is
3062        // the control, proving the accept came from the fragment path and not from a rule.
3063        assert!(
3064            keep(
3065                &DenyAll,
3066                v4_packet(132, MIN_FRAG_BLKS, false, &[0x01, 0x02, 0x03, 0x04])
3067            ),
3068            "IPv4: a valid later SCTP fragment is passed through ahead of the ACL"
3069        );
3070
3071        // Go's short-header refusal: `q.IPProto = unknown`, dropped by `pre()` before the ACL
3072        // exists, so not even an allow-everything filter is consulted.
3073        let short = sctp_header(443, SCTP_HEADER_LEN - 1);
3074        for (family, packet) in [
3075            ("IPv4", v4_packet(132, 0, false, &short)),
3076            ("IPv6", ipv6_packet(132, &short)),
3077        ] {
3078            assert!(
3079                seen(packet.clone()).is_none(),
3080                "{family}: an SCTP header too short to hold its ports never reaches the ACL"
3081            );
3082            assert!(
3083                !keep(&AllowAll, packet),
3084                "{family}: ...and an allow-all ACL does not admit it"
3085            );
3086        }
3087    }
3088
3089    /// Build the IPv4 packet a Go peer puts on the wire for a TSMP message: a 20-byte IPv4
3090    /// header with proto 99 and `body` appended (Go `packet.Generate(IP4Header{...}, body)`,
3091    /// which is what `TSMPDiscoKeyAdvertisement.Marshal` calls). The header checksum is left
3092    /// zero — nothing on this path verifies it, and neither does Go's decoder.
3093    fn tsmp_packet4(src: [u8; 4], dst: [u8; 4], body: &[u8]) -> PacketMut {
3094        let mut buf = vec![0u8; 20 + body.len()];
3095        buf[20..].copy_from_slice(body);
3096        buf[0] = 0x45;
3097        let total_len = buf.len() as u16;
3098        buf[2..4].copy_from_slice(&total_len.to_be_bytes());
3099        buf[8] = 64;
3100        buf[9] = 99;
3101        buf[12..16].copy_from_slice(&src);
3102        buf[16..20].copy_from_slice(&dst);
3103        PacketMut::from(buf)
3104    }
3105
3106    /// A body a real Go peer sends: `'a'` then its 32-byte disco key.
3107    fn advertisement_body(key: [u8; 32]) -> Vec<u8> {
3108        let mut body = vec![ts_packet::tsmp::TSMP_TYPE_DISCO_ADVERTISEMENT];
3109        body.extend_from_slice(&key);
3110        body
3111    }
3112
3113    /// The receive side of the TSMP disco-key advertisement, at the point Go handles it: a
3114    /// well-formed advertisement is CONSUMED — the peer's key is learned and the packet is
3115    /// dropped rather than delivered to the local stack (Go `filter.DropSilently`) — while every
3116    /// other TSMP body is left alone and still admitted by the TSMP ACL bypass.
3117    ///
3118    /// The ACL here denies everything, so an admitted packet can only have come through the
3119    /// TSMP bypass, and a learned key can only have come from the advertisement path.
3120    #[test]
3121    fn tsmp_disco_key_advertisement_is_learned_and_dropped() {
3122        let peer = PeerId(7);
3123        let src = [100, 64, 0, 2];
3124        let dst = [100, 64, 0, 1];
3125        let key = [0xa5u8; 32];
3126
3127        let mut packets = vec![tsmp_packet4(src, dst, &advertisement_body(key))];
3128        let mut harvest = InboundHarvest::default();
3129        filter_inbound_from_peer(
3130            &DenyAll,
3131            &mut flowtrack::FlowCache::default(),
3132            peer,
3133            &mut packets,
3134            RejectConfig::default(),
3135            &mut harvest,
3136        );
3137
3138        assert!(
3139            packets.is_empty(),
3140            "a consumed advertisement must not be delivered to the local stack"
3141        );
3142        let learned = &harvest.learned_disco_keys;
3143        assert_eq!(learned.len(), 1, "the advertisement must be harvested");
3144        assert_eq!(
3145            learned[0].0, peer,
3146            "attributed to the sending wireguard peer"
3147        );
3148        assert_eq!(learned[0].1.key, key, "the advertised disco key is learned");
3149        assert_eq!(learned[0].1.src, std::net::IpAddr::from(src));
3150
3151        // A TSMP message that is NOT an advertisement stays in the batch (Go leaves the types it
3152        // does not consume to the filter, which accepts TSMP) and teaches us nothing.
3153        let mut ping = vec![ts_packet::tsmp::TSMP_TYPE_PING];
3154        ping.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
3155        let mut packets = vec![tsmp_packet4(src, dst, &ping)];
3156        let mut harvest = InboundHarvest::default();
3157        filter_inbound_from_peer(
3158            &DenyAll,
3159            &mut flowtrack::FlowCache::default(),
3160            peer,
3161            &mut packets,
3162            RejectConfig::default(),
3163            &mut harvest,
3164        );
3165        assert_eq!(packets.len(), 1, "a TSMP ping still bypasses the ACL");
3166        assert!(
3167            harvest.learned_disco_keys.is_empty(),
3168            "a ping advertises no disco key"
3169        );
3170    }
3171
3172    /// The negative case, at the dataplane boundary: a TSMP body that is *nearly* an
3173    /// advertisement must not be half-parsed into a learned key. None of these may put anything
3174    /// in the harvest — a truncated key that was zero-padded, or a zero key that was accepted,
3175    /// would be a wrong disco key bound to a real peer.
3176    #[test]
3177    fn malformed_tsmp_disco_key_advertisements_teach_nothing() {
3178        let peer = PeerId(7);
3179        let src = [100, 64, 0, 2];
3180        let dst = [100, 64, 0, 1];
3181
3182        // A truncated advertisement: the type byte and only 31 of 32 key bytes.
3183        let mut truncated = advertisement_body([0xa5u8; 32]);
3184        truncated.truncate(32);
3185
3186        for (name, body, still_delivered) in [
3187            ("truncated advertisement", truncated, true),
3188            (
3189                "unknown TSMP type byte",
3190                {
3191                    let mut b = advertisement_body([0xa5u8; 32]);
3192                    b[0] = b'Z';
3193                    b
3194                },
3195                true,
3196            ),
3197            // A well-formed advertisement of the zero key: Go parses it but publishes only
3198            // `if !discoKeyAdvert.Key.IsZero()`, so it teaches nothing — and it is still a TSMP
3199            // message we consumed, so it is still dropped.
3200            (
3201                "zero-key advertisement",
3202                advertisement_body([0u8; 32]),
3203                false,
3204            ),
3205        ] {
3206            let mut packets = vec![tsmp_packet4(src, dst, &body)];
3207            let mut harvest = InboundHarvest::default();
3208            filter_inbound_from_peer(
3209                &DenyAll,
3210                &mut flowtrack::FlowCache::default(),
3211                peer,
3212                &mut packets,
3213                RejectConfig::default(),
3214                &mut harvest,
3215            );
3216
3217            assert!(
3218                harvest.learned_disco_keys.is_empty(),
3219                "a {name} must not be half-parsed into a learned disco key"
3220            );
3221            assert_eq!(
3222                packets.len(),
3223                usize::from(still_delivered),
3224                "a {name} must {} be delivered",
3225                if still_delivered { "still" } else { "not" }
3226            );
3227        }
3228    }
3229
3230    /// Our own disco key, the one this node advertises. Asymmetric so a reversed or offset slice
3231    /// would be visible in the marshalled bytes.
3232    const SELF_DISCO_KEY: [u8; 32] = [
3233        0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
3234        0x00, 0x9c, 0x5f, 0x3a, 0x01, 0x7d, 0xe2, 0x44, 0xb8, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a,
3235        0x69, 0x78,
3236    ];
3237
3238    /// An advertisement state with one peer, a v4 and a v6 address of our own, and a real disco key.
3239    fn advertisement_state(peer: PeerId, target: AdvertisementTarget) -> DiscoAdvertisementState {
3240        DiscoAdvertisementState {
3241            disco_key: SELF_DISCO_KEY,
3242            self_addrs: vec![
3243                std::net::IpAddr::from([100, 64, 0, 1]),
3244                std::net::IpAddr::from([
3245                    0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
3246                ]),
3247            ],
3248            peers: HashMap::from([(peer, target)]),
3249        }
3250    }
3251
3252    /// What this node advertises, and to whom (Go `magicsock.Conn.PriorityMessageForPeer`): the
3253    /// happy path emits the exact bytes `TSMPDiscoKeyAdvertisement.Marshal` emits, and each of Go's
3254    /// refusals emits nothing at all.
3255    #[test]
3256    fn disco_advertisement_matches_priority_message_for_peer() {
3257        let peer = PeerId(3);
3258        let peer_v4 = std::net::IpAddr::from([100, 64, 0, 2]);
3259        let target = AdvertisementTarget {
3260            node_addr: peer_v4,
3261            wireguard_only: false,
3262        };
3263        let state = advertisement_state(peer, target);
3264
3265        // Happy path: a v4 peer gets a v4 advertisement sourced from our v4 address — the first
3266        // self address in the destination's family (Go `selfIPMatchingFamily`).
3267        let msg = state
3268            .advertisement_for(peer)
3269            .expect("a Tailscale peer with a matching-family address must be advertised to");
3270        let parsed = ts_packet::tsmp::DiscoKeyAdvertisement::parse(&msg)
3271            .expect("what we emit must parse as an advertisement");
3272        assert_eq!(parsed.key, SELF_DISCO_KEY, "we advertise OUR disco key");
3273        assert_eq!(parsed.src, std::net::IpAddr::from([100, 64, 0, 1]));
3274        assert_eq!(parsed.dst, peer_v4);
3275        assert_eq!(
3276            msg,
3277            ts_packet::tsmp::DiscoKeyAdvertisement {
3278                src: std::net::IpAddr::from([100, 64, 0, 1]),
3279                dst: peer_v4,
3280                key: SELF_DISCO_KEY,
3281            }
3282            .marshal()
3283            .unwrap(),
3284            "the emitted bytes are exactly what Marshal produces"
3285        );
3286
3287        // A v6 peer is sourced from our v6 address, not our v4 one.
3288        let peer_v6 = std::net::IpAddr::from([
3289            0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
3290        ]);
3291        let v6_state = advertisement_state(
3292            peer,
3293            AdvertisementTarget {
3294                node_addr: peer_v6,
3295                wireguard_only: false,
3296            },
3297        );
3298        let parsed = v6_state
3299            .advertisement_for(peer)
3300            .and_then(|m| ts_packet::tsmp::DiscoKeyAdvertisement::parse(&m))
3301            .expect("a v6 peer must be advertised to over v6");
3302        assert!(parsed.src.is_ipv6(), "source must match the peer's family");
3303        assert_eq!(parsed.dst, peer_v6);
3304
3305        // Refusal 1 (Go `disco.IsZero()`): no disco key of our own, nothing to advertise.
3306        let mut no_key = advertisement_state(peer, target);
3307        no_key.disco_key = [0u8; 32];
3308        assert!(
3309            no_key.advertisement_for(peer).is_none(),
3310            "the zero disco key must never be advertised"
3311        );
3312
3313        // Refusal 2 (Go `endpointForNodeKey` miss / `!self.Valid()`): a peer the netmap snapshot
3314        // does not cover, and a node with no addresses of its own.
3315        assert!(
3316            state.advertisement_for(PeerId(0xbad)).is_none(),
3317            "an unknown peer must not be advertised to"
3318        );
3319        let mut no_self = advertisement_state(peer, target);
3320        no_self.self_addrs.clear();
3321        assert!(
3322            no_self.advertisement_for(peer).is_none(),
3323            "a node with no tailnet address of its own has no source to advertise from"
3324        );
3325
3326        // Refusal 3 (Go `ep.isWireguardOnly`): "Do not send TSMP messages to peers that only speaks
3327        // wireguard" — such a peer would hand it to its host stack as an unknown protocol.
3328        let wg_only = advertisement_state(
3329            peer,
3330            AdvertisementTarget {
3331                node_addr: peer_v4,
3332                wireguard_only: true,
3333            },
3334        );
3335        assert!(
3336            wg_only.advertisement_for(peer).is_none(),
3337            "a WireGuard-only peer must never be sent TSMP"
3338        );
3339
3340        // Refusal 4 (Go `selfIPMatchingFamily` returning the zero Addr): an IPv4-only node has no
3341        // source address for a packet to a peer's IPv6 address.
3342        let mut v4_only = advertisement_state(
3343            peer,
3344            AdvertisementTarget {
3345                node_addr: peer_v6,
3346                wireguard_only: false,
3347            },
3348        );
3349        v4_only.self_addrs = vec![std::net::IpAddr::from([100, 64, 0, 1])];
3350        assert!(
3351            v4_only.advertisement_for(peer).is_none(),
3352            "no self address in the peer's family means no advertisement"
3353        );
3354    }
3355
3356    /// End to end, over a real WireGuard handshake: when a session with a peer comes up, this
3357    /// node's dataplane emits its own TSMP disco-key advertisement to that peer — and the peer's
3358    /// dataplane learns the key from it and drops the packet.
3359    ///
3360    /// This is the send side (Go capability version 144) meeting the receive side already in this
3361    /// tree, so the assertion is not "some bytes went out" but "the far side learned exactly the
3362    /// disco key we hold". B is deliberately left with no advertisement state, which also pins the
3363    /// unconfigured case: it establishes the same session and sends nothing back.
3364    #[test]
3365    fn session_establishment_advertises_our_disco_key_to_the_peer() {
3366        let underlay: UnderlayTransportId = 0.into();
3367        let wg_peer = ts_tunnel::PeerId(1);
3368        let peer = PeerId(1);
3369        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
3370        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
3371
3372        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
3373        let (mut a, mut b) = (
3374            DataPlane::new(a_static.clone()),
3375            DataPlane::new(b_static.clone()),
3376        );
3377
3378        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
3379            dp.wireguard.upsert_peer(
3380                wg_peer,
3381                ts_tunnel::PeerConfig {
3382                    key,
3383                    psk: [0u8; 32].into(),
3384                    persistent_keepalive_interval: None,
3385                },
3386            );
3387            dp.ur_out.table.insert(peer, underlay);
3388        }
3389
3390        // Only A knows how to advertise: its own disco key, its own address, and B's address.
3391        a.disco_advertisement = Some(Arc::new(advertisement_state(
3392            peer,
3393            AdvertisementTarget {
3394                node_addr: b_addr,
3395                wireguard_only: false,
3396            },
3397        )));
3398
3399        // B attributes A's tailnet address to the WireGuard peer that carries it, as the runtime's
3400        // source filter does — without that, B drops the advertisement before parsing it.
3401        let mut src_filter = ts_bart::Table::default();
3402        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
3403        b.src_filter_in = Arc::new(src_filter);
3404
3405        // Drive the handshake. Only the initiation is kicked off directly (the dataplane starts one
3406        // from routed outbound traffic, which is not what this test is about); everything after it
3407        // goes through `process_inbound`, the path under test.
3408        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
3409            out.into_values().flatten().collect::<Vec<_>>()
3410        };
3411        let init = a
3412            .wireguard
3413            .send([(wg_peer, vec![PacketMut::from(&b"hello"[..])])])
3414            .to_peers
3415            .remove(&wg_peer)
3416            .expect("handshake initiation");
3417
3418        let resp = take(b.process_inbound(init).to_peers);
3419        assert!(!resp.is_empty(), "B must answer the handshake initiation");
3420
3421        // A completes the handshake. Its session is now current, so alongside the queued data it
3422        // emits the advertisement.
3423        let from_a = take(a.process_inbound(resp).to_peers);
3424        assert_eq!(
3425            from_a.len(),
3426            2,
3427            "A must emit the queued data AND its disco-key advertisement"
3428        );
3429
3430        // B learns A's disco key from it, and the advertisement itself is consumed rather than
3431        // delivered to B's local stack.
3432        let inbound = b.process_inbound(from_a);
3433        assert_eq!(
3434            inbound
3435                .learned_disco_keys
3436                .iter()
3437                .map(|(peer, advert)| (*peer, advert.key))
3438                .collect::<Vec<_>>(),
3439            vec![(peer, SELF_DISCO_KEY)],
3440            "B must learn exactly the disco key A holds, attributed to A's wireguard peer"
3441        );
3442        assert!(
3443            inbound.to_peers.is_empty(),
3444            "B has no advertisement state, so it advertises nothing back"
3445        );
3446    }
3447
3448    /// Order regression: the advertisement must LEAD the traffic the same establishment released,
3449    /// not trail it.
3450    ///
3451    /// wireguard-go hands a priority message straight to the peer's *outbound* queue
3452    /// (`SendPriorityMessage` → `queueOutboundIfRunning`) and runs it before the flush that
3453    /// follows at both call sites — `peer.SendPriorityMessage()` ahead of `peer.SendKeepalive()`
3454    /// on the initiator and ahead of `peer.SendStagedPackets()` on the responder
3455    /// (`device/receive.go`) — so the advertisement is the first thing on the wire once a keypair
3456    /// becomes current. In this tree the flush has already happened inside `Endpoint::recv` by the
3457    /// time the advertisement exists, so `process_inbound` has to splice it in front; appending it
3458    /// would put it behind up to `MAX_QUEUED_PER_PEER` packets of queued traffic.
3459    ///
3460    /// The order is read off B's *decrypted* stream — its capture tee, which sees every inbound
3461    /// packet before any filtering — so what is pinned is the order the peer actually observes,
3462    /// not the order of a local vector.
3463    #[test]
3464    fn the_advertisement_leads_the_traffic_released_by_the_same_establishment() {
3465        let underlay: UnderlayTransportId = 0.into();
3466        let wg_peer = ts_tunnel::PeerId(1);
3467        let peer = PeerId(1);
3468        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
3469        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
3470
3471        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
3472        let (mut a, mut b) = (
3473            DataPlane::new(a_static.clone()),
3474            DataPlane::new(b_static.clone()),
3475        );
3476
3477        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
3478            dp.wireguard.upsert_peer(
3479                wg_peer,
3480                ts_tunnel::PeerConfig {
3481                    key,
3482                    psk: [0u8; 32].into(),
3483                    persistent_keepalive_interval: None,
3484                },
3485            );
3486            dp.ur_out.table.insert(peer, underlay);
3487        }
3488
3489        a.disco_advertisement = Some(Arc::new(advertisement_state(
3490            peer,
3491            AdvertisementTarget {
3492                node_addr: b_addr,
3493                wireguard_only: false,
3494            },
3495        )));
3496
3497        let mut src_filter = ts_bart::Table::default();
3498        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
3499        b.src_filter_in = Arc::new(src_filter);
3500
3501        // Everything B decrypts, in arrival order, before any filtering runs.
3502        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
3503        let sink = recorded.clone();
3504        b.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
3505            sink.lock().unwrap().push((path, bytes.to_vec()));
3506        }));
3507
3508        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
3509            out.into_values().flatten().collect::<Vec<_>>()
3510        };
3511
3512        // Traffic for a peer with no session yet: it stages, and a handshake starts.
3513        const QUEUED: &[u8] = b"staged while the session was still coming up";
3514        let init = a
3515            .wireguard
3516            .send([(wg_peer, vec![PacketMut::from(QUEUED)])])
3517            .to_peers
3518            .remove(&wg_peer)
3519            .expect("handshake initiation");
3520        let resp = take(b.process_inbound(init).to_peers);
3521
3522        // A's keypair becomes current here, which both flushes the staged packet and produces the
3523        // advertisement — the batch whose order is under test.
3524        let from_a = take(a.process_inbound(resp).to_peers);
3525        assert_eq!(
3526            from_a.len(),
3527            2,
3528            "A must emit the queued data AND its disco-key advertisement"
3529        );
3530
3531        // Hand them to B in exactly the order A produced them.
3532        let learned = b.process_inbound(from_a).learned_disco_keys;
3533        assert_eq!(
3534            learned
3535                .iter()
3536                .map(|(peer, advert)| (*peer, advert.key))
3537                .collect::<Vec<_>>(),
3538            vec![(peer, SELF_DISCO_KEY)],
3539            "B must still learn A's disco key"
3540        );
3541
3542        let advertisement = ts_packet::tsmp::DiscoKeyAdvertisement {
3543            src: a_addr,
3544            dst: b_addr,
3545            key: SELF_DISCO_KEY,
3546        }
3547        .marshal()
3548        .expect("a v4 advertisement between two v4 addresses marshals");
3549
3550        let captured = recorded.lock().unwrap();
3551        let from_peer = captured
3552            .iter()
3553            .filter(|(path, _)| *path == CapturePath::FromPeer)
3554            .map(|(_, bytes)| bytes.as_slice())
3555            .collect::<Vec<_>>();
3556        assert_eq!(from_peer.len(), 2, "B must decrypt both of A's packets");
3557        // The send path zero-pads each payload up to a 16-byte boundary and the receiver delivers
3558        // it with that padding intact (see `session::PADDING_MULTIPLE`), so compare on the leading
3559        // bytes rather than for equality.
3560        assert!(
3561            from_peer[0].starts_with(&advertisement),
3562            "the advertisement must reach the peer FIRST, ahead of the traffic the same \
3563             establishment released"
3564        );
3565        assert!(
3566            from_peer[1].starts_with(QUEUED),
3567            "the queued traffic follows the advertisement"
3568        );
3569    }
3570
3571    /// Behavioral guard: an installed capture hook MUST be invoked with `CapturePath::FromLocal`
3572    /// and the exact packet bytes for every outbound packet. The tee sits at the top of
3573    /// `process_outbound`, before `or_out.route` consumes the packets, so it fires regardless of
3574    /// whether a wireguard peer exists (an empty router just drops the routed packets afterward).
3575    /// This is the only end-to-end guard that the dataplane capture tee actually fires; a refactor
3576    /// that drops the tee would leave every byte-layout test green.
3577    #[test]
3578    fn capture_hook_fires_on_outbound() {
3579        let mut dp = DataPlane::new(NodeKeyPair::new());
3580
3581        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
3582        let sink = recorded.clone();
3583        dp.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
3584            sink.lock().unwrap().push((path, bytes.to_vec()));
3585        }));
3586
3587        // The outbound tee passes `p.as_ref()` as-given; the bytes need not be a valid IP packet.
3588        let payload: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
3589        let packet = PacketMut::from(payload.clone());
3590
3591        drop(dp.process_outbound(vec![packet]));
3592
3593        let captured = recorded.lock().unwrap();
3594        assert_eq!(captured.len(), 1, "hook must fire exactly once per packet");
3595        assert_eq!(captured[0].0, CapturePath::FromLocal);
3596        assert_eq!(captured[0].1, payload);
3597    }
3598
3599    /// A minimal IPv4/UDP datagram from `src` to `dst`. The control for the outbound TSMP refusal:
3600    /// same source, same destination, same batch as the forged advertisement — only the protocol
3601    /// byte differs.
3602    fn v4_udp_packet(
3603        src: std::net::SocketAddr,
3604        dst: std::net::SocketAddr,
3605        payload: &[u8],
3606    ) -> Vec<u8> {
3607        let (std::net::IpAddr::V4(src_ip), std::net::IpAddr::V4(dst_ip)) = (src.ip(), dst.ip())
3608        else {
3609            panic!("v4_udp_packet needs two IPv4 addresses");
3610        };
3611        let total_len = u16::try_from(IP4_HEADER_LEN + 8 + payload.len()).unwrap();
3612        let mut buf = vec![0u8; usize::from(total_len)];
3613        buf[0] = 0x45; // version 4, IHL 5 (no options)
3614        buf[2..4].copy_from_slice(&total_len.to_be_bytes());
3615        buf[8] = 64; // TTL
3616        buf[9] = 17; // UDP
3617        buf[12..16].copy_from_slice(&src_ip.octets());
3618        buf[16..20].copy_from_slice(&dst_ip.octets());
3619        buf[20..22].copy_from_slice(&src.port().to_be_bytes());
3620        buf[22..24].copy_from_slice(&dst.port().to_be_bytes());
3621        let udp_len = u16::try_from(8 + payload.len()).unwrap();
3622        buf[24..26].copy_from_slice(&udp_len.to_be_bytes());
3623        // UDP checksum left 0 ("not computed"), which is legal for IPv4.
3624        buf[IP4_HEADER_LEN + 8..].copy_from_slice(payload);
3625        buf
3626    }
3627
3628    /// What `process_outbound` refuses, mirroring the `p.IPProto == ipproto.TSMP` arm of Go
3629    /// `tstun.filterPacketOutboundToWireGuard` — plus the three shapes this tree drops that Go's
3630    /// TSMP arm alone does not, because there is no outbound ACL behind it here to refuse them as
3631    /// `ipproto.Unknown`. See [`outbound_packet_carries_tsmp`].
3632    #[test]
3633    fn outbound_tsmp_classification_matches_go_decode() {
3634        let v4_src = std::net::IpAddr::from([100, 64, 0, 1]);
3635        let v4_dst = std::net::IpAddr::from([100, 64, 0, 2]);
3636        let v4 = ts_packet::tsmp::DiscoKeyAdvertisement {
3637            src: v4_src,
3638            dst: v4_dst,
3639            key: SELF_DISCO_KEY,
3640        }
3641        .marshal()
3642        .expect("a v4 advertisement between two v4 addresses marshals");
3643        let v6 = ts_packet::tsmp::DiscoKeyAdvertisement {
3644            src: std::net::IpAddr::V6(IPV6_FIXTURE_SRC),
3645            dst: std::net::IpAddr::V6(IPV6_FIXTURE_DST),
3646            key: SELF_DISCO_KEY,
3647        }
3648        .marshal()
3649        .expect("a v6 advertisement between two v6 addresses marshals");
3650
3651        // The forgery this exists to stop, in both families: bytes byte-identical to what this node
3652        // would itself emit, handed to us by the host instead.
3653        assert!(
3654            outbound_packet_carries_tsmp(&v4),
3655            "an IPv4 TSMP packet from the host is refused"
3656        );
3657        assert!(
3658            outbound_packet_carries_tsmp(&v6),
3659            "an IPv6 TSMP packet from the host is refused"
3660        );
3661
3662        // Ordinary traffic is untouched — the refusal is protocol-specific, not a blanket drop.
3663        assert!(
3664            !outbound_packet_carries_tsmp(&v4_udp_packet(
3665                std::net::SocketAddr::new(v4_src, 4242),
3666                std::net::SocketAddr::new(v4_dst, 4343),
3667                b"hello"
3668            )),
3669            "IPv4 UDP passes"
3670        );
3671        assert!(
3672            !outbound_packet_carries_tsmp(&ipv6_udp_packet(&udp_header(53))),
3673            "IPv6 UDP passes"
3674        );
3675
3676        // Go demotes a *fragmented* IPv4 TSMP packet to `ipproto.Unknown`, which its outbound ACL
3677        // then drops for "unknown proto". With no outbound ACL here the protocol byte is the whole
3678        // verdict, so the refusal happens one step earlier and the packet still never ships.
3679        let mut fragmented = v4.clone();
3680        fragmented[6] = 0x20; // More Fragments
3681        assert!(
3682            outbound_packet_carries_tsmp(&fragmented),
3683            "a fragmented IPv4 TSMP packet is refused too"
3684        );
3685
3686        // An IPv6 Fragment extension header naming TSMP: Go classifies the head fragment TSMP and
3687        // its followers `ipproto.Fragment`. Both are refused here — every fragment of one datagram
3688        // repeats the same Next Header, and with the head refused no peer could reassemble anyway.
3689        assert!(
3690            outbound_packet_carries_tsmp(&ipv6_fragment_packet(
3691                ts_packet::tsmp::IP_PROTO_TSMP,
3692                0,
3693                true,
3694                &[b'a'; 33],
3695            )),
3696            "the head fragment of an IPv6 TSMP datagram is refused"
3697        );
3698        assert!(
3699            outbound_packet_carries_tsmp(&ipv6_fragment_packet(
3700                ts_packet::tsmp::IP_PROTO_TSMP,
3701                MIN_FRAG_BLKS,
3702                false,
3703                &[0u8; 8],
3704            )),
3705            "so are its later fragments"
3706        );
3707        assert!(
3708            !outbound_packet_carries_tsmp(&ipv6_fragment_packet(17, 0, true, &udp_header(53))),
3709            "a fragmented IPv6 UDP datagram is not TSMP and still passes"
3710        );
3711
3712        // Nothing to classify: not IP at all, or truncated before the protocol byte can be trusted.
3713        assert!(
3714            !outbound_packet_carries_tsmp(&[]),
3715            "the empty buffer passes"
3716        );
3717        assert!(
3718            !outbound_packet_carries_tsmp(&[0xde, 0xad, 0xbe, 0xef]),
3719            "a non-IP buffer passes (the router drops it for want of a destination)"
3720        );
3721        assert!(
3722            !outbound_packet_carries_tsmp(&v4[..IP4_HEADER_LEN - 1]),
3723            "an IPv4 packet cut off inside its header passes"
3724        );
3725        assert!(
3726            !outbound_packet_carries_tsmp(&v6[..IP6_HEADER_LEN - 1]),
3727            "an IPv6 packet cut off inside its header passes"
3728        );
3729    }
3730
3731    /// The whole point of the outbound TSMP refusal, end to end, together with the negative case
3732    /// that keeps it from silently disabling capability version 144.
3733    ///
3734    /// A local process writes a well-formed disco-key advertisement — naming a disco key of its own
3735    /// choosing, addressed to a peer whose route really does resolve to a live WireGuard session —
3736    /// into the tun. The peer must never see it: it arrives inside this node's session from this
3737    /// node's tailnet address, so it is indistinguishable from one this node meant to send, and the
3738    /// peer would bind the forger's key for us. Ordinary traffic in the same batch to the same
3739    /// destination must be untouched.
3740    ///
3741    /// And the advertisement this node itself sends must still go out. It is built by
3742    /// `DiscoAdvertisementState::advertisement_for` and injected by `process_inbound` on session
3743    /// establishment, *below* the refusal — Go has the same relationship, where `injectedRead`
3744    /// bypasses the outbound filter. Without this half of the test a drop placed one layer too low
3745    /// would look green.
3746    #[test]
3747    fn host_written_tsmp_is_dropped_while_our_own_advertisement_still_goes_out() {
3748        let underlay: UnderlayTransportId = 0.into();
3749        let wg_peer = ts_tunnel::PeerId(1);
3750        let peer = PeerId(1);
3751        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
3752        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
3753
3754        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
3755        let (mut a, mut b) = (
3756            DataPlane::new(a_static.clone()),
3757            DataPlane::new(b_static.clone()),
3758        );
3759
3760        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
3761            dp.wireguard.upsert_peer(
3762                wg_peer,
3763                ts_tunnel::PeerConfig {
3764                    key,
3765                    psk: [0u8; 32].into(),
3766                    persistent_keepalive_interval: None,
3767                },
3768            );
3769            dp.ur_out.table.insert(peer, underlay);
3770        }
3771
3772        a.disco_advertisement = Some(Arc::new(advertisement_state(
3773            peer,
3774            AdvertisementTarget {
3775                node_addr: b_addr,
3776                wireguard_only: false,
3777            },
3778        )));
3779
3780        // A routes B's tailnet address to the wireguard peer, so a host-written packet addressed to
3781        // B really would be encrypted and shipped were it not refused. Without this the test would
3782        // pass on an empty routing table and prove nothing.
3783        let mut routes = ts_bart::Table::default();
3784        routes.insert(
3785            ipnet::IpNet::from(b_addr),
3786            or::outbound::RouteAction::Wireguard(peer),
3787        );
3788        a.or_out.swap(routes);
3789
3790        // B attributes A's tailnet address to the wireguard peer that carries it, as the runtime's
3791        // source filter does.
3792        let mut src_filter = ts_bart::Table::default();
3793        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
3794        b.src_filter_in = Arc::new(src_filter);
3795
3796        // Everything B decrypts, in arrival order, before any filtering runs.
3797        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
3798        let sink = recorded.clone();
3799        b.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
3800            sink.lock().unwrap().push((path, bytes.to_vec()));
3801        }));
3802
3803        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
3804            out.into_values().flatten().collect::<Vec<_>>()
3805        };
3806
3807        // Establish the session. A's own advertisement rides the establishment.
3808        let init = a
3809            .wireguard
3810            .send([(wg_peer, vec![PacketMut::from(&b"hello"[..])])])
3811            .to_peers
3812            .remove(&wg_peer)
3813            .expect("handshake initiation");
3814        let resp = take(b.process_inbound(init).to_peers);
3815        let from_a = take(a.process_inbound(resp).to_peers);
3816        let learned = b.process_inbound(from_a).learned_disco_keys;
3817        assert_eq!(
3818            learned
3819                .iter()
3820                .map(|(peer, advert)| (*peer, advert.key))
3821                .collect::<Vec<_>>(),
3822            vec![(peer, SELF_DISCO_KEY)],
3823            "our own advertisement must still reach the peer: it is injected below process_outbound"
3824        );
3825
3826        // Now the forgery, alongside ordinary traffic to the same destination in the same batch.
3827        const FORGED_KEY: [u8; 32] = [0xff; 32];
3828        let forged = ts_packet::tsmp::DiscoKeyAdvertisement {
3829            src: a_addr,
3830            dst: b_addr,
3831            key: FORGED_KEY,
3832        }
3833        .marshal()
3834        .expect("a v4 advertisement between two v4 addresses marshals");
3835        const CARRIED: &[u8] = b"ordinary traffic in the same batch";
3836        let control = v4_udp_packet(
3837            std::net::SocketAddr::new(a_addr, 4242),
3838            std::net::SocketAddr::new(b_addr, 4343),
3839            CARRIED,
3840        );
3841
3842        // This is the only test that increments this counter, so the delta is exact.
3843        let counted_before = metric_out_to_wg_drop_tsmp().value();
3844        let out = a.process_outbound(vec![
3845            PacketMut::from(&forged[..]),
3846            PacketMut::from(&control[..]),
3847        ]);
3848
3849        let mark = recorded.lock().unwrap().len();
3850        let inbound = b.process_inbound(take(out.to_peers));
3851        assert!(
3852            inbound.learned_disco_keys.is_empty(),
3853            "the forged advertisement must never reach the peer, or it binds the forger's key for us"
3854        );
3855
3856        let captured = recorded.lock().unwrap();
3857        let delivered = captured[mark..]
3858            .iter()
3859            .filter(|(path, _)| *path == CapturePath::FromPeer)
3860            .map(|(_, bytes)| bytes.as_slice())
3861            .collect::<Vec<_>>();
3862        assert_eq!(
3863            delivered.len(),
3864            1,
3865            "exactly the one non-TSMP packet of the batch crosses the tunnel"
3866        );
3867        // The send path zero-pads each payload up to a 16-byte boundary and the receiver delivers it
3868        // with that padding intact (see `session::PADDING_MULTIPLE`), so compare on the leading bytes.
3869        assert!(
3870            delivered[0].starts_with(&control),
3871            "and it is the ordinary traffic, unaltered"
3872        );
3873
3874        assert_eq!(
3875            metric_out_to_wg_drop_tsmp().value(),
3876            counted_before + 1,
3877            "the drop is counted in tstun_out_to_wg_drop_tsmp (Go metricPacketOutDropTSMP)"
3878        );
3879    }
3880
3881    /// Run one inbound packet through the real inbound filter with `flows` as the connection-
3882    /// tracking state, and report whether it survived.
3883    fn admitted(
3884        filter: &(dyn ts_packetfilter::Filter + Send + Sync),
3885        flows: &mut flowtrack::FlowCache,
3886        packet: Vec<u8>,
3887    ) -> bool {
3888        let mut packets = vec![PacketMut::from(packet)];
3889        let mut harvest = InboundHarvest::default();
3890        filter_inbound_from_peer(
3891            filter,
3892            flows,
3893            PeerId(1),
3894            &mut packets,
3895            RejectConfig::default(),
3896            &mut harvest,
3897        );
3898        assert!(
3899            harvest.learned_disco_keys.is_empty(),
3900            "no TSMP advertisement in these fixtures"
3901        );
3902        !packets.is_empty()
3903    }
3904
3905    /// A dataplane wired up well enough that `process_outbound` really routes a packet to a peer,
3906    /// so the flow-tracking tests exercise the live outbound path rather than a stub.
3907    fn dataplane_routing_to(peer: PeerId, dsts: &[std::net::IpAddr]) -> DataPlane {
3908        let mut dp = DataPlane::new(NodeKeyPair::new());
3909        dp.wireguard.upsert_peer(
3910            ts_tunnel::PeerId(peer.0),
3911            ts_tunnel::PeerConfig {
3912                key: NodeKeyPair::new().public,
3913                psk: [0u8; 32].into(),
3914                persistent_keepalive_interval: None,
3915            },
3916        );
3917        dp.ur_out.table.insert(peer, 0.into());
3918        let mut routes = ts_bart::Table::default();
3919        for dst in dsts {
3920            routes.insert(
3921                ipnet::IpNet::from(*dst),
3922                or::outbound::RouteAction::Wireguard(peer),
3923            );
3924        }
3925        dp.or_out.swap(routes);
3926        dp
3927    }
3928
3929    /// Go's reverse-flow connection tracking (`wgengine/filter`'s `Filter.state`), across the two
3930    /// paths it spans: `process_outbound` records the reversed tuple of every outbound UDP
3931    /// datagram (Go `UpdateOutboundFlowState`, which upstream `e0677ccc7` had to start calling on
3932    /// the netstack-injected path — the only path this engine has), and the inbound filter admits
3933    /// the reply on it ahead of any rule (Go `runIn4`'s `return Accept, "cached"`).
3934    ///
3935    /// Every verdict here is taken under a **deny-all** ACL, so an admission can only have come
3936    /// from the flow cache. The refusals are the point of the test as much as the admission is: a
3937    /// conntrack that admits too much is an open door, so each of the four fields of Go's
3938    /// `flowtrack.Tuple` is varied in turn and must miss.
3939    #[test]
3940    fn an_outbound_udp_flow_admits_its_own_reply_and_nothing_else() {
3941        let peer = PeerId(1);
3942        let me = std::net::IpAddr::from([100, 64, 0, 1]);
3943        let them = std::net::IpAddr::from([100, 64, 0, 2]);
3944        let elsewhere = std::net::IpAddr::from([100, 64, 0, 3]);
3945        let sa = |ip, port| std::net::SocketAddr::new(ip, port);
3946
3947        let mut dp = dataplane_routing_to(peer, &[them, elsewhere]);
3948
3949        // Our datagram out, and the reply it should get: the same tuple, reversed. Our source port
3950        // is ephemeral, which is exactly why no ACL from control can name it.
3951        let query = v4_udp_packet(sa(me, 41234), sa(them, 53), b"query");
3952        let reply = v4_udp_packet(sa(them, 53), sa(me, 41234), b"answer");
3953
3954        // Before anything is sent the reply is an unsolicited datagram to an ephemeral port, and a
3955        // deny-all ACL drops it. That is what every UDP reply used to get in this fork.
3956        assert!(
3957            !admitted(&DenyAll, &mut dp.flows, reply.clone()),
3958            "an inbound datagram matching no outbound flow and no rule is dropped"
3959        );
3960
3961        let out = dp.process_outbound(vec![PacketMut::from(query)]);
3962        assert!(
3963            !out.to_peers.is_empty(),
3964            "the datagram really did route to the peer, so this is the live outbound path"
3965        );
3966
3967        assert!(
3968            admitted(&DenyAll, &mut dp.flows, reply.clone()),
3969            "the reply to our own datagram is admitted with no rule matching it"
3970        );
3971
3972        // One field of Go's tuple differs in each of these, and each must miss. Without them the
3973        // cache would be a blanket "any inbound UDP is fine once we have sent one".
3974        for (why, packet) in [
3975            (
3976                "a different source PORT",
3977                v4_udp_packet(sa(them, 5353), sa(me, 41234), b"x"),
3978            ),
3979            (
3980                "a different source ADDRESS",
3981                v4_udp_packet(sa(elsewhere, 53), sa(me, 41234), b"x"),
3982            ),
3983            (
3984                "a different destination PORT",
3985                v4_udp_packet(sa(them, 53), sa(me, 41235), b"x"),
3986            ),
3987            (
3988                "a different destination ADDRESS",
3989                v4_udp_packet(sa(them, 53), sa(elsewhere, 41234), b"x"),
3990            ),
3991        ] {
3992            assert!(
3993                !admitted(&DenyAll, &mut dp.flows, packet),
3994                "{why} does not ride the recorded entry"
3995            );
3996        }
3997
3998        // And the entry admits, it never denies: the ACL still decides everything the cache misses.
3999        assert!(
4000            admitted(
4001                &AllowAll,
4002                &mut dp.flows,
4003                v4_udp_packet(sa(elsewhere, 53), sa(me, 41234), b"x")
4004            ),
4005            "a cache miss falls through to the rule match, exactly as Go does"
4006        );
4007    }
4008
4009    /// SCTP is the second arm of Go's `case ipproto.UDP, ipproto.SCTP` — on both the recording and
4010    /// the admitting side — and the protocol is part of the tuple, so a UDP entry cannot carry an
4011    /// SCTP datagram or the other way round.
4012    #[test]
4013    fn outbound_sctp_flows_are_tracked_and_do_not_cross_protocols() {
4014        let peer = PeerId(1);
4015        let dst = std::net::IpAddr::V4(IPV4_FIXTURE_DST);
4016        let mut dp = dataplane_routing_to(peer, &[dst]);
4017
4018        // `v4_packet` addresses this fixture src -> dst, so the reply is dst -> src. `sctp_header`
4019        // writes source port 54276.
4020        let out = v4_packet(132, 0, false, &sctp_header(443, SCTP_HEADER_LEN));
4021        let mut reply = v4_packet(132, 0, false, &sctp_header(54276, SCTP_HEADER_LEN));
4022        reply[12..16].copy_from_slice(&IPV4_FIXTURE_DST.octets());
4023        reply[16..20].copy_from_slice(&IPV4_FIXTURE_SRC.octets());
4024        reply[20..22].copy_from_slice(&443u16.to_be_bytes());
4025
4026        assert!(
4027            !admitted(&DenyAll, &mut dp.flows, reply.clone()),
4028            "an unsolicited SCTP packet with no rule is dropped"
4029        );
4030        drop(dp.process_outbound(vec![PacketMut::from(out)]));
4031        assert!(
4032            admitted(&DenyAll, &mut dp.flows, reply.clone()),
4033            "the SCTP reply to our own packet is admitted (Go's second switch arm)"
4034        );
4035
4036        // Same addresses, same ports, UDP instead of SCTP: the protocol is part of Go's tuple.
4037        let mut as_udp = reply.clone();
4038        as_udp[9] = 17;
4039        assert!(
4040            !admitted(&DenyAll, &mut dp.flows, as_udp),
4041            "a UDP datagram does not ride an SCTP entry"
4042        );
4043    }
4044
4045    /// What [`outbound_udp_or_sctp_flow`] records and — the half that keeps the cache honest —
4046    /// what it refuses to record, carrying Go's `decode4`/`decode6` refusals into
4047    /// `UpdateOutboundFlowState`. Everything this function declines leaves the reply where it was
4048    /// before: needing an ACL rule.
4049    #[test]
4050    fn outbound_flow_decoding_matches_go_decode() {
4051        let me = std::net::IpAddr::from([100, 64, 0, 1]);
4052        let them = std::net::IpAddr::from([100, 64, 0, 2]);
4053        let sa = |ip, port| std::net::SocketAddr::new(ip, port);
4054
4055        // UDP: both ports off the wire, addresses in packet order — reversing them is the cache's
4056        // job (Go `MakeTuple(q.IPProto, q.Dst, q.Src)`), not the decoder's.
4057        assert_eq!(
4058            outbound_udp_or_sctp_flow(&v4_udp_packet(sa(me, 41234), sa(them, 53), b"q")),
4059            Some((IpProto::UDP, sa(me, 41234), sa(them, 53))),
4060            "an outbound UDP datagram yields its own tuple"
4061        );
4062
4063        // SCTP: etherparse has no arm for it, so the ports come from Go's `sub[0:2]`/`sub[2:4]`.
4064        let v4_fixture_src = std::net::IpAddr::V4(IPV4_FIXTURE_SRC);
4065        let v4_fixture_dst = std::net::IpAddr::V4(IPV4_FIXTURE_DST);
4066        assert_eq!(
4067            outbound_udp_or_sctp_flow(&v4_packet(
4068                132,
4069                0,
4070                false,
4071                &sctp_header(443, SCTP_HEADER_LEN)
4072            )),
4073            Some((
4074                IpProto::SCTP,
4075                sa(v4_fixture_src, 54276),
4076                sa(v4_fixture_dst, 443)
4077            )),
4078            "an outbound SCTP packet yields its tuple, ports read the way Go reads them"
4079        );
4080        assert_eq!(
4081            outbound_udp_or_sctp_flow(&v4_packet(
4082                132,
4083                0,
4084                false,
4085                &sctp_header(443, SCTP_HEADER_LEN - 1)
4086            )),
4087            None,
4088            "an SCTP common header too short to hold its ports records nothing, not a port-0 flow"
4089        );
4090
4091        // Go's switch has exactly two arms; TCP, ICMP and TSMP fall through it.
4092        for (proto, name) in [(6u8, "TCP"), (1, "ICMP"), (99, "TSMP")] {
4093            assert_eq!(
4094                outbound_udp_or_sctp_flow(&v4_packet(proto, 0, false, &[0u8; 20])),
4095                None,
4096                "{name} is not tracked (Go tracks UDP and SCTP only)"
4097            );
4098        }
4099
4100        // A *first* IPv4 fragment carries its whole UDP header, so `decode4` reads its ports and
4101        // the flow is recorded like an unfragmented datagram's...
4102        let mut head = v4_udp_packet(sa(me, 41234), sa(them, 53), b"payload!");
4103        head[6] = 0x20; // More Fragments, offset 0
4104        assert_eq!(
4105            outbound_udp_or_sctp_flow(&head),
4106            Some((IpProto::UDP, sa(me, 41234), sa(them, 53))),
4107            "the head fragment of an outbound datagram records the same tuple"
4108        );
4109        // ...while a non-first fragment is `ipproto.Fragment` to Go, matching neither arm, and has
4110        // no transport header whose ports could be invented.
4111        let mut later = v4_udp_packet(sa(me, 41234), sa(them, 53), b"payload!");
4112        later[6..8].copy_from_slice(&MIN_FRAG_BLKS.to_be_bytes());
4113        assert_eq!(
4114            outbound_udp_or_sctp_flow(&later),
4115            None,
4116            "a non-first IPv4 fragment records nothing"
4117        );
4118
4119        // IPv6: the protocol comes from the base header only (Go `decode6`'s `b[6]`), so a UDP
4120        // header buried behind a chained extension header is not a flow upstream tracks either —
4121        // even though etherparse could walk to it.
4122        assert_eq!(
4123            outbound_udp_or_sctp_flow(&ipv6_udp_packet(&udp_header(53))),
4124            Some((
4125                IpProto::UDP,
4126                sa(std::net::IpAddr::V6(IPV6_FIXTURE_SRC), 54276),
4127                sa(std::net::IpAddr::V6(IPV6_FIXTURE_DST), 53)
4128            )),
4129            "an outbound IPv6 UDP datagram yields its tuple"
4130        );
4131        assert_eq!(
4132            outbound_udp_or_sctp_flow(&ipv6_with_prepended_ext_header(
4133                60,
4134                &ipv6_udp_packet(&udp_header(53))
4135            )),
4136            None,
4137            "a UDP header behind a chained extension header is not a tracked flow"
4138        );
4139
4140        // A leading IPv6 Fragment header IS stepped over, exactly as `decode6` does.
4141        assert_eq!(
4142            outbound_udp_or_sctp_flow(&ipv6_fragment_packet(17, 0, true, &udp_header(53))),
4143            Some((
4144                IpProto::UDP,
4145                sa(std::net::IpAddr::V6(IPV6_FIXTURE_SRC), 54276),
4146                sa(std::net::IpAddr::V6(IPV6_FIXTURE_DST), 53)
4147            )),
4148            "a first IPv6 fragment records the tuple behind its Fragment header"
4149        );
4150        assert_eq!(
4151            outbound_udp_or_sctp_flow(&ipv6_fragment_packet(17, MIN_FRAG_BLKS, false, &[0u8; 8])),
4152            None,
4153            "a later IPv6 fragment records nothing"
4154        );
4155
4156        // Nothing to decode at all.
4157        assert_eq!(outbound_udp_or_sctp_flow(&[]), None, "the empty buffer");
4158        assert_eq!(
4159            outbound_udp_or_sctp_flow(&[0xde, 0xad, 0xbe, 0xef]),
4160            None,
4161            "a non-IP buffer"
4162        );
4163    }
4164
4165    /// Go `packet.TCPFin`, the flag bit `tcp_header` writes into the byte Go reads as `q.TCPFlags`.
4166    const TCP_FIN: u8 = 0x01;
4167    /// Go `packet.TCPSyn`.
4168    const TCP_SYN: u8 = 0x02;
4169    /// Go `packet.TCPRst`.
4170    const TCP_RST: u8 = 0x04;
4171    /// Go `packet.TCPAck`.
4172    const TCP_ACK: u8 = 0x10;
4173
4174    /// A minimal 20-byte TCP header: the two ports, a data offset of 5 (no options, which is what
4175    /// makes it 20 bytes and what etherparse bounds-checks), and `flags` in `sub[13]` — the byte Go
4176    /// `decode4`/`decode6` copy into `q.TCPFlags`. The checksum is left zero; nothing on this path
4177    /// verifies it, and neither does Go's decoder.
4178    fn tcp_header(src_port: u16, dst_port: u16, flags: u8) -> Vec<u8> {
4179        let mut hdr = vec![0u8; 20];
4180        hdr[0..2].copy_from_slice(&src_port.to_be_bytes());
4181        hdr[2..4].copy_from_slice(&dst_port.to_be_bytes());
4182        hdr[12] = 0x50; // data offset 5 (a 20-byte header), no options
4183        hdr[13] = flags;
4184        hdr
4185    }
4186
4187    /// A minimal 8-byte ICMP/ICMPv6 message: type, code, a zero checksum, and four bytes of
4188    /// rest-of-header. Eight is the length Go guards every one of its type/code reads with
4189    /// (`len(q.b) >= q.subofs+8`), and etherparse's ICMP slices refuse anything shorter too.
4190    fn icmp_header(icmp_type: u8, icmp_code: u8) -> Vec<u8> {
4191        let mut hdr = vec![0u8; 8];
4192        hdr[0] = icmp_type;
4193        hdr[1] = icmp_code;
4194        hdr
4195    }
4196
4197    /// Go's TCP reply carve-out, on real bytes through the whole inbound filter: `runIn4`/`runIn6`
4198    /// accept an inbound TCP segment that is not a SYN *before* any rule is consulted.
4199    ///
4200    /// ```text
4201    /// if !q.IsTCPSyn() {
4202    ///     return Accept, "tcp non-syn"
4203    /// }
4204    /// ```
4205    ///
4206    /// Every accept below is taken under a **deny-all** filter, so it can only have come from the
4207    /// carve-out. The motivating case is the first one: this node dials out, the peer answers with
4208    /// a SYN-ACK aimed at our ephemeral source port, and no ACL control can write will ever name
4209    /// that port. Without the carve-out the handshake never completes and the failure reads as a
4210    /// network fault rather than as a filter decision.
4211    ///
4212    /// The refusals are what keep it from being an open door, and they are the point of the test as
4213    /// much as the accepts are. A SYN — the one segment that can *start* an inbound session — still
4214    /// faces the rules, and so does a non-SYN segment buried under an IPv6 extension header, which
4215    /// upstream filters as protocol 43 rather than as TCP. The third refusal, a segment whose flags
4216    /// this fork never decoded, is pinned by every case that goes through [`stateless_verdict`]:
4217    /// they all carry [`ts_packetfilter::L4Header::Unknown`].
4218    ///
4219    /// Ported from github.com/tailscale/tailscale `wgengine/filter/filter.go` (`runIn4`, `runIn6`)
4220    /// and `net/packet/packet.go` (`IsTCPSyn`, `decode4`, `decode6`) at
4221    /// `3945b82f8a9550b54c33e61d4ed2227862d53e8a`.
4222    #[test]
4223    fn inbound_tcp_non_syn_is_admitted_without_a_rule_but_a_syn_is_not() {
4224        let flows = &mut flowtrack::FlowCache::default();
4225        // Source port 443, destination port 41234: the shape of a reply to a connection this node
4226        // opened. No rule in `DenyAll` names it, and none in a real tailnet ACL could.
4227        let v4 = |flags| v4_packet(6, 0, false, &tcp_header(443, 41234, flags));
4228        let v6 = |flags| ipv6_packet(6, &tcp_header(443, 41234, flags));
4229
4230        for (why, flags) in [
4231            (
4232                "a SYN-ACK — the answer to our own outbound connection",
4233                TCP_SYN | TCP_ACK,
4234            ),
4235            (
4236                "a bare ACK — the continuation of an established session",
4237                TCP_ACK,
4238            ),
4239            ("a FIN-ACK closing that session", TCP_FIN | TCP_ACK),
4240            ("an RST tearing it down", TCP_RST),
4241            // Go's test is `(flags & (SYN|ACK)) == SYN`, so a segment with neither bit set is a
4242            // non-SYN as well. Ported as it stands rather than tightened.
4243            ("a segment with no flags at all", 0x00),
4244        ] {
4245            assert!(
4246                admitted(&DenyAll, flows, v4(flags)),
4247                "{why} is admitted with no rule matching it"
4248            );
4249            assert!(
4250                admitted(&DenyAll, flows, v6(flags)),
4251                "{why} is admitted on IPv6 too (Go `runIn6` carries the same arm)"
4252            );
4253        }
4254
4255        // The negative that gives the carve-out its value: SYN set and ACK clear is Go's
4256        // `IsTCPSyn`, and it is the only way to open an inbound session.
4257        assert!(
4258            !admitted(&DenyAll, flows, v4(TCP_SYN)),
4259            "an inbound SYN with no matching rule is still dropped"
4260        );
4261        assert!(
4262            !admitted(&DenyAll, flows, v6(TCP_SYN)),
4263            "an inbound SYN with no matching rule is still dropped on IPv6"
4264        );
4265        // ...and it is admitted when a rule really does open the port, so the drop above is the
4266        // rules speaking and not the carve-out swallowing the packet.
4267        assert!(
4268            admitted(
4269                &acl(IPV4_FIXTURE_NET, &[6], 41234..=41234),
4270                flows,
4271                v4(TCP_SYN)
4272            ),
4273            "a rule opening the destination port admits the SYN"
4274        );
4275
4276        // The protocol number the carve-out is scoped to is the one the **base** IP header
4277        // declares, which is what Go's `runIn4`/`runIn6` switch dispatches on. etherparse walks an
4278        // IPv6 extension-header chain through to the real transport, so a non-SYN segment behind a
4279        // Routing header hands this code a TCP transport slice for a packet upstream filters as
4280        // protocol 43. Admitting it would make "prepend an extension header" a way past the rules.
4281        for ext in [43u8, 60] {
4282            assert!(
4283                !admitted(
4284                    &DenyAll,
4285                    flows,
4286                    ipv6_with_prepended_ext_header(ext, &v6(TCP_ACK))
4287                ),
4288                "a non-SYN segment behind extension header {ext} is not TCP to the filter"
4289            );
4290        }
4291    }
4292
4293    /// Go's ICMP reply carve-out, on real bytes through the whole inbound filter: `runIn4`/`runIn6`
4294    /// accept an ICMP echo *response* or an ICMP *error* before any rule is consulted.
4295    ///
4296    /// ```text
4297    /// if q.IsEchoResponse() || q.IsError() {
4298    ///     // ICMP responses are allowed.
4299    ///     return Accept, "icmp response ok"
4300    /// } else if f.matches4.matchIPsOnly(q, f.srcIPHasCap) {
4301    ///     // If any port is open to an IP, allow ICMP to it.
4302    ///     return Accept, "icmp ok"
4303    /// }
4304    /// ```
4305    ///
4306    /// The reply to a ping this node sent is unsolicited as far as any ACL is concerned, and so is
4307    /// the Packet Too Big that path-MTU discovery needs; both were silently dropped under a policy
4308    /// that does not grant the peer inbound access back.
4309    ///
4310    /// The negative half is the `else if`, which this fork already had: ICMP that is *not* a
4311    /// response must still go to the rules and be matched IPs-only, never admitted as a "response".
4312    /// An echo request is the case that matters — it is how an inbound ICMP session starts.
4313    ///
4314    /// Ported from github.com/tailscale/tailscale `wgengine/filter/filter.go` (`runIn4`, `runIn6`)
4315    /// and `net/packet/packet.go` (`IsEchoResponse`, `IsError`) at
4316    /// `3945b82f8a9550b54c33e61d4ed2227862d53e8a`.
4317    #[test]
4318    fn inbound_icmp_responses_are_admitted_without_a_rule_but_requests_are_not() {
4319        let flows = &mut flowtrack::FlowCache::default();
4320        let v4 = |icmp_type, icmp_code| v4_packet(1, 0, false, &icmp_header(icmp_type, icmp_code));
4321        let v6 = |icmp_type, icmp_code| ipv6_packet(58, &icmp_header(icmp_type, icmp_code));
4322
4323        // Go `IsEchoResponse` and `IsError` on ICMPv4.
4324        for (why, icmp_type, icmp_code) in [
4325            ("an echo reply — the answer to our own ping", 0x00u8, 0u8),
4326            ("destination unreachable", 0x03, 1),
4327            ("time exceeded", 0x0b, 0),
4328            // Upstream's `ICMP4ParamProblem` is 0x12. IANA's Parameter Problem is 12 (0x0c) and
4329            // 0x12 is Address Mask Reply, so the constant names one message and holds another —
4330            // ported as it stands, because "fixing" it would admit type 12 traffic upstream drops.
4331            (
4332                "upstream's ICMP4ParamProblem constant, whatever IANA calls it",
4333                0x12,
4334                0,
4335            ),
4336        ] {
4337            assert!(
4338                admitted(&DenyAll, flows, v4(icmp_type, icmp_code)),
4339                "{why} is admitted with no rule matching it"
4340            );
4341        }
4342
4343        // ...and on ICMPv6, where the type numbers are entirely different and Packet Too Big is an
4344        // error class of its own.
4345        for (why, icmp_type, icmp_code) in [
4346            ("an ICMPv6 echo reply", 129u8, 0u8),
4347            ("ICMPv6 destination unreachable", 1, 3),
4348            (
4349                "ICMPv6 packet too big — what path-MTU discovery rides on",
4350                2,
4351                0,
4352            ),
4353            ("ICMPv6 time exceeded", 3, 0),
4354            ("ICMPv6 parameter problem", 4, 0),
4355        ] {
4356            assert!(
4357                admitted(&DenyAll, flows, v6(icmp_type, icmp_code)),
4358                "{why} is admitted with no rule matching it"
4359            );
4360        }
4361
4362        // The negatives. None of these is a "response", so each falls through to the rules — and a
4363        // deny-all filter says no.
4364        for (why, packet) in [
4365            (
4366                "an echo REQUEST is how an inbound session starts",
4367                v4(0x08, 0),
4368            ),
4369            (
4370                "an echo reply with a non-zero code is not one (Go tests the code)",
4371                v4(0x00, 1),
4372            ),
4373            (
4374                "IANA's Parameter Problem (12) is not upstream's constant (0x12)",
4375                v4(0x0c, 0),
4376            ),
4377            ("an ICMPv6 echo REQUEST", v6(128, 0)),
4378            ("an ICMPv6 echo reply with a non-zero code", v6(129, 1)),
4379            (
4380                "an ICMPv6 router advertisement is neither response nor error",
4381                v6(134, 0),
4382            ),
4383        ] {
4384            assert!(!admitted(&DenyAll, flows, packet), "{why}: still dropped");
4385        }
4386
4387        // And what "still faces the rules" means: Go's `matchIPsOnly`, which this fork already
4388        // ported. A rule that opens *any* port to this destination admits the echo request, ports
4389        // ignored — so the drop above is the deny-all ruleset speaking, not the decode failing.
4390        assert!(
4391            admitted(&acl(IPV4_FIXTURE_NET, &[1], 443..=443), flows, v4(0x08, 0)),
4392            "an echo request is matched IPs-only, so a port-scoped ICMP rule admits it"
4393        );
4394    }
4395
4396    /// The reply carve-outs sit inside Go's proto switch, and `pre()` runs *before* that switch. So
4397    /// the unconditional multicast and link-local drops outrank both of them: a SYN-ACK or an echo
4398    /// reply addressed somewhere `pre()` refuses is still dropped, however much of a reply it is.
4399    ///
4400    /// This pins the branch ordering, which is the only thing keeping the carve-outs from
4401    /// re-opening destinations upstream closes unconditionally.
4402    #[test]
4403    fn pre_rule_drops_outrank_the_reply_carve_outs() {
4404        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
4405        let src = ip("100.64.0.9");
4406        let verdict = |proto, dst, l4| {
4407            inbound_filter_verdict(
4408                &AllowAll,
4409                &mut flowtrack::FlowCache::default(),
4410                proto,
4411                std::net::SocketAddr::new(src, 443),
4412                std::net::SocketAddr::new(dst, 41234),
4413                l4,
4414                None,
4415            )
4416        };
4417        let syn_ack = ts_packetfilter::L4Header::Tcp {
4418            flags: TCP_SYN | TCP_ACK,
4419        };
4420        let echo_reply = ts_packetfilter::L4Header::Icmp {
4421            icmp_type: 0x00,
4422            icmp_code: 0,
4423        };
4424
4425        // The controls: to an ordinary tailnet address both are admitted.
4426        assert!(verdict(IpProto::TCP, ip("100.64.0.1"), syn_ack));
4427        assert!(verdict(IpProto::ICMP, ip("100.64.0.1"), echo_reply));
4428
4429        for dst in ["224.0.0.1", "169.254.1.1"] {
4430            assert!(
4431                !verdict(IpProto::TCP, ip(dst), syn_ack),
4432                "a non-SYN segment to {dst} is still dropped by pre()"
4433            );
4434            assert!(
4435                !verdict(IpProto::ICMP, ip(dst), echo_reply),
4436                "an echo reply to {dst} is still dropped by pre()"
4437            );
4438        }
4439        // The one link-local address `pre()` allows through is still allowed through.
4440        assert!(
4441            verdict(IpProto::ICMP, ip("169.254.169.254"), echo_reply),
4442            "the cloud-metadata exception is unaffected"
4443        );
4444    }
4445
4446    /// A *first* IPv6 fragment is decoded past its Fragment extension header, TCP flags included —
4447    /// Go `decode6` reaches `q.TCPFlags = TCPFlag(sub[13])` through exactly the same switch a
4448    /// unfragmented segment takes — so the reply carve-out applies to it on the same terms.
4449    ///
4450    /// Without this a peer that source-fragments could not complete a handshake this node started
4451    /// under a restrictive ACL; with it wrong, a fragmented SYN would slip past the rules. Both
4452    /// directions are asserted, on real bytes and on the classification itself.
4453    #[test]
4454    fn ipv6_first_fragment_carries_its_tcp_flags_to_the_reply_carve_out() {
4455        let flows = &mut flowtrack::FlowCache::default();
4456        let frag = |flags| ipv6_fragment_packet(6, 0, true, &tcp_header(443, 41234, flags));
4457
4458        assert!(
4459            admitted(&DenyAll, flows, frag(TCP_SYN | TCP_ACK)),
4460            "a fragmented SYN-ACK takes the non-SYN carve-out like an unfragmented one"
4461        );
4462        assert!(
4463            !admitted(&DenyAll, flows, frag(TCP_SYN)),
4464            "a fragmented SYN does not"
4465        );
4466
4467        // The classification carries the byte, not merely the verdict — this is the value the
4468        // verdict reads, and the ports are still Go's `sub[0:2]`/`sub[2:4]` beside it.
4469        assert_eq!(
4470            decode6_first_fragment(IpProto::TCP, &tcp_header(443, 41234, TCP_SYN | TCP_ACK)),
4471            Ipv6Fragment::First {
4472                proto: IpProto::TCP,
4473                src_port: 443,
4474                dst_port: 41234,
4475                l4: ts_packetfilter::L4Header::Tcp {
4476                    flags: TCP_SYN | TCP_ACK
4477                },
4478            },
4479            "`decode6` reads the flags byte past the Fragment header"
4480        );
4481    }
4482
4483    /// A dropped inbound packet's four-tuple, as `filter_inbound_from_peer` hands it to
4484    /// [`tsmp_reject_for_drop`]: the fixture peer dialling this node's SSH port.
4485    fn dropped_syn_tuple() -> (std::net::SocketAddr, std::net::SocketAddr) {
4486        (
4487            std::net::SocketAddr::new(IPV4_FIXTURE_SRC.into(), 41234),
4488            std::net::SocketAddr::new(IPV4_FIXTURE_DST.into(), 22),
4489        )
4490    }
4491
4492    /// The happy path of the send half, at the function Go's guard lives in: a dropped IPv4 TCP SYN
4493    /// produces a rejected-connection message addressed back at the dialer, carrying the four-tuple
4494    /// with the *connection's* own direction and `RejectedDueToACLs`.
4495    ///
4496    /// The assertion runs the emitted bytes back through the production parser, which is the far
4497    /// side's view of them — the peer has to read out the flow it opened, not the one we saw.
4498    #[test]
4499    fn an_acl_dropped_syn_produces_a_reject_addressed_back_to_the_dialer() {
4500        let (src, dst) = dropped_syn_tuple();
4501
4502        let bytes = tsmp_reject_for_drop(
4503            IpProto::TCP,
4504            src,
4505            dst,
4506            ts_packetfilter::L4Header::Tcp { flags: TCP_SYN },
4507            false,
4508            RejectConfig::default(),
4509        )
4510        .expect("a dropped IPv4 TCP SYN must be answered");
4511
4512        let reject = ts_packet::tsmp::TailscaleRejectedHeader::parse(&bytes)
4513            .expect("what we emit must parse as a rejected-connection message");
4514
4515        // Go `IPSrc: p.Dst.Addr(), IPDst: p.Src.Addr()`: the reply travels back the way the SYN
4516        // came, so the IP pair is reversed...
4517        assert_eq!(reject.ip_src, dst.ip(), "sourced from the address dialled");
4518        assert_eq!(reject.ip_dst, src.ip(), "addressed to the dialer");
4519        // ...while the four-tuple keeps the rejected connection's own direction, which is what lets
4520        // the dialer match it against the flow it opened.
4521        assert_eq!(reject.src, src);
4522        assert_eq!(reject.dst, dst);
4523        assert_eq!(reject.proto, IPPROTO_TCP_BYTE);
4524        assert_eq!(reject.reason, ts_packet::tsmp::RejectReason::ACLS);
4525        assert!(!reject.maybe_broken, "an ACL deny is a terminal verdict");
4526    }
4527
4528    /// Go: `if t.filter.ShieldsUp() { rj.Reason = packet.RejectedDueToShieldsUp }`. The two reasons
4529    /// render differently in a peer's connection history, so a shields-up deny must not be reported
4530    /// as an ACL deny.
4531    #[test]
4532    fn shields_up_is_a_distinct_reject_reason() {
4533        let (src, dst) = dropped_syn_tuple();
4534        let syn = ts_packetfilter::L4Header::Tcp { flags: TCP_SYN };
4535
4536        let reason = |shields_up| {
4537            ts_packet::tsmp::TailscaleRejectedHeader::parse(
4538                &tsmp_reject_for_drop(
4539                    IpProto::TCP,
4540                    src,
4541                    dst,
4542                    syn,
4543                    shields_up,
4544                    RejectConfig::default(),
4545                )
4546                .expect("emitted"),
4547            )
4548            .expect("parses")
4549            .reason
4550        };
4551
4552        assert_eq!(reason(true), ts_packet::tsmp::RejectReason::SHIELDS_UP);
4553        assert_eq!(reason(false), ts_packet::tsmp::RejectReason::ACLS);
4554    }
4555
4556    /// Everything Go stays **silent** about. Each of these is a packet the ACL drops just the same;
4557    /// the drop is unchanged and only the reply is withheld, which is what makes them the part of
4558    /// the port worth testing.
4559    #[test]
4560    fn most_drops_produce_no_reject_at_all() {
4561        let (src, dst) = dropped_syn_tuple();
4562        let syn = ts_packetfilter::L4Header::Tcp { flags: TCP_SYN };
4563        let v6_src = std::net::SocketAddr::new(std::net::IpAddr::V6(IPV6_FIXTURE_SRC), 41234);
4564        let v6_dst = std::net::SocketAddr::new(std::net::IpAddr::V6(IPV6_FIXTURE_DST), 22);
4565
4566        for (name, proto, src, dst, l4, cfg) in [
4567            // Go `p.TCPFlags&packet.TCPSyn != 0`: a segment with no SYN bit is not a connection
4568            // being opened, so there is nothing to refuse.
4569            (
4570                "a non-SYN segment",
4571                IpProto::TCP,
4572                src,
4573                dst,
4574                ts_packetfilter::L4Header::Tcp {
4575                    flags: TCP_ACK | TCP_FIN,
4576                },
4577                RejectConfig::default(),
4578            ),
4579            (
4580                "a RST",
4581                IpProto::TCP,
4582                src,
4583                dst,
4584                ts_packetfilter::L4Header::Tcp { flags: TCP_RST },
4585                RejectConfig::default(),
4586            ),
4587            // Go `p.IPProto == ipproto.TCP`: a datagram protocol has no SYN and no connection.
4588            (
4589                "a UDP datagram",
4590                IpProto::UDP,
4591                src,
4592                dst,
4593                ts_packetfilter::L4Header::Unknown,
4594                RejectConfig::default(),
4595            ),
4596            // A UDP datagram whose 14th payload byte happens to have the SYN bit pattern must not
4597            // be read as a TCP header: the protocol number decides, as it does in Go's guard.
4598            (
4599                "a UDP datagram carrying SYN-shaped bytes",
4600                IpProto::UDP,
4601                src,
4602                dst,
4603                syn,
4604                RejectConfig::default(),
4605            ),
4606            (
4607                "an ICMP echo request",
4608                IpProto::ICMP,
4609                src,
4610                dst,
4611                ts_packetfilter::L4Header::Icmp {
4612                    icmp_type: 8,
4613                    icmp_code: 0,
4614                },
4615                RejectConfig::default(),
4616            ),
4617            // Go `p.IPVersion == 4`: an IPv6 drop is silent, even though the message itself is
4618            // family-agnostic and `TailscaleRejectedHeader::marshal` would happily emit one.
4619            (
4620                "an IPv6 SYN",
4621                IpProto::TCP,
4622                v6_src,
4623                v6_dst,
4624                syn,
4625                RejectConfig::default(),
4626            ),
4627            // A packet whose L4 header was never decoded — a first IPv4 fragment, say — is not
4628            // known to be a SYN. Go reaches the same place: a header it could not decode leaves
4629            // `q.TCPFlags` zero.
4630            (
4631                "a SYN whose TCP header was never decoded",
4632                IpProto::TCP,
4633                src,
4634                dst,
4635                ts_packetfilter::L4Header::Unknown,
4636                RejectConfig::default(),
4637            ),
4638            // Go's peerAPI carve-out, which flips an ACL-refused peerAPI SYN back to `Accept`
4639            // before the reject block runs. Same outcome reached here by withholding the reply.
4640            (
4641                "a SYN to the peerAPI port",
4642                IpProto::TCP,
4643                src,
4644                dst,
4645                syn,
4646                RejectConfig {
4647                    disabled: false,
4648                    peerapi_port: Some(dst.port()),
4649                },
4650            ),
4651            // Go `!t.disableTSMPRejected`, the node-wide off switch.
4652            (
4653                "any SYN at all, with rejects disabled",
4654                IpProto::TCP,
4655                src,
4656                dst,
4657                syn,
4658                RejectConfig {
4659                    disabled: true,
4660                    peerapi_port: None,
4661                },
4662            ),
4663        ] {
4664            assert!(
4665                tsmp_reject_for_drop(proto, src, dst, l4, false, cfg).is_none(),
4666                "{name} must be dropped silently"
4667            );
4668        }
4669
4670        // Control: with the peerAPI on some *other* port, the same SYN is answered — the carve-out
4671        // is scoped to the port, not a blanket mute.
4672        assert!(
4673            tsmp_reject_for_drop(
4674                IpProto::TCP,
4675                src,
4676                dst,
4677                syn,
4678                false,
4679                RejectConfig {
4680                    disabled: false,
4681                    peerapi_port: Some(dst.port() + 1),
4682                },
4683            )
4684            .is_some(),
4685            "a SYN to a port that is not the peerAPI's is still answered"
4686        );
4687    }
4688
4689    /// The send half through the real filter step, on real bytes: an ACL that denies everything
4690    /// drops the SYN *and* queues a reject for the peer that sent it, while the packets Go stays
4691    /// silent about queue nothing. This is the assertion that the guard is wired into the drop
4692    /// path at all, not merely correct in isolation.
4693    #[test]
4694    fn the_filter_step_queues_a_reject_only_for_a_dropped_ipv4_syn() {
4695        let peer = PeerId(7);
4696
4697        // Returns `(survived, harvest)`: whether the ACL admitted the packet, and what the filter
4698        // step produced alongside it.
4699        let run = |packet: Vec<u8>| {
4700            let mut packets = vec![PacketMut::from(packet)];
4701            let mut harvest = InboundHarvest::default();
4702            filter_inbound_from_peer(
4703                &DenyAll,
4704                &mut flowtrack::FlowCache::default(),
4705                peer,
4706                &mut packets,
4707                RejectConfig::default(),
4708                &mut harvest,
4709            );
4710            (!packets.is_empty(), harvest)
4711        };
4712
4713        let (survived, harvest) = run(v4_packet(6, 0, false, &tcp_header(41234, 22, TCP_SYN)));
4714        assert!(!survived, "the deny-all ACL drops the SYN");
4715        assert_eq!(
4716            harvest.rejects_to_send.len(),
4717            1,
4718            "a dropped SYN is answered"
4719        );
4720        assert_eq!(harvest.rejects_to_send[0].0, peer, "back to the sender");
4721        let reject =
4722            ts_packet::tsmp::TailscaleRejectedHeader::parse(harvest.rejects_to_send[0].1.as_ref())
4723                .expect("the queued packet is a rejected-connection message");
4724        assert_eq!(reject.src.port(), 41234);
4725        assert_eq!(reject.dst.port(), 22);
4726        assert_eq!(reject.reason, ts_packet::tsmp::RejectReason::ACLS);
4727
4728        // A segment that is not a SYN never produces one. Under this deny-all ACL it is in fact
4729        // *admitted*, by the "tcp non-syn" reply carve-out that runs ahead of the rules — which is
4730        // the more useful assertion anyway: the reject path must not fire on a packet that was
4731        // never dropped in the first place.
4732        let (survived, harvest) = run(v4_packet(6, 0, false, &tcp_header(41234, 22, TCP_RST)));
4733        assert!(survived, "Go's 'tcp non-syn' carve-out admits it");
4734        assert!(harvest.rejects_to_send.is_empty(), "and answers nothing");
4735
4736        for (name, packet) in [
4737            ("a UDP datagram", v4_packet(17, 0, false, &udp_header(22))),
4738            (
4739                "an IPv6 SYN",
4740                ipv6_packet(6, &tcp_header(41234, 22, TCP_SYN)),
4741            ),
4742        ] {
4743            let (survived, harvest) = run(packet);
4744            assert!(!survived, "{name} is dropped by the deny-all ACL");
4745            assert!(
4746                harvest.rejects_to_send.is_empty(),
4747                "{name} must be dropped in silence"
4748            );
4749        }
4750    }
4751
4752    /// The peerAPI ordering, through the real filter step: a SYN to the peerAPI port is dropped by
4753    /// the ACL exactly as before — this fork has no L7 carve-out that admits it — but the peer is
4754    /// told nothing, because upstream's carve-out has already turned that packet into an `Accept`
4755    /// by the time its reject block runs.
4756    #[test]
4757    fn a_peerapi_syn_is_dropped_without_a_reject() {
4758        let mut packets = vec![PacketMut::from(v4_packet(
4759            6,
4760            0,
4761            false,
4762            &tcp_header(41234, 8089, TCP_SYN),
4763        ))];
4764        let mut harvest = InboundHarvest::default();
4765        filter_inbound_from_peer(
4766            &DenyAll,
4767            &mut flowtrack::FlowCache::default(),
4768            PeerId(7),
4769            &mut packets,
4770            RejectConfig {
4771                disabled: false,
4772                peerapi_port: Some(8089),
4773            },
4774            &mut harvest,
4775        );
4776
4777        assert!(packets.is_empty(), "the verdict on the packet is unchanged");
4778        assert!(
4779            harvest.rejects_to_send.is_empty(),
4780            "a peerAPI SYN must never produce a reject"
4781        );
4782    }
4783
4784    /// The receive half through the real filter step: a peer's rejected-connection message is
4785    /// consumed — harvested for the embedder and **dropped**, never handed to the local stack,
4786    /// which has no handler for IP protocol 99 (Go `filter.DropSilently`).
4787    ///
4788    /// The ACL denies everything, so the packet surviving could only have come from the TSMP
4789    /// bypass; asserting it does *not* survive is the whole point.
4790    #[test]
4791    fn a_received_reject_is_consumed_and_not_delivered() {
4792        let peer = PeerId(7);
4793
4794        // The bytes a Go peer puts on the wire when its ACL refuses our dial: it marshals from its
4795        // own side, we read it from ours.
4796        let wire = ts_packet::tsmp::TailscaleRejectedHeader {
4797            ip_src: IPV4_FIXTURE_SRC.into(),
4798            ip_dst: IPV4_FIXTURE_DST.into(),
4799            src: std::net::SocketAddr::new(IPV4_FIXTURE_DST.into(), 41234),
4800            dst: std::net::SocketAddr::new(IPV4_FIXTURE_SRC.into(), 22),
4801            proto: IPPROTO_TCP_BYTE,
4802            reason: ts_packet::tsmp::RejectReason::ACLS,
4803            maybe_broken: false,
4804        }
4805        .marshal()
4806        .expect("marshals");
4807
4808        let mut packets = vec![PacketMut::from(wire)];
4809        let mut harvest = InboundHarvest::default();
4810        filter_inbound_from_peer(
4811            &DenyAll,
4812            &mut flowtrack::FlowCache::default(),
4813            peer,
4814            &mut packets,
4815            RejectConfig::default(),
4816            &mut harvest,
4817        );
4818
4819        assert!(
4820            packets.is_empty(),
4821            "a reject must be consumed, not delivered to the local stack"
4822        );
4823        assert_eq!(harvest.rejected_flows.len(), 1);
4824        assert_eq!(harvest.rejected_flows[0].0, peer);
4825        let reject = harvest.rejected_flows[0].1;
4826        assert_eq!(
4827            reject.src,
4828            std::net::SocketAddr::new(IPV4_FIXTURE_DST.into(), 41234),
4829            "src is OUR address and ephemeral port — the flow we opened"
4830        );
4831        assert_eq!(
4832            reject.dst,
4833            std::net::SocketAddr::new(IPV4_FIXTURE_SRC.into(), 22)
4834        );
4835        assert_eq!(reject.reason, ts_packet::tsmp::RejectReason::ACLS);
4836        assert!(!reject.maybe_broken);
4837        assert!(
4838            harvest.rejects_to_send.is_empty(),
4839            "consuming a reject must not answer it with another one"
4840        );
4841
4842        // The `MaybeBroken` bit reaches the embedder intact: Go treats such a rejection as
4843        // non-terminal (it marks the flow problematic instead of removing it), so flattening the
4844        // bit would turn a transient problem into a hard failure.
4845        let broken = ts_packet::tsmp::TailscaleRejectedHeader {
4846            ip_src: IPV4_FIXTURE_SRC.into(),
4847            ip_dst: IPV4_FIXTURE_DST.into(),
4848            src: std::net::SocketAddr::new(IPV4_FIXTURE_DST.into(), 41234),
4849            dst: std::net::SocketAddr::new(IPV4_FIXTURE_SRC.into(), 22),
4850            proto: IPPROTO_TCP_BYTE,
4851            reason: ts_packet::tsmp::RejectReason::IP_FORWARDING,
4852            maybe_broken: true,
4853        }
4854        .marshal()
4855        .expect("marshals");
4856        let mut packets = vec![PacketMut::from(broken)];
4857        let mut harvest = InboundHarvest::default();
4858        filter_inbound_from_peer(
4859            &DenyAll,
4860            &mut flowtrack::FlowCache::default(),
4861            peer,
4862            &mut packets,
4863            RejectConfig::default(),
4864            &mut harvest,
4865        );
4866        assert!(packets.is_empty());
4867        assert!(harvest.rejected_flows[0].1.maybe_broken);
4868        assert_eq!(
4869            harvest.rejected_flows[0].1.reason,
4870            ts_packet::tsmp::RejectReason::IP_FORWARDING
4871        );
4872    }
4873
4874    /// A peer cannot drive the default-level log with rejected-connection messages.
4875    ///
4876    /// Go logs `open-conn-track` only for a flow sitting in its pending-open table, so its volume
4877    /// is bounded by the dials *this* node made. This fork keeps no such table and logs every
4878    /// well-formed reject it can attribute to a peer, which bounds the volume by what the peer
4879    /// chooses to send instead — nothing here matches a pending flow and nothing rate-limits it.
4880    /// At `info!` that is an authenticated peer holding the operator's default-level log open;
4881    /// `debug!` keeps the diagnostic without putting it there.
4882    ///
4883    /// The record itself must keep flowing regardless: `rejected_flows` is the path the embedder
4884    /// uses to do the pending-open match this layer cannot, so quieting the log must not quiet it.
4885    #[test]
4886    #[tracing_test::traced_test]
4887    fn a_flood_of_rejects_stays_off_the_default_log_level() {
4888        let peer = PeerId(7);
4889        // More than a real flow-refusal burst, few enough to keep the test cheap: what matters is
4890        // that the count of default-level lines does not track it.
4891        const FLOOD: usize = 32;
4892
4893        let wire = ts_packet::tsmp::TailscaleRejectedHeader {
4894            ip_src: IPV4_FIXTURE_SRC.into(),
4895            ip_dst: IPV4_FIXTURE_DST.into(),
4896            src: std::net::SocketAddr::new(IPV4_FIXTURE_DST.into(), 41234),
4897            dst: std::net::SocketAddr::new(IPV4_FIXTURE_SRC.into(), 22),
4898            proto: IPPROTO_TCP_BYTE,
4899            reason: ts_packet::tsmp::RejectReason::ACLS,
4900            maybe_broken: false,
4901        }
4902        .marshal()
4903        .expect("marshals");
4904
4905        let mut packets: Vec<PacketMut> =
4906            (0..FLOOD).map(|_| PacketMut::from(wire.clone())).collect();
4907        let mut harvest = InboundHarvest::default();
4908        filter_inbound_from_peer(
4909            &DenyAll,
4910            &mut flowtrack::FlowCache::default(),
4911            peer,
4912            &mut packets,
4913            RejectConfig::default(),
4914            &mut harvest,
4915        );
4916
4917        assert!(packets.is_empty(), "every reject is consumed");
4918        assert_eq!(
4919            harvest.rejected_flows.len(),
4920            FLOOD,
4921            "the embedder must still receive every record — quieting the log is not dropping it"
4922        );
4923
4924        logs_assert(|lines: &[&str]| {
4925            let tracked: Vec<&&str> = lines
4926                .iter()
4927                .filter(|l| l.contains("open-conn-track"))
4928                .collect();
4929            if tracked.len() != FLOOD {
4930                return Err(format!(
4931                    "expected {FLOOD} open-conn-track lines at debug, got {}",
4932                    tracked.len()
4933                ));
4934            }
4935            // `INFO` and above is what an operator sees by default; a peer must not reach it.
4936            let loud: Vec<&&&str> = tracked.iter().filter(|l| !l.contains(" DEBUG ")).collect();
4937            if let Some(first) = loud.first() {
4938                return Err(format!(
4939                    "{} of {FLOOD} peer-driven reject line(s) above debug, e.g. {first}",
4940                    loud.len()
4941                ));
4942            }
4943            Ok(())
4944        });
4945    }
4946
4947    /// A *fragmented* rejected-connection message is refused before the ACL ever sees it, and is
4948    /// not consumed either: Go's `decode4` demotes a first TSMP fragment with More-Fragments set to
4949    /// `ipproto.Unknown`, which `pre()` drops, and classifies a later fragment as
4950    /// `ipproto.Fragment`. Without the whole message in hand it cannot be a valid control packet,
4951    /// and half a reject must never be read as a flow being refused.
4952    #[test]
4953    fn a_fragmented_reject_is_refused_before_the_acl() {
4954        let body = {
4955            let mut b = vec![
4956                ts_packet::tsmp::TSMP_TYPE_REJECTED_CONN,
4957                IPPROTO_TCP_BYTE,
4958                b'A',
4959            ];
4960            b.extend_from_slice(&41234u16.to_be_bytes());
4961            b.extend_from_slice(&22u16.to_be_bytes());
4962            b.push(0);
4963            b
4964        };
4965
4966        // Control: unfragmented, the same body is consumed as a reject.
4967        let mut packets = vec![PacketMut::from(v4_packet(99, 0, false, &body))];
4968        let mut harvest = InboundHarvest::default();
4969        filter_inbound_from_peer(
4970            &DenyAll,
4971            &mut flowtrack::FlowCache::default(),
4972            PeerId(7),
4973            &mut packets,
4974            RejectConfig::default(),
4975            &mut harvest,
4976        );
4977        assert!(packets.is_empty() && harvest.rejected_flows.len() == 1);
4978
4979        // A first fragment with More-Fragments set, and a later fragment. Neither is parsed as a
4980        // reject. What happens to the bytes afterwards is the fragment rule this tree already
4981        // had, unchanged: the first fragment is demoted to `unknown` and dropped, the later
4982        // fragment takes Go's stateless pass-through (`pre()`'s `case ipproto.Fragment: Accept`).
4983        for (name, offset, more, delivered) in [
4984            ("first fragment, MF set", 0, true, false),
4985            ("later fragment", MIN_FRAG_BLKS, false, true),
4986        ] {
4987            let mut packets = vec![PacketMut::from(v4_packet(99, offset, more, &body))];
4988            let mut harvest = InboundHarvest::default();
4989            filter_inbound_from_peer(
4990                &DenyAll,
4991                &mut flowtrack::FlowCache::default(),
4992                PeerId(7),
4993                &mut packets,
4994                RejectConfig::default(),
4995                &mut harvest,
4996            );
4997            assert!(
4998                harvest.rejected_flows.is_empty(),
4999                "{name}: half a reject is not a rejected flow"
5000            );
5001            assert_eq!(
5002                packets.len(),
5003                usize::from(delivered),
5004                "{name}: the existing fragment rule decides what happens to the bytes"
5005            );
5006        }
5007    }
5008
5009    /// Both halves meeting over a real WireGuard session, which is the only test that proves the
5010    /// reject is actually *sent*: A dials B, B's ACL refuses the SYN, and A learns why.
5011    ///
5012    /// This engine is always the dialing client, so A's side is the one that matters day to day —
5013    /// without it a dial into a peer's ACL drop waits out a TCP timeout with no reason anywhere.
5014    /// B's side is the peer-observable one: a real Go node dialling into our ACL drop expects to be
5015    /// told, and was not.
5016    #[test]
5017    fn a_refused_dial_is_answered_over_the_wire_and_understood() {
5018        let underlay: UnderlayTransportId = 0.into();
5019        let wg_peer = ts_tunnel::PeerId(1);
5020        let peer = PeerId(1);
5021        let a_addr = std::net::IpAddr::from(IPV4_FIXTURE_SRC);
5022        let b_addr = std::net::IpAddr::from(IPV4_FIXTURE_DST);
5023
5024        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
5025        let (mut a, mut b) = (
5026            DataPlane::new(a_static.clone()),
5027            DataPlane::new(b_static.clone()),
5028        );
5029
5030        for (dp, key, remote) in [
5031            (&mut a, b_static.public, b_addr),
5032            (&mut b, a_static.public, a_addr),
5033        ] {
5034            dp.wireguard.upsert_peer(
5035                wg_peer,
5036                ts_tunnel::PeerConfig {
5037                    key,
5038                    psk: [0u8; 32].into(),
5039                    persistent_keepalive_interval: None,
5040                },
5041            );
5042            dp.ur_out.table.insert(peer, underlay);
5043            // Each side attributes the other's tailnet address to the WireGuard peer carrying it,
5044            // as the runtime's source filter does.
5045            let mut src_filter = ts_bart::Table::default();
5046            src_filter.insert(ipnet::IpNet::from(remote), peer);
5047            dp.src_filter_in = Arc::new(src_filter);
5048        }
5049
5050        // B refuses everything: this is the ACL drop the message exists to explain.
5051        b.packet_filter = Arc::new(DenyAll);
5052
5053        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
5054            out.into_values().flatten().collect::<Vec<_>>()
5055        };
5056
5057        // A dials B. The SYN rides the handshake this send starts.
5058        let syn = PacketMut::from(v4_packet(6, 0, false, &tcp_header(41234, 22, TCP_SYN)));
5059        let init = a
5060            .wireguard
5061            .send([(wg_peer, vec![syn])])
5062            .to_peers
5063            .remove(&wg_peer)
5064            .expect("handshake initiation");
5065        let resp = take(b.process_inbound(init).to_peers);
5066        let from_a = take(a.process_inbound(resp).to_peers);
5067
5068        // B drops the SYN on its ACL and answers with a rejected-connection message.
5069        let at_b = b.process_inbound(from_a);
5070        assert!(
5071            at_b.to_local.values().all(|v| v.is_empty()),
5072            "the SYN itself must not reach B's local stack"
5073        );
5074        let to_a = take(at_b.to_peers);
5075        assert!(
5076            !to_a.is_empty(),
5077            "B must tell A why it dropped the connection"
5078        );
5079
5080        // A consumes it: it learns the flow was refused and the message never reaches its stack.
5081        let at_a = a.process_inbound(to_a);
5082        assert!(
5083            at_a.to_local.values().all(|v| v.is_empty()),
5084            "the reject must be consumed, not delivered to A's local stack"
5085        );
5086        assert_eq!(at_a.rejected_flows.len(), 1, "A must learn of the refusal");
5087        let (from_peer, reject) = at_a.rejected_flows[0];
5088        assert_eq!(from_peer, peer);
5089        assert_eq!(
5090            reject.src,
5091            std::net::SocketAddr::new(a_addr, 41234),
5092            "the flow A opened, from A's own address and ephemeral port"
5093        );
5094        assert_eq!(reject.dst, std::net::SocketAddr::new(b_addr, 22));
5095        assert_eq!(reject.proto, IPPROTO_TCP_BYTE);
5096        assert_eq!(reject.reason, ts_packet::tsmp::RejectReason::ACLS);
5097        assert!(!reject.maybe_broken);
5098
5099        // And A does not answer the reject with a reject of its own — that would be a loop between
5100        // two nodes that both deny.
5101        assert!(
5102            take(at_a.to_peers).is_empty(),
5103            "consuming a reject must put nothing back on the wire"
5104        );
5105    }
5106}