Gunnar

nordisk/tunnr-tailscale

src/netmap.rs

//! **Netmap → peer set.** The control plane's answer becomes the peers the one
//! data plane understands, with every lossy step named rather than performed
//! quietly.
//!
//! # The wire model is Tailscale's own, not a re-implementation
//!
//! `MapResponse`, `Node` and the rest come from **`ts_control_serde`**, one of
//! the crates `tailscale/tailscale-rs` publishes under BSD-3-Clause. That is
//! deliberate: their coordination protocol is a version-coupled contract
//! documented mostly by that source, so a hand-written parser would be a second
//! definition of it that drifts silently. Depending on theirs makes their
//! release the update. See `THIRD-PARTY.md`, and the crate docs for why
//! `ts_control` and `ts_derp` are *not* taken.
//!
//! # Four lossy steps, all of them findings
//!
//! The conversion is not a mapping. Four things genuinely do not survive it, and
//! every one produces a working-looking tunnel that carries nothing if it is
//! done silently:
//!
//! 1. **A node has many endpoints; a `PeerSpec` holds one.** Tailscale sends
//!    every candidate — public, LAN, STUN-discovered — because **disco picks**
//!    which one works. With no disco we are guessing, and a guess that lands on
//!    a LAN address from another network is a peer that never answers. Named
//!    ([`NodeFinding::EndpointGuessed`]), not hidden.
//! 2. **`AllowedIPs: null` does not mean "no routes".** Since capability
//!    version 112 it means *the same as `Addresses`*. Read as an empty list, a
//!    peer would be routed nothing — which is a tunnel that comes up and
//!    silently drops every packet meant for that node. This is the subtlety
//!    that most rewards using their own types and their own captured bytes.
//! 3. **IPv6 is dropped.** `PeerSpec::allowed_ips` is `(Ipv4Addr, u8)` and
//!    `endpoint` is a `SocketAddrV4`. Every Tailscale node has an
//!    `fd7a:115c:a1e0::/48` address too, and traffic to it will not enter the
//!    tunnel.
//! 4. **An offline peer is still in the netmap.** Building it is correct — it
//!    may come back — but a caller that expected a working path to it deserves
//!    to be told.

use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};

use ts_control_serde::{MapResponse, Node};
use wg_appliance_core::dataplane::{PeerSpec, TunnelSpec};
use wg_appliance_core::keys::KEY_LEN;

use crate::reach::{MissingPiece, Reachability, NOT_IMPLEMENTED_IN_THIS_BUILD};

/// Something true about one node that the conversion could not carry across.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeFinding {
    /// The node's stable id, so a finding names a device an operator can find.
    pub stable_id: String,
    /// What happened.
    pub kind: NodeFindingKind,
}

/// The kinds of thing [`NodeFinding`] reports.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeFindingKind {
    /// The node listed several candidate endpoints and one was chosen without
    /// disco to say which of them works. **The choice may be wrong**, and a
    /// wrong choice is a peer that never answers.
    EndpointGuessed {
        /// How many candidates the control plane offered.
        candidates: usize,
        /// The one taken.
        chosen: SocketAddrV4,
    },
    /// The node offered no IPv4 endpoint at all, so this peer can only be
    /// reached through DERP — which this build does not have.
    NoIpv4Endpoint {
        /// How many endpoints there were in total, IPv6 included.
        total_endpoints: usize,
    },
    /// `AllowedIPs` was absent, which since capability version 112 means "the
    /// same as `Addresses`". Recorded because reading it as an empty list is a
    /// silent black hole and this is where that would have happened.
    AllowedIpsDefaultedToAddresses,
    /// IPv6 prefixes this node owns were dropped, because the data plane routes
    /// IPv4 only.
    Ipv6PrefixesDropped {
        /// How many v6 prefixes were dropped.
        count: usize,
    },
    /// The control plane says this node is offline. It is still built — it may
    /// return — but nothing will answer today.
    Offline,
    /// The node's key was not usable as a 32-byte WireGuard public key.
    UnusableKey,
    /// The node owned no IPv4 prefix at all, so no outbound packet could ever
    /// select it. Not built.
    NoIpv4Routes,
}

impl NodeFinding {
    /// The one-line marker a log prints.
    pub fn marker_line(&self) -> String {
        let what = match &self.kind {
            NodeFindingKind::EndpointGuessed { candidates, chosen } => format!(
                "{candidates} candidate endpoints and no disco to choose between them — took \
                 {chosen}. If it is the wrong one this peer never answers"
            ),
            NodeFindingKind::NoIpv4Endpoint { total_endpoints } => format!(
                "no IPv4 endpoint among {total_endpoints} — reachable only through DERP, \
                 which this build does not have"
            ),
            NodeFindingKind::AllowedIpsDefaultedToAddresses => {
                "AllowedIPs absent, so it means the node's own Addresses (capability version \
                 112). Reading it as an empty list would have routed this peer nothing"
                    .to_string()
            }
            NodeFindingKind::Ipv6PrefixesDropped { count } => format!(
                "{count} IPv6 prefix(es) dropped — the data plane routes IPv4 only, so \
                 traffic to them leaves on the underlay"
            ),
            NodeFindingKind::Offline => {
                "the control plane says this node is offline".to_string()
            }
            NodeFindingKind::UnusableKey => {
                "the node key is not a usable 32-byte WireGuard public key — not built"
                    .to_string()
            }
            NodeFindingKind::NoIpv4Routes => {
                "no IPv4 prefix, so no outbound packet could ever select this peer — not built"
                    .to_string()
            }
        };
        format!("[tunnr] tailscale: {} — {what}", self.stable_id)
    }
}

/// What a netmap yielded: the peers, what was lost getting there, and whether
/// any of it can actually carry traffic.
#[derive(Debug, Clone)]
pub struct TailnetPeerSet {
    /// The peers, in the order the control plane listed them.
    pub peers: Vec<PeerSpec>,
    /// Everything the conversion could not carry.
    pub findings: Vec<NodeFinding>,
    /// Whether a tunnel built from this would be a tailnet member.
    pub reachability: Reachability,
}

impl TailnetPeerSet {
    /// Build a tunnel, or refuse.
    ///
    /// **Today this always refuses**, because [`Reachability::is_reachable`] is
    /// false while there is no DERP client and no disco. That is the point: a
    /// caller cannot obtain a `TunnelSpec` from this crate and bring it up
    /// believing it has joined a tailnet. The `Err` carries the sentence that
    /// says why, so a boot log is actionable instead of merely negative.
    pub fn into_tunnel_or_refuse(
        self,
        private_key: [u8; KEY_LEN],
    ) -> Result<TunnelSpec, String> {
        if !self.reachability.is_reachable() {
            return Err(self.reachability.refusal());
        }
        if self.peers.is_empty() {
            return Err("the netmap named no usable peers".into());
        }
        Ok(TunnelSpec {
            private_key,
            // Tailscale's conventional port. Not load-bearing here — with no
            // disco nothing will dial us — and stated rather than left at a
            // value someone would later read as meaningful.
            listen_port: Some(41641),
            peers: self.peers,
        })
    }
}

/// Turn a control-plane `MapResponse` into a peer set.
///
/// Reads `Peers` when present and falls back to `PeersChanged` otherwise, which
/// is what the protocol says: `Peers` is the complete list and takes precedence,
/// `PeersChanged` is the delta. A first response in a long-poll stream may carry
/// either.
pub fn peers_from_map_response(response: &MapResponse<'_>) -> TailnetPeerSet {
    let nodes: &[Node<'_>] = response
        .peers
        .as_deref()
        .or(response.peers_changed.as_deref())
        .unwrap_or(&[]);

    let mut peers = Vec::new();
    let mut findings = Vec::new();
    for node in nodes {
        match peer_for_node(node, &mut findings) {
            Some(peer) => peers.push(peer),
            None => continue,
        }
    }

    let mut missing = NOT_IMPLEMENTED_IN_THIS_BUILD.to_vec();
    if peers.is_empty() {
        missing.push(MissingPiece::Peers);
    }
    if findings
        .iter()
        .any(|f| matches!(f.kind, NodeFindingKind::Ipv6PrefixesDropped { .. }))
    {
        missing.push(MissingPiece::Ipv6Routing);
    }
    missing.sort();
    missing.dedup();

    TailnetPeerSet {
        peers,
        findings,
        reachability: Reachability { missing },
    }
}

/// One node → one `PeerSpec`, or `None` when it cannot be built at all.
fn peer_for_node(node: &Node<'_>, findings: &mut Vec<NodeFinding>) -> Option<PeerSpec> {
    let id = node.stable_id.0.to_string();
    let mut note = |kind: NodeFindingKind| {
        findings.push(NodeFinding { stable_id: id.clone(), kind });
    };

    let public_key: [u8; KEY_LEN] = node.key.to_bytes();
    // A key of all zeroes is the `Default` a missing field deserialises to, and
    // it is a valid-looking 32 bytes that would build a peer nothing can ever
    // hand-shake with. The one identity value that must be refused here.
    if public_key == [0u8; KEY_LEN] {
        note(NodeFindingKind::UnusableKey);
        return None;
    }

    if !node.online.unwrap_or(false) {
        note(NodeFindingKind::Offline);
    }

    // `AllowedIPs: null` means "the same as Addresses" from capability version
    // 112 onward. Reading it as an empty list routes this peer NOTHING, which
    // is a tunnel that comes up and silently drops every packet for that node.
    let routes: Vec<ipnet::IpNet> = match node.allowed_ips.as_ref() {
        Some(list) => list.clone(),
        None => {
            note(NodeFindingKind::AllowedIpsDefaultedToAddresses);
            vec![
                ipnet::IpNet::V4(node.addresses.0),
                ipnet::IpNet::V6(node.addresses.1),
            ]
        }
    };

    let mut allowed_ips: Vec<(Ipv4Addr, u8)> = Vec::new();
    let mut dropped_v6 = 0usize;
    for net in &routes {
        match net {
            ipnet::IpNet::V4(v4) => allowed_ips.push((v4.network(), v4.prefix_len())),
            ipnet::IpNet::V6(_) => dropped_v6 += 1,
        }
    }
    if dropped_v6 > 0 {
        note(NodeFindingKind::Ipv6PrefixesDropped { count: dropped_v6 });
    }
    if allowed_ips.is_empty() {
        note(NodeFindingKind::NoIpv4Routes);
        return None;
    }

    // The endpoint. There is no right answer without disco, so the choice is
    // made explicitly and reported: the first IPv4 candidate, which is the
    // publicly routable one in every captured netmap because the control plane
    // lists the STUN-discovered address first.
    let v4_endpoints: Vec<SocketAddrV4> = node
        .endpoints
        .iter()
        .filter_map(|e| match e {
            SocketAddr::V4(v4) => Some(*v4),
            SocketAddr::V6(_) => None,
        })
        .collect();
    let endpoint = match v4_endpoints.first() {
        Some(first) => {
            if v4_endpoints.len() > 1 {
                note(NodeFindingKind::EndpointGuessed {
                    candidates: v4_endpoints.len(),
                    chosen: *first,
                });
            }
            Some(*first)
        }
        None => {
            note(NodeFindingKind::NoIpv4Endpoint {
                total_endpoints: node.endpoints.len(),
            });
            // `None` is not a failure: it is a peer whose endpoint would be
            // learned from the first authenticated packet it sends us. With no
            // DERP nothing will, which is what `Reachability` already says.
            None
        }
    };

    Some(PeerSpec {
        public_key,
        // Tailscale does not use WireGuard's pre-shared key. Its post-quantum
        // story is elsewhere entirely, and putting anything here would be an
        // invention.
        preshared_key: None,
        endpoint,
        allowed_ips,
        // 25 s, as a node behind NAT needs. Harmless when the path is direct.
        persistent_keepalive: Some(25),
    })
}