Gunnar

nordisk/tunnr-tailscale

src/reach.rs

//! **Whether a peer set can actually carry traffic**, which for Tailscale is a
//! different question from whether it parsed.
//!
//! # Why this is a type and not a comment
//!
//! The failure this guards against is not a crash. It is an appliance that
//! parses a netmap, builds a correct-looking peer set, brings a WireGuard
//! interface up, reports healthy — and that no peer will ever send a packet to.
//! Every step succeeds. Nothing logs an error. The node is simply not a member
//! of the tailnet it says it is in.
//!
//! So "is this reachable" is carried as a value that a caller has to look at,
//! and the one function that produces a tunnel refuses while the answer is no.

/// A piece of Tailscale that this build does not have, and what its absence
/// costs. Ordered roughly by how fatal it is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MissingPiece {
    /// **No DERP client.** Fatal, and the one most likely to be
    /// underestimated.
    ///
    /// DERP is not a fallback path for when NAT traversal fails. Disco — the
    /// probe protocol that makes a direct endpoint usable — runs over it, and
    /// a peer will not send to an endpoint disco has not validated. A node
    /// with no DERP client is unreachable, not degraded.
    DerpClient,
    /// **No disco.** Fatal for the same reason from the other side: even with
    /// a DERP connection, this node cannot answer the probes that would let a
    /// peer accept its direct endpoint.
    Disco,
    /// **No control-plane client.** Without it there is no netmap at all, so
    /// there is no peer set to have an opinion about — and, just as important,
    /// no node-key renewal and no updates when a peer moves.
    ControlClient,
    /// **The netmap carried no peers.** Not a missing implementation: a
    /// tailnet of one. Worth naming separately so it is not mistaken for a
    /// parse failure.
    Peers,
    /// **IPv6 was dropped.** The data plane's `PeerSpec` routes IPv4 only, and
    /// a Tailscale node always has both a `100.64.0.0/10` v4 address and an
    /// `fd7a:115c:a1e0::/48` v6 one. Traffic to the v6 half will not be routed
    /// into the tunnel — it will take whatever the underlay does with it.
    /// Not fatal to the tunnel, and absolutely not silent.
    Ipv6Routing,
}

impl MissingPiece {
    /// Whether this absence makes the node **unreachable** rather than merely
    /// reduced.
    pub fn is_fatal(&self) -> bool {
        matches!(
            self,
            MissingPiece::DerpClient | MissingPiece::Disco | MissingPiece::ControlClient
        )
    }

    /// The sentence that says what is missing and what it costs.
    pub fn explain(&self) -> &'static str {
        match self {
            MissingPiece::DerpClient => {
                "no DERP client. DERP is MANDATORY, not a fallback: disco runs over it, and a \
                 peer will not send to an endpoint disco has not validated. Without it this \
                 node is unreachable — every other part can succeed and no packet will ever \
                 arrive"
            }
            MissingPiece::Disco => {
                "no disco. Even over DERP, this node cannot answer the probes that would let \
                 a peer accept its direct endpoints, so no peer will use them"
            }
            MissingPiece::ControlClient => {
                "no control-plane client. A tailnet member is defined by its coordination \
                 server, not by a config file: without one there is no peer set, no node-key \
                 renewal and no update when a peer moves"
            }
            MissingPiece::Peers => {
                "the netmap named no usable peers. A tailnet of one is a legitimate state, \
                 not a parse failure — but there is nothing to route to"
            }
            MissingPiece::Ipv6Routing => {
                "IPv6 is not routed into the tunnel. The data plane's peer routing is IPv4 \
                 only, and every Tailscale node also has an fd7a:115c:a1e0::/48 address, so \
                 traffic to the v6 half of the tailnet leaves on the underlay"
            }
        }
    }
}

/// What a peer set can and cannot do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reachability {
    /// Everything absent, worst first.
    pub missing: Vec<MissingPiece>,
}

impl Reachability {
    /// Whether a tunnel built from this peer set would actually be a member of
    /// the tailnet.
    ///
    /// False whenever anything fatal is missing — which, in this build, it
    /// always is: there is no DERP client and no disco. That is not
    /// pessimism, it is the measured state, and it is asserted in the tests so
    /// it cannot quietly become optimism.
    pub fn is_reachable(&self) -> bool {
        !self.missing.iter().any(MissingPiece::is_fatal)
    }

    /// The one-line refusal, naming every fatal absence.
    pub fn refusal(&self) -> String {
        let reasons = self
            .missing
            .iter()
            .filter(|m| m.is_fatal())
            .map(|m| m.explain())
            .collect::<Vec<_>>()
            .join("; ");
        format!(
            "this peer set is NOT a working tailnet membership: {reasons}. Bringing a \
             WireGuard interface up from it would report healthy and carry nothing"
        )
    }

    /// The non-fatal absences, which a caller should log and proceed past.
    pub fn warnings(&self) -> impl Iterator<Item = &MissingPiece> {
        self.missing.iter().filter(|m| !m.is_fatal())
    }
}

/// What this build is missing, unconditionally, today.
///
/// Both are transports: an HTTPS client to the coordination server and a DERP
/// client. Tailscale's own implementations of both exist and are BSD-3-Clause —
/// and are unusable here, because `ts_derp` reaches MPL-2.0 through
/// `ts_transport`'s `dyn-eq` and `ts_control`/`ts_http_util` reach C through
/// `ts_tls_util`'s hardcoded `aws-lc-rs`. Writing them is the real remaining
/// cost of Tailscale support, and naming them here is how that cost stays
/// visible.
pub const NOT_IMPLEMENTED_IN_THIS_BUILD: &[MissingPiece] = &[
    MissingPiece::DerpClient,
    MissingPiece::Disco,
    MissingPiece::ControlClient,
];

#[cfg(test)]
mod tests {
    use super::*;

    /// RED-BY-CONSTRUCTION — the gap that defines this crate's honesty. If
    /// this test ever passes trivially because `is_fatal` started returning
    /// false, an appliance would start claiming tailnet membership it does not
    /// have.
    #[test]
    fn a_build_without_derp_is_unreachable_not_degraded() {
        let r = Reachability {
            missing: NOT_IMPLEMENTED_IN_THIS_BUILD.to_vec(),
        };
        assert!(!r.is_reachable());
        let refusal = r.refusal();
        for expected in ["DERP is MANDATORY", "disco", "coordination server", "unreachable"] {
            assert!(refusal.contains(expected), "the refusal names {expected}: {refusal}");
        }
    }

    /// The other half, or the assertion above is satisfied by a function that
    /// always says no: a peer set missing only non-fatal things IS reachable.
    #[test]
    fn a_peer_set_missing_only_ipv6_routing_is_reachable() {
        let r = Reachability {
            missing: vec![MissingPiece::Ipv6Routing],
        };
        assert!(r.is_reachable());
        assert_eq!(r.warnings().count(), 1);
        let r = Reachability { missing: Vec::new() };
        assert!(r.is_reachable());
        assert_eq!(r.warnings().count(), 0);
    }

    /// Each piece is classified deliberately, and the classification is what
    /// `is_reachable` reads — so it is asserted item by item rather than left
    /// to a `matches!` nobody rechecks.
    #[test]
    fn every_missing_piece_is_classified_and_explains_itself() {
        for (piece, fatal) in [
            (MissingPiece::DerpClient, true),
            (MissingPiece::Disco, true),
            (MissingPiece::ControlClient, true),
            (MissingPiece::Peers, false),
            (MissingPiece::Ipv6Routing, false),
        ] {
            assert_eq!(piece.is_fatal(), fatal, "{piece:?}");
            assert!(piece.explain().len() > 40, "{piece:?} explains itself");
        }
        // A tailnet of one is NOT fatal — it is a legitimate state, and
        // treating it as fatal would refuse a correct single-node tailnet.
        assert!(!MissingPiece::Peers.is_fatal());
    }
}