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