Skip to main content

ts_control/
serve.rs

1//! TLS termination on the tailnet (`tsnet`'s `Serve` / `ListenTLS`).
2//!
3//! [`ServeConfig`] is a scoped-down mirror of upstream Tailscale's
4//! `ipn.ServeConfig`: it describes terminating TLS for the node's MagicDNS name
5//! on a tailnet port and what to do with the decrypted stream. [`tls_acceptor`]
6//! turns a [`CertifiedKey`] (obtained via [`crate::cert::get_certificate`]) into
7//! a [`tokio_rustls::TlsAcceptor`] using the same `ring` provider as the rest of
8//! the stack ([`ts_tls_util`]), and [`accept_tls`] wraps an accepted overlay
9//! stream.
10//!
11//! # Anti-leak
12//!
13//! TLS is terminated only for tailnet (`*.ts.net`) names (enforced by
14//! [`crate::cert::is_tailnet_name`] at certificate-acquisition time) and only on
15//! the **overlay** netstack — never a host socket. There is no plaintext
16//! downgrade and no self-signed fallback: if a certificate cannot be obtained,
17//! [`listen_tls`] surfaces the same fail-closed [`CertError`] as
18//! [`crate::cert::get_certificate`].
19
20use std::sync::Arc;
21
22use serde::{Deserialize, Serialize};
23use tokio::io::{AsyncRead, AsyncWrite};
24use tokio_rustls::{
25    TlsAcceptor,
26    rustls::{
27        ServerConfig,
28        crypto::ring::default_provider,
29        server::{ClientHello, ResolvesServerCert},
30        sign::CertifiedKey,
31    },
32    server::TlsStream,
33};
34
35use crate::{
36    cert::{self, CertError},
37    node::Node,
38};
39
40/// What to do with a stream once TLS is terminated (or, for [`ServeTarget::TcpForward`], a raw TCP
41/// stream with no TLS).
42///
43/// Mirrors the handler shapes of upstream `ipn.ServeConfig`'s `HTTPHandler`/`TCPPortHandler`
44/// (`Proxy`/`Text`/`TCPForward`/`Path`/`Redirect`), plus an `Accept` hand-back the in-process Rust
45/// embedder uses in place of Go's `net.Listener`.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case", tag = "kind")]
48#[non_exhaustive]
49pub enum ServeTarget {
50    /// Hand the accepted, decrypted stream back to the embedder (like
51    /// `tsnet`'s `ListenTLS` returning a `net.Listener`).
52    Accept,
53    /// Reverse-proxy the decrypted stream to a local address (like a `Serve`
54    /// `Proxy` handler). The address is a real OS socket target on this host.
55    Proxy {
56        /// `host:port` to dial for the proxied backend.
57        to: String,
58    },
59    /// Serve a fixed plaintext body to every connection, then close (Go `HTTPHandler.Text`). The
60    /// bytes are written as-is after TLS termination — the embedder supplies any HTTP framing.
61    Text {
62        /// The exact bytes to write to each accepted stream.
63        body: String,
64    },
65    /// Forward the **raw** (non-TLS-terminated) TCP stream to a local backend (Go
66    /// `TCPPortHandler.TCPForward`). Unlike [`ServeTarget::Proxy`], no TLS is terminated — bytes are
67    /// spliced through verbatim to `to` (a real OS socket on this host).
68    TcpForward {
69        /// `host:port` to dial for the raw-TCP backend.
70        to: String,
71    },
72    /// HTTP path-prefix mux (Go `HTTPHandler` path map). Terminates TLS, reads the request line, and
73    /// dispatches the longest-matching mount's nested target on the already-decrypted stream.
74    Path {
75        /// Mount point → nested target. A mount at `P` claims `P` itself and the paths below it
76        /// (`P` followed by `/`), never an unrelated path that merely starts with the same bytes —
77        /// a `/api` mount does not claim `/apifoo`, matching Go's segment-boundary handler lookup.
78        /// Longest match wins at dispatch; an unmatched path is a fail-closed 404. Nested `Path` is
79        /// rejected by [`validate`](ServeState::validate) to bound recursion (one level of nesting
80        /// only).
81        handlers: alloc::collections::BTreeMap<String, ServeTarget>,
82    },
83    /// HTTP redirect response (Go `HTTPHandler` redirect). Terminates TLS, then writes a bodyless
84    /// `status`/`Location: to` response and closes.
85    Redirect {
86        /// Absolute or relative `Location` header value.
87        to: String,
88        /// HTTP redirect status; [`validate`](ServeState::validate) rejects anything outside
89        /// `300..=399`.
90        status: u16,
91    },
92}
93
94impl ServeTarget {
95    /// Whether this target requires TLS termination on the serve port. `Accept`/`Proxy`/`Text`/
96    /// `Path`/`Redirect` ride an HTTPS port and terminate TLS; only `TcpForward` is a raw passthrough
97    /// with no TLS. Explicit arms (not a single `matches!`) so the `#[non_exhaustive]` intent — every
98    /// future variant must declare its TLS posture deliberately — is clear at the call site.
99    pub fn terminates_tls(&self) -> bool {
100        match self {
101            ServeTarget::Accept
102            | ServeTarget::Proxy { .. }
103            | ServeTarget::Text { .. }
104            | ServeTarget::Path { .. }
105            | ServeTarget::Redirect { .. } => true,
106            ServeTarget::TcpForward { .. } => false,
107        }
108    }
109}
110
111/// A complete multi-port Serve configuration for one node (mirrors upstream `ipn.ServeConfig`'s
112/// per-port `TCP` map). Stored on the device and reconciled into one accept loop per port by the
113/// Serve runtime; `set_serve_config` REPLACES the whole config (Go semantics).
114///
115/// All TLS-terminating ports share the node's single MagicDNS [`name`](ServeState::name)
116/// certificate (obtained via the ACME path). `TcpForward` ports need no cert.
117#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
118pub struct ServeState {
119    /// The node's MagicDNS name the TLS-terminating ports' certificate is for (e.g.
120    /// `host.tailnet.ts.net`). Must be a tailnet name when any TLS-terminating port is configured.
121    pub name: String,
122    /// Map of tailnet (overlay) port → what to serve on it.
123    pub ports: alloc::collections::BTreeMap<u16, ServeTarget>,
124}
125
126impl ServeState {
127    /// Validate the whole config. Fail-closed: rejects port 0, empty proxy/forward targets, and —
128    /// when any TLS-terminating port is present — a non-tailnet `name` (anti-leak: we never mint a
129    /// cert for an off-tailnet name). An empty config (no ports) is valid (serves nothing).
130    pub fn validate(&self) -> Result<(), CertError> {
131        let any_tls = self.ports.values().any(ServeTarget::terminates_tls);
132        if any_tls && !cert::is_tailnet_name(&self.name) {
133            return Err(CertError::NotTailnetName(self.name.clone()));
134        }
135        for (port, target) in &self.ports {
136            if *port == 0 {
137                return Err(CertError::Acme("serve port must be non-zero".into()));
138            }
139            validate_target(target, 0)?;
140        }
141        Ok(())
142    }
143}
144
145/// Maximum depth of nested [`ServeTarget::Path`] handlers. A top-level `Path` (depth 0) may hold
146/// non-`Path` nested targets; a `Path` nested inside another `Path` is rejected. This bounds
147/// validation (and dispatch) recursion so an attacker-supplied config can't blow the stack.
148const MAX_PATH_NESTING_DEPTH: usize = 1;
149
150/// Fail-closed validation for one [`ServeTarget`], shared by [`ServeState::validate`] and
151/// [`ServeConfig::validate`]. `depth` is the current `Path` nesting level (0 at the top).
152///
153/// Rejects: empty `Proxy`/`TcpForward` targets; `Redirect` with an out-of-`300..=399` status, an
154/// empty `to`, or a `to` containing CR/LF (the value is written verbatim into a `Location:` response
155/// header, so embedded CR/LF would allow HTTP response-header injection / response splitting);
156/// `Path` with empty `handlers`, a `Path` nested deeper than [`MAX_PATH_NESTING_DEPTH`]
157/// (no unbounded recursion), or any nested target that itself fails validation.
158fn validate_target(target: &ServeTarget, depth: usize) -> Result<(), CertError> {
159    match target {
160        ServeTarget::Proxy { to } | ServeTarget::TcpForward { to } if to.trim().is_empty() => Err(
161            CertError::Acme("serve proxy/forward target must not be empty".into()),
162        ),
163        ServeTarget::Redirect { to, status } => {
164            if to.trim().is_empty() {
165                return Err(CertError::Acme(
166                    "serve redirect target must not be empty".into(),
167                ));
168            }
169            // The redirect `to` is written verbatim into a `Location:` response header at runtime.
170            // A CR or LF would terminate the header line and allow injection of arbitrary headers
171            // or a response body (response splitting). Reject it fail-closed.
172            if to.contains(['\r', '\n']) {
173                return Err(CertError::Acme(
174                    "serve redirect target must not contain CR/LF".into(),
175                ));
176            }
177            if !(300..=399).contains(status) {
178                return Err(CertError::Acme(
179                    "serve redirect status must be in 300..=399".into(),
180                ));
181            }
182            Ok(())
183        }
184        ServeTarget::Path { handlers } => {
185            if depth >= MAX_PATH_NESTING_DEPTH {
186                return Err(CertError::Acme(
187                    "serve path handlers must not nest more than one level".into(),
188                ));
189            }
190            if handlers.is_empty() {
191                return Err(CertError::Acme(
192                    "serve path handlers must not be empty".into(),
193                ));
194            }
195            for nested in handlers.values() {
196                validate_target(nested, depth + 1)?;
197            }
198            Ok(())
199        }
200        _ => Ok(()),
201    }
202}
203
204/// Configuration for terminating TLS on one tailnet port for one MagicDNS name.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct ServeConfig {
207    /// The node's MagicDNS name the certificate is for (e.g.
208    /// `host.tailnet.ts.net`). Must be a tailnet name.
209    pub name: String,
210    /// The tailnet (overlay) port to terminate TLS on.
211    pub port: u16,
212    /// What to do with each decrypted stream.
213    pub target: ServeTarget,
214}
215
216impl ServeConfig {
217    /// Validate the config. Fail-closed: rejects non-tailnet names, port 0, and
218    /// empty proxy targets, so a misconfiguration can't silently serve the wrong
219    /// thing.
220    pub fn validate(&self) -> Result<(), CertError> {
221        if !cert::is_tailnet_name(&self.name) {
222            return Err(CertError::NotTailnetName(self.name.clone()));
223        }
224        if self.port == 0 {
225            return Err(CertError::Acme("serve port must be non-zero".into()));
226        }
227        validate_target(&self.target, 0)
228    }
229}
230
231/// A [`ResolvesServerCert`] that always answers with one pre-obtained
232/// [`CertifiedKey`]. The cert is for a single MagicDNS name, so SNI selection is
233/// trivial — every `ClientHello` gets the same key.
234#[derive(Debug)]
235struct SingleCert(Arc<CertifiedKey>);
236
237impl ResolvesServerCert for SingleCert {
238    fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
239        Some(self.0.clone())
240    }
241}
242
243/// Build a [`TlsAcceptor`] for an already-obtained [`CertifiedKey`].
244///
245/// Pins the `ring` provider explicitly (matching [`ts_tls_util`]); never
246/// auto-detects the process-default provider, which panics under ring+aws-lc
247/// feature unification.
248pub fn tls_acceptor(cert: CertifiedKey) -> Result<TlsAcceptor, CertError> {
249    let config = ServerConfig::builder_with_provider(Arc::new(default_provider()))
250        .with_safe_default_protocol_versions()
251        .map_err(CertError::Rustls)?
252        .with_no_client_auth()
253        .with_cert_resolver(Arc::new(SingleCert(Arc::new(cert))));
254
255    Ok(TlsAcceptor::from(Arc::new(config)))
256}
257
258/// Terminate TLS on a single already-accepted overlay stream.
259///
260/// Generic over the stream type so the orchestrator can pass an overlay netstack
261/// `TcpStream` (this crate does not depend on the netstack). The acceptor is
262/// built from [`tls_acceptor`]; reuse one acceptor across many connections.
263pub async fn accept_tls<Io>(acceptor: &TlsAcceptor, io: Io) -> Result<TlsStream<Io>, CertError>
264where
265    Io: AsyncRead + AsyncWrite + Unpin,
266{
267    acceptor.accept(io).await.map_err(CertError::Io)
268}
269
270/// Obtain a certificate for `cfg.name` and build a [`TlsAcceptor`] for it.
271///
272/// **Fail-closed.** Delegates to [`crate::cert::get_certificate`], which in this
273/// fork returns [`CertError::Unimplemented`] (no client-side ACME engine / no
274/// `set-dns` DNS-01 publish RPC, and a self-hosted control plane typically 501s on `set-dns`). This function
275/// therefore returns the same error rather than ever falling back to plaintext or
276/// a self-signed certificate. When issuance lands, this starts returning a
277/// working acceptor with no caller change.
278pub async fn listen_tls(cfg: &ServeConfig) -> Result<TlsAcceptor, CertError> {
279    cfg.validate()?;
280    let cert = cert::get_certificate(&cfg.name).await?;
281    tls_acceptor(cert)
282}
283
284/// Options for a Funnel listener (mirrors `tsnet.FunnelOption`).
285///
286/// Funnel exposes a tailnet TLS service to the *public* internet via Tailscale's ingress relays.
287/// These knobs scope down from upstream to what this fork models; the listener itself is
288/// fail-closed in this fork (see [`listen_funnel`]).
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290pub struct FunnelOptions {
291    /// Reject tailnet-internal connections, serving *only* public Funnel ingress (`tsnet`'s
292    /// `FunnelOnly`). When `false`, the same listener accepts both tailnet and Funnel traffic.
293    pub funnel_only: bool,
294}
295
296/// Why a Funnel listen request was denied or could not be served.
297///
298/// Fail-closed by construction: the access-gate variants ([`FunnelError::NotAllowed`],
299/// [`FunnelError::PortNotAllowed`]) deny before any listener is built, and the terminal
300/// [`FunnelError::Cert`] carries the same fail-closed [`CertError`] as [`listen_tls`] (no
301/// self-signed/plaintext fallback). [`FunnelError::Unsupported`] marks the public-relay leg that
302/// this fork cannot stand up against its control plane.
303#[derive(Debug)]
304pub enum FunnelError {
305    /// The node is not permitted to funnel: it lacks the `https` and/or `funnel` node attributes
306    /// (Go `ipn.NodeCanFunnel`). The tailnet admin must enable HTTPS and grant the `funnel`
307    /// attribute via the ACL policy.
308    NotAllowed,
309    /// The node may funnel, but `port` is not in the set granted by the `funnel-ports` capability
310    /// (Go `ipn.CheckFunnelPort`).
311    PortNotAllowed(u16),
312    /// Certificate acquisition / TLS material assembly failed. Funnel terminates public TLS with the
313    /// node's `*.ts.net` cert (the Funnel hostname *is* the node's MagicDNS name, so the existing
314    /// DNS-01 cert matches — no TLS-ALPN-01 needed). Without the `acme` feature (or before a cert is
315    /// issued) this carries the same fail-closed [`CertError`] as [`listen_tls`] — no self-signed or
316    /// plaintext fallback.
317    Cert(CertError),
318    /// The public ingress relay leg is unavailable. Funnel ingress arrives as a tailnet-peer POST to
319    /// this node's peerAPI `/v0/ingress` (the relay is a Tailscale-operated peer that the control
320    /// plane stands up); against a self-hosted control plane no such relay exists, so no
321    /// public traffic is ever delivered. This is *not* returned by [`listen_funnel`] anymore (the
322    /// listener is built and works against real SaaS); it remains for callers that want to surface
323    /// the relay gap explicitly. `detail` names what is missing.
324    Unsupported {
325        /// Names exactly what is missing to serve public Funnel ingress.
326        detail: String,
327    },
328}
329
330impl core::fmt::Display for FunnelError {
331    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
332        match self {
333            FunnelError::NotAllowed => write!(
334                f,
335                "Funnel not available: node lacks the \"https\" and/or \"funnel\" attributes"
336            ),
337            FunnelError::PortNotAllowed(port) => {
338                write!(f, "port {port} is not allowed for funnel")
339            }
340            FunnelError::Cert(e) => write!(f, "Funnel certificate error: {e}"),
341            FunnelError::Unsupported { detail } => {
342                write!(f, "Funnel ingress is unsupported in this fork: {detail}")
343            }
344        }
345    }
346}
347
348impl std::error::Error for FunnelError {
349    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
350        match self {
351            FunnelError::Cert(e) => Some(e),
352            FunnelError::NotAllowed
353            | FunnelError::PortNotAllowed(_)
354            | FunnelError::Unsupported { .. } => None,
355        }
356    }
357}
358
359impl From<CertError> for FunnelError {
360    fn from(e: CertError) -> Self {
361        FunnelError::Cert(e)
362    }
363}
364
365/// Names what is needed to actually receive public Funnel ingress on a node whose client-side
366/// listener is up. This is **Tailscale infrastructure, not buildable in this fork**: the public DNS
367/// `<node>.<tailnet>.ts.net:443` → relay mapping plus the ingress relay itself (a Tailscale-operated
368/// tailnet peer that POSTs the public client's bytes to this node's peerAPI `/v0/ingress`). Against
369/// real Tailscale SaaS (with a Funnel-enabled ACL) control stands these up automatically and
370/// [`listen_funnel`]'s listener serves real public traffic; against a self-hosted control plane
371/// no relay exists, so the listener is correct but never fed. Surfaced verbatim in
372/// [`FunnelError::Unsupported`] for callers that want to flag the relay gap.
373pub const MISSING_FUNNEL_RELAY: &str = "the Tailscale-operated public ingress relay + the public DNS \
374     <node>.<tailnet>.ts.net:443 -> relay mapping that POST public client bytes to this node's peerAPI \
375     /v0/ingress; these are Tailscale infrastructure (provisioned automatically against real Tailscale \
376     SaaS with a Funnel-enabled ACL) and a self-hosted control plane provides no such relay";
377
378/// Check whether `node` may funnel on `port`, mirroring Go's `ipn.NodeCanFunnel` +
379/// `ipn.CheckFunnelPort` gate. Pure and fail-closed: a missing attribute or out-of-range port
380/// denies. This is the access decision; it does not build a listener.
381pub fn funnel_access(node: &Node, port: u16) -> Result<(), FunnelError> {
382    if !node.can_funnel() {
383        return Err(FunnelError::NotAllowed);
384    }
385    if !node.check_funnel_port(port) {
386        return Err(FunnelError::PortNotAllowed(port));
387    }
388    Ok(())
389}
390
391/// Build a [`TlsAcceptor`] terminating public Funnel ingress for `cfg.name` on `cfg.port` (like
392/// `tsnet`'s `ListenFunnel`).
393///
394/// **Fail-closed gates, then the working TLS acceptor.** First the node-attribute gate
395/// ([`funnel_access`], mirroring Go `NodeCanFunnel` + `CheckFunnelPort`) must pass — fully enforced
396/// from the node's capability map. Then TLS material is obtained via [`cert::get_certificate`]: the
397/// Funnel hostname *is* the node's MagicDNS `*.ts.net` name, so the node's existing DNS-01 cert
398/// matches and no TLS-ALPN-01 is required. Without the `acme` feature this fork's stub still returns
399/// [`CertError::Unimplemented`] (carried as [`FunnelError::Cert`]); the device-level
400/// `listen_funnel` routes through the ACME-aware cert path instead, so with `acme` (and a control
401/// plane that answers `set-dns`) this yields a real acceptor.
402///
403/// Unlike the previous fail-closed stub, an allowed request with a cert now returns a usable
404/// acceptor (the caller — `Device::listen_funnel` — registers a funnel manager that TLS-terminates
405/// hijacked `/v0/ingress` streams with it and hands the decrypted streams back). The public ingress
406/// **relay + DNS mapping** that feed `/v0/ingress` are Tailscale infrastructure
407/// ([`MISSING_FUNNEL_RELAY`]) provisioned automatically against real Tailscale SaaS; against a
408/// self-hosted control plane no relay exists, so the listener is correct but never fed.
409///
410/// Anti-leak: Funnel TLS terminates only on the overlay netstack (the hijacked ingress stream
411/// arrives on the peerAPI overlay listener), never a host socket; there is no self-signed or
412/// plaintext fallback. `_opts` is accepted now so the public surface is stable as ingress wiring
413/// evolves.
414pub async fn listen_funnel(
415    node: &Node,
416    cfg: &ServeConfig,
417    _opts: FunnelOptions,
418) -> Result<TlsAcceptor, FunnelError> {
419    cfg.validate()?;
420    funnel_access(node, cfg.port)?;
421
422    // Access granted. Build the TLS acceptor from the node's `*.ts.net` cert (the Funnel hostname is
423    // the node's MagicDNS name, so the existing DNS-01 cert matches). Fail-closed on CertError — no
424    // self-signed/plaintext fallback. The cert path here is the non-acme stub; the device-level
425    // listen_funnel routes through the acme-aware Device::get_certificate.
426    let cert = cert::get_certificate(&cfg.name).await?;
427    Ok(tls_acceptor(cert)?)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn cfg(name: &str, port: u16) -> ServeConfig {
435        ServeConfig {
436            name: name.into(),
437            port,
438            target: ServeTarget::Accept,
439        }
440    }
441
442    #[test]
443    fn validate_accepts_tailnet_name() {
444        assert!(cfg("host.tail1.ts.net", 443).validate().is_ok());
445    }
446
447    #[test]
448    fn validate_rejects_offtailnet_name() {
449        let err = cfg("example.com", 443).validate().unwrap_err();
450        assert!(matches!(err, CertError::NotTailnetName(_)));
451    }
452
453    #[test]
454    fn validate_rejects_zero_port() {
455        assert!(cfg("host.tail1.ts.net", 0).validate().is_err());
456    }
457
458    #[test]
459    fn validate_rejects_empty_proxy_target() {
460        let c = ServeConfig {
461            name: "host.tail1.ts.net".into(),
462            port: 443,
463            target: ServeTarget::Proxy { to: "  ".into() },
464        };
465        assert!(c.validate().is_err());
466    }
467
468    #[test]
469    fn serve_config_roundtrips_json() {
470        let c = ServeConfig {
471            name: "host.tail1.ts.net".into(),
472            port: 8443,
473            target: ServeTarget::Proxy {
474                to: "127.0.0.1:8080".into(),
475            },
476        };
477        let json = serde_json::to_string(&c).unwrap();
478        let back: ServeConfig = serde_json::from_str(&json).unwrap();
479        assert_eq!(c, back);
480    }
481
482    #[test]
483    fn serve_target_path_redirect_roundtrips_json() {
484        let mut handlers = alloc::collections::BTreeMap::new();
485        handlers.insert(
486            "/".to_string(),
487            ServeTarget::Redirect {
488                to: "https://host.tail1.ts.net/app".into(),
489                status: 308,
490            },
491        );
492        handlers.insert(
493            "/api".to_string(),
494            ServeTarget::Proxy {
495                to: "127.0.0.1:8080".into(),
496            },
497        );
498        let c = ServeConfig {
499            name: "host.tail1.ts.net".into(),
500            port: 443,
501            target: ServeTarget::Path { handlers },
502        };
503        let json = serde_json::to_string(&c).unwrap();
504        let back: ServeConfig = serde_json::from_str(&json).unwrap();
505        assert_eq!(c, back);
506        assert!(c.validate().is_ok());
507    }
508
509    #[test]
510    fn validate_rejects_bad_redirect_status() {
511        let c = ServeConfig {
512            name: "host.tail1.ts.net".into(),
513            port: 443,
514            target: ServeTarget::Redirect {
515                to: "/elsewhere".into(),
516                status: 200,
517            },
518        };
519        assert!(c.validate().is_err());
520    }
521
522    #[test]
523    fn validate_rejects_empty_redirect_target() {
524        let c = ServeConfig {
525            name: "host.tail1.ts.net".into(),
526            port: 443,
527            target: ServeTarget::Redirect {
528                to: "  ".into(),
529                status: 302,
530            },
531        };
532        assert!(c.validate().is_err());
533    }
534
535    #[test]
536    fn validate_rejects_redirect_with_crlf() {
537        // CR/LF in the `to` would terminate the `Location:` header line and allow response-header
538        // injection / response splitting. Must be rejected (bare CR, bare LF, and CRLF), via the
539        // shared validate_target used by ServeConfig::validate and ServeState::validate.
540        for bad in [
541            "https://host.tail1.ts.net/\r\nSet-Cookie: evil=1",
542            "https://host.tail1.ts.net/\rX",
543            "https://host.tail1.ts.net/\nX",
544        ] {
545            let c = ServeConfig {
546                name: "host.tail1.ts.net".into(),
547                port: 443,
548                target: ServeTarget::Redirect {
549                    to: bad.into(),
550                    status: 302,
551                },
552            };
553            assert!(
554                c.validate().is_err(),
555                "ServeConfig must reject CR/LF redirect target: {bad:?}"
556            );
557
558            let mut ports = alloc::collections::BTreeMap::new();
559            ports.insert(
560                443u16,
561                ServeTarget::Redirect {
562                    to: bad.into(),
563                    status: 302,
564                },
565            );
566            let st = ServeState {
567                name: "host.tail1.ts.net".into(),
568                ports,
569            };
570            assert!(
571                st.validate().is_err(),
572                "ServeState must reject CR/LF redirect target: {bad:?}"
573            );
574        }
575
576        // A normal redirect target (no CR/LF) still passes.
577        let ok = ServeConfig {
578            name: "host.tail1.ts.net".into(),
579            port: 443,
580            target: ServeTarget::Redirect {
581                to: "https://host.tail1.ts.net/app".into(),
582                status: 308,
583            },
584        };
585        assert!(ok.validate().is_ok());
586    }
587
588    #[test]
589    fn validate_rejects_empty_path_handlers() {
590        let c = ServeConfig {
591            name: "host.tail1.ts.net".into(),
592            port: 443,
593            target: ServeTarget::Path {
594                handlers: alloc::collections::BTreeMap::new(),
595            },
596        };
597        assert!(c.validate().is_err());
598    }
599
600    #[test]
601    fn validate_rejects_nested_path() {
602        let mut inner = alloc::collections::BTreeMap::new();
603        inner.insert("/deep".to_string(), ServeTarget::Accept);
604        let mut handlers = alloc::collections::BTreeMap::new();
605        handlers.insert("/".to_string(), ServeTarget::Path { handlers: inner });
606        let c = ServeConfig {
607            name: "host.tail1.ts.net".into(),
608            port: 443,
609            target: ServeTarget::Path { handlers },
610        };
611        assert!(c.validate().is_err());
612    }
613
614    #[test]
615    fn validate_recurses_into_nested_path_target() {
616        // A nested target that is itself invalid (empty proxy) must fail through the recursion.
617        let mut handlers = alloc::collections::BTreeMap::new();
618        handlers.insert("/".to_string(), ServeTarget::Proxy { to: "  ".into() });
619        let c = ServeConfig {
620            name: "host.tail1.ts.net".into(),
621            port: 443,
622            target: ServeTarget::Path { handlers },
623        };
624        assert!(c.validate().is_err());
625    }
626
627    #[test]
628    fn serve_state_validate_accepts_path_and_redirect() {
629        let mut handlers = alloc::collections::BTreeMap::new();
630        handlers.insert(
631            "/api".to_string(),
632            ServeTarget::Proxy {
633                to: "127.0.0.1:8080".into(),
634            },
635        );
636        let mut ports = alloc::collections::BTreeMap::new();
637        ports.insert(443u16, ServeTarget::Path { handlers });
638        ports.insert(
639            8443u16,
640            ServeTarget::Redirect {
641                to: "/api".into(),
642                status: 307,
643            },
644        );
645        let st = ServeState {
646            name: "host.tail1.ts.net".into(),
647            ports,
648        };
649        assert!(st.validate().is_ok());
650    }
651
652    #[tokio::test]
653    async fn listen_tls_is_fail_closed() {
654        // No ACME RPC in this fork: must surface Unimplemented, never a usable
655        // acceptor, never a plaintext/self-signed fallback.
656        let err = match listen_tls(&cfg("host.tail1.ts.net", 443)).await {
657            Ok(_) => panic!("must not build an acceptor without a real cert"),
658            Err(e) => e,
659        };
660        assert!(matches!(err, CertError::Unimplemented { .. }));
661    }
662
663    // TEST-ONLY: prove the rustls acceptor wiring works when a CertifiedKey IS
664    // available, using an ephemeral self-signed cert. This never runs in
665    // production (get_certificate is fail-closed); it only exercises tls_acceptor.
666    #[test]
667    fn tls_acceptor_builds_from_certified_key() {
668        let cert = rcgen::generate_simple_self_signed(vec!["host.tail1.ts.net".into()]).unwrap();
669        let cert_pem = cert.cert.pem();
670        let key_pem = cert.key_pair.serialize_pem();
671        let ck = cert::certified_key_from_pem(cert_pem.as_bytes(), key_pem.as_bytes()).unwrap();
672        assert!(tls_acceptor(ck).is_ok());
673    }
674
675    // ---- Funnel gating ----
676
677    use crate::node::{Node, NodeCapMap, StableId, TailnetAddress};
678
679    /// Build a minimal node with the given cap-map keys, for funnel-gate tests.
680    fn funnel_node(caps: &[&str]) -> Node {
681        let mut cap_map = NodeCapMap::new();
682        for c in caps {
683            cap_map.insert((*c).to_string(), vec![]);
684        }
685        Node {
686            id: 1,
687            stable_id: StableId("n1".to_string()),
688            hostname: "host".to_string(),
689            user_id: 0,
690            tailnet: Some("tail1.ts.net".to_string()),
691            tags: vec![],
692            tailnet_address: TailnetAddress {
693                ipv4: "100.64.0.1/32".parse().unwrap(),
694                ipv6: "fd7a::1/128".parse().unwrap(),
695            },
696            node_key: [0u8; 32].into(),
697            node_key_expiry: None,
698            online: None,
699            last_seen: None,
700            machine_key: None,
701            disco_key: None,
702            accepted_routes: vec![],
703            underlay_addresses: vec![],
704            derp_region: None,
705            cap: Default::default(),
706            cap_map,
707            peerapi_port: None,
708            peerapi_dns_proxy: false,
709            is_wireguard_only: false,
710            exit_node_dns_resolvers: vec![],
711            peer_relay: false,
712            ssh_host_keys: vec![],
713            service_vips: Default::default(),
714            // Cross-stream coupling (S4): `Node` gains `key_signature: Vec<u8>`. Empty here so this
715            // exhaustive literal compiles once S4's field lands.
716            key_signature: vec![],
717        }
718    }
719
720    const FUNNEL_PORTS_443_8443: &str =
721        "https://tailscale.com/cap/funnel-ports?ports=443,8443,10000-10010";
722
723    #[test]
724    fn funnel_access_denies_without_both_attrs() {
725        // Neither attr.
726        assert!(matches!(
727            funnel_access(&funnel_node(&[]), 443),
728            Err(FunnelError::NotAllowed)
729        ));
730        // Only https.
731        assert!(matches!(
732            funnel_access(&funnel_node(&["https", FUNNEL_PORTS_443_8443]), 443),
733            Err(FunnelError::NotAllowed)
734        ));
735        // Only funnel.
736        assert!(matches!(
737            funnel_access(&funnel_node(&["funnel", FUNNEL_PORTS_443_8443]), 443),
738            Err(FunnelError::NotAllowed)
739        ));
740    }
741
742    #[test]
743    fn funnel_access_denies_disallowed_port() {
744        let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
745        assert!(matches!(
746            funnel_access(&node, 22),
747            Err(FunnelError::PortNotAllowed(22))
748        ));
749    }
750
751    #[test]
752    fn funnel_access_allows_listed_single_and_range_ports() {
753        let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
754        // Single ports.
755        assert!(funnel_access(&node, 443).is_ok());
756        assert!(funnel_access(&node, 8443).is_ok());
757        // Range endpoints + interior.
758        assert!(funnel_access(&node, 10000).is_ok());
759        assert!(funnel_access(&node, 10005).is_ok());
760        assert!(funnel_access(&node, 10010).is_ok());
761        // Just outside the range.
762        assert!(funnel_access(&node, 9999).is_err());
763        assert!(funnel_access(&node, 10011).is_err());
764    }
765
766    #[test]
767    fn check_funnel_port_denies_without_ports_cap() {
768        // Can funnel, but no funnel-ports cap at all => every port denied.
769        let node = funnel_node(&["https", "funnel"]);
770        assert!(node.can_funnel());
771        assert!(!node.check_funnel_port(443));
772    }
773
774    #[test]
775    fn check_funnel_port_denies_empty_ports_query() {
776        let node = funnel_node(&[
777            "https",
778            "funnel",
779            "https://tailscale.com/cap/funnel-ports?ports=",
780        ]);
781        assert!(!node.check_funnel_port(443));
782    }
783
784    #[test]
785    fn check_funnel_port_rejects_wrong_url_with_ports_query() {
786        // A look-alike host carrying ?ports= must NOT be honored: after stripping the query the
787        // URL must equal the exact funnel-ports cap. (starts_with the cap prefix is the scan
788        // filter, but parse_attr re-validates the full URL.)
789        let node = funnel_node(&[
790            "https",
791            "funnel",
792            "https://tailscale.com/cap/funnel-ports-evil?ports=443",
793        ]);
794        assert!(!node.check_funnel_port(443));
795    }
796
797    #[tokio::test]
798    async fn listen_funnel_is_fail_closed_unsupported_when_allowed() {
799        // Node is allowed to funnel on 443, but the public relay leg + real cert don't exist in
800        // this fork: must surface Unsupported (or Cert), never a usable acceptor.
801        let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
802        let cfg = ServeConfig {
803            name: "host.tail1.ts.net".into(),
804            port: 443,
805            target: ServeTarget::Accept,
806        };
807        let err = match listen_funnel(&node, &cfg, FunnelOptions::default()).await {
808            Ok(_) => panic!("must not build a Funnel acceptor without relay + real cert"),
809            Err(e) => e,
810        };
811        assert!(matches!(
812            err,
813            FunnelError::Unsupported { .. } | FunnelError::Cert(_)
814        ));
815    }
816
817    #[tokio::test]
818    async fn listen_funnel_denies_before_cert_when_not_allowed() {
819        // Access gate must run first: a node that can't funnel never reaches the cert path.
820        let node = funnel_node(&[]);
821        let cfg = ServeConfig {
822            name: "host.tail1.ts.net".into(),
823            port: 443,
824            target: ServeTarget::Accept,
825        };
826        let err = match listen_funnel(&node, &cfg, FunnelOptions::default()).await {
827            Ok(_) => panic!("must deny a node that cannot funnel"),
828            Err(e) => e,
829        };
830        assert!(matches!(err, FunnelError::NotAllowed));
831    }
832}