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