use core::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use ts_capabilityversion::CapabilityVersion;
use ts_keys::{DiscoPublicKey, MachinePublicKey, NodePublicKey};
use crate::dns::Resolver;
pub type NodeCapMap = BTreeMap<String, Vec<String>>;
pub fn is_tailscale_ip(addr: IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => {
let cgnat = ipnet::Ipv4Net::new(Ipv4Addr::new(100, 64, 0, 0), 10).unwrap();
let chromeos = ipnet::Ipv4Net::new(Ipv4Addr::new(100, 115, 92, 0), 23).unwrap();
cgnat.contains(&v4) && !chromeos.contains(&v4)
}
IpAddr::V6(v6) => {
let ula = ipnet::Ipv6Net::new(Ipv6Addr::new(0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0), 48)
.unwrap();
ula.contains(&v6)
}
}
}
pub type Id = i64;
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct StableId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ExitNodeSelector {
StableId(StableId),
Ip(IpAddr),
Name(String),
}
impl core::str::FromStr for ExitNodeSelector {
type Err = core::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.parse::<IpAddr>() {
Ok(ip) => ExitNodeSelector::Ip(ip),
Err(_) => ExitNodeSelector::Name(s.to_owned()),
})
}
}
impl ExitNodeSelector {
pub fn resolve<'a>(&self, peers: impl Iterator<Item = &'a Node>) -> Option<StableId> {
peers
.filter(|node| match self {
ExitNodeSelector::StableId(id) => &node.stable_id == id,
ExitNodeSelector::Ip(ip) => node.tailnet_address.contains(*ip),
ExitNodeSelector::Name(name) => node.matches_name(name),
})
.map(|node| &node.stable_id)
.min()
.cloned()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Node {
pub id: Id,
pub stable_id: StableId,
pub hostname: String,
pub user_id: ts_control_serde::UserId,
pub tailnet: Option<String>,
pub tags: Vec<String>,
pub tailnet_address: TailnetAddress,
pub node_key: NodePublicKey,
pub node_key_expiry: Option<DateTime<Utc>>,
pub key_signature: Vec<u8>,
pub machine_key: Option<MachinePublicKey>,
pub disco_key: Option<DiscoPublicKey>,
pub accepted_routes: Vec<ipnet::IpNet>,
pub underlay_addresses: Vec<SocketAddr>,
pub derp_region: Option<ts_derp::RegionId>,
pub cap: CapabilityVersion,
pub cap_map: NodeCapMap,
pub peerapi_port: Option<u16>,
pub peerapi_dns_proxy: bool,
pub is_wireguard_only: bool,
pub exit_node_dns_resolvers: Vec<Resolver>,
pub peer_relay: bool,
pub service_vips: alloc::collections::BTreeMap<String, Vec<IpAddr>>,
}
impl Node {
pub fn fqdn(&self, trailing_dot: bool) -> String {
let dot = if trailing_dot { "." } else { "" };
match &self.tailnet {
Some(tailnet) => format!("{}.{tailnet}{dot}", self.hostname),
None => format!("{}{dot}", self.hostname),
}
}
pub fn key_expired(&self, now: DateTime<Utc>) -> bool {
match self.node_key_expiry {
None => false,
Some(expiry) => expiry < now,
}
}
pub fn key_expiry(&self) -> Option<DateTime<Utc>> {
self.node_key_expiry
}
pub fn is_peer_relay(&self) -> bool {
self.peer_relay
}
pub fn key_expiry_unix(&self) -> Option<i64> {
self.node_key_expiry.map(|t| t.timestamp())
}
pub fn key_expired_at_unix(&self, now_unix_secs: i64) -> bool {
match self.key_expiry_unix() {
None => false,
Some(expiry) => expiry < now_unix_secs,
}
}
pub fn fqdn_opt(&self, trailing_dot: bool) -> Option<String> {
let dot = if trailing_dot { "." } else { "" };
let tailnet = self.tailnet.as_deref()?;
Some(format!("{}.{tailnet}{dot}", self.hostname))
}
pub fn matches_name(&self, name: &str) -> bool {
let name = name.strip_suffix('.').unwrap_or(name);
let name = if let Some(tailnet) = &self.tailnet {
name.get(name.len().saturating_sub(tailnet.len())..)
.filter(|suffix| suffix.eq_ignore_ascii_case(tailnet))
.and_then(|_| name.get(..name.len() - tailnet.len()))
.and_then(|name| name.strip_suffix('.'))
.unwrap_or(name)
} else {
name
};
name.eq_ignore_ascii_case(&self.hostname)
}
pub fn is_subnet_route(&self, route: &ipnet::IpNet) -> bool {
let host_prefix = match route {
ipnet::IpNet::V4(_) => 32,
ipnet::IpNet::V6(_) => 128,
};
if route.prefix_len() != host_prefix {
return true;
}
let addr = route.addr();
!(is_tailscale_ip(addr) || self.tailnet_address.contains(addr))
}
pub fn routes_to_install<'a>(
&'a self,
accept_routes: bool,
exit_node: Option<&StableId>,
) -> impl Iterator<Item = &'a ipnet::IpNet> + 'a {
let is_selected_exit = exit_node == Some(&self.stable_id);
self.accepted_routes.iter().filter(move |route| {
if route.prefix_len() == 0 {
return is_selected_exit;
}
accept_routes || !self.is_subnet_route(route)
})
}
const PEER_CAN_PROXY_DNS: CapabilityVersion = CapabilityVersion::V26;
pub fn peerapi_doh_url(&self) -> Option<String> {
self.peerapi_doh_addr()
.map(|addr| format!("http://{addr}/dns-query"))
}
pub fn peerapi_doh_addr(&self) -> Option<SocketAddr> {
if self.is_wireguard_only {
return None;
}
let port = self.peerapi_port?;
if !(self.peerapi_dns_proxy || self.cap >= Self::PEER_CAN_PROXY_DNS) {
return None;
}
Some(SocketAddr::new(
IpAddr::V4(self.tailnet_address.ipv4.addr()),
port,
))
}
pub fn peerapi_addr(&self) -> Option<SocketAddr> {
if self.is_wireguard_only {
return None;
}
let port = self.peerapi_port?;
Some(SocketAddr::new(
IpAddr::V4(self.tailnet_address.ipv4.addr()),
port,
))
}
const CAP_HTTPS: &'static str = "https";
const NODE_ATTR_FUNNEL: &'static str = "funnel";
const CAP_FUNNEL_PORTS: &'static str = "https://tailscale.com/cap/funnel-ports";
pub fn has_node_attr(&self, cap: &str) -> bool {
self.cap_map.contains_key(cap)
}
pub fn can_funnel(&self) -> bool {
self.has_node_attr(Self::CAP_HTTPS) && self.has_node_attr(Self::NODE_ATTR_FUNNEL)
}
pub fn check_funnel_port(&self, wanted_port: u16) -> bool {
let parse_attr = |attr: &str| -> Option<String> {
let mut url = url::Url::parse(attr).ok()?;
let ports = url
.query_pairs()
.find(|(k, _)| k == "ports")
.map(|(_, v)| v.into_owned())?;
if ports.is_empty() {
return None;
}
url.set_query(None);
if url.as_str() != Self::CAP_FUNNEL_PORTS {
return None;
}
Some(ports)
};
let Some(ports_str) = self
.cap_map
.keys()
.filter(|attr| attr.starts_with(Self::CAP_FUNNEL_PORTS))
.find_map(|attr| parse_attr(attr))
else {
return false;
};
let wanted = wanted_port.to_string();
for ps in ports_str.split(',') {
if ps.is_empty() {
continue;
}
match ps.split_once('-') {
None => {
if ps == wanted {
return true;
}
}
Some((first, last)) => {
let (Ok(fp), Ok(lp)) = (first.parse::<u16>(), last.parse::<u16>()) else {
continue;
};
if fp <= wanted_port && wanted_port <= lp {
return true;
}
}
}
}
false
}
pub fn is_service_host(&self) -> bool {
self.has_node_attr(ts_control_serde::NODE_ATTR_SERVICE_HOST)
&& !self.service_vips.is_empty()
}
pub fn service_addresses_for(&self, service: &str) -> &[IpAddr] {
self.service_vips
.get(service)
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn service_addresses(&self) -> Vec<IpAddr> {
let mut seen = alloc::collections::BTreeSet::new();
let mut out = Vec::new();
for addr in self.service_vips.values().flatten() {
if seen.insert(*addr) {
out.push(*addr);
}
}
out
}
}
pub fn validate_service_name(name: &str) -> Option<&str> {
let label = name.strip_prefix(ts_control_serde::SERVICE_NAME_PREFIX)?;
if label.is_empty() || label.len() > 63 {
return None;
}
if label.starts_with('-') || label.ends_with('-') {
return None;
}
if label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
Some(label)
} else {
None
}
}
fn service_vips_from_cap_map(
cap_map: &NodeCapMap,
) -> alloc::collections::BTreeMap<String, Vec<IpAddr>> {
let mut out: alloc::collections::BTreeMap<String, Vec<IpAddr>> =
alloc::collections::BTreeMap::new();
let Some(values) = cap_map.get(ts_control_serde::NODE_ATTR_SERVICE_HOST) else {
return out;
};
for raw in values {
let Ok(mappings) = serde_json::from_str::<ts_control_serde::ServiceIpMappings>(raw) else {
continue;
};
for (name, addrs) in &mappings.0 {
let entry = out.entry((*name).to_string()).or_default();
for addr in addrs {
if !entry.contains(addr) {
entry.push(*addr);
}
}
}
}
out
}
fn cap_map_from_serde(wire: &ts_nodecapability::Map<'_>) -> NodeCapMap {
wire.iter()
.map(|(&key, values)| {
let owned_values = values.0.iter().map(|v| v.get().to_owned()).collect();
(key.to_owned(), owned_values)
})
.collect()
}
fn peerapi_from_services(
services: Option<&[ts_control_serde::Service<'_>]>,
) -> (Option<u16>, bool) {
use ts_control_serde::ServiceProto;
let Some(services) = services else {
return (None, false);
};
let mut port = None;
let mut dns_proxy = false;
for svc in services {
match svc.proto {
ServiceProto::PeerApi4 => port = Some(svc.port),
ServiceProto::PeerApiDnsProxy => dns_proxy = true,
_ => {}
}
}
(port, dns_proxy)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TailnetAddress {
pub ipv4: ipnet::Ipv4Net,
pub ipv6: ipnet::Ipv6Net,
}
impl TailnetAddress {
pub fn contains(&self, addr: IpAddr) -> bool {
match addr {
IpAddr::V4(a) => self.ipv4.addr() == a,
IpAddr::V6(a) => self.ipv6.addr() == a,
}
}
}
impl From<&ts_control_serde::Node<'_>> for Node {
fn from(value: &ts_control_serde::Node) -> Self {
let fqdn_without_trailing_dot = value.name.strip_suffix('.').unwrap_or(value.name);
let (hostname, tailnet) = match fqdn_without_trailing_dot.split_once('.') {
Some((hostname, tailnet)) => (hostname, Some(tailnet.to_owned())),
None => (fqdn_without_trailing_dot, None),
};
let (peerapi_port, peerapi_dns_proxy) =
peerapi_from_services(value.host_info.services.as_deref());
let cap_map = cap_map_from_serde(&value.cap_map);
let service_vips = service_vips_from_cap_map(&cap_map);
let ipv4 = value
.addresses
.iter()
.find_map(|p| match p {
ipnet::IpNet::V4(n) => Some(*n),
ipnet::IpNet::V6(_) => None,
})
.unwrap_or_else(|| ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 32).unwrap());
let ipv6 = value
.addresses
.iter()
.find_map(|p| match p {
ipnet::IpNet::V6(n) => Some(*n),
ipnet::IpNet::V4(_) => None,
})
.unwrap_or_else(|| ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap());
Self {
id: value.id,
stable_id: StableId(value.stable_id.0.to_string()),
hostname: hostname.to_owned(),
user_id: value.user,
tailnet,
tags: value
.tags
.as_ref()
.map(|x| x.iter().map(|x| x.to_string()).collect())
.unwrap_or_default(),
tailnet_address: TailnetAddress { ipv4, ipv6 },
node_key: value.key,
node_key_expiry: value.key_expiry,
key_signature: value.key_signature.to_vec(),
machine_key: value.machine,
disco_key: value.disco_key,
accepted_routes: value
.allowed_ips
.clone()
.unwrap_or_else(|| value.addresses.clone()),
underlay_addresses: value.endpoints.clone(),
#[allow(deprecated)]
derp_region: value
.home_derp
.or(value.legacy_derp_string)
.or_else(|| value.host_info.net_info.as_ref()?.preferred_derp)
.map(|x| ts_derp::RegionId(x.into())),
cap: value.cap,
cap_map,
peerapi_port,
peerapi_dns_proxy,
is_wireguard_only: value.is_wireguard_only,
exit_node_dns_resolvers: value
.exit_node_dns_resolvers
.iter()
.filter_map(Resolver::from_serde)
.collect(),
peer_relay: value.host_info.peer_relay,
service_vips,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerChange {
pub id: Id,
pub derp_region: Option<ts_derp::RegionId>,
pub cap: Option<CapabilityVersion>,
pub cap_map: Option<NodeCapMap>,
pub underlay_addresses: Option<Vec<SocketAddr>>,
pub node_key: Option<NodePublicKey>,
pub key_signature: Option<Vec<u8>>,
pub disco_key: Option<DiscoPublicKey>,
pub node_key_expiry: Option<DateTime<Utc>>,
}
impl From<&ts_control_serde::PeerChange<'_>> for PeerChange {
fn from(value: &ts_control_serde::PeerChange) -> Self {
Self {
id: value.node_id,
derp_region: value.derp_region.map(|x| ts_derp::RegionId(x.into())),
cap: value.cap,
cap_map: value.cap_map.as_ref().map(cap_map_from_serde),
underlay_addresses: value.endpoints.clone(),
node_key: value.key,
key_signature: value.key_signature.map(|s| s.to_vec()),
disco_key: value.disco_key,
node_key_expiry: value.key_expiry,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserProfile {
pub id: ts_control_serde::UserId,
pub login_name: String,
pub display_name: Option<String>,
}
impl From<&ts_control_serde::UserProfile<'_>> for UserProfile {
fn from(value: &ts_control_serde::UserProfile) -> Self {
Self {
id: value.id,
login_name: value.login_name.to_string(),
display_name: value.display_name.map(str::to_string),
}
}
}
impl UserProfile {
pub fn best_label(&self) -> Option<String> {
if !self.login_name.is_empty() {
Some(self.login_name.clone())
} else {
self.display_name.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_wire_node_carries_user_id() {
let mut wire = ts_control_serde::Node {
user: 4242,
..Default::default()
};
wire.name = "host.tail.ts.net.";
let domain: Node = (&wire).into();
assert_eq!(domain.user_id, 4242);
let tagged = ts_control_serde::Node::default();
assert_eq!(Node::from(&tagged).user_id, 0);
}
#[test]
fn from_wire_node_ipv4_only_addresses() {
let wire = ts_control_serde::Node {
addresses: vec!["100.64.0.5/32".parse().unwrap()],
..Default::default()
};
let domain: Node = (&wire).into();
assert_eq!(
domain.tailnet_address.ipv4,
"100.64.0.5/32".parse().unwrap()
);
assert_eq!(
domain.tailnet_address.ipv6,
ipnet::Ipv6Net::new(core::net::Ipv6Addr::UNSPECIFIED, 128).unwrap()
);
assert_eq!(
domain.accepted_routes,
vec!["100.64.0.5/32".parse::<ipnet::IpNet>().unwrap()]
);
}
#[test]
fn from_wire_node_dual_stack_addresses() {
let wire = ts_control_serde::Node {
addresses: vec![
"100.64.0.7/32".parse().unwrap(),
"fd7a:115c:a1e0::7/128".parse().unwrap(),
],
..Default::default()
};
let domain: Node = (&wire).into();
assert_eq!(
domain.tailnet_address.ipv4,
"100.64.0.7/32".parse().unwrap()
);
assert_eq!(
domain.tailnet_address.ipv6,
"fd7a:115c:a1e0::7/128".parse().unwrap()
);
}
#[test]
fn deserialize_node_with_single_address() {
let json = r#"{
"ID": 1,
"StableID": "n1",
"Name": "host.tail.ts.net.",
"User": 1,
"Addresses": ["100.64.0.9/32"],
"Key": "nodekey:0000000000000000000000000000000000000000000000000000000000000000",
"Machine": null,
"DiscoKey": null,
"AllowedIPs": null,
"Endpoints": []
}"#;
let wire: ts_control_serde::Node = serde_json::from_str(json).expect("1-addr node parses");
assert_eq!(wire.addresses.len(), 1);
let domain: Node = (&wire).into();
assert_eq!(
domain.tailnet_address.ipv4,
"100.64.0.9/32".parse().unwrap()
);
}
#[test]
fn key_expiry_semantics() {
let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
let mut n = node("h", Some("t.ts.net"));
n.node_key_expiry = None;
assert!(!n.key_expired(now));
assert_eq!(n.key_expiry(), None);
n.node_key_expiry = Some(future);
assert!(!n.key_expired(now));
assert_eq!(n.key_expiry(), Some(future));
n.node_key_expiry = Some(past);
assert!(n.key_expired(now));
}
#[test]
fn key_expiry_unix_agrees_with_chrono() {
let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
let past: DateTime<Utc> = "2020-01-01T00:00:00Z".parse().unwrap();
let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
let now_unix = now.timestamp();
let mut n = node("h", Some("t.ts.net"));
n.node_key_expiry = None;
assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
assert!(!n.key_expired_at_unix(now_unix));
assert_eq!(n.key_expiry_unix(), None);
n.node_key_expiry = Some(future);
assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
assert!(!n.key_expired_at_unix(now_unix));
assert_eq!(n.key_expiry_unix(), Some(future.timestamp()));
n.node_key_expiry = Some(past);
assert_eq!(n.key_expired(now), n.key_expired_at_unix(now_unix));
assert!(n.key_expired_at_unix(now_unix));
assert_eq!(n.key_expiry_unix(), Some(past.timestamp()));
}
#[test]
fn key_expiry_boundary_is_not_expired() {
let now: DateTime<Utc> = "2026-06-05T00:00:00Z".parse().unwrap();
let now_unix = now.timestamp();
let mut n = node("h", Some("t.ts.net"));
n.node_key_expiry = Some(now);
assert!(!n.key_expired(now));
assert!(!n.key_expired_at_unix(now_unix));
}
#[test]
fn is_peer_relay_returns_field() {
let mut n = node("h", Some("t.ts.net"));
n.peer_relay = true;
assert!(n.is_peer_relay());
n.peer_relay = false;
assert!(!n.is_peer_relay());
}
fn node(hostname: &str, tailnet: Option<&str>) -> Node {
Node {
id: 1,
stable_id: StableId("n1".to_string()),
hostname: hostname.to_string(),
user_id: 0,
tailnet: tailnet.map(str::to_string),
tags: vec![],
tailnet_address: TailnetAddress {
ipv4: "100.64.0.1/32".parse().unwrap(),
ipv6: "fd7a::1/128".parse().unwrap(),
},
node_key: [0u8; 32].into(),
node_key_expiry: None,
key_signature: vec![],
machine_key: None,
disco_key: None,
accepted_routes: vec![],
underlay_addresses: vec![],
derp_region: None,
cap: CapabilityVersion::default(),
cap_map: NodeCapMap::new(),
peerapi_port: None,
peerapi_dns_proxy: false,
is_wireguard_only: false,
exit_node_dns_resolvers: vec![],
peer_relay: false,
service_vips: Default::default(),
}
}
#[test]
fn matches_name_is_case_and_trailing_dot_insensitive() {
let n = node("MyHost", Some("tail-scale.ts.net"));
assert!(n.matches_name("myhost"));
assert!(n.matches_name("MYHOST"));
assert!(n.matches_name("MyHost"));
assert!(n.matches_name("myhost.tail-scale.ts.net"));
assert!(n.matches_name("MYHOST.TAIL-SCALE.TS.NET"));
assert!(n.matches_name("myhost.tail-scale.ts.net."));
assert!(n.matches_name("MyHost.Tail-Scale.TS.NET."));
assert!(!n.matches_name("other"));
assert!(!n.matches_name("myhost.other.ts.net"));
}
#[test]
fn matches_name_no_tailnet() {
let n = node("solo", None);
assert!(n.matches_name("solo"));
assert!(n.matches_name("SOLO."));
assert!(!n.matches_name("solo.ts.net"));
}
#[test]
fn is_tailscale_ip_ranges() {
assert!(is_tailscale_ip("100.64.0.1".parse().unwrap()));
assert!(is_tailscale_ip("100.127.255.254".parse().unwrap()));
assert!(!is_tailscale_ip("100.115.92.5".parse().unwrap()));
assert!(!is_tailscale_ip("10.0.0.1".parse().unwrap()));
assert!(!is_tailscale_ip("100.128.0.1".parse().unwrap()));
assert!(is_tailscale_ip("fd7a:115c:a1e0::1".parse().unwrap()));
assert!(!is_tailscale_ip("fd00::1".parse().unwrap()));
}
#[test]
fn taildrop_ssrf_guard_rejects_non_cgnat_peerapi_addr() {
let mut n = node("evil", Some("ts.net"));
n.tailnet_address.ipv4 = "1.2.3.4/32".parse().unwrap();
n.peerapi_port = Some(443);
let addr = n
.peerapi_addr()
.expect("peerapi_addr yields Some with a port set");
assert_eq!(addr.ip(), Ipv4Addr::new(1, 2, 3, 4));
assert!(
!is_tailscale_ip(addr.ip()),
"SSRF guard must reject a peer whose peerAPI addr is not a Tailscale CGNAT IP"
);
let mut good = node("friend", Some("ts.net"));
good.peerapi_port = Some(443);
let good_addr = good.peerapi_addr().expect("peerapi_addr yields Some");
assert!(is_tailscale_ip(good_addr.ip()));
}
#[test]
fn is_subnet_route_distinguishes_self_from_subnet() {
let n = node("host", Some("ts.net"));
assert!(!n.is_subnet_route(&"100.64.0.1/32".parse().unwrap()));
assert!(!n.is_subnet_route(&"fd7a::1/128".parse().unwrap()));
assert!(!n.is_subnet_route(&"100.64.5.5/32".parse().unwrap()));
assert!(n.is_subnet_route(&"192.168.1.0/24".parse().unwrap()));
assert!(n.is_subnet_route(&"8.8.8.8/32".parse().unwrap()));
assert!(n.is_subnet_route(&"0.0.0.0/0".parse().unwrap()));
assert!(n.is_subnet_route(&"::/0".parse().unwrap()));
}
#[test]
fn routes_to_install_gates_subnets_on_accept_routes() {
let mut n = node("host", Some("ts.net"));
let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
let self6: ipnet::IpNet = "fd7a::1/128".parse().unwrap();
let subnet: ipnet::IpNet = "192.168.1.0/24".parse().unwrap();
n.accepted_routes = vec![self4, self6, subnet];
let off: Vec<_> = n.routes_to_install(false, None).copied().collect();
assert_eq!(off, vec![self4, self6]);
let on: Vec<_> = n.routes_to_install(true, None).copied().collect();
assert_eq!(on, vec![self4, self6, subnet]);
}
#[test]
fn routes_to_install_default_route_only_for_selected_exit_node() {
let mut n = node("host", Some("ts.net"));
n.stable_id = StableId("exit1".to_string());
let self4: ipnet::IpNet = "100.64.0.1/32".parse().unwrap();
let default4: ipnet::IpNet = "0.0.0.0/0".parse().unwrap();
let default6: ipnet::IpNet = "::/0".parse().unwrap();
n.accepted_routes = vec![self4, default4, default6];
let none_off: Vec<_> = n.routes_to_install(false, None).copied().collect();
assert_eq!(none_off, vec![self4]);
let none_on: Vec<_> = n.routes_to_install(true, None).copied().collect();
assert_eq!(none_on, vec![self4]);
let other = StableId("exit2".to_string());
let other_sel: Vec<_> = n.routes_to_install(false, Some(&other)).copied().collect();
assert_eq!(other_sel, vec![self4]);
let me = StableId("exit1".to_string());
let sel: Vec<_> = n.routes_to_install(false, Some(&me)).copied().collect();
assert_eq!(sel, vec![self4, default4, default6]);
}
fn exit_node_with(id: &str, ipv4: &str, hostname: &str, tailnet: Option<&str>) -> Node {
let mut n = node(hostname, tailnet);
n.stable_id = StableId(id.to_string());
n.tailnet_address.ipv4 = format!("{ipv4}/32").parse().unwrap();
n
}
#[test]
fn exit_node_selector_resolves_by_id_ip_and_name() {
let a = exit_node_with("nA", "100.64.0.5", "alpha", Some("ts.net"));
let b = exit_node_with("nB", "100.64.0.6", "beta", Some("ts.net"));
let peers = [a, b];
let it = || peers.iter();
assert_eq!(
ExitNodeSelector::StableId(StableId("nB".into())).resolve(it()),
Some(StableId("nB".into()))
);
assert_eq!(
ExitNodeSelector::Ip("100.64.0.5".parse().unwrap()).resolve(it()),
Some(StableId("nA".into()))
);
assert_eq!(
ExitNodeSelector::Name("BETA.ts.net".into()).resolve(it()),
Some(StableId("nB".into()))
);
assert_eq!(
ExitNodeSelector::Name("alpha".into()).resolve(it()),
Some(StableId("nA".into()))
);
assert_eq!(
ExitNodeSelector::Ip("100.64.0.99".parse().unwrap()).resolve(it()),
None
);
assert_eq!(ExitNodeSelector::Name("ghost".into()).resolve(it()), None);
}
#[test]
fn exit_node_selector_resolution_is_deterministic_on_ties() {
let a = exit_node_with("nZ", "100.64.0.5", "dup", Some("ts.net"));
let b = exit_node_with("nA", "100.64.0.6", "dup", Some("ts.net"));
let peers = [a, b];
assert_eq!(
ExitNodeSelector::Name("dup".into()).resolve(peers.iter()),
Some(StableId("nA".into())),
"smallest stable id wins the tie"
);
assert_eq!(
ExitNodeSelector::Name("dup".into()).resolve(peers.iter().rev()),
Some(StableId("nA".into()))
);
}
#[test]
fn peerapi_doh_url_requires_port_and_capability() {
let mut n = node("exit", Some("ts.net"));
n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
n.peerapi_port = None;
n.cap = CapabilityVersion::V130;
assert_eq!(n.peerapi_doh_url(), None);
n.peerapi_port = Some(8080);
n.cap = CapabilityVersion::V25;
n.peerapi_dns_proxy = false;
assert_eq!(n.peerapi_doh_url(), None);
n.cap = CapabilityVersion::V26;
assert_eq!(
n.peerapi_doh_url().as_deref(),
Some("http://100.64.0.5:8080/dns-query")
);
n.cap = CapabilityVersion::V25;
n.peerapi_dns_proxy = true;
assert_eq!(
n.peerapi_doh_url().as_deref(),
Some("http://100.64.0.5:8080/dns-query")
);
n.is_wireguard_only = true;
assert_eq!(n.peerapi_doh_url(), None);
}
#[test]
fn peerapi_doh_addr_matches_url_gate() {
let mut n = node("exit", Some("ts.net"));
n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
n.peerapi_port = Some(8080);
n.cap = CapabilityVersion::V26;
assert_eq!(
n.peerapi_doh_addr(),
Some("100.64.0.5:8080".parse().unwrap())
);
assert_eq!(
n.peerapi_doh_url().as_deref(),
Some("http://100.64.0.5:8080/dns-query")
);
n.peerapi_port = None;
assert_eq!(n.peerapi_doh_addr(), None);
}
#[test]
fn peerapi_addr_returns_addr_when_advertised() {
let mut n = node("peer", Some("ts.net"));
n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
n.peerapi_port = Some(8089);
assert_eq!(n.peerapi_addr(), Some("100.64.0.5:8089".parse().unwrap()));
}
#[test]
fn peerapi_addr_none_when_no_port() {
let mut n = node("peer", Some("ts.net"));
n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
n.peerapi_port = None;
assert_eq!(n.peerapi_addr(), None);
}
#[test]
fn peerapi_addr_none_for_wireguard_only() {
let mut n = node("peer", Some("ts.net"));
n.tailnet_address.ipv4 = "100.64.0.5/32".parse().unwrap();
n.peerapi_port = Some(8089);
n.is_wireguard_only = true;
assert_eq!(n.peerapi_addr(), None);
}
#[test]
fn peerapi_from_services_extracts_v4_port_and_dns_proxy_flag() {
use ts_control_serde::{Service, ServiceProto};
let services = [
Service {
proto: ServiceProto::PeerApi4,
port: 8080,
description: "peerapi",
},
Service {
proto: ServiceProto::PeerApi6,
port: 9090,
description: "peerapi6",
},
Service {
proto: ServiceProto::PeerApiDnsProxy,
port: 1,
description: "dns",
},
];
let (port, dns_proxy) = peerapi_from_services(Some(&services));
assert_eq!(port, Some(8080), "only the IPv4 peerAPI port is taken");
assert!(dns_proxy);
assert_eq!(peerapi_from_services(None), (None, false));
}
#[test]
fn exit_node_selector_parses_ip_vs_name() {
assert_eq!(
"100.64.0.5".parse::<ExitNodeSelector>().unwrap(),
ExitNodeSelector::Ip("100.64.0.5".parse().unwrap())
);
assert_eq!(
"fd7a::5".parse::<ExitNodeSelector>().unwrap(),
ExitNodeSelector::Ip("fd7a::5".parse().unwrap())
);
assert_eq!(
"my-exit.ts.net".parse::<ExitNodeSelector>().unwrap(),
ExitNodeSelector::Name("my-exit.ts.net".into())
);
}
}