ts_runtime/serve.rs
1//! Stored Serve config + accept-loop runtime (`tsnet`'s `Get/SetServeConfig` + serving runtime).
2//!
3//! Go `tsnet` stores an `ipn.ServeConfig` on the node and runs one accept loop per configured
4//! tailnet port, dispatching each accepted connection per its handler (proxy / text / raw TCP
5//! forward / hand-back). This module is the faithful equivalent on the **application** netstack: a
6//! [`ServeManager`](crate::serve::ServeManager) owns the current [`ServeState`](ts_control::ServeState), one accept-loop task
7//! per bound port, and tears every loop down on drop / on the next `set`.
8//!
9//! ## Storage + reconcile (full-replace)
10//!
11//! The manager holds the current [`ServeState`](ts_control::ServeState) plus one [`tokio::task::AbortHandle`] per bound
12//! port behind a single `Arc<Mutex<Inner>>` (mirroring [`crate::fallback_tcp::FallbackTcpManager`]).
13//! [`ServeManager::set`](crate::serve::ServeManager::set) uses **full-replace** semantics: it aborts *every* existing accept loop and
14//! respawns from the new config. Go reconciles incrementally (leaving unchanged ports running); we
15//! do full-replace because it is simpler and correct, and a `SetServeConfig` is a rare control-plane
16//! operation, not a hot path. The passed [`ServeState`](ts_control::ServeState) becomes the whole config (REPLACE, matching
17//! Go). `pure_reconcile` computes the add/remove port deltas for testing and documentation, even
18//! though the live path replaces wholesale.
19//!
20//! ## TLS termination
21//!
22//! TLS-terminating ports (`ServeTarget::terminates_tls`) need a `TlsAcceptor`; the caller
23//! (`Device::set_serve_config`) obtains it **once** via the cert path and hands it in per port. The
24//! manager never builds an acceptor and never touches the cert/ACME machinery — that keeps
25//! `ts_runtime` off the cert path and lets the device fail the whole `set` closed if a cert cannot
26//! be issued (no plaintext downgrade).
27//!
28//! ## Anti-leak
29//!
30//! Every accept loop binds the **overlay** netstack only (via `Channel::tcp_listen` on the
31//! device's own tailnet IPv4) — never a host socket. The `ServeTarget::Proxy` /
32//! `ServeTarget::TcpForward` backend dial is a **local host socket** to the embedder's own backend
33//! (exactly like Go's reverse-proxy to `127.0.0.1` and like [`crate::Runtime`]'s loopback proxy) —
34//! it is intentionally NOT routed through the `ts_forwarder` exit-egress path, so the exit-node
35//! anti-leak chokepoint is untouched. A backend dial failure drops the connection (fail-closed,
36//! logged); it never falls back to anything.
37
38use std::{
39 collections::{BTreeMap, BTreeSet},
40 net::{Ipv4Addr, SocketAddr},
41 sync::{Arc, Mutex},
42};
43
44use netstack::{CreateSocket, netcore::Channel, netsock::TcpStream as OverlayStream};
45use tokio::{
46 io::{AsyncRead, AsyncWrite, AsyncWriteExt},
47 sync::{Semaphore, mpsc},
48};
49use ts_control::{ServeState, ServeTarget, tls::TlsAcceptor};
50
51/// Max concurrent in-flight connections served per bound port. Bounds the per-port spawn fan-out so
52/// a flood of accepts on one serve port cannot grow tasks (and overlay sockets) without limit;
53/// saturated => the accept loop back-pressures (stops accepting) until an in-flight conn finishes.
54/// Mirrors the loopback proxy's `MAX_CONCURRENT_CONNS` rationale (each accepted conn pins an overlay
55/// TCP socket, ~512 KiB of rx+tx buffers — see `tcp_buffer_size` in AGENTS.md).
56const MAX_SERVE_CONNS_PER_PORT: usize = 256;
57
58/// A connection handed back to the embedder for a [`ServeTarget::Accept`] port (the in-process
59/// stand-in for Go `tsnet`'s `ListenTLS`-returned `net.Listener`).
60///
61/// `stream` is already TLS-terminated (the overlay stream wrapped in `tokio_rustls`'s server
62/// `TlsStream`), boxed so the channel is target-agnostic. `port` is the serve port it arrived on so
63/// an embedder serving `Accept` on several ports can demultiplex.
64pub struct ServeAccepted {
65 /// The tailnet (overlay) port this connection was accepted on.
66 pub port: u16,
67 /// The accepted, TLS-terminated stream, ready to read/write.
68 pub stream: Box<dyn AsyncReadWrite>,
69}
70
71/// Object-safe alias for the boxed accepted stream: an `AsyncRead + AsyncWrite` the embedder drives.
72pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {}
73impl<T: AsyncRead + AsyncWrite + Send + Unpin> AsyncReadWrite for T {}
74
75/// Receiver side of the [`ServeTarget::Accept`] hand-back channel (mirrors a `net.Listener`'s accept
76/// queue). [`ServeManager::set`] returns one; await [`recv`](mpsc::Receiver::recv) to take the next
77/// accepted, TLS-terminated connection. Dropped/replaced when the next `set` runs.
78pub type ServeAcceptedReceiver = mpsc::Receiver<ServeAccepted>;
79
80/// A fully-resolved per-port serve plan: the target plus, for TLS-terminating targets, the acceptor
81/// the device built up-front from the cert path. The caller guarantees `acceptor.is_some()` exactly
82/// when `target.terminates_tls()` — the manager asserts this is never violated by failing the bind.
83pub struct ResolvedPort {
84 /// What to serve on this port.
85 pub target: ServeTarget,
86 /// The TLS acceptor for this port, present iff `target.terminates_tls()`.
87 pub acceptor: Option<TlsAcceptor>,
88}
89
90/// Shared manager state behind a single lock.
91struct Inner {
92 /// The currently-stored config (what [`get`](ServeManager::get) returns). Empty default until
93 /// the first `set`.
94 state: ServeState,
95 /// One accept-loop abort handle per currently-bound port. Aborting a handle stops that port's
96 /// accept loop (and, transitively, drops its listener so the overlay port is released).
97 ports: BTreeMap<u16, tokio::task::AbortHandle>,
98}
99
100impl Drop for Inner {
101 fn drop(&mut self) {
102 for h in self.ports.values() {
103 h.abort();
104 }
105 }
106}
107
108/// Owns the stored Serve config and the live per-port accept loops (`tsnet` serving runtime).
109///
110/// Built once from the application netstack [`Channel`] and the device's overlay IPv4, held by the
111/// [`crate::Runtime`]. [`set`](Self::set) replaces the whole config (full-replace reconcile);
112/// dropping the manager (with the runtime / device) aborts every accept loop.
113pub struct ServeManager {
114 inner: Arc<Mutex<Inner>>,
115 channel: Channel,
116 self_ipv4: Ipv4Addr,
117}
118
119impl ServeManager {
120 /// Build a manager bound to the application netstack `channel` and the device's own tailnet
121 /// `self_ipv4` (the overlay address every serve listener binds on). No accept loop runs until the
122 /// first [`set`](Self::set).
123 pub fn new(channel: Channel, self_ipv4: Ipv4Addr) -> Self {
124 Self {
125 inner: Arc::new(Mutex::new(Inner {
126 state: ServeState::default(),
127 ports: BTreeMap::new(),
128 })),
129 channel,
130 self_ipv4,
131 }
132 }
133
134 /// The currently-stored config (Go `GetServeConfig`); empty default if none was ever set.
135 pub fn get(&self) -> ServeState {
136 self.inner
137 .lock()
138 .unwrap_or_else(|e| e.into_inner())
139 .state
140 .clone()
141 }
142
143 /// Replace the whole Serve config (Go `SetServeConfig`, REPLACE semantics), full-replace
144 /// reconcile.
145 ///
146 /// `state` is the new config; `resolved` carries the per-port target + (for TLS ports) the
147 /// pre-built acceptor, keyed identically to `state.ports`. Aborts every existing accept loop and
148 /// spawns one per port in `resolved`. Returns a fresh [`ServeAcceptedReceiver`] delivering
149 /// connections for every [`ServeTarget::Accept`] port (empty if there are none).
150 ///
151 /// The caller is responsible for `state.validate()` and for obtaining the acceptors (failing the
152 /// whole call closed if a cert can't be issued) before calling this; the manager only binds and
153 /// dispatches.
154 pub fn set(
155 &self,
156 state: ServeState,
157 resolved: BTreeMap<u16, ResolvedPort>,
158 ) -> ServeAcceptedReceiver {
159 // A bounded channel back-pressures a slow embedder rather than buffering unboundedly.
160 let (accept_tx, accept_rx) = mpsc::channel::<ServeAccepted>(MAX_SERVE_CONNS_PER_PORT);
161
162 let mut new_ports: BTreeMap<u16, tokio::task::AbortHandle> = BTreeMap::new();
163 for (port, rp) in resolved {
164 let channel = self.channel.clone();
165 let self_ipv4 = self.self_ipv4;
166 let accept_tx = accept_tx.clone();
167 let handle = tokio::spawn(async move {
168 if let Err(e) = run_port(channel, self_ipv4, port, rp, accept_tx).await {
169 tracing::warn!(%port, error = %e, "serve listener exited");
170 }
171 })
172 .abort_handle();
173 new_ports.insert(port, handle);
174 }
175
176 // Swap in the new state + handles under the lock; aborting the OLD handles happens when the
177 // replaced map is dropped at end of scope (after the lock is released).
178 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
179 inner.state = state;
180 let old = std::mem::replace(&mut inner.ports, new_ports);
181 drop(inner);
182 for h in old.values() {
183 h.abort();
184 }
185
186 accept_rx
187 }
188}
189
190/// Compute which ports must be added and removed to go from `current` to `next` (pure; the diff Go
191/// reconciles incrementally). The live [`ServeManager::set`] uses full-replace, but this captures
192/// the delta for tests/documentation: a port is *changed* iff its target differs, which counts as
193/// both a remove and an add.
194#[cfg_attr(not(test), allow(dead_code))]
195fn pure_reconcile(
196 current: &BTreeMap<u16, ServeTarget>,
197 next: &BTreeMap<u16, ServeTarget>,
198) -> (BTreeSet<u16>, BTreeSet<u16>) {
199 let mut to_add = BTreeSet::new();
200 let mut to_remove = BTreeSet::new();
201 for (port, target) in next {
202 match current.get(port) {
203 Some(cur) if cur == target => {}
204 _ => {
205 to_add.insert(*port);
206 }
207 }
208 }
209 for port in current.keys() {
210 match next.get(port) {
211 Some(target) if current.get(port) == Some(target) => {}
212 _ => {
213 to_remove.insert(*port);
214 }
215 }
216 }
217 (to_add, to_remove)
218}
219
220/// Accept loop for one serve port: bind the overlay listener on `(self_ipv4, port)` and dispatch
221/// each accepted connection per `rp.target`, capped at [`MAX_SERVE_CONNS_PER_PORT`] in flight.
222async fn run_port(
223 channel: Channel,
224 self_ipv4: Ipv4Addr,
225 port: u16,
226 rp: ResolvedPort,
227 accept_tx: mpsc::Sender<ServeAccepted>,
228) -> Result<(), netstack::netcore::Error> {
229 // Anti-leak: bind the OVERLAY netstack on this node's own tailnet IPv4, never a host socket.
230 let listen_addr = SocketAddr::new(self_ipv4.into(), port);
231 let listener = channel.tcp_listen(listen_addr).await?;
232 tracing::debug!(%port, "serve listener accepting");
233
234 let rp = Arc::new(rp);
235 let inflight = Arc::new(Semaphore::new(MAX_SERVE_CONNS_PER_PORT));
236
237 loop {
238 // Acquire a permit BEFORE accepting so the loop back-pressures at the cap.
239 let Ok(permit) = inflight.clone().acquire_owned().await else {
240 return Ok(());
241 };
242 let overlay = listener.accept().await?;
243
244 let rp = rp.clone();
245 let accept_tx = accept_tx.clone();
246 tokio::spawn(async move {
247 let _permit = permit; // released when this connection finishes
248 dispatch_conn(port, overlay, rp, accept_tx).await;
249 });
250 }
251}
252
253/// Dispatch one accepted overlay connection per the port's target. TLS is terminated here (once per
254/// connection) for TLS-terminating targets; failures drop the connection (fail-closed, logged).
255async fn dispatch_conn(
256 port: u16,
257 overlay: OverlayStream,
258 rp: Arc<ResolvedPort>,
259 accept_tx: mpsc::Sender<ServeAccepted>,
260) {
261 match &rp.target {
262 // Raw passthrough: NO TLS. Splice the raw overlay stream to the local backend.
263 ServeTarget::TcpForward { to } => {
264 forward_to_backend(port, overlay, to).await;
265 }
266 // TLS-terminating targets: terminate TLS once, then act on the decrypted stream.
267 _ => {
268 let Some(acceptor) = rp.acceptor.as_ref() else {
269 // The caller's contract guarantees a TLS acceptor for every TLS-terminating port;
270 // a missing one means we must never serve plaintext — drop, fail-closed.
271 tracing::warn!(%port, "serve: missing TLS acceptor for TLS port; dropping conn");
272 return;
273 };
274 let tls = match acceptor.accept(overlay).await {
275 Ok(s) => s,
276 Err(e) => {
277 tracing::debug!(%port, error = %e, "serve: TLS handshake failed; dropping conn");
278 return;
279 }
280 };
281 match &rp.target {
282 ServeTarget::Accept => {
283 // Hand the TLS-terminated stream back to the embedder over the channel.
284 let accepted = ServeAccepted {
285 port,
286 stream: Box::new(tls),
287 };
288 if accept_tx.send(accepted).await.is_err() {
289 tracing::debug!(%port, "serve: accept receiver dropped; closing conn");
290 }
291 }
292 // Reached DIRECTLY (no request head consumed off `tls`): a plain splice with no
293 // prefix replay — the backend sees the client's bytes verbatim.
294 ServeTarget::Proxy { to } => {
295 proxy_to_backend(port, tls, to).await;
296 }
297 ServeTarget::Text { body } => {
298 write_text(port, tls, body).await;
299 }
300 ServeTarget::Redirect { to, status } => {
301 serve_redirect(port, tls, to, *status).await;
302 }
303 ServeTarget::Path { handlers } => {
304 serve_path(port, tls, handlers).await;
305 }
306 // `TcpForward` is handled in the non-TLS arm above; nothing else terminates TLS.
307 // The wildcard covers `#[non_exhaustive]` future raw (non-TLS) variants: if one is
308 // added it must NOT silently terminate TLS here — drop it fail-closed until this
309 // dispatch is taught how to serve it.
310 other => {
311 debug_assert!(
312 !other.terminates_tls(),
313 "TLS-terminating ServeTarget reached fall-through arm"
314 );
315 tracing::warn!(%port, "serve: unhandled ServeTarget on TLS port; dropping conn");
316 }
317 }
318 }
319 }
320}
321
322/// Reverse-proxy a TLS-terminated stream to a local host backend (Go `Proxy` handler). The backend
323/// dial is a LOCAL host socket to the embedder's own backend — never the forwarder egress path.
324///
325/// Reached DIRECTLY from [`dispatch_conn`] (no request head has been consumed off `tls`), so no
326/// prefix replay is needed — the backend sees the client's bytes verbatim via the bidirectional
327/// splice. The `Path`-nested case (where a head WAS consumed) uses [`proxy_to_backend_with_prefix`]
328/// instead.
329async fn proxy_to_backend<S>(port: u16, tls: S, to: &str)
330where
331 S: AsyncRead + AsyncWrite + Unpin,
332{
333 proxy_to_backend_with_prefix(port, tls, to, &[]).await;
334}
335
336/// Reverse-proxy a TLS-terminated stream to a local host backend, writing `prefix` to the backend
337/// FIRST (before the bidirectional splice). This replays an HTTP request head already consumed off
338/// `tls` (e.g. by [`serve_path`]'s [`read_http_head`]) so the backend sees the complete request: the
339/// consumed request line + headers, then the rest of the body/stream via the splice. An empty
340/// `prefix` is equivalent to a plain splice ([`proxy_to_backend`]). The backend dial is a LOCAL host
341/// socket — never the forwarder egress path; any failure (dial or prefix write) drops the conn
342/// fail-closed.
343async fn proxy_to_backend_with_prefix<S>(port: u16, mut tls: S, to: &str, prefix: &[u8])
344where
345 S: AsyncRead + AsyncWrite + Unpin,
346{
347 let mut backend = match tokio::net::TcpStream::connect(to).await {
348 Ok(b) => b,
349 Err(e) => {
350 tracing::debug!(%port, %to, error = %e, "serve proxy: backend dial failed; dropping conn");
351 return;
352 }
353 };
354 if !prefix.is_empty()
355 && let Err(e) = backend.write_all(prefix).await
356 {
357 tracing::debug!(%port, %to, error = %e, "serve proxy: prefix replay failed; dropping conn");
358 return;
359 }
360 if let Err(e) = tokio::io::copy_bidirectional(&mut tls, &mut backend).await {
361 tracing::debug!(%port, %to, error = %e, "serve proxy: splice ended");
362 }
363}
364
365/// Forward a RAW (non-TLS) overlay stream to a local host backend (Go `TCPForward` handler). The
366/// backend dial is a LOCAL host socket — never the forwarder egress path.
367async fn forward_to_backend(port: u16, mut overlay: OverlayStream, to: &str) {
368 let mut backend = match tokio::net::TcpStream::connect(to).await {
369 Ok(b) => b,
370 Err(e) => {
371 tracing::debug!(%port, %to, error = %e, "serve forward: backend dial failed; dropping conn");
372 return;
373 }
374 };
375 if let Err(e) = tokio::io::copy_bidirectional(&mut overlay, &mut backend).await {
376 tracing::debug!(%port, %to, error = %e, "serve forward: splice ended");
377 }
378}
379
380/// Write a fixed body to the TLS-terminated stream, flush, and close (Go `Text` handler).
381async fn write_text<S>(port: u16, mut tls: S, body: &str)
382where
383 S: AsyncRead + AsyncWrite + Unpin,
384{
385 if let Err(e) = tls.write_all(body.as_bytes()).await {
386 tracing::debug!(%port, error = %e, "serve text: write failed");
387 return;
388 }
389 if let Err(e) = tls.flush().await {
390 tracing::debug!(%port, error = %e, "serve text: flush failed");
391 }
392 drop(tls.shutdown().await);
393}
394
395/// Max bytes of an HTTP request head (request line + headers) we will buffer before giving up. A
396/// peer that never sends `\r\n\r\n` within this exact bound is dropped fail-closed (no unbounded
397/// read); the buffer is bound-checked AFTER each read, so it never exceeds this cap.
398const MAX_HTTP_HEAD: usize = 8 * 1024;
399
400/// Read the HTTP request head (up to and including `\r\n\r\n`) from a TLS-terminated stream into a
401/// buffer. Returns `(buf, header_end)` where `header_end` is the offset just past the terminator, or
402/// `None` if the peer closed early or the head exceeded [`MAX_HTTP_HEAD`]. Hand-rolled (no
403/// axum/hyper); mirrors the peerAPI router's head-read style.
404async fn read_http_head<S>(stream: &mut S) -> Option<(Vec<u8>, usize)>
405where
406 S: AsyncRead + AsyncWrite + Unpin,
407{
408 use tokio::io::AsyncReadExt;
409
410 let mut buf = Vec::with_capacity(1024);
411 let mut tmp = [0u8; 1024];
412 loop {
413 if let Some(end) = crate::peerapi_doh::find_header_end(&buf) {
414 return Some((buf, end));
415 }
416 match stream.read(&mut tmp).await {
417 Ok(0) => return None,
418 Ok(n) => {
419 buf.extend_from_slice(&tmp[..n]);
420 // Bound-check AFTER extending so the buffer never exceeds MAX_HTTP_HEAD. The
421 // terminator is re-checked at the top of the loop, so a head whose terminator lands
422 // exactly at the bound still succeeds; only a head with no terminator within
423 // MAX_HTTP_HEAD is dropped fail-closed.
424 if crate::peerapi_doh::find_header_end(&buf).is_none() && buf.len() >= MAX_HTTP_HEAD
425 {
426 return None;
427 }
428 }
429 Err(_) => return None,
430 }
431 }
432}
433
434/// Parse the request-line path from an HTTP head. Returns the path component (without the query
435/// string), or `None` if the head is malformed. Hand-rolled; no HTTP library framing assumptions
436/// beyond the request line.
437///
438/// The target is returned **raw**, exactly as the client wrote it: normalizing it is
439/// [`match_path_handler`]'s job, because Go's `getServeHandler` looks the raw target up first and
440/// only then cleans it. A malformed target (`*`, an authority-form `host:port`) comes back here as
441/// itself and is refused there, not here.
442fn request_path(buf: &[u8]) -> Option<String> {
443 let mut headers = [httparse::EMPTY_HEADER; 32];
444 let mut req = httparse::Request::new(&mut headers);
445 match req.parse(buf) {
446 Ok(_) => {}
447 Err(_) => return None,
448 }
449 let path = req.path?;
450 let raw = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
451 Some(raw.to_string())
452}
453
454/// Reason phrase for a redirect status (best-effort; falls back to "Redirect").
455fn redirect_reason(status: u16) -> &'static str {
456 match status {
457 301 => "Moved Permanently",
458 302 => "Found",
459 303 => "See Other",
460 307 => "Temporary Redirect",
461 308 => "Permanent Redirect",
462 _ => "Redirect",
463 }
464}
465
466/// Write a bodyless HTTP redirect (Go `HTTPHandler` redirect) on a TLS-terminated stream, then close.
467/// Fail-closed: any write error drops the conn. No request parsing is needed — every request on a
468/// `Redirect` target gets the same response.
469async fn serve_redirect<S>(port: u16, mut tls: S, to: &str, status: u16)
470where
471 S: AsyncRead + AsyncWrite + Unpin,
472{
473 let head = format!(
474 "HTTP/1.1 {status} {reason}\r\nLocation: {to}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
475 reason = redirect_reason(status),
476 );
477 if let Err(e) = tls.write_all(head.as_bytes()).await {
478 tracing::debug!(%port, error = %e, "serve redirect: write failed");
479 return;
480 }
481 if let Err(e) = tls.flush().await {
482 tracing::debug!(%port, error = %e, "serve redirect: flush failed");
483 }
484 drop(tls.shutdown().await);
485}
486
487/// Write a bodyless HTTP status response (e.g. `404 Not Found`) on a TLS-terminated stream, then
488/// close. Local mirror of `peerapi_doh::write_status` (which takes the concrete peerAPI stream type).
489async fn write_http_status<S>(port: u16, mut tls: S, status: &str)
490where
491 S: AsyncRead + AsyncWrite + Unpin,
492{
493 let head = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
494 if let Err(e) = tls.write_all(head.as_bytes()).await {
495 tracing::debug!(%port, error = %e, "serve path: status write failed");
496 return;
497 }
498 drop(tls.flush().await);
499 drop(tls.shutdown().await);
500}
501
502/// Go's `path.Clean` (Go stdlib `path/path.go`), transliterated. This is the lexical cleaning
503/// `getServeHandler` (`ipn/ipnlocal/serve.go` @ `49e148c4a30b4f8098f69468fd27a7021d85ea02`) applies
504/// to the request path *before* it walks the mounts, so it must be the same cleaning here: dot and
505/// dot-dot segments are resolved, repeated and trailing separators collapse, and a leading dot-dot
506/// on a rooted path is dropped (`/api/../secret` ⇒ `/secret`, `/../x` ⇒ `/x`, `//a//b/` ⇒ `/a/b`).
507///
508/// Purely lexical, exactly like Go's: it never touches a filesystem and never decodes percent
509/// escapes. Non-rooted inputs keep Go's answers too — `""` and `"."` clean to `"."`, and `"*"`
510/// cleans to `"*"` — which is what makes the "not absolute" refusal in [`match_path_handler`]
511/// catch the malformed request targets.
512fn clean_path(path: &str) -> String {
513 let s = path.as_bytes();
514 if s.is_empty() {
515 return ".".to_string();
516 }
517 let n = s.len();
518 let rooted = s[0] == b'/';
519
520 // `out` is Go's `lazybuf`: the cleaned bytes written so far. `dotdot` is the index past which
521 // a `..` may still eat an element (1 on a rooted path, so `..` can never eat the leading `/`).
522 let mut out: Vec<u8> = Vec::with_capacity(n);
523 let mut r = 0usize;
524 let mut dotdot = 0usize;
525 if rooted {
526 out.push(b'/');
527 r = 1;
528 dotdot = 1;
529 }
530
531 while r < n {
532 if s[r] == b'/' {
533 // Empty path element: drop it (this is what collapses `//` and a trailing `/`).
534 r += 1;
535 } else if s[r] == b'.' && (r + 1 == n || s[r + 1] == b'/') {
536 // `.` element: drop it.
537 r += 1;
538 } else if s[r] == b'.' && r + 1 < n && s[r + 1] == b'.' && (r + 2 == n || s[r + 2] == b'/')
539 {
540 // `..` element: back up over the previously written element, if there is one.
541 r += 2;
542 if out.len() > dotdot {
543 let mut w = out.len() - 1;
544 while w > dotdot && out[w] != b'/' {
545 w -= 1;
546 }
547 out.truncate(w);
548 } else if !rooted {
549 // Nothing to back up over and no leading `/` to anchor to: the `..` is kept, as
550 // Go keeps it (`../..` cleans to itself). A rooted path drops it instead, which is
551 // why `/../secret` is `/secret` and can never escape above the root.
552 if !out.is_empty() {
553 out.push(b'/');
554 }
555 out.extend_from_slice(b"..");
556 dotdot = out.len();
557 }
558 } else {
559 // A real path element: add the separator if one is needed, then copy the element.
560 if (rooted && out.len() != 1) || (!rooted && !out.is_empty()) {
561 out.push(b'/');
562 }
563 while r < n && s[r] != b'/' {
564 out.push(s[r]);
565 r += 1;
566 }
567 }
568 }
569
570 if out.is_empty() {
571 return ".".to_string();
572 }
573 // Every byte written is copied verbatim from `path` (valid UTF-8) and the buffer is only ever
574 // truncated at an ASCII `/`, so this cannot split a multi-byte character.
575 String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
576}
577
578/// Whether a mount point in a [`ServeTarget::Path`] map claims `path`.
579///
580/// A mount at `P` claims exactly `P` itself and the paths **below** it — i.e. `path == P`, or `path`
581/// begins with `P` followed by `/`. It does **not** claim arbitrary strings that merely start with
582/// the same bytes: a `/api` mount does not claim `/apifoo`, `/apibar` or `/api-internal`, which fall
583/// through to whatever shorter mount (typically `/`) does claim them.
584///
585/// A mount written with a trailing slash means the same thing as one without: `/api/` is normalized
586/// to `/api`, so it claims `/api/v2` without needing the request to be `/api//v2`, and it also
587/// claims the bare `/api`. The root mount `/` normalizes to the empty prefix and therefore claims
588/// every path.
589///
590/// ## Go behaviour this mirrors
591///
592/// Go's `getServeHandler` (`ipn/ipnlocal/serve.go`) never does a raw byte-prefix test. It first
593/// looks the cleaned request path up in the handler map exactly, and only then walks *backwards*
594/// over the path's `/` separators, retrying the lookup on each successively shorter truncation of
595/// the path. Because every candidate it ever tries is the path cut at a `/`, a handler can only ever
596/// be reached at a path-segment boundary — `/apifoo` never reaches the `/api` handler there, and it
597/// must not here either.
598fn mount_claims_path(mount: &str, path: &str) -> bool {
599 // "/api/" and "/api" are the same mount; "/" becomes the empty prefix, which claims everything.
600 let base = mount.strip_suffix('/').unwrap_or(mount);
601 if base.is_empty() {
602 return true;
603 }
604 match path.strip_prefix(base) {
605 // Exactly the mount itself, or a path below it. Anything else (`/apifoo` for `/api`) is a
606 // different path that merely shares a byte prefix.
607 Some(rest) => rest.is_empty() || rest.starts_with('/'),
608 None => false,
609 }
610}
611
612/// Pick the [`ServeTarget`] a request `path` dispatches to in a [`ServeTarget::Path`] mux, given the
613/// raw request target from the request line.
614///
615/// Pure and total over `(handlers, path)` — the whole routing decision, with no I/O — so it is
616/// testable directly instead of only through a TLS-terminated socket. [`serve_path`] calls this; it
617/// is the single definition of the rule, and a test that re-implemented it would be testing its own
618/// copy rather than what dispatch does.
619///
620/// ## Go behaviour this mirrors
621///
622/// `getServeHandler` (`ipn/ipnlocal/serve.go` @ `49e148c4a30b4f8098f69468fd27a7021d85ea02`) resolves
623/// a request in three steps, and so does this:
624///
625/// 1. **Exact lookup of the raw target.** A mount spelled exactly as the request target wins
626/// verbatim, before any normalization (Go: `wsc.Handlers().GetOk(r.URL.Path)`).
627/// 2. **Clean, then match.** Otherwise the target is [`clean_path`]ed — Go's `path.Clean` — and only
628/// the *cleaned* path is offered to the mounts. Dot-dot is therefore resolved **before** any
629/// mount is consulted: with mounts at `/` and `/api`, `/api/../secret` is `/secret` and is served
630/// by `/`; it must never reach the `/api` backend, which was never mounted for it.
631/// 3. **Refuse a target that is not an absolute path.** A cleaned path not starting with `/` matches
632/// nothing. Go needs this guard because the malformed request targets — `*` (`GET *`) and the
633/// empty authority-form target — clean to `*` and `.`, which are `path.Dir` fixed points that
634/// would spin its backwards walk forever. Here the walk cannot spin, but the guard still carries
635/// Go's *routing* answer: those targets match no mount. Without it a root mount claims them,
636/// because `/` normalizes to the empty prefix that claims every string.
637///
638/// Longest match wins among the mounts that claim the cleaned path (see [`mount_claims_path`]): the
639/// one with the most path bytes is chosen, so `/api/v2` beats `/api` beats `/`. This is the same
640/// answer as Go's backwards walk, which tries the path cut at each `/` from longest to shortest.
641/// Ties (only reachable between the same mount spelled with and without a trailing slash, e.g.
642/// `/api` and `/api/`) resolve to the last in `BTreeMap` order, deterministically. `None` means no
643/// mount claims the path, which dispatch turns into a fail-closed 404.
644fn match_path_handler<'h>(
645 handlers: &'h BTreeMap<String, ServeTarget>,
646 path: &str,
647) -> Option<&'h ServeTarget> {
648 // (1) The raw target, looked up exactly.
649 if let Some(target) = handlers.get(path) {
650 return Some(target);
651 }
652 // (2) Everything else routes on the cleaned path, never the raw one.
653 let cleaned = clean_path(path);
654 // (3) Not an absolute path => no mount claims it.
655 if !cleaned.starts_with('/') {
656 return None;
657 }
658 handlers
659 .iter()
660 .filter(|(mount, _)| mount_claims_path(mount, &cleaned))
661 .max_by_key(|(mount, _)| mount.strip_suffix('/').unwrap_or(mount).len())
662 .map(|(_, target)| target)
663}
664
665/// Serve a [`ServeTarget::Path`] mux on a TLS-terminated stream: read the request head, pick the
666/// longest-matching mount in `handlers` (via [`match_path_handler`]), and dispatch the matched
667/// nested target on the already-decrypted stream. Fail-closed: a malformed head, no matching mount,
668/// or an un-dispatchable nested target ⇒ 404/drop. For a matched nested `Proxy`, the request head consumed
669/// here is replayed to the backend first (via [`proxy_to_backend_with_prefix`]) so the backend sees
670/// the complete request. Backend dial failures inside a nested `Proxy` drop the conn. Nested `Path`
671/// is rejected by `ServeState::validate`, so it is not expected here; it is dropped fail-closed if it
672/// ever reaches dispatch.
673async fn serve_path<S>(port: u16, mut tls: S, handlers: &BTreeMap<String, ServeTarget>)
674where
675 S: AsyncRead + AsyncWrite + Unpin,
676{
677 let Some((buf, _end)) = read_http_head(&mut tls).await else {
678 tracing::debug!(%port, "serve path: incomplete/oversized request head; dropping conn");
679 return;
680 };
681 let Some(path) = request_path(&buf) else {
682 write_http_status(port, tls, "400 Bad Request").await;
683 return;
684 };
685
686 let Some(target) = match_path_handler(handlers, &path) else {
687 write_http_status(port, tls, "404 Not Found").await;
688 return;
689 };
690
691 match target {
692 // The request head was already consumed off `tls` by `read_http_head`; replay it (`buf`) to
693 // the backend FIRST so the backend sees the complete request (head + remaining body/stream),
694 // not a request with its first request-line+headers missing.
695 ServeTarget::Proxy { to } => proxy_to_backend_with_prefix(port, tls, to, &buf).await,
696 ServeTarget::Text { body } => write_text(port, tls, body).await,
697 ServeTarget::Redirect { to, status } => serve_redirect(port, tls, to, *status).await,
698 // Accept (no hand-back channel here), TcpForward (raw, not on a TLS path), nested Path
699 // (rejected by validate), and any future `#[non_exhaustive]` variant are not servable as a
700 // Path leaf: drop fail-closed rather than guess.
701 _ => {
702 tracing::warn!(%port, "serve path: unsupported nested target; dropping conn");
703 write_http_status(port, tls, "404 Not Found").await;
704 }
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 fn proxy(to: &str) -> ServeTarget {
713 ServeTarget::Proxy { to: to.into() }
714 }
715
716 #[test]
717 fn cap_is_bounded() {
718 assert_eq!(MAX_SERVE_CONNS_PER_PORT, 256);
719 }
720
721 #[test]
722 fn reconcile_adds_new_ports() {
723 let current = BTreeMap::new();
724 let mut next = BTreeMap::new();
725 next.insert(443u16, ServeTarget::Accept);
726 next.insert(8443u16, proxy("127.0.0.1:8080"));
727 let (add, remove) = pure_reconcile(¤t, &next);
728 assert_eq!(add, BTreeSet::from([443, 8443]));
729 assert!(remove.is_empty());
730 }
731
732 #[test]
733 fn reconcile_removes_dropped_ports() {
734 let mut current = BTreeMap::new();
735 current.insert(443u16, ServeTarget::Accept);
736 current.insert(8443u16, proxy("127.0.0.1:8080"));
737 let mut next = BTreeMap::new();
738 next.insert(443u16, ServeTarget::Accept);
739 let (add, remove) = pure_reconcile(¤t, &next);
740 assert!(add.is_empty());
741 assert_eq!(remove, BTreeSet::from([8443]));
742 }
743
744 #[test]
745 fn reconcile_changed_port_is_remove_and_add() {
746 // Same port, different target => counts as both (full-replace would respawn it anyway).
747 let mut current = BTreeMap::new();
748 current.insert(443u16, proxy("127.0.0.1:8080"));
749 let mut next = BTreeMap::new();
750 next.insert(443u16, proxy("127.0.0.1:9090"));
751 let (add, remove) = pure_reconcile(¤t, &next);
752 assert_eq!(add, BTreeSet::from([443]));
753 assert_eq!(remove, BTreeSet::from([443]));
754 }
755
756 #[test]
757 fn reconcile_unchanged_port_is_noop() {
758 let mut current = BTreeMap::new();
759 current.insert(443u16, ServeTarget::Accept);
760 let next = current.clone();
761 let (add, remove) = pure_reconcile(¤t, &next);
762 assert!(add.is_empty());
763 assert!(remove.is_empty());
764 }
765
766 #[test]
767 fn terminates_tls_matches_dispatch_arm() {
768 // The dispatch decision (TLS vs raw) must agree with the type's own `terminates_tls`: only
769 // TcpForward is raw; Accept/Proxy/Text/Path/Redirect all terminate TLS.
770 assert!(ServeTarget::Accept.terminates_tls());
771 assert!(proxy("127.0.0.1:8080").terminates_tls());
772 assert!(ServeTarget::Text { body: "ok".into() }.terminates_tls());
773 assert!(
774 ServeTarget::Redirect {
775 to: "/elsewhere".into(),
776 status: 302,
777 }
778 .terminates_tls()
779 );
780 let mut handlers = BTreeMap::new();
781 handlers.insert("/".to_string(), proxy("127.0.0.1:8080"));
782 assert!(ServeTarget::Path { handlers }.terminates_tls());
783 assert!(
784 !ServeTarget::TcpForward {
785 to: "127.0.0.1:5000".into()
786 }
787 .terminates_tls()
788 );
789 }
790
791 #[test]
792 fn find_header_end_shared_with_peerapi_doh() {
793 // The local mirror was removed; serve dispatch now uses the shared peerAPI helper. Keep one
794 // assertion that the shared fn behaves as serve dispatch relies on (peerapi_doh owns the
795 // exhaustive coverage).
796 assert_eq!(
797 crate::peerapi_doh::find_header_end(b"GET / HTTP/1.1\r\n\r\n"),
798 Some(18)
799 );
800 assert_eq!(
801 crate::peerapi_doh::find_header_end(b"GET / HTTP/1.1\r\n"),
802 None
803 );
804 }
805
806 #[test]
807 fn request_path_strips_query() {
808 assert_eq!(
809 request_path(b"GET /api/v1?x=1 HTTP/1.1\r\nHost: h\r\n\r\n").as_deref(),
810 Some("/api/v1")
811 );
812 assert_eq!(
813 request_path(b"GET / HTTP/1.1\r\n\r\n").as_deref(),
814 Some("/")
815 );
816 assert_eq!(request_path(b"not a request").as_deref(), None);
817 }
818
819 #[test]
820 fn request_path_none_on_malformed_request_line() {
821 // No method/version framing at all => httparse rejects => None.
822 assert_eq!(request_path(b"GARBAGE\r\n\r\n").as_deref(), None);
823 // Empty buffer => incomplete => None.
824 assert_eq!(request_path(b"").as_deref(), None);
825 }
826
827 /// The mux `serve_path` dispatch tests below route against: root, `/api`, `/api/v2`, each with a
828 /// distinguishable backend so a test can assert which one a path did *not* reach.
829 fn mux() -> BTreeMap<String, ServeTarget> {
830 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
831 handlers.insert("/".to_string(), proxy("127.0.0.1:1"));
832 handlers.insert("/api".to_string(), proxy("127.0.0.1:2"));
833 handlers.insert("/api/v2".to_string(), proxy("127.0.0.1:3"));
834 handlers
835 }
836
837 #[test]
838 fn longest_matching_mount_wins() {
839 // Calls the production selection (`serve_path` calls the same fn) — not a copy of it.
840 let handlers = mux();
841 assert_eq!(
842 match_path_handler(&handlers, "/api/v2/x"),
843 Some(&proxy("127.0.0.1:3")),
844 "the longest mount claiming the path must win"
845 );
846 assert_eq!(
847 match_path_handler(&handlers, "/api/v1"),
848 Some(&proxy("127.0.0.1:2"))
849 );
850 assert_eq!(
851 match_path_handler(&handlers, "/api"),
852 Some(&proxy("127.0.0.1:2"))
853 );
854 assert_eq!(
855 match_path_handler(&handlers, "/other"),
856 Some(&proxy("127.0.0.1:1"))
857 );
858 }
859
860 #[test]
861 fn mount_does_not_claim_a_longer_first_segment() {
862 // The negative case, and the whole point: a `/api` mount must NOT swallow `/apifoo`. A raw
863 // byte-prefix test routes these to the `/api` backend; Go's segment-boundary lookup does
864 // not, and neither may we. Assert where they must *not* go, not only where they must.
865 let handlers = mux();
866 let api = proxy("127.0.0.1:2");
867 let root = proxy("127.0.0.1:1");
868 for path in ["/apifoo", "/apibar", "/api-internal", "/api_v2", "/apis/x"] {
869 let picked = match_path_handler(&handlers, path);
870 assert_ne!(picked, Some(&api), "{path} must not reach the /api backend");
871 assert_eq!(
872 picked,
873 Some(&root),
874 "{path} must fall through to the / mount"
875 );
876 }
877 // Same shape one level down: `/api/v2` must not claim `/api/v20`.
878 let picked = match_path_handler(&handlers, "/api/v20");
879 assert_ne!(
880 picked,
881 Some(&proxy("127.0.0.1:3")),
882 "/api/v20 must not reach the /api/v2 backend"
883 );
884 assert_eq!(picked, Some(&api));
885 }
886
887 #[test]
888 fn mount_claims_itself_and_paths_below_it() {
889 assert!(mount_claims_path("/api", "/api"));
890 assert!(mount_claims_path("/api", "/api/"));
891 assert!(mount_claims_path("/api", "/api/v2/x"));
892 assert!(!mount_claims_path("/api", "/apifoo"));
893 assert!(!mount_claims_path("/api", "/ap"));
894 assert!(!mount_claims_path("/api", "/"));
895 // The root mount claims everything.
896 assert!(mount_claims_path("/", "/"));
897 assert!(mount_claims_path("/", "/anything/at/all"));
898 }
899
900 #[test]
901 fn trailing_slash_mount_needs_no_doubled_slash() {
902 // `/api/` is the same mount as `/api`: it claims `/api/v2`, not only `/api//v2`.
903 assert!(mount_claims_path("/api/", "/api/v2"));
904 assert!(mount_claims_path("/api/", "/api/"));
905 assert!(mount_claims_path("/api/", "/api"));
906 assert!(!mount_claims_path("/api/", "/apifoo"));
907
908 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
909 handlers.insert("/".to_string(), proxy("127.0.0.1:1"));
910 handlers.insert("/api/".to_string(), proxy("127.0.0.1:2"));
911 assert_eq!(
912 match_path_handler(&handlers, "/api/v2"),
913 Some(&proxy("127.0.0.1:2"))
914 );
915 assert_eq!(
916 match_path_handler(&handlers, "/apifoo"),
917 Some(&proxy("127.0.0.1:1")),
918 "/apifoo must fall through to / even when the mount is spelled /api/"
919 );
920 }
921
922 #[test]
923 fn clean_path_matches_go_path_clean() {
924 // The table is Go's own `path.Clean` test table (Go stdlib `path/path_test.go`), which is
925 // the cleaning `getServeHandler` applies before it consults the mounts.
926 for (input, want) in [
927 ("", "."),
928 ("abc", "abc"),
929 ("abc/def", "abc/def"),
930 ("a/b/c", "a/b/c"),
931 (".", "."),
932 ("..", ".."),
933 ("../..", "../.."),
934 ("/abc", "/abc"),
935 ("/", "/"),
936 ("abc/", "abc"),
937 ("abc/def/", "abc/def"),
938 ("a/b/c/", "a/b/c"),
939 ("./", "."),
940 ("../", ".."),
941 ("../../", "../.."),
942 ("/abc/", "/abc"),
943 ("abc//def//ghi", "abc/def/ghi"),
944 ("//abc", "/abc"),
945 ("///abc", "/abc"),
946 ("//abc//", "/abc"),
947 ("abc//", "abc"),
948 ("abc/./def", "abc/def"),
949 ("/./abc/def", "/abc/def"),
950 ("abc/..", "."),
951 ("abc/def/..", "abc"),
952 ("abc/def/../ghi", "abc/ghi"),
953 ("abc/def/../../ghi", "ghi"),
954 ("abc/def/../../..", ".."),
955 ("/abc/def/../../..", "/"),
956 ("abc/./../def", "def"),
957 ("abc//./../def", "def"),
958 ("abc/../../././../def", "../../def"),
959 // A rooted path can never climb above the root: the leading `..` is dropped.
960 ("/../abc", "/abc"),
961 ("/api/../secret", "/secret"),
962 // The malformed request targets. Neither becomes absolute, which is what the
963 // "not absolute" refusal keys off.
964 ("*", "*"),
965 ("host:443", "host:443"),
966 ] {
967 assert_eq!(clean_path(input), want, "clean_path({input:?})");
968 }
969 }
970
971 #[test]
972 fn dot_dot_segment_is_cleaned_before_the_mounts_are_consulted() {
973 // The bug: matching the RAW target means `/api/../secret` starts with `/api/`, so the `/api`
974 // mount claims it and the request reaches a backend it was never mounted for. Go cleans
975 // first — the path is `/secret`, which only the `/` mount claims.
976 let handlers = mux();
977 let root = proxy("127.0.0.1:1");
978 let api = proxy("127.0.0.1:2");
979 let api_v2 = proxy("127.0.0.1:3");
980
981 for path in [
982 "/api/../secret",
983 "/api/v2/../../secret",
984 "/api/./../secret",
985 "/api/..//secret",
986 // Climbing above the root is dropped, not an escape: still `/secret`.
987 "/../api/../secret",
988 ] {
989 let picked = match_path_handler(&handlers, path);
990 assert_ne!(picked, Some(&api), "{path} must not reach the /api backend");
991 assert_ne!(
992 picked,
993 Some(&api_v2),
994 "{path} must not reach the /api/v2 backend"
995 );
996 assert_eq!(
997 picked,
998 Some(&root),
999 "{path} cleans to /secret, which only / claims"
1000 );
1001 }
1002
1003 // Cleaning cuts both ways: a dot-dot that lands back inside a mount still routes there.
1004 assert_eq!(
1005 match_path_handler(&handlers, "/api/v2/../v2/x"),
1006 Some(&api_v2),
1007 "/api/v2/../v2/x cleans to /api/v2/x"
1008 );
1009 assert_eq!(
1010 match_path_handler(&handlers, "/api/v2/.."),
1011 Some(&api),
1012 "/api/v2/.. cleans to /api"
1013 );
1014 // Redundant separators and dot segments normalize away too.
1015 assert_eq!(match_path_handler(&handlers, "//api//v2//x"), Some(&api_v2));
1016 assert_eq!(match_path_handler(&handlers, "/api/./v2"), Some(&api_v2));
1017 }
1018
1019 #[test]
1020 fn malformed_request_target_matches_no_mount() {
1021 // `GET * HTTP/1.1` yields the target `*`, and an authority-form target has no path at all.
1022 // Go refuses both (they do not clean to an absolute path). A root mount normalizes to the
1023 // empty prefix that claims every string, so without the refusal `*` would be served by `/`.
1024 let handlers = mux();
1025 for target in ["*", "host:443", "example.com:443", "", ".", "..", "api/v2"] {
1026 assert_eq!(
1027 match_path_handler(&handlers, target),
1028 None,
1029 "{target:?} is not an absolute path and must match no mount, not even /"
1030 );
1031 }
1032 // A mount spelled exactly as the raw target still wins: that is Go's first lookup, which
1033 // happens before the cleaning and the absolute-path refusal.
1034 let mut odd: BTreeMap<String, ServeTarget> = BTreeMap::new();
1035 odd.insert("*".to_string(), proxy("127.0.0.1:9"));
1036 assert_eq!(match_path_handler(&odd, "*"), Some(&proxy("127.0.0.1:9")));
1037 }
1038
1039 #[test]
1040 fn unmatched_path_selects_nothing() {
1041 // No root mount => a path no mount claims is `None`, which dispatch turns into a 404.
1042 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1043 handlers.insert("/api".to_string(), proxy("127.0.0.1:2"));
1044 assert_eq!(match_path_handler(&handlers, "/apifoo"), None);
1045 assert_eq!(match_path_handler(&handlers, "/other"), None);
1046 assert_eq!(
1047 match_path_handler(&handlers, "/api/v2"),
1048 Some(&proxy("127.0.0.1:2"))
1049 );
1050 }
1051
1052 #[test]
1053 fn redirect_reason_known_statuses() {
1054 assert_eq!(redirect_reason(301), "Moved Permanently");
1055 assert_eq!(redirect_reason(308), "Permanent Redirect");
1056 assert_eq!(redirect_reason(399), "Redirect");
1057 }
1058
1059 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1060
1061 /// Read everything the server side wrote to the `client` half of a duplex until the server task
1062 /// closes its end (drop/shutdown), returning it as a `String`.
1063 async fn drain_to_string(mut client: tokio::io::DuplexStream) -> String {
1064 let mut out = Vec::new();
1065 drop(client.read_to_end(&mut out).await);
1066 String::from_utf8(out).expect("server emitted valid utf8")
1067 }
1068
1069 #[tokio::test]
1070 async fn serve_redirect_emits_exact_response() {
1071 let (client, server) = tokio::io::duplex(4096);
1072 let t = tokio::spawn(async move {
1073 serve_redirect(443, server, "/elsewhere", 302).await;
1074 });
1075 let got = drain_to_string(client).await;
1076 t.await.unwrap();
1077 assert_eq!(
1078 got,
1079 "HTTP/1.1 302 Found\r\nLocation: /elsewhere\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1080 );
1081 }
1082
1083 #[tokio::test]
1084 async fn write_http_status_emits_status_line() {
1085 let (client, server) = tokio::io::duplex(4096);
1086 let t = tokio::spawn(async move {
1087 write_http_status(443, server, "404 Not Found").await;
1088 });
1089 let got = drain_to_string(client).await;
1090 t.await.unwrap();
1091 assert_eq!(
1092 got,
1093 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1094 );
1095
1096 let (client, server) = tokio::io::duplex(4096);
1097 let t = tokio::spawn(async move {
1098 write_http_status(443, server, "400 Bad Request").await;
1099 });
1100 let got = drain_to_string(client).await;
1101 t.await.unwrap();
1102 assert_eq!(
1103 got,
1104 "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn read_http_head_reads_terminated_head() {
1110 let (mut client, mut server) = tokio::io::duplex(4096);
1111 client
1112 .write_all(b"GET /api HTTP/1.1\r\nHost: h\r\n\r\nBODY")
1113 .await
1114 .unwrap();
1115 drop(client);
1116 let (buf, end) = read_http_head(&mut server).await.expect("complete head");
1117 // `end` points just past the terminator; the head + trailing body are both buffered.
1118 assert_eq!(&buf[..end], b"GET /api HTTP/1.1\r\nHost: h\r\n\r\n");
1119 assert_eq!(&buf[end..], b"BODY");
1120 }
1121
1122 #[tokio::test]
1123 async fn read_http_head_none_on_early_eof() {
1124 let (mut client, mut server) = tokio::io::duplex(4096);
1125 client.write_all(b"GET / HTTP/1.1\r\n").await.unwrap();
1126 drop(client); // EOF before the terminator
1127 assert!(read_http_head(&mut server).await.is_none());
1128 }
1129
1130 #[tokio::test]
1131 async fn read_http_head_none_on_oversized_head() {
1132 let (mut client, mut server) = tokio::io::duplex(64 * 1024);
1133 // A head that never terminates and exceeds MAX_HTTP_HEAD must be dropped fail-closed.
1134 let oversized = vec![b'a'; MAX_HTTP_HEAD + 1024];
1135 client.write_all(&oversized).await.unwrap();
1136 drop(client);
1137 assert!(read_http_head(&mut server).await.is_none());
1138 }
1139
1140 #[tokio::test]
1141 async fn read_http_head_never_exceeds_max_head() {
1142 // A terminator landing exactly at the bound still succeeds (the buffer never overshoots).
1143 let (mut client, mut server) = tokio::io::duplex(MAX_HTTP_HEAD + 16);
1144 let mut head = vec![b'a'; MAX_HTTP_HEAD - 4];
1145 head.extend_from_slice(b"\r\n\r\n");
1146 assert_eq!(head.len(), MAX_HTTP_HEAD);
1147 client.write_all(&head).await.unwrap();
1148 drop(client);
1149 let (buf, end) = read_http_head(&mut server).await.expect("head at bound");
1150 assert_eq!(end, MAX_HTTP_HEAD);
1151 assert!(buf.len() <= MAX_HTTP_HEAD);
1152 }
1153
1154 #[tokio::test]
1155 async fn proxy_with_prefix_writes_prefix_before_bidi_copy() {
1156 // Fix 1 regression guard: the consumed request head MUST hit the backend FIRST, before the
1157 // bidirectional splice forwards the rest of the client stream. The backend is a real
1158 // loopback TcpListener (the helper dials `to` via tokio TcpStream).
1159 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1160 let backend_addr = listener.local_addr().unwrap();
1161
1162 let prefix = b"GET /api HTTP/1.1\r\nHost: h\r\n\r\n";
1163 let body = b"trailing-body-bytes";
1164 let backend = tokio::spawn(async move {
1165 let (mut sock, _) = listener.accept().await.unwrap();
1166 let mut head = vec![0u8; prefix.len()];
1167 sock.read_exact(&mut head).await.unwrap();
1168 let mut rest = vec![0u8; body.len()];
1169 sock.read_exact(&mut rest).await.unwrap();
1170 (head, rest)
1171 });
1172
1173 // Client side of the duplex stands in for the TLS-terminated stream the helper splices.
1174 let (mut client, server) = tokio::io::duplex(4096);
1175 let to = backend_addr.to_string();
1176 let proxy_task = tokio::spawn(async move {
1177 proxy_to_backend_with_prefix(443, server, &to, prefix).await;
1178 });
1179
1180 // Feed the rest of the request body through the splice, then close.
1181 client.write_all(body).await.unwrap();
1182 drop(client);
1183
1184 let (head, rest) = backend.await.unwrap();
1185 proxy_task.await.unwrap();
1186 assert_eq!(
1187 head, prefix,
1188 "prefix (consumed head) replayed to backend first"
1189 );
1190 assert_eq!(rest, body, "remaining stream spliced after the prefix");
1191 }
1192
1193 #[tokio::test]
1194 async fn serve_path_proxy_replays_consumed_head_to_backend() {
1195 // End-to-end longest-prefix selection routing to a nested Proxy: the head consumed by
1196 // `read_http_head` must reach the backend, proving the request is not dropped (the bug).
1197 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1198 let backend_addr = listener.local_addr().unwrap();
1199 let request = b"GET /api/v2/x HTTP/1.1\r\nHost: h\r\n\r\n";
1200 let backend = tokio::spawn(async move {
1201 let (mut sock, _) = listener.accept().await.unwrap();
1202 let mut head = vec![0u8; request.len()];
1203 sock.read_exact(&mut head).await.unwrap();
1204 head
1205 });
1206
1207 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1208 handlers.insert("/".to_string(), proxy("127.0.0.1:1")); // shorter prefix (not selected)
1209 handlers.insert("/api/v2".to_string(), proxy(&backend_addr.to_string())); // longest match
1210
1211 let (mut client, server) = tokio::io::duplex(4096);
1212 let path_task = tokio::spawn(async move {
1213 serve_path(443, server, &handlers).await;
1214 });
1215 client.write_all(request).await.unwrap();
1216 drop(client);
1217
1218 let head = backend.await.unwrap();
1219 path_task.await.unwrap();
1220 assert_eq!(
1221 head, request,
1222 "serve_path routed to the longest-prefix Proxy and replayed the consumed head"
1223 );
1224 }
1225
1226 #[tokio::test]
1227 async fn serve_path_text_target_emits_body() {
1228 // Longest-prefix selection routing to a nested Text target: the body is emitted verbatim.
1229 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1230 handlers.insert(
1231 "/".to_string(),
1232 ServeTarget::Text {
1233 body: "root".into(),
1234 },
1235 );
1236 handlers.insert(
1237 "/hello".to_string(),
1238 ServeTarget::Text {
1239 body: "hello-body".into(),
1240 },
1241 );
1242
1243 let (mut client, server) = tokio::io::duplex(4096);
1244 let t = tokio::spawn(async move {
1245 serve_path(443, server, &handlers).await;
1246 });
1247 client
1248 .write_all(b"GET /hello/world HTTP/1.1\r\nHost: h\r\n\r\n")
1249 .await
1250 .unwrap();
1251 // Keep the client half open: `read_http_head` already saw the full head, and the Text target
1252 // neither reads further nor needs EOF. Drain the body the server writes + shuts down.
1253 let got = drain_to_string(client).await;
1254 t.await.unwrap();
1255 assert_eq!(got, "hello-body");
1256 }
1257
1258 #[tokio::test]
1259 async fn serve_path_does_not_route_a_longer_first_segment_to_the_shorter_mount() {
1260 // End to end through the real dispatch: with `/` and `/hello` mounted, `/hellofoo` is a
1261 // different path, not a path below `/hello`, so it must be served by the `/` mount.
1262 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1263 handlers.insert(
1264 "/".to_string(),
1265 ServeTarget::Text {
1266 body: "root".into(),
1267 },
1268 );
1269 handlers.insert(
1270 "/hello".to_string(),
1271 ServeTarget::Text {
1272 body: "hello-body".into(),
1273 },
1274 );
1275
1276 let (mut client, server) = tokio::io::duplex(4096);
1277 let t = tokio::spawn(async move {
1278 serve_path(443, server, &handlers).await;
1279 });
1280 client
1281 .write_all(b"GET /hellofoo HTTP/1.1\r\nHost: h\r\n\r\n")
1282 .await
1283 .unwrap();
1284 let got = drain_to_string(client).await;
1285 t.await.unwrap();
1286 assert_ne!(
1287 got, "hello-body",
1288 "/hellofoo must not reach the /hello mount"
1289 );
1290 assert_eq!(got, "root");
1291 }
1292
1293 /// Text mux used by the dispatch tests below: `/` and `/api` with distinguishable bodies, so a
1294 /// test can assert which backend a request did *not* reach.
1295 fn text_mux() -> BTreeMap<String, ServeTarget> {
1296 let mut handlers: BTreeMap<String, ServeTarget> = BTreeMap::new();
1297 handlers.insert(
1298 "/".to_string(),
1299 ServeTarget::Text {
1300 body: "root".into(),
1301 },
1302 );
1303 handlers.insert(
1304 "/api".to_string(),
1305 ServeTarget::Text {
1306 body: "api-body".into(),
1307 },
1308 );
1309 handlers
1310 }
1311
1312 /// Run one raw request line through the real dispatch and return everything the server wrote.
1313 async fn serve_path_response(
1314 request: &[u8],
1315 handlers: BTreeMap<String, ServeTarget>,
1316 ) -> String {
1317 let (mut client, server) = tokio::io::duplex(4096);
1318 let t = tokio::spawn(async move {
1319 serve_path(443, server, &handlers).await;
1320 });
1321 client.write_all(request).await.unwrap();
1322 let got = drain_to_string(client).await;
1323 t.await.unwrap();
1324 got
1325 }
1326
1327 #[tokio::test]
1328 async fn serve_path_does_not_route_a_dot_dot_target_to_the_mount_it_climbed_out_of() {
1329 // End to end through the real dispatch: the request target names `/api`, but it climbs out
1330 // of it. Go cleans to `/secret` and serves it from `/`; the `/api` backend must never see
1331 // it — it was never mounted for `/secret`.
1332 let got = serve_path_response(
1333 b"GET /api/../secret HTTP/1.1\r\nHost: h\r\n\r\n",
1334 text_mux(),
1335 )
1336 .await;
1337 assert_ne!(
1338 got, "api-body",
1339 "/api/../secret must not reach the /api mount"
1340 );
1341 assert_eq!(got, "root", "/api/../secret cleans to /secret, served by /");
1342
1343 // The query string is stripped before cleaning, exactly as Go cleans `r.URL.Path`.
1344 let got = serve_path_response(
1345 b"GET /api/../secret?x=1 HTTP/1.1\r\nHost: h\r\n\r\n",
1346 text_mux(),
1347 )
1348 .await;
1349 assert_eq!(got, "root");
1350
1351 // And a target that stays inside the mount after cleaning still reaches it.
1352 let got =
1353 serve_path_response(b"GET /api/v2/../v2 HTTP/1.1\r\nHost: h\r\n\r\n", text_mux()).await;
1354 assert_eq!(got, "api-body");
1355 }
1356
1357 #[tokio::test]
1358 async fn serve_path_404s_a_malformed_request_target() {
1359 // `GET *` parses fine as a request line but is not an absolute path. Go matches no handler
1360 // for it; here the root mount would otherwise claim it, since `/` normalizes to the empty
1361 // prefix. Fail closed with a 404 instead of serving the root backend.
1362 let got = serve_path_response(b"GET * HTTP/1.1\r\nHost: h\r\n\r\n", text_mux()).await;
1363 assert_ne!(got, "root", "`GET *` must not be served by the / mount");
1364 assert!(
1365 got.starts_with("HTTP/1.1 404 Not Found\r\n"),
1366 "expected a 404, got {got:?}"
1367 );
1368
1369 // Authority-form (`CONNECT host:443`) likewise has no path to route on.
1370 let got =
1371 serve_path_response(b"CONNECT host:443 HTTP/1.1\r\nHost: h\r\n\r\n", text_mux()).await;
1372 assert!(
1373 got.starts_with("HTTP/1.1 404 Not Found\r\n"),
1374 "expected a 404, got {got:?}"
1375 );
1376 }
1377
1378 // NOTE: a live bind+accept test needs a running netstack channel + overlay; the existing
1379 // netstack-backed managers (fallback_tcp) likewise unit-test only the pure pieces (port diff,
1380 // dispatch decision) and leave the bind/accept path to integration coverage. The byte-emission
1381 // helpers above are exercised directly over `tokio::io::duplex` + loopback `TcpStream` backends;
1382 // the bind/accept/splice path is exercised via `Device::set_serve_config` against a real device.
1383}