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