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}