use crate::node::Node;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceMode {
Tcp {
port: u16,
},
Http {
port: u16,
},
}
impl ServiceMode {
pub fn port(&self) -> u16 {
match self {
ServiceMode::Tcp { port } | ServiceMode::Http { port } => *port,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceError {
InvalidName(String),
UntaggedHost,
NoAssignedVip(String),
Listen(String),
}
impl core::fmt::Display for ServiceError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ServiceError::InvalidName(name) => {
write!(
f,
"invalid VIP service name {name:?} (expected svc:<dns-label>)"
)
}
ServiceError::UntaggedHost => write!(
f,
"this node is untagged and cannot host a Tailscale service (it must be tagged)"
),
ServiceError::NoAssignedVip(name) => write!(
f,
"control has not assigned a VIP address for service {name:?} on this node"
),
ServiceError::Listen(detail) => {
write!(f, "failed to bind the VIP service listener: {detail}")
}
}
}
}
impl std::error::Error for ServiceError {}
pub fn resolve_service_listen(
node: &Node,
name: &str,
mode: ServiceMode,
enable_ipv6: bool,
) -> Result<core::net::SocketAddr, ServiceError> {
if crate::validate_service_name(name).is_none() {
return Err(ServiceError::InvalidName(name.to_string()));
}
if node.tags.is_empty() {
return Err(ServiceError::UntaggedHost);
}
let vips = node.service_addresses_for(name);
let vip = vips
.iter()
.find(|ip| ip.is_ipv4())
.or_else(|| vips.iter().find(|ip| enable_ipv6 && ip.is_ipv6()))
.ok_or_else(|| ServiceError::NoAssignedVip(name.to_string()))?;
Ok(core::net::SocketAddr::new(*vip, mode.port()))
}
#[cfg(test)]
mod tests {
use alloc::{collections::BTreeMap, string::ToString, vec, vec::Vec};
use core::net::{IpAddr, Ipv4Addr};
use super::*;
use crate::node::{NodeCapMap, StableId, TailnetAddress};
fn node(tags: &[&str], service: &str, vips: Vec<IpAddr>) -> Node {
node_multi(tags, &[(service, vips)])
}
fn node_multi(tags: &[&str], services: &[(&str, Vec<IpAddr>)]) -> Node {
let mut cap_map = NodeCapMap::new();
let mut service_vips: BTreeMap<String, Vec<IpAddr>> = BTreeMap::new();
for (service, vips) in services {
if !service.is_empty() && !vips.is_empty() {
service_vips.insert((*service).to_string(), vips.clone());
}
}
if !service_vips.is_empty() {
cap_map.insert(ts_control_serde::NODE_ATTR_SERVICE_HOST.to_string(), vec![]);
}
Node {
id: 1,
stable_id: StableId("n1".to_string()),
hostname: "host".to_string(),
user_id: 0,
tailnet: Some("tail1.ts.net".to_string()),
tags: tags.iter().map(|t| t.to_string()).collect(),
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,
online: None,
last_seen: None,
key_signature: vec![],
machine_key: None,
disco_key: None,
accepted_routes: vec![],
underlay_addresses: vec![],
derp_region: None,
cap: Default::default(),
cap_map,
peerapi_port: None,
peerapi_dns_proxy: false,
is_wireguard_only: false,
exit_node_dns_resolvers: vec![],
peer_relay: false,
service_vips,
}
}
fn vip4() -> IpAddr {
IpAddr::V4(Ipv4Addr::new(100, 65, 32, 1))
}
#[test]
fn tagged_host_with_vip_resolves_listen_addr() {
let n = node(&["tag:samba"], "svc:samba", vec![vip4()]);
let addr = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
.expect("a tagged host with an assigned VIP must resolve");
assert_eq!(addr, core::net::SocketAddr::new(vip4(), 445));
}
#[test]
fn http_mode_binds_same_vip_and_port() {
let n = node(&["tag:web"], "svc:web", vec![vip4()]);
let addr =
resolve_service_listen(&n, "svc:web", ServiceMode::Http { port: 8080 }, false).unwrap();
assert_eq!(addr, core::net::SocketAddr::new(vip4(), 8080));
}
#[test]
fn invalid_name_is_rejected() {
let n = node(&["tag:x"], "svc:x", vec![vip4()]);
let err =
resolve_service_listen(&n, "samba", ServiceMode::Tcp { port: 1 }, false).unwrap_err();
assert!(matches!(err, ServiceError::InvalidName(_)));
let err = resolve_service_listen(&n, "svc:-bad", ServiceMode::Tcp { port: 1 }, false)
.unwrap_err();
assert!(matches!(err, ServiceError::InvalidName(_)));
}
#[test]
fn untagged_host_is_rejected() {
let n = node(&[], "svc:samba", vec![vip4()]);
let err = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
.unwrap_err();
assert_eq!(err, ServiceError::UntaggedHost);
}
#[test]
fn no_assigned_vip_is_rejected() {
let n = node(&["tag:samba"], "", vec![]);
let err = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
.unwrap_err();
assert!(matches!(err, ServiceError::NoAssignedVip(_)));
}
#[test]
fn ipv6_vip_only_chosen_when_ipv6_enabled() {
let vip6: IpAddr = "fd7a:115c:a1e0::1234".parse().unwrap();
let n = node(&["tag:samba"], "svc:samba", vec![vip6]);
let err = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
.unwrap_err();
assert!(matches!(err, ServiceError::NoAssignedVip(_)));
let addr =
resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, true).unwrap();
assert_eq!(addr, core::net::SocketAddr::new(vip6, 445));
}
#[test]
fn ipv4_vip_preferred_over_ipv6() {
let vip6: IpAddr = "fd7a:115c:a1e0::1234".parse().unwrap();
let n = node(&["tag:samba"], "svc:samba", vec![vip6, vip4()]);
let addr =
resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, true).unwrap();
assert_eq!(addr, core::net::SocketAddr::new(vip4(), 445));
}
#[test]
fn co_hosted_services_bind_their_own_vip() {
let vip_a = IpAddr::V4(Ipv4Addr::new(100, 65, 32, 1));
let vip_b = IpAddr::V4(Ipv4Addr::new(100, 65, 32, 2));
let n = node_multi(
&["tag:multi"],
&[("svc:a", vec![vip_a]), ("svc:b", vec![vip_b])],
);
let a = resolve_service_listen(&n, "svc:a", ServiceMode::Tcp { port: 1 }, false).unwrap();
let b = resolve_service_listen(&n, "svc:b", ServiceMode::Tcp { port: 2 }, false).unwrap();
assert_eq!(a, core::net::SocketAddr::new(vip_a, 1));
assert_eq!(b, core::net::SocketAddr::new(vip_b, 2));
let err = resolve_service_listen(&n, "svc:absent", ServiceMode::Tcp { port: 3 }, false)
.unwrap_err();
assert!(matches!(err, ServiceError::NoAssignedVip(_)));
}
}