use core::fmt::Debug;
use std::net::SocketAddr;
use url::Url;
lazy_static::lazy_static! {
pub static ref DEFAULT_CONTROL_SERVER: Url = Url::parse("https://controlplane.tailscale.com/").unwrap();
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ExitProxyScheme {
Socks5,
HttpConnect,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct ExitProxyConfig {
pub addr: SocketAddr,
pub scheme: ExitProxyScheme,
pub auth: Option<(String, String)>,
}
impl Debug for ExitProxyConfig {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ExitProxyConfig")
.field("addr", &self.addr)
.field("scheme", &self.scheme)
.field("auth", &self.auth.as_ref().map(|_| "<redacted>"))
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransportMode {
#[default]
Netstack,
Tun(TunConfig),
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TunConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub mtu: Option<u16>,
}
fn default_ephemeral() -> bool {
true
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct Config {
pub server_url: Url,
pub hostname: Option<String>,
pub client_name: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_ephemeral")]
pub ephemeral: bool,
#[serde(default)]
pub accept_routes: bool,
#[serde(default)]
pub exit_node: Option<crate::ExitNodeSelector>,
#[serde(default)]
pub advertise_routes: Vec<ipnet::IpNet>,
#[serde(default)]
pub advertise_exit_node: bool,
#[serde(default)]
pub forward_tcp_ports: Vec<u16>,
#[serde(default)]
pub forward_udp_ports: Vec<u16>,
#[serde(default)]
pub forward_all_ports: bool,
#[serde(default)]
pub forward_exit_egress: bool,
#[serde(default)]
pub exit_proxy: Option<ExitProxyConfig>,
#[serde(default)]
pub peerapi_port: Option<u16>,
#[serde(default)]
pub taildrop_dir: Option<std::path::PathBuf>,
#[serde(default)]
pub tcp_buffer_size: Option<usize>,
#[serde(default)]
pub enable_ipv6: bool,
#[serde(default)]
pub transport_mode: TransportMode,
#[serde(default)]
pub wire_ingress: bool,
#[serde(skip, default)]
pub ingress_active: std::sync::Arc<std::sync::atomic::AtomicBool>,
#[serde(default)]
pub advertise_services: Vec<String>,
#[serde(default)]
pub allow_http_key_fetch: bool,
}
impl Config {
pub fn format_client_name(&self) -> String {
let mut full_name = "tailscale-rs".to_owned();
if let Some(client_name) = &self.client_name {
full_name.push_str(&format!(" ({client_name})"));
}
full_name
}
pub fn advertised_routes(&self) -> Vec<ipnet::IpNet> {
let mut routes: Vec<ipnet::IpNet> = Vec::new();
let mut push_unique = |net: ipnet::IpNet| {
if !routes.contains(&net) {
routes.push(net);
}
};
for net in &self.advertise_routes {
if matches!(net, ipnet::IpNet::V4(_)) {
push_unique(*net);
} else {
tracing::warn!(prefix = %net, "dropping IPv6 advertise_routes prefix (IPv6-off posture)");
}
}
if self.advertise_exit_node {
let default_v4 = ipnet::IpNet::V4(
ipnet::Ipv4Net::new(core::net::Ipv4Addr::UNSPECIFIED, 0)
.expect("0.0.0.0/0 is a valid prefix"),
);
push_unique(default_v4);
}
routes
}
pub fn advertised_services(&self) -> Vec<ts_control_serde::Service<'static>> {
use ts_control_serde::{Service, ServiceProto};
let Some(port) = self.peerapi_port else {
return Vec::new();
};
vec![
Service {
proto: ServiceProto::PeerApi4,
port,
description: "tailscale-rs",
},
Service {
proto: ServiceProto::PeerApiDnsProxy,
port: 1,
description: "tailscale-rs",
},
]
}
pub fn advertised_vip_services(&self) -> Vec<ts_control_serde::VipServiceOwned> {
use ts_control_serde::{ProtoPortRange, VipServiceOwned};
self.advertise_services
.iter()
.filter_map(|name| {
if crate::validate_service_name(name).is_none() {
tracing::warn!(
service = %name,
"dropping invalid advertise_services name (expected svc:<dns-label>)"
);
return None;
}
Some(VipServiceOwned {
name: name.clone(),
ports: vec![ProtoPortRange {
proto: 0,
first: 0,
last: 65535,
}],
active: true,
})
})
.collect()
}
}
pub fn services_hash(services: &[ts_control_serde::VipServiceOwned]) -> String {
if services.is_empty() {
return String::new();
}
let mut sorted = services.to_vec();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let json = serde_json::to_vec(&sorted).expect("VipServiceOwned list always serializes");
let digest = ring::digest::digest(&ring::digest::SHA256, &json);
let mut hex = String::with_capacity(digest.as_ref().len() * 2);
for byte in digest.as_ref() {
hex.push_str(&format!("{byte:02x}"));
}
hex
}
impl Debug for Config {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Config")
.field("hostname", &self.hostname)
.field("server_url", &self.server_url.as_str())
.field("client_name", &self.client_name)
.finish()
}
}
impl Default for Config {
fn default() -> Self {
Self {
server_url: DEFAULT_CONTROL_SERVER.clone(),
hostname: gethostname::gethostname().into_string().ok(),
client_name: None,
tags: Default::default(),
ephemeral: default_ephemeral(),
accept_routes: false,
exit_node: None,
advertise_routes: Vec::new(),
advertise_exit_node: false,
forward_tcp_ports: Vec::new(),
forward_udp_ports: Vec::new(),
forward_all_ports: false,
forward_exit_egress: false,
exit_proxy: None,
peerapi_port: None,
taildrop_dir: None,
tcp_buffer_size: None,
enable_ipv6: false,
transport_mode: TransportMode::default(),
wire_ingress: false,
ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
advertise_services: Vec::new(),
allow_http_key_fetch: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v4(s: &str) -> ipnet::IpNet {
ipnet::IpNet::V4(s.parse().unwrap())
}
fn v6(s: &str) -> ipnet::IpNet {
ipnet::IpNet::V6(s.parse().unwrap())
}
#[test]
fn default_advertises_nothing() {
let cfg = Config::default();
assert!(cfg.advertised_routes().is_empty());
}
#[test]
fn advertises_v4_subnet_routes() {
let cfg = Config {
advertise_routes: vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")],
..Default::default()
};
assert_eq!(
cfg.advertised_routes(),
vec![v4("10.0.0.0/24"), v4("192.168.1.0/24")]
);
}
#[test]
fn exit_node_adds_default_v4_route() {
let cfg = Config {
advertise_exit_node: true,
..Default::default()
};
assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
}
#[test]
fn v6_prefixes_are_dropped() {
let cfg = Config {
advertise_routes: vec![v4("10.0.0.0/24"), v6("fd00::/64")],
..Default::default()
};
assert_eq!(cfg.advertised_routes(), vec![v4("10.0.0.0/24")]);
}
#[test]
fn exit_node_never_advertises_v6_default() {
let cfg = Config {
advertise_routes: vec![v6("::/0")],
advertise_exit_node: true,
..Default::default()
};
assert_eq!(cfg.advertised_routes(), vec![v4("0.0.0.0/0")]);
}
#[test]
fn default_is_ephemeral() {
assert!(Config::default().ephemeral);
}
#[test]
fn ephemeral_deserializes_default_true_when_absent() {
let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
assert!(cfg.ephemeral);
}
#[test]
fn ephemeral_can_be_disabled_for_persistent_nodes() {
let cfg: Config =
serde_json::from_str(r#"{"server_url":"https://example.com/","ephemeral":false}"#)
.unwrap();
assert!(!cfg.ephemeral);
}
#[test]
fn tags_default_empty_and_deserialize() {
let cfg: Config =
serde_json::from_str(r#"{"server_url":"https://example.com/","tags":["tag:exit"]}"#)
.unwrap();
assert_eq!(cfg.tags, vec!["tag:exit".to_owned()]);
assert!(Config::default().tags.is_empty());
}
#[test]
fn advertises_no_services_without_peerapi_port() {
assert!(Config::default().advertised_services().is_empty());
}
#[test]
fn advertises_peerapi4_and_dns_proxy_when_port_set() {
use ts_control_serde::ServiceProto;
let cfg = Config {
peerapi_port: Some(8080),
..Default::default()
};
let services = cfg.advertised_services();
assert_eq!(services.len(), 2);
assert_eq!(services[0].proto, ServiceProto::PeerApi4);
assert_eq!(services[0].port, 8080);
assert_eq!(services[1].proto, ServiceProto::PeerApiDnsProxy);
assert_eq!(services[1].port, 1);
}
#[test]
fn peerapi_port_deserializes_default_none() {
let cfg: Config = serde_json::from_str(r#"{"server_url":"https://example.com/"}"#).unwrap();
assert_eq!(cfg.peerapi_port, None);
}
#[test]
fn advertise_services_default_empty() {
assert!(Config::default().advertise_services.is_empty());
assert!(Config::default().advertised_vip_services().is_empty());
}
#[test]
fn advertise_services_deserializes() {
let cfg: Config = serde_json::from_str(
r#"{"server_url":"https://example.com/","advertise_services":["svc:samba"]}"#,
)
.unwrap();
assert_eq!(cfg.advertise_services, vec!["svc:samba".to_owned()]);
}
#[test]
fn advertised_vip_services_validates_and_drops_bad_names() {
let cfg = Config {
advertise_services: vec![
"svc:good".to_owned(),
"bad-no-prefix".to_owned(),
"svc:-bad-label".to_owned(),
],
..Default::default()
};
let svcs = cfg.advertised_vip_services();
assert_eq!(svcs.len(), 1);
assert_eq!(svcs[0].name, "svc:good");
assert_eq!(svcs[0].ports.len(), 1);
assert_eq!(svcs[0].ports[0].first, 0);
assert_eq!(svcs[0].ports[0].last, 65535);
assert!(svcs[0].active);
}
#[test]
fn services_hash_empty_is_empty_string() {
assert_eq!(services_hash(&[]), "");
}
#[test]
fn services_hash_is_order_independent() {
let a = Config {
advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
..Default::default()
};
let b = Config {
advertise_services: vec!["svc:b".to_owned(), "svc:a".to_owned()],
..Default::default()
};
let ha = services_hash(&a.advertised_vip_services());
let hb = services_hash(&b.advertised_vip_services());
assert_eq!(ha, hb);
assert!(!ha.is_empty());
}
#[test]
fn services_hash_changes_with_set() {
let one = Config {
advertise_services: vec!["svc:a".to_owned()],
..Default::default()
};
let two = Config {
advertise_services: vec!["svc:a".to_owned(), "svc:b".to_owned()],
..Default::default()
};
assert_ne!(
services_hash(&one.advertised_vip_services()),
services_hash(&two.advertised_vip_services())
);
}
#[test]
fn services_hash_known_answer() {
let cfg = Config {
advertise_services: vec!["svc:samba".to_owned()],
..Default::default()
};
let hash = services_hash(&cfg.advertised_vip_services());
assert_eq!(hash.len(), 64);
assert!(hash.bytes().all(|b| b.is_ascii_hexdigit()));
assert_eq!(
hash,
"f96574bfe9f637164f5d7fff37ea169b3aa86b12e25d98f5c3b7fd049839f4e9"
);
}
#[test]
fn deduplicates_routes() {
let cfg = Config {
advertise_routes: vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")],
advertise_exit_node: true,
..Default::default()
};
assert_eq!(
cfg.advertised_routes(),
vec![v4("0.0.0.0/0"), v4("10.0.0.0/24")]
);
}
}