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: ðerparse::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}