Skip to main content

ts_control/
service.rs

1//! Tailscale VIP-service hosting (`tsnet`'s `ListenService`).
2//!
3//! A node hosts a **VIP service** (`svc:<label>`) by binding a listener on the virtual IP
4//! address(es) control assigned that service. This module provides the fail-closed gating that
5//! decides *whether* and *on which address* a node may listen for a service, mirroring Go's
6//! `tsnet.Server.ListenService` preconditions:
7//!
8//! 1. the service name must be a valid `svc:<dns-label>` ([`crate::validate_service_name`]);
9//! 2. the host must be **tagged** (Go `ErrUntaggedServiceHost`) — an untagged node cannot host
10//!    services;
11//! 3. control must have assigned the service a VIP address on this node (delivered via the
12//!    `service-host` node-capability, parsed into [`Node::service_addresses`]).
13//!
14//! When all hold, [`resolve_service_listen`] returns the [`core::net::SocketAddr`] the embedder
15//! should bind via `Device::tcp_listen` on the overlay netstack. Otherwise it returns a typed
16//! [`ServiceError`] and the node serves nothing — never a host socket, never an unbound listen.
17//!
18//! The L3/`Tun` service mode (Go `ServiceConfig.Tun`) is intentionally unsupported: it is a TODO in
19//! upstream tsnet and the fork's default data path is the userspace netstack.
20
21use crate::node::Node;
22
23/// How a VIP service terminates incoming connections (a scoped mirror of tsnet's `ServiceMode`).
24///
25/// Both modes bind a TCP listener on the service VIP; the distinction is what terminates the
26/// stream. L3/`Tun` forwarding (Go `ServiceConfig.Tun`) is deliberately omitted — see the module
27/// docs.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ServiceMode {
30    /// Raw TCP on `port`: the embedder is handed the accepted overlay stream (like tsnet's
31    /// `ServiceModeTCP`).
32    Tcp {
33        /// The service port to listen on.
34        port: u16,
35    },
36    /// HTTP(S) on `port`: the embedder serves an HTTP handler over the accepted stream (like
37    /// tsnet's `ServiceModeHTTP`). The fork treats this identically to [`ServiceMode::Tcp`] at the
38    /// listen layer — it binds the same VIP:port and hands back the stream; TLS termination /
39    /// HTTP handling is the embedder's concern.
40    Http {
41        /// The service port to listen on.
42        port: u16,
43    },
44}
45
46impl ServiceMode {
47    /// The TCP port this mode listens on.
48    pub fn port(&self) -> u16 {
49        match self {
50            ServiceMode::Tcp { port } | ServiceMode::Http { port } => *port,
51        }
52    }
53}
54
55/// Why a VIP-service listen request was refused. Fail-closed by construction: there is no variant
56/// that yields a usable listen address without a genuine control-assigned VIP on a tagged host.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ServiceError {
59    /// The service name is not a valid `svc:<dns-label>` (Go `ServiceName.Validate`).
60    InvalidName(String),
61    /// The node is not tagged, so it cannot host VIP services (Go `ErrUntaggedServiceHost`).
62    UntaggedHost,
63    /// Control has not assigned this node a VIP address for the service (no `service-host` cap
64    /// entry, or none covering this service). The node serves nothing rather than binding an
65    /// arbitrary address — fail-closed.
66    NoAssignedVip(String),
67    /// Binding the overlay listener on the resolved VIP failed (e.g. the netstack is unavailable,
68    /// as in TUN transport mode, or the address is already in use). Carries a human-readable detail.
69    Listen(String),
70}
71
72impl core::fmt::Display for ServiceError {
73    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
74        match self {
75            ServiceError::InvalidName(name) => {
76                write!(
77                    f,
78                    "invalid VIP service name {name:?} (expected svc:<dns-label>)"
79                )
80            }
81            ServiceError::UntaggedHost => write!(
82                f,
83                "this node is untagged and cannot host a Tailscale service (it must be tagged)"
84            ),
85            ServiceError::NoAssignedVip(name) => write!(
86                f,
87                "control has not assigned a VIP address for service {name:?} on this node"
88            ),
89            ServiceError::Listen(detail) => {
90                write!(f, "failed to bind the VIP service listener: {detail}")
91            }
92        }
93    }
94}
95
96impl std::error::Error for ServiceError {}
97
98/// Resolve the overlay [`core::net::SocketAddr`] a node should bind to host VIP service `name` in
99/// `mode`, enforcing the three fail-closed preconditions (valid name, tagged host, control-assigned
100/// VIP). See the module docs.
101///
102/// Picks the first hosted VIP whose IP family the netstack can serve: an IPv6 VIP is only chosen
103/// when `enable_ipv6` is set (the fork is IPv4-only by default), preferring an IPv4 VIP otherwise.
104pub fn resolve_service_listen(
105    node: &Node,
106    name: &str,
107    mode: ServiceMode,
108    enable_ipv6: bool,
109) -> Result<core::net::SocketAddr, ServiceError> {
110    if crate::validate_service_name(name).is_none() {
111        return Err(ServiceError::InvalidName(name.to_string()));
112    }
113
114    // Go refuses to host a service from an untagged node (ErrUntaggedServiceHost).
115    if node.tags.is_empty() {
116        return Err(ServiceError::UntaggedHost);
117    }
118
119    // Pick a VIP assigned to *this specific service* that the netstack can actually serve. Prefer
120    // IPv4; only use an IPv6 VIP when IPv6 is enabled on the overlay (matching the netstack's
121    // accepted-address set). Using the per-service mapping (not the flattened host set) ensures a
122    // multi-service co-host binds the correct VIP for `name`.
123    let vips = node.service_addresses_for(name);
124    let vip = vips
125        .iter()
126        .find(|ip| ip.is_ipv4())
127        .or_else(|| vips.iter().find(|ip| enable_ipv6 && ip.is_ipv6()))
128        .ok_or_else(|| ServiceError::NoAssignedVip(name.to_string()))?;
129
130    Ok(core::net::SocketAddr::new(*vip, mode.port()))
131}
132
133#[cfg(test)]
134mod tests {
135    use alloc::{collections::BTreeMap, string::ToString, vec, vec::Vec};
136    use core::net::{IpAddr, Ipv4Addr};
137
138    use super::*;
139    use crate::node::{NodeCapMap, StableId, TailnetAddress};
140
141    /// Build a node hosting `service` on `vips` (when both are non-empty), tagged with `tags`.
142    fn node(tags: &[&str], service: &str, vips: Vec<IpAddr>) -> Node {
143        node_multi(tags, &[(service, vips)])
144    }
145
146    /// Build a node hosting several `(service, vips)` mappings.
147    fn node_multi(tags: &[&str], services: &[(&str, Vec<IpAddr>)]) -> Node {
148        let mut cap_map = NodeCapMap::new();
149        let mut service_vips: BTreeMap<String, Vec<IpAddr>> = BTreeMap::new();
150        for (service, vips) in services {
151            if !service.is_empty() && !vips.is_empty() {
152                service_vips.insert((*service).to_string(), vips.clone());
153            }
154        }
155        if !service_vips.is_empty() {
156            cap_map.insert(ts_control_serde::NODE_ATTR_SERVICE_HOST.to_string(), vec![]);
157        }
158        Node {
159            id: 1,
160            stable_id: StableId("n1".to_string()),
161            hostname: "host".to_string(),
162            user_id: 0,
163            tailnet: Some("tail1.ts.net".to_string()),
164            tags: tags.iter().map(|t| t.to_string()).collect(),
165            addresses: vec![
166                "100.64.0.1/32".parse().unwrap(),
167                "fd7a::1/128".parse().unwrap(),
168            ],
169            tailnet_address: TailnetAddress {
170                ipv4: "100.64.0.1/32".parse().unwrap(),
171                ipv6: "fd7a::1/128".parse().unwrap(),
172            },
173            node_key: [0u8; 32].into(),
174            node_key_expiry: None,
175            online: None,
176            last_seen: None,
177            key_signature: vec![],
178            machine_key: None,
179            disco_key: None,
180            accepted_routes: vec![],
181            underlay_addresses: vec![],
182            derp_region: None,
183            cap: Default::default(),
184            cap_map,
185            peerapi_port: None,
186            peerapi_dns_proxy: false,
187            is_wireguard_only: false,
188            exit_node_dns_resolvers: vec![],
189            peer_relay: false,
190            ssh_host_keys: vec![],
191            service_vips,
192            unsigned_peer_api_only: false,
193        }
194    }
195
196    fn vip4() -> IpAddr {
197        IpAddr::V4(Ipv4Addr::new(100, 65, 32, 1))
198    }
199
200    #[test]
201    fn tagged_host_with_vip_resolves_listen_addr() {
202        let n = node(&["tag:samba"], "svc:samba", vec![vip4()]);
203        let addr = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
204            .expect("a tagged host with an assigned VIP must resolve");
205        assert_eq!(addr, core::net::SocketAddr::new(vip4(), 445));
206    }
207
208    #[test]
209    fn http_mode_binds_same_vip_and_port() {
210        let n = node(&["tag:web"], "svc:web", vec![vip4()]);
211        let addr =
212            resolve_service_listen(&n, "svc:web", ServiceMode::Http { port: 8080 }, false).unwrap();
213        assert_eq!(addr, core::net::SocketAddr::new(vip4(), 8080));
214    }
215
216    #[test]
217    fn invalid_name_is_rejected() {
218        let n = node(&["tag:x"], "svc:x", vec![vip4()]);
219        // Missing svc: prefix.
220        let err =
221            resolve_service_listen(&n, "samba", ServiceMode::Tcp { port: 1 }, false).unwrap_err();
222        assert!(matches!(err, ServiceError::InvalidName(_)));
223        // Bad label.
224        let err = resolve_service_listen(&n, "svc:-bad", ServiceMode::Tcp { port: 1 }, false)
225            .unwrap_err();
226        assert!(matches!(err, ServiceError::InvalidName(_)));
227    }
228
229    #[test]
230    fn untagged_host_is_rejected() {
231        let n = node(&[], "svc:samba", vec![vip4()]);
232        let err = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
233            .unwrap_err();
234        assert_eq!(err, ServiceError::UntaggedHost);
235    }
236
237    #[test]
238    fn no_assigned_vip_is_rejected() {
239        let n = node(&["tag:samba"], "", vec![]);
240        let err = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
241            .unwrap_err();
242        assert!(matches!(err, ServiceError::NoAssignedVip(_)));
243    }
244
245    #[test]
246    fn ipv6_vip_only_chosen_when_ipv6_enabled() {
247        let vip6: IpAddr = "fd7a:115c:a1e0::1234".parse().unwrap();
248        let n = node(&["tag:samba"], "svc:samba", vec![vip6]);
249
250        // IPv6 disabled: no servable VIP -> fail closed.
251        let err = resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, false)
252            .unwrap_err();
253        assert!(matches!(err, ServiceError::NoAssignedVip(_)));
254
255        // IPv6 enabled: the v6 VIP is used.
256        let addr =
257            resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, true).unwrap();
258        assert_eq!(addr, core::net::SocketAddr::new(vip6, 445));
259    }
260
261    #[test]
262    fn ipv4_vip_preferred_over_ipv6() {
263        let vip6: IpAddr = "fd7a:115c:a1e0::1234".parse().unwrap();
264        let n = node(&["tag:samba"], "svc:samba", vec![vip6, vip4()]);
265        let addr =
266            resolve_service_listen(&n, "svc:samba", ServiceMode::Tcp { port: 445 }, true).unwrap();
267        assert_eq!(addr, core::net::SocketAddr::new(vip4(), 445));
268    }
269
270    #[test]
271    fn co_hosted_services_bind_their_own_vip() {
272        // Two services on distinct VIPs: each resolves to its OWN VIP, not the other's (the fix for
273        // the flattened-set wrong-bind).
274        let vip_a = IpAddr::V4(Ipv4Addr::new(100, 65, 32, 1));
275        let vip_b = IpAddr::V4(Ipv4Addr::new(100, 65, 32, 2));
276        let n = node_multi(
277            &["tag:multi"],
278            &[("svc:a", vec![vip_a]), ("svc:b", vec![vip_b])],
279        );
280
281        let a = resolve_service_listen(&n, "svc:a", ServiceMode::Tcp { port: 1 }, false).unwrap();
282        let b = resolve_service_listen(&n, "svc:b", ServiceMode::Tcp { port: 2 }, false).unwrap();
283        assert_eq!(a, core::net::SocketAddr::new(vip_a, 1));
284        assert_eq!(b, core::net::SocketAddr::new(vip_b, 2));
285
286        // A service this host does not have is denied even though the host has OTHER VIPs.
287        let err = resolve_service_listen(&n, "svc:absent", ServiceMode::Tcp { port: 3 }, false)
288            .unwrap_err();
289        assert!(matches!(err, ServiceError::NoAssignedVip(_)));
290    }
291}