Skip to main content

ts_dataplane/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{collections::HashMap, sync::Arc, time::Instant};
4
5use ts_bart::RoutingTable;
6use ts_overlay_router as or;
7use ts_packet::PacketMut;
8use ts_packetfilter::{FilterExt, IpProto};
9use ts_time::{Handle, Scheduler};
10use ts_transport::{OverlayTransportId, PeerId, UnderlayTransportId};
11use ts_tunnel::{Endpoint, NodeKeyPair};
12use ts_underlay_router as ur;
13
14pub mod async_tokio;
15
16/// The single link-local destination Go's filter `pre()` exempts from the link-local drop: the
17/// cloud-metadata address `169.254.169.254` (Go `isAllowedLinkLocal`).
18const ALLOWED_LINK_LOCAL_V4: std::net::Ipv4Addr = std::net::Ipv4Addr::new(169, 254, 169, 254);
19
20/// Whether an inbound packet to destination `dst` must be dropped BEFORE consulting the ACL rules,
21/// mirroring Go's filter `pre()`: drop multicast destinations (`ReasonMulticast`) and link-local
22/// unicast destinations that are not the allowlisted cloud-metadata address (`ReasonLinkLocalUnicast`).
23/// Returning `true` means drop. This runs ahead of `can_access` so a permissive ACL cannot admit the
24/// multicast / link-local traffic Go rejects unconditionally.
25///
26/// Go's `isAllowedLinkLocal` is `dst == gcpDNSAddr || any(LinkLocalAllowHooks)`; only the static
27/// `gcpDNSAddr` arm is modeled here. The dynamic `LinkLocalAllowHooks` slice is empty in a plain
28/// engine/tsnet embedding (its only upstream producer is the GCP metadata path), so the omission is
29/// behaviorally equivalent for this fork; a feature that needs a dynamic link-local allowlist would
30/// have to extend this. Like Go's `netip.Addr` predicates, an IPv4-mapped-IPv6 destination (e.g.
31/// `::ffff:224.0.0.1`) matches NEITHER arm and falls through to the ACL — we deliberately do not
32/// canonicalize/unmap, to stay byte-faithful to Go (see the mapped-v6 test cases).
33fn drop_before_rules(dst: std::net::IpAddr) -> bool {
34    if dst.is_multicast() {
35        return true;
36    }
37    match dst {
38        // IPv4 link-local is 169.254.0.0/16; allow only the cloud-metadata address (Go parity).
39        std::net::IpAddr::V4(v4) => v4.is_link_local() && v4 != ALLOWED_LINK_LOCAL_V4,
40        // IPv6 unicast link-local is fe80::/10. (`Ipv6Addr::is_unicast_link_local` is unstable, so
41        // test the prefix directly.) This fork is IPv4-only by default, but match Go for any v6.
42        std::net::IpAddr::V6(v6) => (v6.segments()[0] & 0xffc0) == 0xfe80,
43    }
44}
45
46/// IPv4 fragment state read from the base header (Go `net/packet.decode4` reads `b[6:8]`): the
47/// fragment offset in 8-byte blocks and the more-fragments flag. A non-first fragment carries no L4
48/// header, so it needs its own verdict path rather than the (always-port-0) ACL match.
49#[derive(Debug, Clone, Copy)]
50struct Ipv4Fragment {
51    /// Fragment offset in 8-byte blocks (the 13-bit IPv4 field), 0 for the first/only fragment.
52    offset_blocks: u16,
53    /// The "more fragments" (MF) flag.
54    more_fragments: bool,
55}
56
57/// Minimum fragment offset (in 8-byte blocks) Go permits for a non-first fragment — Go
58/// `net/packet.minFragBlks = (60 + 20) / 8 = 10` (max IPv4 header + a basic TCP header). A later
59/// fragment starting before this could overlap a transport header (the RFC 1858 overlapping-fragment
60/// evasion), so Go demotes it to `unknown` and drops it; only fragments at or beyond this offset are
61/// allowed to "slide through".
62const MIN_FRAG_BLKS: u16 = (60 + 20) / 8;
63
64/// The inbound packet-filter verdict for an already-parsed packet (`true` = admit). This is the
65/// proto-switch of Go's filter `runIn4`/`runIn6`, applied after `pre()` and after this fork's
66/// source-attribution and local-destination routing (the analogues of Go's `local4`/`local6`
67/// precondition) have run:
68///
69/// 1. `drop_before_rules` — Go `pre()`'s unconditional multicast / link-local-unicast drops.
70/// 2. **Fragment classification** (Go `net/packet.decode4` + filter `pre()`): a non-first IPv4
71///    fragment carries no L4 header, so it cannot be port-matched. Go classifies it by offset — a
72///    fragment at offset `>= MIN_FRAG_BLKS` is mapped to `ipproto.Fragment` and `pre()` **accepts**
73///    it (stateless pass-through; the receiver's kernel discards it if the head fragment was
74///    dropped), while a fragment at a smaller offset is dropped (RFC 1858). A *fragmented* TSMP is
75///    disallowed (`moreFrags` on a first TSMP fragment → drop). Without this, etherparse leaves the
76///    transport `None` and the port reads as 0, so a normal ACL rule would silently drop every valid
77///    later fragment — breaking large/fragmented inbound traffic on the 1280-MTU overlay.
78/// 3. TSMP (proto 99) is always admitted, bypassing the ACL — Go `case ipproto.TSMP: return Accept`.
79///    TSMP carries in-band control messages between nodes, so it must reach the local stack
80///    regardless of the ACL rules.
81/// 4. Everything else consults the control-derived ACL via `can_access` — Go's `matches4.match`.
82fn inbound_filter_verdict(
83    filter: &(dyn ts_packetfilter::Filter + Send + Sync),
84    proto: IpProto,
85    src: std::net::IpAddr,
86    dst: std::net::IpAddr,
87    dst_port: u16,
88    frag: Option<Ipv4Fragment>,
89) -> bool {
90    if drop_before_rules(dst) {
91        tracing::trace!(?dst, "dropping multicast/link-local dst (pre-rule)");
92        return false;
93    }
94
95    if let Some(frag) = frag {
96        if frag.offset_blocks > 0 {
97            // A non-first fragment (Go `decode4`'s `fragOfs != 0` branch). It has no transport
98            // header to match, so the verdict is decided purely by offset:
99            if frag.offset_blocks < MIN_FRAG_BLKS {
100                // Potentially overlaps a transport header (RFC 1858); Go demotes to `unknown` → drop.
101                tracing::trace!(?dst, "dropping low-offset IPv4 fragment (RFC 1858)");
102                return false;
103            }
104            // A valid later fragment — Go maps it to `ipproto.Fragment`, which `pre()` accepts
105            // ahead of the ACL. Stateless: if the head fragment was filtered the receiver's kernel
106            // drops this on reassembly timeout. Accepting here is what large fragmented inbound
107            // traffic relies on.
108            tracing::trace!(
109                ?dst,
110                "accepting later IPv4 fragment (Go pre() pass-through)"
111            );
112            return true;
113        }
114        // `frag.offset_blocks == 0`: the first fragment (or an unfragmented packet). Go disallows a
115        // *fragmented* TSMP (a first fragment with MF set) — without the whole message it can't be a
116        // valid inter-node control packet. Fall through to the normal proto-switch for everything
117        // else; the first fragment of TCP/UDP carries its L4 header, so `dst_port` was parsed above.
118        if proto == IpProto::TSMP && frag.more_fragments {
119            tracing::trace!(?dst, "dropping fragmented TSMP (Go parity)");
120            return false;
121        }
122    }
123
124    if proto == IpProto::TSMP {
125        tracing::trace!(?dst, "accepting TSMP inbound (bypasses ACL, Go parity)");
126        return true;
127    }
128
129    let info = ts_packetfilter::PacketInfo {
130        ip_proto: proto,
131        port: dst_port,
132        src,
133        dst,
134    };
135    // TODO(npry): wire in nodecaps
136    let caps = [];
137    let verdict = filter.can_access(&info, caps);
138    tracing::trace!(?info, ?caps, verdict);
139    verdict
140}
141
142/// Apply the inbound packet filter to one peer's already-source-attributed batch of decrypted
143/// packets, in place, and harvest any TSMP disco-key advertisements it carried.
144///
145/// This is the body of Go's `tstun.Wrapper.filterPacketInboundFromWireGuard`, in Go's order:
146///
147/// 1. **TSMP consumption.** Go inspects TSMP *before* running the ACL filter and returns
148///    `filter.DropSilently` for the messages it consumes itself. The one consumed here is the
149///    disco-key advertisement (Go `packet.TSMPDiscoKeyAdvertisement`, upstream capability version
150///    144): a peer announces its disco public key right after an eligible WireGuard session comes
151///    up, so the receiver learns it without waiting for a netmap update or restarting WireGuard.
152///    A real Go peer sends this unprompted. Every *other* TSMP message (ping, pong,
153///    rejected-connection) is left in the batch and falls through to step 2, which admits it —
154///    exactly as Go's filter does for the TSMP types it does not consume.
155/// 2. **The ACL verdict**, [`inbound_filter_verdict`] (Go `runIn4`/`runIn6`).
156///
157/// `learned_disco_keys` is appended to, never cleared, so one batch can carry advertisements from
158/// several peers. A learned key is attributed to `peer_id` — the WireGuard peer whose session
159/// decrypted the packet, and whose source addresses the caller's source filter has already bound.
160/// Go reaches the same peer the long way round, looking the advertisement's source IP up in the
161/// netmap (`wgengine.userspaceEngine.peerForIP`). Either way a peer can only advertise a key for
162/// *itself*: it cannot speak for another peer.
163fn filter_inbound_from_peer(
164    filter: &(dyn ts_packetfilter::Filter + Send + Sync),
165    peer_id: PeerId,
166    packets: &mut Vec<PacketMut>,
167    learned_disco_keys: &mut Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
168) {
169    packets.retain(|packet| {
170        let bytes = packet.as_ref();
171        let Ok(pkt) = etherparse::SlicedPacket::from_ip(bytes) else {
172            tracing::trace!("does not look like ip packet");
173            return false;
174        };
175
176        let (proto, src, dst, frag) = match pkt.net {
177            Some(etherparse::NetSlice::Ipv4(ipv4)) => {
178                // IPv4 fragment state (Go `net/packet.decode4` reads `b[6:8]`): a
179                // non-first fragment carries no L4 header, so etherparse leaves
180                // `transport == None` and the port would read as 0 below — which a normal
181                // ACL rule never admits. Without classifying the fragment that silently
182                // drops valid later fragments Go *accepts* (breaking large/fragmented
183                // inbound traffic on the 1280-MTU overlay). Capture the offset (in 8-byte
184                // blocks) + the more-fragments bit so the verdict can mirror Go's
185                // `decode4`/`pre()` fragment handling.
186                let hdr = ipv4.header();
187                (
188                    IpProto::new(ipv4.payload().ip_number.0 as _),
189                    hdr.source_addr().into(),
190                    hdr.destination_addr().into(),
191                    Some(Ipv4Fragment {
192                        offset_blocks: hdr.fragments_offset().value(),
193                        more_fragments: hdr.more_fragments(),
194                    }),
195                )
196            }
197            Some(etherparse::NetSlice::Ipv6(ipv6)) => (
198                IpProto::new(ipv6.payload().ip_number.0 as _),
199                ipv6.header().source_addr().into(),
200                ipv6.header().destination_addr().into(),
201                // IPv6 fragmentation is carried in a Fragment extension header, not the
202                // base header; the tailnet is IPv4-only by default so a v6 fragment can't
203                // reach here on the live path. Treat v6 as non-fragment (the existing
204                // behavior) — full v6 fragment parity is tracked separately.
205                None,
206            ),
207            _ => {
208                // A packet that parsed as IP but is neither IPv4 nor IPv6 (e.g. a
209                // future/odd `NetSlice` shape). These bytes are attacker-controlled
210                // post-decrypt, so fail closed — drop it — rather than `unreachable!`,
211                // which would panic the single-threaded dataplane on a crafted packet.
212                // Go's filter `pre()` likewise returns Drop/"not-ip" here, never panics.
213                tracing::trace!("parsed packet is neither IPv4 nor IPv6; dropping");
214                return false;
215            }
216        };
217
218        let (_src_port, dst_port) = match pkt.transport {
219            Some(etherparse::TransportSlice::Udp(udp)) => {
220                (udp.source_port(), udp.destination_port())
221            }
222            Some(etherparse::TransportSlice::Tcp(tcp)) => {
223                (tcp.source_port(), tcp.destination_port())
224            }
225            _ => (0, 0),
226        };
227
228        // TSMP disco-key advertisement (Go `packet.TSMPDiscoKeyAdvertisement`,
229        // upstream capability version 144). Go handles TSMP in
230        // `tstun.filterPacketInboundFromWireGuard` *before* the ACL filter runs, and
231        // returns `filter.DropSilently` for an advertisement: it is an inter-node
232        // control message consumed here, never delivered to the local stack. Mirror
233        // both the position (after source attribution, before the ACL) and the drop.
234        //
235        if proto == IpProto::TSMP
236            && let Some(advert) = ts_packet::tsmp::DiscoKeyAdvertisement::parse(bytes)
237        {
238            if advert.key_is_zero() {
239                // Go publishes only `if !discoKeyAdvert.Key.IsZero()`. Still a
240                // well-formed advertisement, so it is still dropped.
241                tracing::debug!(
242                    ?peer_id,
243                    "TSMP disco-key advertisement carried the zero key; ignoring"
244                );
245            } else {
246                tracing::debug!(?peer_id, %src, "learned peer disco key over TSMP");
247                learned_disco_keys.push((peer_id, advert));
248            }
249            return false;
250        }
251
252        // The inbound proto-switch (Go `runIn4`/`runIn6`): Go `pre()` multicast/link-local
253        // drops, then the fragment classification (Go `decode4` + `pre()`), then
254        // unconditional TSMP accept, then the control-derived ACL. The caller's source
255        // attribution and `or_in.route` bound this to attributable peers and local
256        // destinations (Go's `local4`/`local6` precondition).
257        inbound_filter_verdict(filter, proto, src, dst, dst_port, frag)
258    });
259}
260
261/// Where this node sends a TSMP disco-key advertisement, and what it puts in one.
262///
263/// The send half of Go's capability version 144 (`packet.TSMPDiscoKeyAdvertisement`): when a
264/// WireGuard session with a peer is established, this node announces its own disco public key to
265/// that peer over TSMP, so the peer can learn (or re-learn) the key without waiting for a netmap
266/// update from control. It is the mirror image of the receive half in
267/// [`filter_inbound_from_peer`], and both are unconditional — a real Go peer sends us one whether
268/// or not we send one back.
269///
270/// This is the netmap state Go's [`magicsock.Conn.PriorityMessageForPeer`] reads, snapshotted into
271/// the dataplane so building the message stays a cheap, synchronous, allocation-only step on the
272/// datapath. wireguard-go requires the same of its callback: "must be cheap and must not call back
273/// into the [`Device`]". The runtime refreshes the snapshot whenever the netmap changes.
274///
275/// [`magicsock.Conn.PriorityMessageForPeer`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
276/// [`Device`]: https://github.com/tailscale/wireguard-go/blob/main/device/device.go
277#[derive(Debug, Clone, Default)]
278pub struct DiscoAdvertisementState {
279    /// This node's own disco public key, raw (Go `Conn.DiscoPublicKey()`). The all-zero key means
280    /// "no disco key", and nothing is ever advertised — Go's first refusal.
281    pub disco_key: [u8; ts_packet::tsmp::DISCO_KEY_LEN],
282    /// This node's own tailnet addresses, in the order control sent them (Go `self.Addresses()`,
283    /// already narrowed to the single-IP prefixes `selfIPMatchingFamily` accepts). The
284    /// advertisement's source is the first entry matching the destination's family.
285    pub self_addrs: Vec<std::net::IpAddr>,
286    /// Where to send an advertisement, per peer. A peer absent from this map is never advertised
287    /// to — Go's `endpointForNodeKey` miss.
288    pub peers: HashMap<PeerId, AdvertisementTarget>,
289}
290
291/// One peer's advertisement destination, as [`DiscoAdvertisementState`] holds it.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub struct AdvertisementTarget {
294    /// The peer's first tailnet address (Go `endpoint.nodeAddr`), which is the advertisement's
295    /// destination address.
296    pub node_addr: std::net::IpAddr,
297    /// Whether this is a plain WireGuard peer rather than a Tailscale node (Go
298    /// `endpoint.isWireguardOnly`). Such a peer speaks no TSMP, so Go never sends it one — and a
299    /// kernel-WireGuard or `wireguard-go` peer would hand the advertisement straight to its host
300    /// network stack as an unknown-protocol packet.
301    pub wireguard_only: bool,
302}
303
304impl DiscoAdvertisementState {
305    /// The marshalled TSMP disco-key advertisement to send `peer` on session establishment, or
306    /// `None` if this node must not advertise to it.
307    ///
308    /// Go [`magicsock.Conn.PriorityMessageForPeer`], refusal for refusal — every one of these is a
309    /// silent "send nothing", never a fallback to some other message:
310    ///
311    /// 1. **No disco key of our own** (`disco.IsZero()`): there is nothing to advertise.
312    /// 2. **Unknown peer** (`endpointForNodeKey` miss, or `!self.Valid()`): the netmap snapshot has
313    ///    no destination address for this WireGuard peer, so any address we invented would be a
314    ///    guess.
315    /// 3. **A WireGuard-only peer** (`ep.isWireguardOnly`): "Do not send TSMP messages to peers
316    ///    that only speaks wireguard."
317    /// 4. **No source address in the destination's family** (`selfIPMatchingFamily` returning the
318    ///    zero `Addr`): an IPv4-only node has nothing to put in the source field of a packet to a
319    ///    peer's IPv6 address.
320    /// 5. A marshal refusal, which by construction of (4) cannot happen — see
321    ///    [`ts_packet::tsmp::DiscoKeyAdvertisement::marshal`].
322    ///
323    /// [`magicsock.Conn.PriorityMessageForPeer`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
324    pub fn advertisement_for(&self, peer: PeerId) -> Option<Vec<u8>> {
325        if self.disco_key == [0u8; ts_packet::tsmp::DISCO_KEY_LEN] {
326            tracing::debug!(?peer, "no disco key of our own; not advertising");
327            return None;
328        }
329
330        let target = self.peers.get(&peer)?;
331
332        if target.wireguard_only {
333            return None;
334        }
335
336        let src = self_ip_matching_family(&self.self_addrs, target.node_addr)?;
337
338        ts_packet::tsmp::DiscoKeyAdvertisement {
339            src,
340            dst: target.node_addr,
341            key: self.disco_key,
342        }
343        .marshal()
344        .inspect_err(|e| tracing::debug!(?peer, error = %e, "not advertising our disco key"))
345        .ok()
346    }
347}
348
349/// This node's first tailnet address whose family matches `want`, or `None`.
350///
351/// Go `magicsock.selfIPMatchingFamily`, which walks `self.Addresses()` and returns the first
352/// single-IP prefix with `Addr().BitLen() == want.BitLen()`. `addrs` is already narrowed to
353/// single IPs by the caller that builds the snapshot, so only the family test remains.
354fn self_ip_matching_family(
355    addrs: &[std::net::IpAddr],
356    want: std::net::IpAddr,
357) -> Option<std::net::IpAddr> {
358    addrs
359        .iter()
360        .copied()
361        .find(|addr| addr.is_ipv4() == want.is_ipv4())
362}
363
364/// A data plane subsystem that can be the subject of timer events.
365pub enum Subsystem {
366    /// The wireguard component.
367    Wireguard,
368}
369
370/// The direction/path of a captured packet, mirroring Go Tailscale's `capture.Path`. The numeric
371/// values are the on-wire path codes written into each pcap record's Tailscale preamble.
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum CapturePath {
374    /// A packet from the local device, heading out to a peer (pre-encrypt).
375    FromLocal = 0,
376    /// A packet received from a peer, decrypted, heading to the local device.
377    FromPeer = 1,
378    /// A packet synthesized by us toward the local device. Retained for Go `capture.Path` on-wire
379    /// code parity (so captured pcap path codes match Go's, and a future synthesized-packet tee
380    /// point can emit it); not currently emitted — the tee only produces `FromLocal`/`FromPeer`.
381    SynthesizedToLocal = 2,
382    /// A packet synthesized by us toward a peer. Retained for Go `capture.Path` on-wire code parity
383    /// (see [`Self::SynthesizedToLocal`]); not currently emitted.
384    SynthesizedToPeer = 3,
385}
386
387impl CapturePath {
388    /// The on-wire path code (the `uint16` written into the pcap record preamble).
389    pub fn code(self) -> u16 {
390        self as u16
391    }
392}
393
394/// A debug packet-capture hook. When installed on a [`DataPlane`], it is invoked with the path and
395/// the raw IP packet bytes for every plaintext packet crossing the datapath. It must be cheap and
396/// non-blocking — it runs inline on the single-threaded dataplane step, so a slow hook backs up the
397/// datapath. Wrapped in `Arc` so it is cheap to clone and `Send + Sync` for the actor that installs
398/// it.
399pub type CaptureHook = std::sync::Arc<dyn Fn(CapturePath, &[u8]) + Send + Sync>;
400
401/// Transforms packets to make tailscale happen.
402pub struct DataPlane {
403    /// Wireguard encryption/decryption.
404    pub wireguard: Endpoint,
405
406    /// Outbound overlay router.
407    pub or_out: or::outbound::Router,
408    /// Outbound underlay router.
409    pub ur_out: ur::outbound::Router,
410
411    /// Inbound source filter.
412    pub src_filter_in: Arc<ts_bart::Table<PeerId>>,
413    /// Inbound overlay router.
414    pub or_in: or::inbound::Router,
415
416    /// The packet filter.
417    pub packet_filter: Arc<dyn ts_packetfilter::Filter + Send + Sync>,
418
419    /// Events queued for future processing.
420    pub events: Scheduler<Subsystem>,
421
422    /// Next event for the wireguard subsystem.
423    pub wg_next: Option<Handle<Subsystem>>,
424
425    /// Optional debug packet-capture hook (Go `tstun.Wrapper` capture hook). `None` (the default)
426    /// means no capture and zero datapath overhead. Installed/cleared at runtime by the dataplane
427    /// actor; see [`DataPlane::process_outbound`]/[`DataPlane::process_inbound`] for the tee points.
428    pub capture: Option<CaptureHook>,
429
430    /// Netmap snapshot for the TSMP disco-key advertisement this node sends on session
431    /// establishment (Go capability version 144). `None` (the default) advertises nothing at all,
432    /// which is what an embedder that never populates it gets — the same position this fork was in
433    /// before the send side existed, and still fully interoperable, since a peer's own
434    /// advertisement is unsolicited. Refreshed from the netmap by the runtime's dataplane actor.
435    pub disco_advertisement: Option<Arc<DiscoAdvertisementState>>,
436}
437
438impl DataPlane {
439    /// Creates a new data plane for a wireguard node key.
440    pub fn new(my_key: NodeKeyPair) -> Self {
441        DataPlane {
442            wireguard: Endpoint::new(my_key),
443            or_out: Default::default(),
444            ur_out: Default::default(),
445            src_filter_in: Default::default(),
446            or_in: Default::default(),
447            events: Default::default(),
448            packet_filter: Arc::new(ts_packetfilter::DropAllFilter),
449            wg_next: None,
450            capture: None,
451            disco_advertisement: None,
452        }
453    }
454
455    /// Processes packets originating from the local device.
456    #[tracing::instrument(skip_all, fields(n_packets = packets.len()))]
457    pub fn process_outbound(&mut self, packets: Vec<PacketMut>) -> OutboundResult {
458        if let Some(hook) = &self.capture {
459            for p in &packets {
460                hook(CapturePath::FromLocal, p.as_ref());
461            }
462        }
463
464        let or::outbound::Result {
465            to_wireguard,
466            loopback,
467        } = self.or_out.route(packets);
468
469        let to_wireguard = to_wireguard
470            .into_iter()
471            .map(|(k, v)| (ts_tunnel::PeerId(k.0), v))
472            .collect::<Vec<_>>();
473
474        let ts_tunnel::SendResult {
475            to_peers: encrypted,
476        } = self.wireguard.send(to_wireguard);
477
478        let to_peers = self
479            .ur_out
480            .route(encrypted.into_iter().map(|(k, v)| (PeerId(k.0), v)));
481
482        if let Some(next) = self.wireguard.next_event()
483            && let Some(prev) = self
484                .wg_next
485                .replace(self.events.add(next, Subsystem::Wireguard))
486        {
487            prev.cancel();
488        }
489
490        OutboundResult { to_peers, loopback }
491    }
492
493    /// Processes packets received from elsewhere.
494    pub fn process_inbound(
495        &mut self,
496        packets: impl IntoIterator<Item = PacketMut>,
497    ) -> InboundResult {
498        let ts_tunnel::RecvResult {
499            to_local,
500            to_peers,
501            sessions_established,
502        } = self.wireguard.recv(packets);
503
504        if let Some(hook) = &self.capture {
505            for packets in to_local.values() {
506                for p in packets {
507                    hook(CapturePath::FromPeer, p.as_ref());
508                }
509            }
510        }
511
512        // TSMP disco-key advertisements learned from this batch (Go `tstun.Wrapper`'s
513        // `discoKeyAdvertisementPub` publisher). Filled in by the packet-filter stage below, which
514        // is the point at which a packet has both been attributed to a peer and decoded far enough
515        // to know it is TSMP.
516        let mut learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)> =
517            Vec::new();
518
519        let to_local = to_local
520            .into_iter()
521            .map(|(peer_id, mut packets)| -> (PeerId, Vec<PacketMut>) {
522                let _span = tracing::trace_span!(
523                    "src_filter_inbound",
524                    peer_id = ?peer_id,
525                    n_packet = packets.len(),
526                )
527                .entered();
528
529                packets.retain(|packet| {
530                    let Some(src) = packet.get_src_addr() else {
531                        tracing::trace!("does not look like ip packet");
532                        return false;
533                    };
534                    let verdict = if let Some(allowed_peer) = self.src_filter_in.lookup(src) {
535                        *allowed_peer == PeerId(peer_id.0)
536                    } else {
537                        tracing::trace!(remote_ip = %src, "unknown peer address");
538                        false
539                    };
540                    tracing::trace!(?src, verdict);
541                    verdict
542                });
543
544                (PeerId(peer_id.0), packets)
545            })
546            .map(|(peer_id, mut v)| {
547                let _span = tracing::trace_span!(
548                    "packet_filter_inbound",
549                    peer_id = ?peer_id,
550                    n_packet = v.len()
551                )
552                .entered();
553
554                filter_inbound_from_peer(
555                    self.packet_filter.as_ref(),
556                    peer_id,
557                    &mut v,
558                    &mut learned_disco_keys,
559                );
560
561                v
562            });
563
564        // TSMP disco-key advertisement, send side (Go capability version 144). wireguard-go calls
565        // `peer.SendPriorityMessage()` the moment a keypair becomes current for forward
566        // transmission — on the initiator when the handshake response lands, and on the responder
567        // when the first transport packet authenticates on the new keypair (`device/receive.go`).
568        // `sessions_established` is exactly those two moments; the message is Go's
569        // `magicsock.Conn.PriorityMessageForPeer` return value. A peer we must not advertise to
570        // (see [`DiscoAdvertisementState::advertisement_for`]) simply gets nothing, and the fresh
571        // session is otherwise untouched.
572        let mut to_peers = to_peers;
573        if let Some(advert) = self.disco_advertisement.clone() {
574            // Held apart from what `recv` already queued for these peers so it can be spliced in
575            // FRONT of it below, rather than appended behind it.
576            let mut priority: HashMap<ts_tunnel::PeerId, Vec<PacketMut>> = HashMap::new();
577            for peer in sessions_established {
578                let Some(msg) = advert.advertisement_for(PeerId(peer.0)) else {
579                    continue;
580                };
581                tracing::debug!(peer_id = ?peer, "advertising our disco key over TSMP");
582                for (peer, packets) in self.wireguard.send_priority_message(peer, &msg).to_peers {
583                    priority.entry(peer).or_default().extend(packets);
584                }
585            }
586            // A priority message leads the traffic the same establishment released. wireguard-go
587            // hands it straight to the peer's *outbound* queue (`SendPriorityMessage` →
588            // `queueOutboundIfRunning`), never to the staged queue, and both call sites run it
589            // before the flush that follows — `peer.SendPriorityMessage()` ahead of
590            // `peer.SendKeepalive()` on the initiator and ahead of `peer.SendStagedPackets()` on
591            // the responder (`device/receive.go`). Here the flush has already happened inside
592            // [`Endpoint::recv`] (`activate` encrypts whatever was queued), so restoring Go's wire
593            // order means splicing the advertisement in front of it.
594            //
595            // Only the wire order is restored, not Go's nonce order: those flushed packets were
596            // sealed first and so hold the lower nonces, where Go would have numbered the priority
597            // message first. That is invisible to the peer. A WireGuard receiver accepts an
598            // earlier counter after a later one by construction, and the inversion is bounded by
599            // the send queue a session flushes on activation (`MAX_QUEUED_PER_PEER`, 32 packets) —
600            // two orders of magnitude inside the 8128-packet anti-replay window WireGuard
601            // receivers carry (`ts_tunnel`'s `ReplayWindow::WINDOW_SIZE`, wireguard-go parity).
602            for (peer, mut packets) in priority {
603                let queued = to_peers.entry(peer).or_default();
604                packets.append(queued);
605                *queued = packets;
606            }
607        }
608
609        let to_peers = to_peers
610            .into_iter()
611            .map(|(k, v)| (ts_transport::PeerId(k.0), v));
612
613        let to_local = self.or_in.route(to_local.flatten());
614        let to_peers = self.ur_out.route(to_peers);
615
616        if let Some(next) = self.wireguard.next_event()
617            && let Some(prev) = self
618                .wg_next
619                .replace(self.events.add(next, Subsystem::Wireguard))
620        {
621            prev.cancel();
622        }
623
624        InboundResult {
625            to_local,
626            to_peers,
627            learned_disco_keys,
628        }
629    }
630
631    /// Return the next time at which [`DataPlane::process_events`] must be called.
632    ///
633    /// [`DataPlane::process_outbound`], [`DataPlane::process_inbound`] and
634    /// [`DataPlane::process_events`] may all update the next event time. Callers should prefer
635    /// calling `next_event` as needed to get a correct result, rather than store the returned
636    /// value.
637    pub fn next_event(&self) -> Option<Instant> {
638        self.events.next_dispatch()
639    }
640
641    /// Process all queued events that are due for processing.
642    ///
643    /// Must be called at least as often as dictated by [`DataPlane::next_event`] for the
644    /// data plane to function correctly. It is harmless to call it more frequently.
645    pub fn process_events(&mut self) -> EventResult {
646        let mut to_peers = HashMap::new();
647        let now = Instant::now();
648        for event in self.events.dispatch(now) {
649            match event {
650                Subsystem::Wireguard => {
651                    let res = self.wireguard.dispatch_events(now);
652                    to_peers.extend(
653                        res.to_peers
654                            .into_iter()
655                            .map(|(id, pkts)| (ts_transport::PeerId(id.0), pkts)),
656                    );
657                }
658            }
659        }
660        let to_peers = self.ur_out.route(to_peers);
661
662        if let Some(next) = self.wireguard.next_event()
663            && let Some(prev) = self
664                .wg_next
665                .replace(self.events.add(next, Subsystem::Wireguard))
666        {
667            prev.cancel();
668        }
669
670        EventResult { to_peers }
671    }
672}
673
674/// The result of processing outbound packets.
675pub struct OutboundResult {
676    /// Packets to be sent into underlay transports for transmission.
677    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
678    /// Packets to be looped back and delivered to overlay transports.
679    pub loopback: HashMap<OverlayTransportId, Vec<PacketMut>>,
680}
681
682/// The result of processing inbound packets.
683pub struct InboundResult {
684    /// Decrypted packets to be delivered to overlay transports.
685    pub to_local: HashMap<OverlayTransportId, Vec<PacketMut>>,
686    /// Encrypted packets to be sent to wireguard peers by the underlay.
687    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
688    /// Disco keys peers advertised over TSMP in this batch, each paired with the WireGuard peer
689    /// whose session carried it (Go `tstun.Wrapper` publishing `events.PeerDiscoKeyUpdate`, which
690    /// `wgengine` turns into a `magicsock.Conn.HandleDiscoKeyAdvertisement` call).
691    ///
692    /// The advertisement packets themselves are dropped: they are inter-node control messages, not
693    /// traffic for the local stack. Zero keys are already filtered out. Empty for a batch that
694    /// carried none, which is the overwhelmingly common case.
695    pub learned_disco_keys: Vec<(PeerId, ts_packet::tsmp::DiscoKeyAdvertisement)>,
696}
697
698/// The result of processing an event.
699#[derive(Default)]
700pub struct EventResult {
701    /// Encrypted packets to be sent to wireguard peers by the underlay.
702    pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
703}
704
705#[cfg(test)]
706mod tests {
707    use std::sync::Mutex;
708
709    use super::*;
710
711    /// Records `(path, bytes)` for each capture-hook invocation in a test.
712    type CaptureLog = Arc<Mutex<Vec<(CapturePath, Vec<u8>)>>>;
713
714    #[test]
715    fn capture_path_codes() {
716        assert_eq!(CapturePath::FromLocal.code(), 0);
717        assert_eq!(CapturePath::FromPeer.code(), 1);
718        assert_eq!(CapturePath::SynthesizedToLocal.code(), 2);
719        assert_eq!(CapturePath::SynthesizedToPeer.code(), 3);
720    }
721
722    /// The pre-rule destination screen (Go filter `pre()`): multicast and non-allowlisted link-local
723    /// destinations are dropped before the ACL; ordinary unicast and the cloud-metadata link-local
724    /// exception pass through to the rules.
725    #[test]
726    fn pre_rule_drop_matches_go() {
727        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
728        // Dropped pre-rules:
729        assert!(drop_before_rules(ip("224.0.0.1")), "IPv4 multicast dropped");
730        assert!(
731            drop_before_rules(ip("239.255.255.250")),
732            "IPv4 multicast (SSDP) dropped"
733        );
734        assert!(
735            drop_before_rules(ip("169.254.1.1")),
736            "IPv4 link-local dropped"
737        );
738        assert!(drop_before_rules(ip("ff02::1")), "IPv6 multicast dropped");
739        assert!(drop_before_rules(ip("fe80::1")), "IPv6 link-local dropped");
740        assert!(
741            drop_before_rules(ip("febf:ffff::1")),
742            "top of fe80::/10 dropped (locks the 0xffc0/0xfe80 mask)"
743        );
744        // Passed through to the rules:
745        assert!(
746            !drop_before_rules(ip("fec0::1")),
747            "just past fe80::/10 passes (locks the 0xffc0/0xfe80 mask)"
748        );
749        // IPv4-mapped-IPv6 destinations match NEITHER arm and fall through to the ACL, exactly as
750        // Go's `netip.Addr` predicates do (no unmap/canonicalize). Pinning this guards against a
751        // future "canonicalize to be safe" refactor silently diverging from Go.
752        assert!(
753            !drop_before_rules(ip("::ffff:224.0.0.1")),
754            "4in6-mapped multicast falls through to the ACL, matching Go"
755        );
756        assert!(
757            !drop_before_rules(ip("::ffff:169.254.1.1")),
758            "4in6-mapped link-local falls through to the ACL, matching Go"
759        );
760        assert!(
761            !drop_before_rules(ip("100.64.0.5")),
762            "ordinary tailnet unicast passes"
763        );
764        assert!(
765            !drop_before_rules(ip("8.8.8.8")),
766            "ordinary public unicast passes"
767        );
768        assert!(
769            !drop_before_rules(ip("169.254.169.254")),
770            "the cloud-metadata link-local address is the Go-allowlisted exception"
771        );
772        assert!(
773            !drop_before_rules(ip("fd7a:115c:a1e0::1")),
774            "IPv6 ULA (tailnet) passes"
775        );
776    }
777
778    /// A filter that drops everything (returns `None` for every packet). Lets a test prove that TSMP
779    /// is admitted by bypassing the ACL — not by the ACL happening to allow it.
780    struct DenyAll;
781    impl ts_packetfilter::Filter for DenyAll {
782        fn match_for(
783            &self,
784            _info: &ts_packetfilter::PacketInfo,
785            _caps: ts_packetfilter::filter::CapIter,
786        ) -> Option<&str> {
787            None
788        }
789    }
790
791    /// The inbound proto-switch (Go `runIn4`/`runIn6`): TSMP is always admitted, bypassing the ACL;
792    /// `pre()` drops still win over TSMP; non-TSMP defers to the ACL.
793    #[test]
794    fn tsmp_bypasses_acl_matches_go() {
795        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
796        let src = ip("100.64.0.9");
797        let dst = ip("100.64.0.1");
798        let tsmp = IpProto::new(99);
799
800        // TSMP is accepted even though the ACL denies everything — Go `case TSMP: return Accept`.
801        assert!(
802            inbound_filter_verdict(&DenyAll, tsmp, src, dst, 0, None),
803            "TSMP admitted by bypassing the (deny-all) ACL"
804        );
805        // A non-TSMP proto under the same deny-all ACL is dropped — proves the bypass is TSMP-specific.
806        assert!(
807            !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443, None),
808            "TCP still consults the ACL (deny-all → dropped)"
809        );
810        // `pre()` drops outrank the TSMP accept: TSMP to a multicast/link-local dst is still dropped,
811        // exactly as Go runs `pre()` before the proto switch.
812        assert!(
813            !inbound_filter_verdict(&DenyAll, tsmp, src, ip("224.0.0.1"), 0, None),
814            "TSMP to a multicast dst is still dropped (pre() before the switch)"
815        );
816        assert!(
817            !inbound_filter_verdict(&DenyAll, tsmp, src, ip("169.254.1.1"), 0, None),
818            "TSMP to a link-local dst is still dropped (pre() before the switch)"
819        );
820        // IpProto::TSMP is the named constant for proto 99.
821        assert_eq!(IpProto::TSMP, tsmp, "IpProto::TSMP == 99");
822    }
823
824    /// IPv4 fragment handling, mirroring Go `net/packet.decode4` + filter `pre()`:
825    /// - a valid later fragment (offset ≥ `MIN_FRAG_BLKS`) is ACCEPTED ahead of the ACL (Go maps it
826    ///   to `ipproto.Fragment`, which `pre()` admits) — even under a deny-all ACL and even though its
827    ///   parsed port is 0, which a normal rule would never match;
828    /// - a low-offset later fragment (offset < `MIN_FRAG_BLKS`) is DROPPED (RFC 1858);
829    /// - a first fragment (offset 0) defers to the normal proto-switch/ACL on its real port;
830    /// - a *fragmented* TSMP first fragment (offset 0, MF set) is DROPPED (Go disallows it), unlike a
831    ///   non-fragmented TSMP which bypasses the ACL.
832    #[test]
833    fn ipv4_fragment_handling_matches_go_decode4() {
834        let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
835        let src = ip("100.64.0.9");
836        let dst = ip("100.64.0.1");
837        let frag = |offset_blocks: u16, more_fragments: bool| {
838            Some(Ipv4Fragment {
839                offset_blocks,
840                more_fragments,
841            })
842        };
843
844        // A valid later fragment is accepted under a DENY-ALL ACL with port 0 — proves the accept is
845        // the Go `pre()` Fragment pass-through, not the ACL happening to allow it.
846        assert!(
847            inbound_filter_verdict(
848                &DenyAll,
849                IpProto::TCP,
850                src,
851                dst,
852                0,
853                frag(MIN_FRAG_BLKS, false)
854            ),
855            "a valid later fragment (offset >= MIN_FRAG_BLKS) is accepted ahead of the ACL"
856        );
857        assert!(
858            inbound_filter_verdict(
859                &DenyAll,
860                IpProto::UDP,
861                src,
862                dst,
863                0,
864                frag(MIN_FRAG_BLKS + 50, true)
865            ),
866            "a later fragment well past the floor (MF set) is also accepted"
867        );
868
869        // A low-offset later fragment (could overlap a transport header) is dropped — RFC 1858.
870        assert!(
871            !inbound_filter_verdict(
872                &DenyAll,
873                IpProto::TCP,
874                src,
875                dst,
876                0,
877                frag(MIN_FRAG_BLKS - 1, false)
878            ),
879            "a low-offset later fragment is dropped (RFC 1858)"
880        );
881        assert!(
882            !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 0, frag(1, false)),
883            "the smallest non-zero offset is dropped"
884        );
885
886        // A first fragment (offset 0) defers to the normal ACL on its real port: deny-all drops a
887        // TCP first fragment, exactly as it drops a non-fragmented TCP packet.
888        assert!(
889            !inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443, frag(0, true)),
890            "a first fragment defers to the ACL (deny-all -> dropped) on its parsed port"
891        );
892
893        // A fragmented TSMP first fragment (offset 0, MF set) is dropped — Go disallows it — even
894        // though a non-fragmented TSMP bypasses the ACL.
895        assert!(
896            !inbound_filter_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, true)),
897            "a fragmented TSMP first fragment is dropped (Go parity)"
898        );
899        assert!(
900            inbound_filter_verdict(&DenyAll, IpProto::TSMP, src, dst, 0, frag(0, false)),
901            "a non-fragmented TSMP (offset 0, MF clear) still bypasses the ACL"
902        );
903
904        // A *later* TSMP fragment (offset >= MIN_FRAG_BLKS) is accepted via the offset-based
905        // fragment pass-through, NOT dropped by the fragmented-TSMP rule — that rule is offset-0
906        // only (a first fragment with MF). This proves the later-fragment branch is proto-independent
907        // and wins over the TSMP-specific logic (Go maps any offset>=minFragBlks to ipproto.Fragment
908        // regardless of the L4 proto byte), locking the branch ordering against regression.
909        assert!(
910            inbound_filter_verdict(
911                &DenyAll,
912                IpProto::TSMP,
913                src,
914                dst,
915                0,
916                frag(MIN_FRAG_BLKS, true)
917            ),
918            "a later TSMP fragment is accepted via the fragment path (proto-independent)"
919        );
920    }
921
922    /// Build the IPv4 packet a Go peer puts on the wire for a TSMP message: a 20-byte IPv4
923    /// header with proto 99 and `body` appended (Go `packet.Generate(IP4Header{...}, body)`,
924    /// which is what `TSMPDiscoKeyAdvertisement.Marshal` calls). The header checksum is left
925    /// zero — nothing on this path verifies it, and neither does Go's decoder.
926    fn tsmp_packet4(src: [u8; 4], dst: [u8; 4], body: &[u8]) -> PacketMut {
927        let mut buf = vec![0u8; 20 + body.len()];
928        buf[20..].copy_from_slice(body);
929        buf[0] = 0x45;
930        let total_len = buf.len() as u16;
931        buf[2..4].copy_from_slice(&total_len.to_be_bytes());
932        buf[8] = 64;
933        buf[9] = 99;
934        buf[12..16].copy_from_slice(&src);
935        buf[16..20].copy_from_slice(&dst);
936        PacketMut::from(buf)
937    }
938
939    /// A body a real Go peer sends: `'a'` then its 32-byte disco key.
940    fn advertisement_body(key: [u8; 32]) -> Vec<u8> {
941        let mut body = vec![ts_packet::tsmp::TSMP_TYPE_DISCO_ADVERTISEMENT];
942        body.extend_from_slice(&key);
943        body
944    }
945
946    /// The receive side of the TSMP disco-key advertisement, at the point Go handles it: a
947    /// well-formed advertisement is CONSUMED — the peer's key is learned and the packet is
948    /// dropped rather than delivered to the local stack (Go `filter.DropSilently`) — while every
949    /// other TSMP body is left alone and still admitted by the TSMP ACL bypass.
950    ///
951    /// The ACL here denies everything, so an admitted packet can only have come through the
952    /// TSMP bypass, and a learned key can only have come from the advertisement path.
953    #[test]
954    fn tsmp_disco_key_advertisement_is_learned_and_dropped() {
955        let peer = PeerId(7);
956        let src = [100, 64, 0, 2];
957        let dst = [100, 64, 0, 1];
958        let key = [0xa5u8; 32];
959
960        let mut packets = vec![tsmp_packet4(src, dst, &advertisement_body(key))];
961        let mut learned = Vec::new();
962        filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
963
964        assert!(
965            packets.is_empty(),
966            "a consumed advertisement must not be delivered to the local stack"
967        );
968        assert_eq!(learned.len(), 1, "the advertisement must be harvested");
969        assert_eq!(
970            learned[0].0, peer,
971            "attributed to the sending wireguard peer"
972        );
973        assert_eq!(learned[0].1.key, key, "the advertised disco key is learned");
974        assert_eq!(learned[0].1.src, std::net::IpAddr::from(src));
975
976        // A TSMP message that is NOT an advertisement stays in the batch (Go leaves the types it
977        // does not consume to the filter, which accepts TSMP) and teaches us nothing.
978        let mut ping = vec![ts_packet::tsmp::TSMP_TYPE_PING];
979        ping.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
980        let mut packets = vec![tsmp_packet4(src, dst, &ping)];
981        let mut learned = Vec::new();
982        filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
983        assert_eq!(packets.len(), 1, "a TSMP ping still bypasses the ACL");
984        assert!(learned.is_empty(), "a ping advertises no disco key");
985    }
986
987    /// The negative case, at the dataplane boundary: a TSMP body that is *nearly* an
988    /// advertisement must not be half-parsed into a learned key. None of these may put anything
989    /// in `learned` — a truncated key that was zero-padded, or a zero key that was accepted,
990    /// would be a wrong disco key bound to a real peer.
991    #[test]
992    fn malformed_tsmp_disco_key_advertisements_teach_nothing() {
993        let peer = PeerId(7);
994        let src = [100, 64, 0, 2];
995        let dst = [100, 64, 0, 1];
996
997        // A truncated advertisement: the type byte and only 31 of 32 key bytes.
998        let mut truncated = advertisement_body([0xa5u8; 32]);
999        truncated.truncate(32);
1000
1001        for (name, body, still_delivered) in [
1002            ("truncated advertisement", truncated, true),
1003            (
1004                "unknown TSMP type byte",
1005                {
1006                    let mut b = advertisement_body([0xa5u8; 32]);
1007                    b[0] = b'Z';
1008                    b
1009                },
1010                true,
1011            ),
1012            // A well-formed advertisement of the zero key: Go parses it but publishes only
1013            // `if !discoKeyAdvert.Key.IsZero()`, so it teaches nothing — and it is still a TSMP
1014            // message we consumed, so it is still dropped.
1015            (
1016                "zero-key advertisement",
1017                advertisement_body([0u8; 32]),
1018                false,
1019            ),
1020        ] {
1021            let mut packets = vec![tsmp_packet4(src, dst, &body)];
1022            let mut learned = Vec::new();
1023            filter_inbound_from_peer(&DenyAll, peer, &mut packets, &mut learned);
1024
1025            assert!(
1026                learned.is_empty(),
1027                "a {name} must not be half-parsed into a learned disco key"
1028            );
1029            assert_eq!(
1030                packets.len(),
1031                usize::from(still_delivered),
1032                "a {name} must {} be delivered",
1033                if still_delivered { "still" } else { "not" }
1034            );
1035        }
1036    }
1037
1038    /// Our own disco key, the one this node advertises. Asymmetric so a reversed or offset slice
1039    /// would be visible in the marshalled bytes.
1040    const SELF_DISCO_KEY: [u8; 32] = [
1041        0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
1042        0x00, 0x9c, 0x5f, 0x3a, 0x01, 0x7d, 0xe2, 0x44, 0xb8, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a,
1043        0x69, 0x78,
1044    ];
1045
1046    /// An advertisement state with one peer, a v4 and a v6 address of our own, and a real disco key.
1047    fn advertisement_state(peer: PeerId, target: AdvertisementTarget) -> DiscoAdvertisementState {
1048        DiscoAdvertisementState {
1049            disco_key: SELF_DISCO_KEY,
1050            self_addrs: vec![
1051                std::net::IpAddr::from([100, 64, 0, 1]),
1052                std::net::IpAddr::from([
1053                    0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1054                ]),
1055            ],
1056            peers: HashMap::from([(peer, target)]),
1057        }
1058    }
1059
1060    /// What this node advertises, and to whom (Go `magicsock.Conn.PriorityMessageForPeer`): the
1061    /// happy path emits the exact bytes `TSMPDiscoKeyAdvertisement.Marshal` emits, and each of Go's
1062    /// refusals emits nothing at all.
1063    #[test]
1064    fn disco_advertisement_matches_priority_message_for_peer() {
1065        let peer = PeerId(3);
1066        let peer_v4 = std::net::IpAddr::from([100, 64, 0, 2]);
1067        let target = AdvertisementTarget {
1068            node_addr: peer_v4,
1069            wireguard_only: false,
1070        };
1071        let state = advertisement_state(peer, target);
1072
1073        // Happy path: a v4 peer gets a v4 advertisement sourced from our v4 address — the first
1074        // self address in the destination's family (Go `selfIPMatchingFamily`).
1075        let msg = state
1076            .advertisement_for(peer)
1077            .expect("a Tailscale peer with a matching-family address must be advertised to");
1078        let parsed = ts_packet::tsmp::DiscoKeyAdvertisement::parse(&msg)
1079            .expect("what we emit must parse as an advertisement");
1080        assert_eq!(parsed.key, SELF_DISCO_KEY, "we advertise OUR disco key");
1081        assert_eq!(parsed.src, std::net::IpAddr::from([100, 64, 0, 1]));
1082        assert_eq!(parsed.dst, peer_v4);
1083        assert_eq!(
1084            msg,
1085            ts_packet::tsmp::DiscoKeyAdvertisement {
1086                src: std::net::IpAddr::from([100, 64, 0, 1]),
1087                dst: peer_v4,
1088                key: SELF_DISCO_KEY,
1089            }
1090            .marshal()
1091            .unwrap(),
1092            "the emitted bytes are exactly what Marshal produces"
1093        );
1094
1095        // A v6 peer is sourced from our v6 address, not our v4 one.
1096        let peer_v6 = std::net::IpAddr::from([
1097            0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2,
1098        ]);
1099        let v6_state = advertisement_state(
1100            peer,
1101            AdvertisementTarget {
1102                node_addr: peer_v6,
1103                wireguard_only: false,
1104            },
1105        );
1106        let parsed = v6_state
1107            .advertisement_for(peer)
1108            .and_then(|m| ts_packet::tsmp::DiscoKeyAdvertisement::parse(&m))
1109            .expect("a v6 peer must be advertised to over v6");
1110        assert!(parsed.src.is_ipv6(), "source must match the peer's family");
1111        assert_eq!(parsed.dst, peer_v6);
1112
1113        // Refusal 1 (Go `disco.IsZero()`): no disco key of our own, nothing to advertise.
1114        let mut no_key = advertisement_state(peer, target);
1115        no_key.disco_key = [0u8; 32];
1116        assert!(
1117            no_key.advertisement_for(peer).is_none(),
1118            "the zero disco key must never be advertised"
1119        );
1120
1121        // Refusal 2 (Go `endpointForNodeKey` miss / `!self.Valid()`): a peer the netmap snapshot
1122        // does not cover, and a node with no addresses of its own.
1123        assert!(
1124            state.advertisement_for(PeerId(0xbad)).is_none(),
1125            "an unknown peer must not be advertised to"
1126        );
1127        let mut no_self = advertisement_state(peer, target);
1128        no_self.self_addrs.clear();
1129        assert!(
1130            no_self.advertisement_for(peer).is_none(),
1131            "a node with no tailnet address of its own has no source to advertise from"
1132        );
1133
1134        // Refusal 3 (Go `ep.isWireguardOnly`): "Do not send TSMP messages to peers that only speaks
1135        // wireguard" — such a peer would hand it to its host stack as an unknown protocol.
1136        let wg_only = advertisement_state(
1137            peer,
1138            AdvertisementTarget {
1139                node_addr: peer_v4,
1140                wireguard_only: true,
1141            },
1142        );
1143        assert!(
1144            wg_only.advertisement_for(peer).is_none(),
1145            "a WireGuard-only peer must never be sent TSMP"
1146        );
1147
1148        // Refusal 4 (Go `selfIPMatchingFamily` returning the zero Addr): an IPv4-only node has no
1149        // source address for a packet to a peer's IPv6 address.
1150        let mut v4_only = advertisement_state(
1151            peer,
1152            AdvertisementTarget {
1153                node_addr: peer_v6,
1154                wireguard_only: false,
1155            },
1156        );
1157        v4_only.self_addrs = vec![std::net::IpAddr::from([100, 64, 0, 1])];
1158        assert!(
1159            v4_only.advertisement_for(peer).is_none(),
1160            "no self address in the peer's family means no advertisement"
1161        );
1162    }
1163
1164    /// End to end, over a real WireGuard handshake: when a session with a peer comes up, this
1165    /// node's dataplane emits its own TSMP disco-key advertisement to that peer — and the peer's
1166    /// dataplane learns the key from it and drops the packet.
1167    ///
1168    /// This is the send side (Go capability version 144) meeting the receive side already in this
1169    /// tree, so the assertion is not "some bytes went out" but "the far side learned exactly the
1170    /// disco key we hold". B is deliberately left with no advertisement state, which also pins the
1171    /// unconfigured case: it establishes the same session and sends nothing back.
1172    #[test]
1173    fn session_establishment_advertises_our_disco_key_to_the_peer() {
1174        let underlay: UnderlayTransportId = 0.into();
1175        let wg_peer = ts_tunnel::PeerId(1);
1176        let peer = PeerId(1);
1177        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
1178        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
1179
1180        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
1181        let (mut a, mut b) = (
1182            DataPlane::new(a_static.clone()),
1183            DataPlane::new(b_static.clone()),
1184        );
1185
1186        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
1187            dp.wireguard.upsert_peer(
1188                wg_peer,
1189                ts_tunnel::PeerConfig {
1190                    key,
1191                    psk: [0u8; 32].into(),
1192                    persistent_keepalive_interval: None,
1193                },
1194            );
1195            dp.ur_out.table.insert(peer, underlay);
1196        }
1197
1198        // Only A knows how to advertise: its own disco key, its own address, and B's address.
1199        a.disco_advertisement = Some(Arc::new(advertisement_state(
1200            peer,
1201            AdvertisementTarget {
1202                node_addr: b_addr,
1203                wireguard_only: false,
1204            },
1205        )));
1206
1207        // B attributes A's tailnet address to the WireGuard peer that carries it, as the runtime's
1208        // source filter does — without that, B drops the advertisement before parsing it.
1209        let mut src_filter = ts_bart::Table::default();
1210        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
1211        b.src_filter_in = Arc::new(src_filter);
1212
1213        // Drive the handshake. Only the initiation is kicked off directly (the dataplane starts one
1214        // from routed outbound traffic, which is not what this test is about); everything after it
1215        // goes through `process_inbound`, the path under test.
1216        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
1217            out.into_values().flatten().collect::<Vec<_>>()
1218        };
1219        let init = a
1220            .wireguard
1221            .send([(wg_peer, vec![PacketMut::from(&b"hello"[..])])])
1222            .to_peers
1223            .remove(&wg_peer)
1224            .expect("handshake initiation");
1225
1226        let resp = take(b.process_inbound(init).to_peers);
1227        assert!(!resp.is_empty(), "B must answer the handshake initiation");
1228
1229        // A completes the handshake. Its session is now current, so alongside the queued data it
1230        // emits the advertisement.
1231        let from_a = take(a.process_inbound(resp).to_peers);
1232        assert_eq!(
1233            from_a.len(),
1234            2,
1235            "A must emit the queued data AND its disco-key advertisement"
1236        );
1237
1238        // B learns A's disco key from it, and the advertisement itself is consumed rather than
1239        // delivered to B's local stack.
1240        let inbound = b.process_inbound(from_a);
1241        assert_eq!(
1242            inbound
1243                .learned_disco_keys
1244                .iter()
1245                .map(|(peer, advert)| (*peer, advert.key))
1246                .collect::<Vec<_>>(),
1247            vec![(peer, SELF_DISCO_KEY)],
1248            "B must learn exactly the disco key A holds, attributed to A's wireguard peer"
1249        );
1250        assert!(
1251            inbound.to_peers.is_empty(),
1252            "B has no advertisement state, so it advertises nothing back"
1253        );
1254    }
1255
1256    /// Order regression: the advertisement must LEAD the traffic the same establishment released,
1257    /// not trail it.
1258    ///
1259    /// wireguard-go hands a priority message straight to the peer's *outbound* queue
1260    /// (`SendPriorityMessage` → `queueOutboundIfRunning`) and runs it before the flush that
1261    /// follows at both call sites — `peer.SendPriorityMessage()` ahead of `peer.SendKeepalive()`
1262    /// on the initiator and ahead of `peer.SendStagedPackets()` on the responder
1263    /// (`device/receive.go`) — so the advertisement is the first thing on the wire once a keypair
1264    /// becomes current. In this tree the flush has already happened inside `Endpoint::recv` by the
1265    /// time the advertisement exists, so `process_inbound` has to splice it in front; appending it
1266    /// would put it behind up to `MAX_QUEUED_PER_PEER` packets of queued traffic.
1267    ///
1268    /// The order is read off B's *decrypted* stream — its capture tee, which sees every inbound
1269    /// packet before any filtering — so what is pinned is the order the peer actually observes,
1270    /// not the order of a local vector.
1271    #[test]
1272    fn the_advertisement_leads_the_traffic_released_by_the_same_establishment() {
1273        let underlay: UnderlayTransportId = 0.into();
1274        let wg_peer = ts_tunnel::PeerId(1);
1275        let peer = PeerId(1);
1276        let a_addr = std::net::IpAddr::from([100, 64, 0, 1]);
1277        let b_addr = std::net::IpAddr::from([100, 64, 0, 2]);
1278
1279        let (a_static, b_static) = (NodeKeyPair::new(), NodeKeyPair::new());
1280        let (mut a, mut b) = (
1281            DataPlane::new(a_static.clone()),
1282            DataPlane::new(b_static.clone()),
1283        );
1284
1285        for (dp, key) in [(&mut a, b_static.public), (&mut b, a_static.public)] {
1286            dp.wireguard.upsert_peer(
1287                wg_peer,
1288                ts_tunnel::PeerConfig {
1289                    key,
1290                    psk: [0u8; 32].into(),
1291                    persistent_keepalive_interval: None,
1292                },
1293            );
1294            dp.ur_out.table.insert(peer, underlay);
1295        }
1296
1297        a.disco_advertisement = Some(Arc::new(advertisement_state(
1298            peer,
1299            AdvertisementTarget {
1300                node_addr: b_addr,
1301                wireguard_only: false,
1302            },
1303        )));
1304
1305        let mut src_filter = ts_bart::Table::default();
1306        src_filter.insert(ipnet::IpNet::from(a_addr), peer);
1307        b.src_filter_in = Arc::new(src_filter);
1308
1309        // Everything B decrypts, in arrival order, before any filtering runs.
1310        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
1311        let sink = recorded.clone();
1312        b.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
1313            sink.lock().unwrap().push((path, bytes.to_vec()));
1314        }));
1315
1316        let take = |out: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>| {
1317            out.into_values().flatten().collect::<Vec<_>>()
1318        };
1319
1320        // Traffic for a peer with no session yet: it stages, and a handshake starts.
1321        const QUEUED: &[u8] = b"staged while the session was still coming up";
1322        let init = a
1323            .wireguard
1324            .send([(wg_peer, vec![PacketMut::from(QUEUED)])])
1325            .to_peers
1326            .remove(&wg_peer)
1327            .expect("handshake initiation");
1328        let resp = take(b.process_inbound(init).to_peers);
1329
1330        // A's keypair becomes current here, which both flushes the staged packet and produces the
1331        // advertisement — the batch whose order is under test.
1332        let from_a = take(a.process_inbound(resp).to_peers);
1333        assert_eq!(
1334            from_a.len(),
1335            2,
1336            "A must emit the queued data AND its disco-key advertisement"
1337        );
1338
1339        // Hand them to B in exactly the order A produced them.
1340        let learned = b.process_inbound(from_a).learned_disco_keys;
1341        assert_eq!(
1342            learned
1343                .iter()
1344                .map(|(peer, advert)| (*peer, advert.key))
1345                .collect::<Vec<_>>(),
1346            vec![(peer, SELF_DISCO_KEY)],
1347            "B must still learn A's disco key"
1348        );
1349
1350        let advertisement = ts_packet::tsmp::DiscoKeyAdvertisement {
1351            src: a_addr,
1352            dst: b_addr,
1353            key: SELF_DISCO_KEY,
1354        }
1355        .marshal()
1356        .expect("a v4 advertisement between two v4 addresses marshals");
1357
1358        let captured = recorded.lock().unwrap();
1359        let from_peer = captured
1360            .iter()
1361            .filter(|(path, _)| *path == CapturePath::FromPeer)
1362            .map(|(_, bytes)| bytes.as_slice())
1363            .collect::<Vec<_>>();
1364        assert_eq!(from_peer.len(), 2, "B must decrypt both of A's packets");
1365        // The send path zero-pads each payload up to a 16-byte boundary and the receiver delivers
1366        // it with that padding intact (see `session::PADDING_MULTIPLE`), so compare on the leading
1367        // bytes rather than for equality.
1368        assert!(
1369            from_peer[0].starts_with(&advertisement),
1370            "the advertisement must reach the peer FIRST, ahead of the traffic the same \
1371             establishment released"
1372        );
1373        assert!(
1374            from_peer[1].starts_with(QUEUED),
1375            "the queued traffic follows the advertisement"
1376        );
1377    }
1378
1379    /// Behavioral guard: an installed capture hook MUST be invoked with `CapturePath::FromLocal`
1380    /// and the exact packet bytes for every outbound packet. The tee sits at the top of
1381    /// `process_outbound`, before `or_out.route` consumes the packets, so it fires regardless of
1382    /// whether a wireguard peer exists (an empty router just drops the routed packets afterward).
1383    /// This is the only end-to-end guard that the dataplane capture tee actually fires; a refactor
1384    /// that drops the tee would leave every byte-layout test green.
1385    #[test]
1386    fn capture_hook_fires_on_outbound() {
1387        let mut dp = DataPlane::new(NodeKeyPair::new());
1388
1389        let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
1390        let sink = recorded.clone();
1391        dp.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
1392            sink.lock().unwrap().push((path, bytes.to_vec()));
1393        }));
1394
1395        // The outbound tee passes `p.as_ref()` as-given; the bytes need not be a valid IP packet.
1396        let payload: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
1397        let packet = PacketMut::from(payload.clone());
1398
1399        drop(dp.process_outbound(vec![packet]));
1400
1401        let captured = recorded.lock().unwrap();
1402        assert_eq!(captured.len(), 1, "hook must fire exactly once per packet");
1403        assert_eq!(captured[0].0, CapturePath::FromLocal);
1404        assert_eq!(captured[0].1, payload);
1405    }
1406}