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 online: None,
696 last_seen: None,
697 machine_key: None,
698 disco_key: None,
699 accepted_routes: vec![],
700 underlay_addresses: vec![],
701 derp_region: None,
702 cap: Default::default(),
703 cap_map,
704 peerapi_port: None,
705 peerapi_dns_proxy: false,
706 is_wireguard_only: false,
707 exit_node_dns_resolvers: vec![],
708 peer_relay: false,
709 service_vips: Default::default(),
710 // Cross-stream coupling (S4): `Node` gains `key_signature: Vec<u8>`. Empty here so this
711 // exhaustive literal compiles once S4's field lands.
712 key_signature: vec![],
713 }
714 }
715
716 const FUNNEL_PORTS_443_8443: &str =
717 "https://tailscale.com/cap/funnel-ports?ports=443,8443,10000-10010";
718
719 #[test]
720 fn funnel_access_denies_without_both_attrs() {
721 // Neither attr.
722 assert!(matches!(
723 funnel_access(&funnel_node(&[]), 443),
724 Err(FunnelError::NotAllowed)
725 ));
726 // Only https.
727 assert!(matches!(
728 funnel_access(&funnel_node(&["https", FUNNEL_PORTS_443_8443]), 443),
729 Err(FunnelError::NotAllowed)
730 ));
731 // Only funnel.
732 assert!(matches!(
733 funnel_access(&funnel_node(&["funnel", FUNNEL_PORTS_443_8443]), 443),
734 Err(FunnelError::NotAllowed)
735 ));
736 }
737
738 #[test]
739 fn funnel_access_denies_disallowed_port() {
740 let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
741 assert!(matches!(
742 funnel_access(&node, 22),
743 Err(FunnelError::PortNotAllowed(22))
744 ));
745 }
746
747 #[test]
748 fn funnel_access_allows_listed_single_and_range_ports() {
749 let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
750 // Single ports.
751 assert!(funnel_access(&node, 443).is_ok());
752 assert!(funnel_access(&node, 8443).is_ok());
753 // Range endpoints + interior.
754 assert!(funnel_access(&node, 10000).is_ok());
755 assert!(funnel_access(&node, 10005).is_ok());
756 assert!(funnel_access(&node, 10010).is_ok());
757 // Just outside the range.
758 assert!(funnel_access(&node, 9999).is_err());
759 assert!(funnel_access(&node, 10011).is_err());
760 }
761
762 #[test]
763 fn check_funnel_port_denies_without_ports_cap() {
764 // Can funnel, but no funnel-ports cap at all => every port denied.
765 let node = funnel_node(&["https", "funnel"]);
766 assert!(node.can_funnel());
767 assert!(!node.check_funnel_port(443));
768 }
769
770 #[test]
771 fn check_funnel_port_denies_empty_ports_query() {
772 let node = funnel_node(&[
773 "https",
774 "funnel",
775 "https://tailscale.com/cap/funnel-ports?ports=",
776 ]);
777 assert!(!node.check_funnel_port(443));
778 }
779
780 #[test]
781 fn check_funnel_port_rejects_wrong_url_with_ports_query() {
782 // A look-alike host carrying ?ports= must NOT be honored: after stripping the query the
783 // URL must equal the exact funnel-ports cap. (starts_with the cap prefix is the scan
784 // filter, but parse_attr re-validates the full URL.)
785 let node = funnel_node(&[
786 "https",
787 "funnel",
788 "https://tailscale.com/cap/funnel-ports-evil?ports=443",
789 ]);
790 assert!(!node.check_funnel_port(443));
791 }
792
793 #[tokio::test]
794 async fn listen_funnel_is_fail_closed_unsupported_when_allowed() {
795 // Node is allowed to funnel on 443, but the public relay leg + real cert don't exist in
796 // this fork: must surface Unsupported (or Cert), never a usable acceptor.
797 let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
798 let cfg = ServeConfig {
799 name: "host.tail1.ts.net".into(),
800 port: 443,
801 target: ServeTarget::Accept,
802 };
803 let err = match listen_funnel(&node, &cfg, FunnelOptions::default()).await {
804 Ok(_) => panic!("must not build a Funnel acceptor without relay + real cert"),
805 Err(e) => e,
806 };
807 assert!(matches!(
808 err,
809 FunnelError::Unsupported { .. } | FunnelError::Cert(_)
810 ));
811 }
812
813 #[tokio::test]
814 async fn listen_funnel_denies_before_cert_when_not_allowed() {
815 // Access gate must run first: a node that can't funnel never reaches the cert path.
816 let node = funnel_node(&[]);
817 let cfg = ServeConfig {
818 name: "host.tail1.ts.net".into(),
819 port: 443,
820 target: ServeTarget::Accept,
821 };
822 let err = match listen_funnel(&node, &cfg, FunnelOptions::default()).await {
823 Ok(_) => panic!("must deny a node that cannot funnel"),
824 Err(e) => e,
825 };
826 assert!(matches!(err, FunnelError::NotAllowed));
827 }
828}