//! **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());
}
}