Skip to main content

pitchfork_cli/proxy/
server.rs

1//! Reverse proxy server implementation.
2//!
3//! Listens on a configured port and routes requests to daemon processes based
4//! on the `Host` header subdomain pattern.
5//!
6//! When `proxy.https = true`, a local CA is auto-generated (via `rcgen`) and
7//! each incoming TLS connection is served with a per-domain certificate signed
8//! by that CA (SNI-based dynamic certificate issuance).
9
10use std::net::SocketAddr;
11use std::sync::Arc;
12
13use axum::Router;
14use axum::body::Body;
15use axum::extract::{Request, State};
16use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri};
17use axum::response::{IntoResponse, Response};
18use hyper::header::{COOKIE, HOST};
19
20/// Response header used to identify a pitchfork proxy (for health checks and debugging).
21const PITCHFORK_HEADER: &str = "x-pitchfork";
22
23/// Request header tracking how many times a request has passed through the proxy.
24/// Used to detect forwarding loops.
25const PROXY_HOPS_HEADER: &str = "x-pitchfork-hops";
26
27/// Maximum number of proxy hops before rejecting as a loop.
28const MAX_PROXY_HOPS: u64 = 5;
29
30/// How long a new connection has to reveal whether it is TLS and, if it is, to
31/// finish the handshake.
32///
33/// Both steps happen before the connection is a request the server is willing
34/// to spend time on, so a client that stalls there is holding a socket and a
35/// task for nothing.
36const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
37
38/// Maximum number of minted host certificates kept at once.
39///
40/// Every distinct SNI name under the TLD costs a key generation and a file, and
41/// the set of names under a TLD is unbounded. A real machine serves a handful;
42/// this is generous for that and still refuses to grow without limit when
43/// something walks the namespace. The oldest entry is evicted, in memory and on
44/// disk, so a busy name simply gets minted again.
45const MAX_HOST_CERTS: usize = 256;
46
47/// How often a refusal may be logged. See [`crate::proxy::LogThrottle`].
48const REFUSAL_LOG_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
49
50/// Throttle for the "too many connections negotiating" warning.
51static REFUSED_HANDSHAKE: crate::proxy::LogThrottle = crate::proxy::LogThrottle::new();
52
53/// Throttle for the "refusing to issue a certificate" warning.
54#[cfg(feature = "proxy-tls")]
55static REFUSED_SNI: crate::proxy::LogThrottle = crate::proxy::LogThrottle::new();
56
57/// Total time shutdown spends letting connections and tunnels finish.
58///
59/// Shared by both drain phases and kept inside what the supervisor waits for,
60/// so neither phase is cut off by a caller that has stopped listening.
61pub(crate) const SHUTDOWN_DRAIN_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
62
63/// Maximum number of `CONNECT` tunnels open at once.
64///
65/// A tunnel holds two sockets for as long as the client keeps it, and nothing
66/// stops a client opening them in a loop — including through an existing
67/// tunnel, since the far end is this same listener. The handshake budget does
68/// not cover them, because a tunnel is established by then.
69const MAX_TUNNELS: usize = 256;
70
71/// Throttle for the "too many tunnels" warning.
72static REFUSED_TUNNEL: crate::proxy::LogThrottle = crate::proxy::LogThrottle::new();
73
74/// Throttle for connections abandoned before they said anything.
75///
76/// Debug-level, so silent by default, but `mise run install-dev` turns debug
77/// logging on and these are triggered by exactly the behaviour their
78/// warn-level siblings are throttled for: a client opening connections and
79/// walking away. One line each would let it fill the disk of whoever is
80/// debugging.
81static ABANDONED_HANDSHAKE: crate::proxy::LogThrottle = crate::proxy::LogThrottle::new();
82
83/// Throttle for `CONNECT` tunnels abandoned while being set up.
84static ABANDONED_TUNNEL: crate::proxy::LogThrottle = crate::proxy::LogThrottle::new();
85
86/// Maximum number of connections negotiating at once.
87///
88/// This bounds only protocol detection and the TLS handshake, both of which are
89/// brief for a real client. A connection stops counting against it the moment
90/// its handshake finishes, so long-lived sessions — keep-alive, HTTP/2,
91/// WebSocket, CONNECT tunnels — do not consume the budget.
92const MAX_PENDING_HANDSHAKES: usize = 512;
93
94/// HTTP/1.1 hop-by-hop headers that are forbidden in HTTP/2 responses.
95/// These must be stripped when proxying an HTTP/1.1 backend response back to an HTTP/2 client.
96const HOP_BY_HOP_HEADERS: &[&str] = &[
97    "connection",
98    "keep-alive",
99    "proxy-connection",
100    "transfer-encoding",
101    "upgrade",
102];
103
104use hyper_util::client::legacy::Client;
105use hyper_util::client::legacy::connect::HttpConnector;
106use hyper_util::rt::TokioExecutor;
107use tokio::net::{TcpListener, TcpStream};
108
109use crate::daemon_id::DaemonId;
110use crate::pitchfork_toml::ProxyTlsMode;
111use crate::proxy::activity::{ACTIVITY, ActivityGuard, GuardedBody};
112use crate::settings::settings;
113use crate::supervisor::SUPERVISOR;
114
115// ─── Slug resolution cache ──────────────────────────────────────────────────
116//
117// `read_global_slugs()` reads ~/.config/pitchfork/config.toml from disk on every
118// call, and `namespace_for_dir()` traverses the filesystem upward to find the
119// nearest pitchfork.toml.  Both are called from `resolve_target_port()` which
120// sits in the hot path of every proxied HTTP request.
121//
122// This cache stores the resolved slug → (namespace, daemon_name) mapping
123// in memory with a short TTL so that the proxy does zero disk I/O for the vast
124// majority of requests while still picking up config changes within seconds.
125
126/// How long to cache the slug resolution table before re-reading from disk.
127const SLUG_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
128
129/// How a hostname's TLS is handled, and which daemon port it maps to.
130///
131/// Read from the daemon's `pitchfork.toml` (`proxy_tls`, `proxy_tls_port`) when
132/// the slug table is refreshed, so the routing decision needs no disk I/O and
133/// is available whether or not the daemon is currently running.
134#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
135pub struct ProxyTlsRoute {
136    /// `terminate` (default) or `passthrough`.
137    pub mode: ProxyTlsMode,
138    /// The daemon port this hostname maps to. `None` means the first port,
139    /// which is what a single-port daemon always wants.
140    pub port: Option<u16>,
141}
142
143/// Cached slug entry: pre-resolved namespace + daemon name for a slug.
144#[derive(Clone, Debug)]
145pub struct CachedSlugEntry {
146    /// The slug key as registered in config (needed for display in auto-start pages).
147    pub slug: String,
148    /// Expected namespace derived from `entry.resolve_dir()` (None if derivation failed).
149    pub namespace: Option<String>,
150    /// Daemon short name (defaults to slug name when not explicitly set).
151    pub daemon_name: String,
152    /// Project directory for this slug (needed for auto-start).
153    pub dir: std::path::PathBuf,
154    /// Worktrees (git) / workspaces (jj) discovered under this slug's project directory.
155    pub worktrees: Vec<crate::proxy::worktree::WorktreeEntry>,
156    /// Sanitized worktree prefixes (ASCII-lowercased) that were discovered but
157    /// are ambiguous, kept so a request naming one is refused rather than
158    /// falling through to the parent slug as an unknown wildcard prefix.
159    pub rejected_worktree_prefixes: std::collections::HashSet<String>,
160    /// TLS route for the slug's main checkout.
161    pub tls: ProxyTlsRoute,
162    /// TLS routes per worktree, keyed by ASCII-lowercased sanitized branch.
163    /// A worktree can configure its own `proxy_tls`, since each checkout has
164    /// its own `pitchfork.toml`.
165    pub worktree_tls: std::collections::HashMap<String, ProxyTlsRoute>,
166}
167
168impl CachedSlugEntry {
169    /// The route this entry recorded for its main checkout, provided the slug
170    /// still names the same daemon in the same directory and namespace. A slug
171    /// repointed elsewhere must not carry the old daemon's mode and port over
172    /// to a new target whose config cannot be read.
173    fn known_route(
174        &self,
175        dir: &std::path::Path,
176        namespace: Option<&str>,
177        daemon_name: &str,
178    ) -> Option<ProxyTlsRoute> {
179        (self.dir == dir
180            && self.namespace.as_deref() == namespace
181            && self.daemon_name == daemon_name)
182            .then_some(self.tls)
183    }
184
185    /// The route this entry recorded for a worktree, provided the same daemon
186    /// was known there at the same path and namespace.
187    fn known_worktree_route(
188        &self,
189        wt: &crate::proxy::worktree::WorktreeEntry,
190        daemon_name: &str,
191    ) -> Option<ProxyTlsRoute> {
192        if self.daemon_name != daemon_name {
193            return None;
194        }
195        let branch = wt.sanitized_branch.to_ascii_lowercase();
196        self.worktrees
197            .iter()
198            .any(|known| {
199                known.sanitized_branch.eq_ignore_ascii_case(&branch)
200                    && known.path == wt.path
201                    && known.namespace == wt.namespace
202            })
203            .then(|| self.worktree_tls.get(&branch).copied())
204            .flatten()
205    }
206}
207
208/// In-memory cache for the global slug registry + derived namespaces.
209struct SlugCache {
210    entries: Arc<std::collections::HashMap<String, CachedSlugEntry>>,
211    expires_at: std::time::Instant,
212}
213
214/// The cached slug table.
215///
216/// A `std::sync::RwLock` rather than an async one on purpose: `rustls` calls
217/// its certificate resolver from a synchronous trait method, and that resolver
218/// has to know whether a hostname is passthrough before it issues a certificate
219/// for it. One lock means routing and the resolver read the same table by
220/// construction, so they cannot disagree about a hostname's mode. Every
221/// critical section is a clone of an `Arc` or a comparison of two `Instant`s,
222/// with the disk I/O deliberately outside, and nothing awaits while holding it.
223static SLUG_CACHE: once_cell::sync::Lazy<std::sync::RwLock<SlugCache>> =
224    once_cell::sync::Lazy::new(|| {
225        std::sync::RwLock::new(SlugCache {
226            entries: Arc::new(std::collections::HashMap::new()),
227            expires_at: std::time::Instant::now(), // expired → will be populated on first access
228        })
229    });
230
231/// Read the cached slug table without awaiting.
232///
233/// Empty until the table has been populated once, which every TLS connection
234/// does before reaching the certificate resolver. An empty table resolves every
235/// hostname to `terminate`, which is the behavior of a proxy with no slugs.
236fn slug_snapshot() -> Arc<std::collections::HashMap<String, CachedSlugEntry>> {
237    let guard = SLUG_CACHE
238        .read()
239        .unwrap_or_else(std::sync::PoisonError::into_inner);
240    Arc::clone(&guard.entries)
241}
242
243/// Serializes refreshes of [`SLUG_CACHE`], so at most one build is in flight.
244///
245/// Without it, every request arriving on an expired cache builds its own table
246/// and publishes it, which both duplicates the work — the build runs
247/// subprocesses to discover worktrees — and leaves each caller holding a
248/// different generation than the one the certificate resolver reads. With it,
249/// the first arrival builds and the rest wait and take that table, so one
250/// generation is live at a time.
251static SLUG_REFRESH: once_cell::sync::Lazy<tokio::sync::Mutex<()>> =
252    once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(()));
253
254/// The cached table if it has not expired.
255fn fresh_slugs() -> Option<Arc<std::collections::HashMap<String, CachedSlugEntry>>> {
256    let cache = SLUG_CACHE
257        .read()
258        .unwrap_or_else(std::sync::PoisonError::into_inner);
259    (std::time::Instant::now() < cache.expires_at).then(|| Arc::clone(&cache.entries))
260}
261
262/// Drop every worktree whose sanitized branch is ambiguous under
263/// case-insensitive host matching, keeping the unambiguous ones.
264///
265/// Both sides of a collision are dropped rather than one being picked: the
266/// alternative routes a request to a worktree the user did not name, which is
267/// worse than not routing it at all.
268fn reject_case_colliding_worktrees(
269    wts: Vec<crate::proxy::worktree::WorktreeEntry>,
270) -> (
271    Vec<crate::proxy::worktree::WorktreeEntry>,
272    std::collections::HashSet<String>,
273) {
274    let collisions =
275        crate::proxy::ascii_case_collisions(wts.iter().map(|w| w.sanitized_branch.as_str()));
276    if collisions.is_empty() {
277        return (wts, collisions);
278    }
279
280    let (dropped, kept): (Vec<_>, Vec<_>) = wts
281        .into_iter()
282        .partition(|w| collisions.contains(&w.sanitized_branch.to_ascii_lowercase()));
283
284    let mut folded: Vec<&String> = collisions.iter().collect();
285    folded.sort();
286    for key in folded {
287        let mut branches: Vec<&str> = dropped
288            .iter()
289            .filter(|w| w.sanitized_branch.eq_ignore_ascii_case(key))
290            .map(|w| w.branch.as_str())
291            .collect();
292        branches.sort();
293        log::warn!(
294            "Worktree slug collision: branches [{}] all route to '{key}' under \
295             case-insensitive host matching. None of them will be routed; \
296             rename a branch to disambiguate.",
297            branches.join(", "),
298        );
299    }
300
301    (kept, collisions)
302}
303
304/// Read a daemon's TLS route (`proxy_tls`, `proxy_tls_port`) from the config
305/// that applies in `dir`.
306///
307/// `Ok(None)` means that directory says nothing about the daemon: its config
308/// does not describe it. That is deliberately not the same as a route of
309/// `terminate`: a worktree the proxy knows nothing about inherits its slug's
310/// mode, where a synthesized `terminate` would silently stop splicing a
311/// passthrough daemon and answer with the proxy's certificate instead. A
312/// directory that *does* describe the daemon is authoritative, including when
313/// it leaves `proxy_tls` out and so terminates.
314///
315/// `Err` means the config could not be read at all, which says nothing about
316/// the daemon either way. The merged config fails as a whole, so an error in
317/// an unrelated daemon lands here too; callers that already know a route keep
318/// it rather than treat the error as `terminate`.
319///
320/// Any other way of resolving a hostname to a daemon can reuse this to get the
321/// same TLS decision, since the mode belongs to the daemon rather than to the
322/// hostname that reached it.
323///
324/// The config read goes through the merged-config cache, so a refresh that
325/// finds nothing changed costs a `stat` per config file rather than a reparse.
326pub(crate) fn read_proxy_tls_route(
327    dir: &std::path::Path,
328    namespace: Option<&str>,
329    daemon_name: &str,
330) -> miette::Result<Option<ProxyTlsRoute>> {
331    let Some(id) = namespace.and_then(|ns| DaemonId::try_new(ns, daemon_name).ok()) else {
332        return Ok(None);
333    };
334    let pt = crate::pitchfork_toml::PitchforkToml::all_merged_from(dir)?;
335    Ok(pt.daemons.get(&id).map(|cfg| ProxyTlsRoute {
336        mode: cfg.proxy_tls.unwrap_or_default(),
337        port: cfg.effective_proxy_tls_port(),
338    }))
339}
340
341/// The result of [`read_proxy_tls_route`], falling back to `known` — the route
342/// recorded by the previous slug-table refresh — when the config is unreadable.
343///
344/// The warning is logged once per distinct error, since the table refreshes
345/// every couple of seconds and the error persists until the config is fixed.
346fn route_or_last_known(
347    read: miette::Result<Option<ProxyTlsRoute>>,
348    known: Option<ProxyTlsRoute>,
349    dir: &std::path::Path,
350    daemon_name: &str,
351) -> Option<ProxyTlsRoute> {
352    read.unwrap_or_else(|e| {
353        crate::proxy::hostname::warn_once(&format!(
354            "Proxy TLS route for daemon '{daemon_name}': could not read config in {}; \
355             keeping its last known TLS mode until the config is fixed: {e}",
356            dir.display()
357        ));
358        known
359    })
360}
361
362/// Build the slug lookup table from disk (expensive — involves file I/O + subprocesses).
363/// Called outside the cache lock via `spawn_blocking` to avoid blocking the Tokio runtime.
364///
365/// Keys are ASCII-lowercased, and slugs that collide once folded are left out
366/// entirely — see [`reject_case_colliding_worktrees`] for why ambiguity is
367/// rejected rather than resolved.
368///
369/// `previous` is the table being replaced. When a directory's config cannot be
370/// read, the route recorded there is kept, so a config error — even one in an
371/// unrelated daemon — cannot quietly turn a running passthrough daemon into a
372/// terminated one that answers with the proxy's certificate.
373fn build_slug_entries(
374    previous: &std::collections::HashMap<String, CachedSlugEntry>,
375) -> std::collections::HashMap<String, CachedSlugEntry> {
376    let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
377    let collisions = crate::proxy::ascii_case_collisions(global_slugs.keys().map(String::as_str));
378    let mut folded: Vec<&String> = collisions.iter().collect();
379    folded.sort();
380    for key in folded {
381        let mut spellings: Vec<&str> = global_slugs
382            .keys()
383            .filter(|s| s.eq_ignore_ascii_case(key))
384            .map(String::as_str)
385            .collect();
386        spellings.sort();
387        log::warn!(
388            "Slug collision: [{}] differ only by case and host names are case-insensitive. \
389             None of them will be routed; remove or rename all but one.",
390            spellings.join(", "),
391        );
392    }
393
394    let mut entries: std::collections::HashMap<String, CachedSlugEntry> =
395        std::collections::HashMap::with_capacity(global_slugs.len());
396    let worktree_enabled = crate::settings::settings().general.worktree;
397    for (slug, entry) in &global_slugs {
398        let key = slug.to_ascii_lowercase();
399        if collisions.contains(&key) {
400            continue;
401        }
402        let ns = entry.resolve_namespace();
403        let daemon_name = entry.daemon.as_deref().unwrap_or(slug).to_string();
404        let (worktrees, rejected_worktree_prefixes) = if worktree_enabled {
405            let wts = match entry.resolve_dir() {
406                Some(dir) => crate::proxy::worktree::discover_worktrees(&dir),
407                None => vec![],
408            };
409            let wts = wts
410                .into_iter()
411                .map(|mut wt| {
412                    wt.namespace =
413                        crate::pitchfork_toml::PitchforkToml::namespace_for_dir(&wt.path).ok();
414                    wt
415                })
416                .collect();
417            reject_case_colliding_worktrees(wts)
418        } else {
419            (vec![], std::collections::HashSet::new())
420        };
421        let dir = entry.resolve_dir().unwrap_or_default();
422        let prev = previous.get(&key);
423        // The slug's own directory has nothing to inherit from, so silence
424        // there is the default route. An unreadable config keeps the last
425        // known route; with none known yet, there is nothing else to go on.
426        let tls = route_or_last_known(
427            read_proxy_tls_route(&dir, ns.as_deref(), &daemon_name),
428            prev.and_then(|p| p.known_route(&dir, ns.as_deref(), &daemon_name)),
429            &dir,
430            &daemon_name,
431        )
432        .unwrap_or_default();
433        // A worktree only gets an entry when its own config describes the
434        // daemon, or when it did last time and cannot be read now; the rest
435        // inherit the slug's route at lookup time.
436        let worktree_tls = worktrees
437            .iter()
438            .filter_map(|wt| {
439                let branch = wt.sanitized_branch.to_ascii_lowercase();
440                route_or_last_known(
441                    read_proxy_tls_route(&wt.path, wt.namespace.as_deref(), &daemon_name),
442                    prev.and_then(|p| p.known_worktree_route(wt, &daemon_name)),
443                    &wt.path,
444                    &daemon_name,
445                )
446                .map(|route| (branch, route))
447            })
448            .collect();
449        entries.insert(
450            key,
451            CachedSlugEntry {
452                slug: slug.clone(),
453                namespace: ns,
454                daemon_name,
455                dir,
456                worktrees,
457                rejected_worktree_prefixes,
458                tls,
459                worktree_tls,
460            },
461        );
462    }
463    entries
464}
465
466/// Return the cached slug table, refreshing from disk if expired.
467///
468/// The disk I/O happens *outside* the cache lock so a refresh does not block
469/// readers, and refreshes are serialized against each other so only one build
470/// runs at a time. Every caller therefore returns the one table that is
471/// published, which is what keeps routing and the certificate resolver from
472/// disagreeing about a hostname: a caller holding its own build could route a
473/// hostname as terminating while the resolver, reading the published table,
474/// refuses to issue for it and the handshake is dropped rather than spliced.
475pub async fn get_cached_slugs() -> Arc<std::collections::HashMap<String, CachedSlugEntry>> {
476    // Fast path: cache still valid — just clone the Arc.
477    if let Some(entries) = fresh_slugs() {
478        return entries;
479    }
480
481    // Slow path: one refresh at a time.
482    let _refreshing = SLUG_REFRESH.lock().await;
483
484    // Another caller may have refreshed while this one waited for the lock.
485    if let Some(entries) = fresh_slugs() {
486        return entries;
487    }
488
489    // Build from disk on a blocking thread (involves subprocess calls).
490    let previous = slug_snapshot();
491    let new_entries = Arc::new(
492        tokio::task::spawn_blocking(move || build_slug_entries(&previous))
493            .await
494            .unwrap_or_else(|e| {
495                log::warn!("Failed to refresh slug cache: {e}");
496                std::collections::HashMap::new()
497            }),
498    );
499
500    let mut cache = SLUG_CACHE
501        .write()
502        .unwrap_or_else(std::sync::PoisonError::into_inner);
503    cache.entries = Arc::clone(&new_entries);
504    cache.expires_at = std::time::Instant::now() + SLUG_CACHE_TTL;
505    new_entries
506}
507
508// ─── Hostname registry cache ────────────────────────────────────────────────
509//
510// The automatic `<daemon>.<worktree>.<project>` hostnames are resolved against
511// a registry built from every project pitchfork knows about.  Building it reads
512// configuration files and enumerates git worktrees, so the result is cached
513// with the same short TTL as the slug table.
514
515struct RegistryCache {
516    registry: Arc<crate::proxy::hostname::HostRegistry>,
517    expires_at: std::time::Instant,
518}
519
520/// The cached hostname registry.
521///
522/// A `std::sync::RwLock` for the same reason as [`SLUG_CACHE`]: the certificate
523/// resolver runs in a synchronous trait method and has to know whether a
524/// hostname is passthrough before it issues a certificate for it. Both readers
525/// share one registry, so routing and the resolver cannot disagree.
526static HOST_REGISTRY: once_cell::sync::Lazy<std::sync::RwLock<RegistryCache>> =
527    once_cell::sync::Lazy::new(|| {
528        std::sync::RwLock::new(RegistryCache {
529            registry: Arc::new(crate::proxy::hostname::HostRegistry::default()),
530            expires_at: std::time::Instant::now(), // expired -> built on first access
531        })
532    });
533
534/// Serializes registry refreshes, so one build runs at a time and every caller
535/// returns the table that was published. See [`SLUG_REFRESH`].
536static REGISTRY_REFRESH: once_cell::sync::Lazy<tokio::sync::Mutex<()>> =
537    once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(()));
538
539/// The cached registry if it has not expired.
540fn fresh_registry() -> Option<Arc<crate::proxy::hostname::HostRegistry>> {
541    let cache = HOST_REGISTRY
542        .read()
543        .unwrap_or_else(std::sync::PoisonError::into_inner);
544    (std::time::Instant::now() < cache.expires_at).then(|| Arc::clone(&cache.registry))
545}
546
547/// Read the cached hostname registry without awaiting.
548///
549/// Empty until it has been built once, which every TLS connection does before
550/// reaching the certificate resolver. An empty registry resolves every hostname
551/// to `terminate`, the behavior of a proxy that knows no projects.
552fn registry_snapshot() -> Arc<crate::proxy::hostname::HostRegistry> {
553    let cache = HOST_REGISTRY
554        .read()
555        .unwrap_or_else(std::sync::PoisonError::into_inner);
556    Arc::clone(&cache.registry)
557}
558
559/// Return a snapshot of the cached hostname registry, rebuilding if expired.
560pub async fn get_cached_host_registry() -> Arc<crate::proxy::hostname::HostRegistry> {
561    if let Some(registry) = fresh_registry() {
562        return registry;
563    }
564
565    // One refresh at a time; the rest wait and take its result.
566    let _refreshing = REGISTRY_REFRESH.lock().await;
567    if let Some(registry) = fresh_registry() {
568        return registry;
569    }
570
571    let registry = Arc::new(
572        tokio::task::spawn_blocking(crate::proxy::hostname::HostRegistry::build)
573            .await
574            .unwrap_or_else(|e| {
575                log::warn!("Failed to refresh hostname registry: {e}");
576                crate::proxy::hostname::HostRegistry::default()
577            }),
578    );
579    for err in &registry.errors {
580        crate::proxy::hostname::warn_once(err);
581    }
582
583    let mut cache = HOST_REGISTRY
584        .write()
585        .unwrap_or_else(std::sync::PoisonError::into_inner);
586    cache.registry = Arc::clone(&registry);
587    cache.expires_at = std::time::Instant::now() + SLUG_CACHE_TTL;
588    registry
589}
590
591/// Try to match a subdomain against a slug table, with optional wildcard fallback.
592///
593/// When `wildcard` is true and no exact match is found, progressively strips
594/// subdomain prefixes from the left until a match is found or no dots remain.
595/// For example, with slug "myapp" registered, `tenant.myapp` matches "myapp".
596///
597/// `entries` must be keyed by the ASCII-lowercased slug, as
598/// [`build_slug_entries`] produces: host names are case-insensitive (RFC 4343),
599/// so the subdomain is lowercased before every lookup.
600fn wildcard_slug_lookup<'a>(
601    subdomain: &str,
602    entries: &'a std::collections::HashMap<String, CachedSlugEntry>,
603    wildcard: bool,
604) -> Option<&'a CachedSlugEntry> {
605    let subdomain = subdomain.to_ascii_lowercase();
606
607    entries.get(&subdomain).or_else(|| {
608        if !wildcard {
609            return None;
610        }
611        // "a.b.myapp" has dots at 1,3 → "b.myapp", "myapp"
612        subdomain
613            .match_indices('.')
614            .map(|(i, _)| &subdomain[i + 1..])
615            .find_map(|candidate| entries.get(candidate))
616    })
617}
618
619/// What a wildcard subdomain prefix resolves to within a slug's worktrees.
620#[derive(Debug)]
621enum PrefixMatch<'a> {
622    /// The prefix names exactly one discovered worktree.
623    Worktree(&'a crate::proxy::worktree::WorktreeEntry),
624    /// The prefix names no worktree — an ordinary wildcard subdomain, served
625    /// by the slug's main checkout.
626    Unknown,
627    /// The prefix names worktrees that were rejected as ambiguous.  Serving the
628    /// main checkout here would answer successfully with the wrong content, so
629    /// the request is refused instead.
630    Ambiguous,
631}
632
633/// Resolve a wildcard subdomain prefix against a slug's cached worktrees.
634fn match_worktree_prefix<'a>(cached: &'a CachedSlugEntry, prefix: &str) -> PrefixMatch<'a> {
635    if let Some(wt) = cached
636        .worktrees
637        .iter()
638        .find(|w| w.sanitized_branch.eq_ignore_ascii_case(prefix))
639    {
640        return PrefixMatch::Worktree(wt);
641    }
642    if cached
643        .rejected_worktree_prefixes
644        .contains(&prefix.to_ascii_lowercase())
645    {
646        return PrefixMatch::Ambiguous;
647    }
648    PrefixMatch::Unknown
649}
650
651/// The TLS route for a worktree of `cached`.
652///
653/// A worktree whose own config describes the daemon is authoritative. One the
654/// proxy knows nothing about — an unreadable or momentarily invalid config, a
655/// checkout that predates the daemon — inherits the slug's *mode* but not its
656/// port.
657///
658/// Inheriting the mode is what keeps a passthrough slug from quietly being
659/// terminated with the proxy's certificate while its config is unreadable. The
660/// port is deliberately not inherited: `proxy_tls_port` names a position in one
661/// daemon's port list, and the daemon behind a worktree hostname is a different
662/// process with its own ports, so the hostname falls back to that daemon's own
663/// first port rather than to a number chosen for another checkout.
664fn worktree_route(cached: &CachedSlugEntry, sanitized_branch: &str) -> ProxyTlsRoute {
665    cached
666        .worktree_tls
667        .get(&sanitized_branch.to_ascii_lowercase())
668        .copied()
669        .unwrap_or(ProxyTlsRoute {
670            mode: cached.tls.mode,
671            port: None,
672        })
673}
674
675/// Strip a trailing `.{suffix}` from `s`, ignoring ASCII case.
676///
677/// Returns the remaining prefix, or `None` when `s` does not end that way.
678fn strip_dot_suffix_ignore_case(s: &str, suffix: &str) -> Option<String> {
679    let needle_len = suffix.len() + 1;
680    if s.len() <= needle_len {
681        return None;
682    }
683    let split = s.len() - needle_len;
684    if !s.is_char_boundary(split) {
685        return None;
686    }
687    let (head, tail) = s.split_at(split);
688    if tail.starts_with('.') && tail[1..].eq_ignore_ascii_case(suffix) {
689        Some(head.to_string())
690    } else {
691        None
692    }
693}
694
695/// Look up a slug in the cached table.
696///
697/// With wildcard enabled (default), falls back to progressively shorter
698/// subdomain suffixes when an exact match is not found.  For example,
699/// `tenant.myapp` will match slug `myapp` if no slug named `tenant.myapp`
700/// exists.
701async fn cached_slug_lookup(subdomain: &str) -> Option<CachedSlugEntry> {
702    let entries = get_cached_slugs().await;
703    wildcard_slug_lookup(subdomain, &entries, settings().proxy.wildcard).cloned()
704}
705
706// ─── Auto-start deduplication ───────────────────────────────────────────────
707//
708// When auto_start is enabled, concurrent proxy requests for the same stopped
709// daemon must not trigger multiple start operations.  This set tracks daemon
710// IDs that are currently being auto-started.
711
712static AUTO_START_IN_PROGRESS: once_cell::sync::Lazy<
713    tokio::sync::Mutex<std::collections::HashSet<DaemonId>>,
714> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(std::collections::HashSet::new()));
715
716/// Result of resolving a proxy target for a given host.
717enum ResolveResult {
718    /// Daemon is running and ready — forward to this port.
719    /// Covers both already-running daemons and freshly auto-started ones.
720    ///
721    /// The guard records the forwarded work as the daemon's activity, and has
722    /// to be held for as long as that work lasts.
723    Ready(u16, Option<ActivityGuard>),
724    /// Daemon is currently starting (auto-start in progress or just triggered).
725    Starting { slug: String },
726    /// No matching slug or daemon found.
727    NotFound,
728    /// The hostname is reserved for a project or stack page, which a later
729    /// change will serve.  It must never fall through to a daemon.
730    Page {
731        project: String,
732        worktree: Option<String>,
733        daemons: Vec<String>,
734        /// The checkout the hostname resolved to, which is what maps the page
735        /// back to its URL in the web UI: hostname labels are sanitized and can
736        /// be overridden, so they are not the names those URLs use.
737        dir: Option<std::path::PathBuf>,
738    },
739    /// The hostname named a project or daemon that does not exist.
740    Unknown { heading: String, known: Vec<String> },
741    /// Routing refused with a descriptive reason.
742    Error(String),
743}
744
745/// Shared proxy state passed to each request handler.
746/// Callback type invoked on proxy errors (e.g. for logging/alerting).
747type OnErrorFn = Arc<dyn Fn(&str) + Send + Sync>;
748
749#[derive(Clone)]
750struct ProxyState {
751    /// HTTP client used to forward requests to daemon backends.
752    client: Arc<Client<HttpConnector, Body>>,
753    /// The configured TLD (e.g. "localhost").
754    tld: String,
755    /// Whether the proxy is serving HTTPS.
756    is_tls: bool,
757    /// Address a `CONNECT` tunnel is spliced to: this proxy's own listener.
758    connect_target: Option<SocketAddr>,
759    /// Address a client on this machine uses to reach the listener, which the
760    /// PAC file names.
761    contact_ip: std::net::IpAddr,
762    /// Cancelled when the proxy is shutting down.
763    cancel: tokio_util::sync::CancellationToken,
764    /// Budget for concurrent `CONNECT` tunnels.
765    tunnel_slots: Arc<tokio::sync::Semaphore>,
766    /// Live `CONNECT` tunnels.
767    ///
768    /// A tunnel outlives the request that created it, so it cannot live in the
769    /// connection `JoinSet`. Tracking it here lets shutdown cancel the tunnels
770    /// and then wait for them, rather than returning while one still owns both
771    /// of its sockets.
772    tunnels: tokio_util::task::TaskTracker,
773    /// Optional error callback invoked on proxy errors (e.g. for logging/alerting).
774    on_error: Option<OnErrorFn>,
775}
776
777/// Start the reverse proxy server.
778///
779/// Binds to the configured port and serves until the process exits.
780/// When `proxy.https = true`, TLS is terminated here using a self-signed
781/// certificate (auto-generated if not present).
782///
783/// This function is intended to be spawned as a background task.
784pub async fn serve(
785    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
786    cancel: tokio_util::sync::CancellationToken,
787) -> crate::Result<()> {
788    let s = settings();
789    let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
790
791    let effective_tld = crate::proxy::effective_tld(&s).to_string();
792
793    let Some(effective_port) = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0) else {
794        let msg = format!(
795            "proxy.port {} is out of valid port range (1-65535), proxy server cannot start",
796            s.proxy.port
797        );
798        let _ = bind_tx.send(Err(msg.clone()));
799        miette::bail!("{msg}");
800    };
801
802    let mut connector = HttpConnector::new();
803    // Limit how long the proxy waits to establish a TCP connection to a backend.
804    // Without this, a daemon that accepts the SYN but never completes the handshake
805    // would stall the proxy indefinitely.
806    connector.set_connect_timeout(Some(std::time::Duration::from_secs(10)));
807
808    let client = Client::builder(TokioExecutor::new())
809        // Reclaim idle keep-alive connections after 30 s so that file descriptors
810        // are not held open forever when a backend goes quiet.
811        .pool_idle_timeout(std::time::Duration::from_secs(30))
812        .build(connector);
813
814    // Resolve bind address from settings.
815    // In LAN mode, default to 0.0.0.0 so the proxy is reachable from other
816    // devices on the network.  Users can still override with proxy.host.
817    let bind_ip: std::net::IpAddr = if lan_enabled && s.proxy.host == "127.0.0.1" {
818        std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
819    } else {
820        match s.proxy.host.parse() {
821            Ok(ip) => ip,
822            Err(_) => {
823                log::warn!(
824                    "proxy.host {:?} is not a valid IP address — falling back to 127.0.0.1. \
825                     The proxy will only be reachable on the loopback interface.",
826                    s.proxy.host
827                );
828                std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
829            }
830        }
831    };
832    let addr = SocketAddr::from((bind_ip, effective_port));
833    // The address a local client should use to reach this listener. Derived
834    // from the bind address rather than assumed to be 127.0.0.1: with
835    // `proxy.host = "::1"` nothing is listening on IPv4 at all, so both the PAC
836    // file and the CONNECT tunnel have to name the IPv6 loopback instead.
837    let contact_ip = local_contact_ip(bind_ip);
838    let tunnels = tokio_util::task::TaskTracker::new();
839
840    let state = ProxyState {
841        client: Arc::new(client),
842        tld: effective_tld.clone(),
843        is_tls: s.proxy.https,
844        // CONNECT tunnels loop back into this same listener, so the TLS
845        // handshake inside the tunnel reaches the SNI resolver.
846        connect_target: Some(SocketAddr::from((contact_ip, effective_port))),
847        contact_ip,
848        cancel: cancel.clone(),
849        tunnel_slots: Arc::new(tokio::sync::Semaphore::new(MAX_TUNNELS)),
850        tunnels: tunnels.clone(),
851        on_error: None,
852    };
853
854    // `/proxy.pac` is served from the proxy's own listener because that is the
855    // one HTTP endpoint that always exists while the proxy runs. The handler
856    // falls through to normal proxying when the request is addressed to a
857    // hostname under the TLD, so a daemon can still own that path.
858    let plain_state = state.clone();
859    let app = Router::new()
860        // `any`, not `get`: axum answers a path match with no method match at
861        // the router, before `fallback` runs. Registering GET alone would turn
862        // a POST to `/proxy.pac` on *any* proxied hostname into a 405 that the
863        // backend daemon never sees, which is exactly the fall-through the
864        // comment above promises. The handler decides the method instead.
865        .route(crate::proxy::pac::PAC_PATH, axum::routing::any(pac_handler))
866        .fallback(proxy_handler)
867        .with_state(state);
868
869    if s.proxy.https {
870        serve_https_with_http_fallback(
871            app,
872            addr,
873            &s,
874            effective_port,
875            effective_tld,
876            plain_state,
877            bind_tx,
878            cancel,
879        )
880        .await
881    } else {
882        // `plain_state` carries the tunnel tracker, which this path has to
883        // drain too: `proxy.https = false` still serves CONNECT through the
884        // PAC file, for `http://` and `ws://` URLs.
885        serve_http(app, addr, effective_port, plain_state, bind_tx, cancel).await
886    }
887}
888
889/// Serve one accepted connection until it ends, asking it to close gracefully
890/// once `cancel` fires.
891///
892/// Shared by every listener path. Without the graceful request an idle
893/// keep-alive connection or an HTTP/2 session never ends on its own, so each
894/// one would run out the whole shutdown drain budget and then be aborted.
895macro_rules! serve_conn_until_cancelled {
896    ($io:expr, $svc:expr, $cancel:expr) => {{
897        let builder = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new());
898        let conn = builder.serve_connection_with_upgrades($io, $svc);
899        tokio::pin!(conn);
900        tokio::select! {
901            r = conn.as_mut() => r,
902            _ = $cancel.cancelled() => {
903                conn.as_mut().graceful_shutdown();
904                conn.await
905            }
906        }
907    }};
908}
909
910/// Serve plain HTTP.
911async fn serve_http(
912    app: Router,
913    addr: SocketAddr,
914    effective_port: u16,
915    proxy_state: ProxyState,
916    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
917    cancel: tokio_util::sync::CancellationToken,
918) -> crate::Result<()> {
919    let listener = match TcpListener::bind(addr).await {
920        Ok(l) => {
921            if settings().proxy.sync_hosts {
922                crate::proxy::hosts::sync_hosts_from_settings();
923            }
924            let _ = bind_tx.send(Ok(()));
925            l
926        }
927        Err(e) => {
928            let msg = bind_error_message(effective_port, &e);
929            let _ = bind_tx.send(Err(msg.clone()));
930            return Err(miette::miette!("{msg}"));
931        }
932    };
933
934    log::info!("Proxy server listening on http://{addr}");
935    if effective_port < 1024 {
936        log::info!(
937            "Note: port {effective_port} is a privileged port. \
938             The supervisor must be started with sudo to bind to this port."
939        );
940    }
941    // Served by hand rather than through `axum::serve`, for the same reason as
942    // the HTTPS path: axum spawns each connection as a free task, so once its
943    // shutdown wait is cut short the connections keep running — a `ws://`
944    // stream or a long response splicing on while the supervisor stops the
945    // daemons behind it. Here they live in a `JoinSet`, which aborts whatever
946    // is left when the drain budget runs out.
947    let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
948    loop {
949        while conn_tasks.try_join_next().is_some() {}
950        tokio::select! {
951            accept_result = listener.accept() => {
952                let (stream, peer_addr) = match accept_result {
953                    Ok(conn) => conn,
954                    Err(e) => {
955                        log::warn!("Accept error (will retry): {e}");
956                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
957                        continue;
958                    }
959                };
960                // Handlers read the peer address to decide how much of this
961                // machine's configuration a response may describe.
962                let app = app
963                    .clone()
964                    .layer(axum::Extension(axum::extract::ConnectInfo(peer_addr)));
965                let cancel = cancel.clone();
966                conn_tasks.spawn(async move {
967                    let io = hyper_util::rt::TokioIo::new(stream);
968                    let svc = hyper_util::service::TowerToHyperService::new(app);
969                    if let Err(e) = serve_conn_until_cancelled!(io, svc, cancel) {
970                        log::debug!("Connection error: {e}");
971                    }
972                });
973            }
974            _ = cancel.cancelled() => break,
975        }
976    }
977
978    // One deadline for both drains, as the HTTPS path has: in-flight
979    // connections first, then the CONNECT tunnels.
980    let deadline = tokio::time::Instant::now() + SHUTDOWN_DRAIN_BUDGET;
981    if tokio::time::timeout_at(deadline, async {
982        while conn_tasks.join_next().await.is_some() {}
983    })
984    .await
985    .is_err()
986    {
987        log::debug!("Proxy connections still open after {SHUTDOWN_DRAIN_BUDGET:?}; aborting them");
988    }
989    // Aborts whatever did not finish in time.
990    drop(conn_tasks);
991
992    // A CONNECT request's connection completes the moment it is upgraded; the
993    // tunnel it spawned outlives it, so without this the supervisor would go
994    // on to stop daemons while tunnels were still splicing bytes.
995    proxy_state.tunnels.close();
996    let _ = tokio::time::timeout_at(deadline, proxy_state.tunnels.wait()).await;
997    Ok(())
998}
999
1000/// Serve HTTPS with automatic HTTP detection on the same port.
1001///
1002/// Peeks at the first byte of each incoming TCP connection:
1003/// - `0x16` (TLS ClientHello) → hand off to the TLS acceptor (HTTP/2 + HTTP/1.1 via ALPN)
1004/// - anything else → 302 redirect to HTTPS
1005#[cfg(feature = "proxy-tls")]
1006#[allow(clippy::too_many_arguments)]
1007async fn serve_https_with_http_fallback(
1008    app: Router,
1009    addr: SocketAddr,
1010    s: &crate::settings::Settings,
1011    effective_port: u16,
1012    effective_tld: String,
1013    plain_proxy_state: ProxyState,
1014    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
1015    cancel: tokio_util::sync::CancellationToken,
1016) -> crate::Result<()> {
1017    use rustls::ServerConfig;
1018    use tokio_rustls::TlsAcceptor;
1019
1020    let (cert_path, key_path) = resolve_tls_paths(s)?;
1021
1022    // Install ring as the default CryptoProvider if none has been set yet.
1023    let _ = rustls::crypto::ring::default_provider().install_default();
1024
1025    // A configured `tls_cert` is served as-is: the user supplied a certificate
1026    // for these host names, so pitchfork has no CA to mint from and no business
1027    // replacing what they chose.  Otherwise the local CA signs a leaf per SNI
1028    // host on demand, which is what makes names of any depth work.
1029    let resolver: Arc<dyn rustls::server::ResolvesServerCert> = if s.proxy.tls_cert.is_empty() {
1030        if ensure_ca(&cert_path, &key_path, || {
1031            cert_path.exists() && key_path.exists()
1032        })? {
1033            log::info!("Generated local CA certificate at {}", cert_path.display());
1034            log::info!("To trust the CA in your browser, run: pitchfork proxy trust");
1035        }
1036        Arc::new(SniCertResolver::new(
1037            &cert_path,
1038            &key_path,
1039            effective_tld.clone(),
1040        )?)
1041    } else {
1042        log::info!(
1043            "Serving the configured certificate {} (no certificates are minted)",
1044            cert_path.display()
1045        );
1046        Arc::new(StaticCertResolver::new(
1047            &cert_path,
1048            &key_path,
1049            effective_tld.clone(),
1050        )?)
1051    };
1052
1053    let mut tls_config = ServerConfig::builder()
1054        .with_no_client_auth()
1055        .with_cert_resolver(resolver);
1056    // Advertise HTTP/2 and HTTP/1.1 via ALPN so browsers negotiate HTTP/2
1057    // for multiplexed requests (eliminates the 6-connection-per-host limit).
1058    tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
1059
1060    let acceptor = TlsAcceptor::from(Arc::new(tls_config));
1061
1062    let listener = match TcpListener::bind(addr).await {
1063        Ok(l) => {
1064            if settings().proxy.sync_hosts {
1065                crate::proxy::hosts::sync_hosts_from_settings();
1066            }
1067            let _ = bind_tx.send(Ok(()));
1068            l
1069        }
1070        Err(e) => {
1071            let msg = bind_error_message(effective_port, &e);
1072            let _ = bind_tx.send(Err(msg.clone()));
1073            return Err(miette::miette!("{msg}"));
1074        }
1075    };
1076
1077    log::info!("Proxy server listening on https://{addr} (HTTP also accepted)");
1078    if effective_port < 1024 {
1079        log::info!(
1080            "Note: port {effective_port} is a privileged port. \
1081             The supervisor must be started with sudo to bind to this port."
1082        );
1083    }
1084
1085    // Build a lightweight app for plain-HTTP requests arriving on the TLS port.
1086    // Two things are exempt from the redirect to HTTPS:
1087    //
1088    //   * `/proxy.pac`, because the browser reads the PAC file before it has
1089    //     any way to trust this listener's certificate.
1090    //   * `CONNECT`, which is how a PAC-configured browser opens an `https://`
1091    //     URL. Redirecting it would break the PAC path entirely.
1092    let redirect_app = Router::new()
1093        .route(
1094            // `any` for the same reason as the TLS side: the method check
1095            // belongs in the handler, so a proxied host keeps this path.
1096            crate::proxy::pac::PAC_PATH,
1097            axum::routing::any(plain_pac_handler),
1098        )
1099        .fallback(plain_fallback_handler)
1100        .with_state(PlainState {
1101            tld: effective_tld.clone(),
1102            port: effective_port,
1103            proxy: plain_proxy_state.clone(),
1104        });
1105
1106    // Accept connections and sniff the first byte to decide TLS vs plain HTTP.
1107    let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1108    let handshake_slots = Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES));
1109    loop {
1110        // Reap finished connection tasks during normal operation so the JoinSet
1111        // does not retain one entry per historical connection.
1112        while conn_tasks.try_join_next().is_some() {}
1113
1114        tokio::select! {
1115            accept_result = listener.accept() => {
1116                let (stream, peer_addr) = match accept_result {
1117                    Ok(conn) => conn,
1118                    Err(e) => {
1119                        log::warn!("Accept error (will retry): {e}");
1120                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1121                        continue;
1122                    }
1123                };
1124
1125                // Refuse rather than queue without bound. A client can hold a
1126                // connection in protocol detection or mid-handshake by simply
1127                // not sending, and each one costs a task and a descriptor.
1128                //
1129                // The permit covers the handshake only and is released before
1130                // the connection is served, so established sessions never
1131                // occupy the budget.
1132                let Ok(handshake_permit) = Arc::clone(&handshake_slots).try_acquire_owned() else {
1133                    // Throttled: whoever is saturating the handshake budget can
1134                    // provoke this line as fast as it can open sockets.
1135                    if let Some(suppressed) = REFUSED_HANDSHAKE.allow(REFUSAL_LOG_INTERVAL) {
1136                        log::warn!(
1137                            "Proxy refused a connection: {MAX_PENDING_HANDSHAKES} \
1138                             still negotiating \
1139                             ({suppressed} similar refusals since the last message)"
1140                        );
1141                    }
1142                    drop(stream);
1143                    continue;
1144                };
1145
1146                let acceptor = acceptor.clone();
1147                // This loop serves the router itself rather than going through
1148                // `into_make_service_with_connect_info`, so the peer address is
1149                // attached here. Handlers use it to decide how much of this
1150                // machine's configuration a response may describe.
1151                let app = app
1152                    .clone()
1153                    .layer(axum::Extension(axum::extract::ConnectInfo(peer_addr)));
1154                let redirect_app = redirect_app.clone();
1155                let tld = effective_tld.clone();
1156                let cancel = cancel.clone();
1157
1158                conn_tasks.spawn(async move {
1159                    // Peek at the first byte without consuming it.
1160                    // TLS ClientHello always starts with 0x16 (content type "handshake").
1161                    //
1162                    // One deadline covers the peek and the handshake together,
1163                    // so a client that trickles its first byte in just in time
1164                    // does not start a second full budget.
1165                    let handshake_deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT;
1166                    let mut peek_buf = [0u8; 1];
1167                    match tokio::time::timeout_at(handshake_deadline, stream.peek(&mut peek_buf)).await {
1168                        Ok(Ok(0)) | Ok(Err(_)) => return,
1169                        Err(_) => {
1170                            if let Some(suppressed) =
1171                                ABANDONED_HANDSHAKE.allow(REFUSAL_LOG_INTERVAL)
1172                            {
1173                                log::debug!(
1174                                    "Connection sent nothing within the handshake timeout \
1175                                     ({suppressed} similar since the last message)"
1176                                );
1177                            }
1178                            return;
1179                        }
1180                        Ok(Ok(_)) => {}
1181                    }
1182
1183                    if peek_buf[0] == 0x16 {
1184                        // A TLS connection whose SNI names a `proxy_tls = "passthrough"`
1185                        // daemon is spliced through untouched, so the daemon's own
1186                        // certificate, ALPN and client-certificate request reach the
1187                        // client. Everything else is terminated here as before.
1188                        //
1189                        // The peek counts against the same handshake deadline:
1190                        // it is still negotiation, not a session.
1191                        let sni_budget = SNI_PEEK_TIMEOUT.min(
1192                            handshake_deadline.saturating_duration_since(tokio::time::Instant::now()),
1193                        );
1194                        match peek_sni_host(&stream, sni_budget).await {
1195                            SniProbe::Host(host) => {
1196                                // Also under the handshake deadline: on a cache
1197                                // miss this rebuilds the slug and host
1198                                // registries, which can be slow, and the
1199                                // connection still holds a negotiation slot.
1200                                let Ok(mode) = tokio::time::timeout_at(
1201                                    handshake_deadline,
1202                                    resolve_tls_mode(&host, &tld),
1203                                )
1204                                .await
1205                                else {
1206                                    if let Some(suppressed) =
1207                                        ABANDONED_HANDSHAKE.allow(REFUSAL_LOG_INTERVAL)
1208                                    {
1209                                        log::debug!(
1210                                            "Routing lookup for '{host}' did not finish within the \
1211                                             handshake timeout ({suppressed} similar since the \
1212                                             last message)"
1213                                        );
1214                                    }
1215                                    return;
1216                                };
1217                                if mode.is_passthrough() {
1218                                    // The splice is the session, however long
1219                                    // it lasts; it must not keep a slot meant
1220                                    // for connections still negotiating.
1221                                    drop(handshake_permit);
1222                                    // Ended by shutdown like any other
1223                                    // connection, rather than by the drain
1224                                    // budget running out.
1225                                    tokio::select! {
1226                                        _ = serve_passthrough(stream, &host, &tld) => {}
1227                                        _ = cancel.cancelled() => {}
1228                                    }
1229                                    return;
1230                                }
1231                            }
1232                            SniProbe::NoHost => {}
1233                            // The hostname could not be read here, so this
1234                            // connection is handed to the TLS acceptor like any
1235                            // other: rustls defragments the handshake itself
1236                            // and hands the real host name to the certificate
1237                            // resolver, which refuses to issue for a
1238                            // passthrough hostname rather than answering with
1239                            // the proxy's certificate. Unrelated terminating
1240                            // hostnames are served normally.
1241                            SniProbe::Undetermined => {
1242                                log::debug!(
1243                                    "Could not read the ClientHello of a TLS connection; \
1244                                     handing it to the TLS acceptor, which refuses to terminate \
1245                                     a passthrough hostname."
1246                                );
1247                                // Make sure the resolver's synchronous snapshots
1248                                // have been populated before it has to decide.
1249                                // Bounded like the rest of negotiation; if it
1250                                // runs out, the accept below does too.
1251                                let _ = tokio::time::timeout_at(handshake_deadline, async {
1252                                    let _ = get_cached_slugs().await;
1253                                    let _ = get_cached_host_registry().await;
1254                                })
1255                                .await;
1256                            }
1257                        }
1258
1259                        // TLS handshake → HTTP/2 or HTTP/1.1 (negotiated via ALPN)
1260                        let accepted = match tokio::time::timeout_at(
1261                            handshake_deadline,
1262                            acceptor.accept(stream),
1263                        )
1264                        .await
1265                        {
1266                            Ok(r) => r,
1267                            Err(_) => {
1268                                if let Some(suppressed) =
1269                                    ABANDONED_HANDSHAKE.allow(REFUSAL_LOG_INTERVAL)
1270                                {
1271                                    log::debug!(
1272                                        "TLS handshake did not complete in time \
1273                                         ({suppressed} similar since the last message)"
1274                                    );
1275                                }
1276                                return;
1277                            }
1278                        };
1279                        // Negotiation is over either way; the session that
1280                        // follows can last as long as it likes.
1281                        drop(handshake_permit);
1282                        match accepted {
1283                            Ok(tls_stream) => {
1284                                let io = hyper_util::rt::TokioIo::new(tls_stream);
1285                                let svc = hyper_util::service::TowerToHyperService::new(app);
1286                                if let Err(e) = serve_conn_until_cancelled!(io, svc, cancel) {
1287                                    // HTTP/2 RST_STREAM errors from cancelled browser requests
1288                                    // (navigation, HMR) are normal — log at debug to avoid noise.
1289                                    log::debug!("Connection error: {e}");
1290                                }
1291                            }
1292                            Err(e) => {
1293                                log::debug!("TLS handshake error: {e}");
1294                            }
1295                        }
1296                    } else {
1297                        // Plain HTTP on the TLS port → 302 redirect to HTTPS.
1298                        // Nothing left to negotiate.
1299                        drop(handshake_permit);
1300                        let io = hyper_util::rt::TokioIo::new(stream);
1301                        let svc = hyper_util::service::TowerToHyperService::new(redirect_app);
1302                        let _ = serve_conn_until_cancelled!(io, svc, cancel);
1303                    }
1304                });
1305
1306                while conn_tasks.try_join_next().is_some() {}
1307            }
1308            _ = cancel.cancelled() => {
1309                log::info!("Proxy server shutting down (cancel signal received)");
1310                break;
1311            }
1312        }
1313    }
1314
1315    // One budget for both drains, not one each. The supervisor waits a fixed
1316    // time for this task; two independent timeouts could together outlast it,
1317    // so the second phase would be cut off rather than bounded.
1318    let deadline = tokio::time::Instant::now() + SHUTDOWN_DRAIN_BUDGET;
1319
1320    // In-flight connections first.
1321    let _ = tokio::time::timeout_at(deadline, async {
1322        while conn_tasks.join_next().await.is_some() {}
1323    })
1324    .await;
1325
1326    // Then the CONNECT tunnels. They were cancelled along with everything else
1327    // when the token fired, so this is waiting for them to let go of their
1328    // sockets rather than waiting for their peers to finish.
1329    plain_proxy_state.tunnels.close();
1330    let _ = tokio::time::timeout_at(deadline, plain_proxy_state.tunnels.wait()).await;
1331
1332    Ok(())
1333}
1334
1335/// How long to wait for a complete ClientHello before giving up on reading SNI.
1336///
1337/// A client sends its ClientHello immediately after the TCP handshake, so this
1338/// only ever expires on a stalled or malicious connection.
1339#[cfg(feature = "proxy-tls")]
1340const SNI_PEEK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1341
1342/// How much of the connection's opening bytes to examine while looking for SNI.
1343///
1344/// A ClientHello with a realistic extension set is well under 2 KiB; the cap
1345/// bounds the work done for a connection that never sends a parseable one.
1346#[cfg(feature = "proxy-tls")]
1347const SNI_PEEK_MAX_BYTES: usize = 16 * 1024;
1348
1349/// What peeking at a connection's opening bytes established about its hostname.
1350///
1351/// The third case is the one that matters: "no hostname" and "hostname unknown"
1352/// must not be treated alike, because a connection whose hostname could not be
1353/// read may well belong to a passthrough daemon.
1354#[cfg(feature = "proxy-tls")]
1355#[derive(Debug, PartialEq, Eq)]
1356enum SniProbe {
1357    /// The ClientHello named this host.
1358    Host(String),
1359    /// A complete ClientHello carried no SNI, or the connection is not a TLS
1360    /// handshake at all. Either way it cannot name a passthrough daemon.
1361    NoHost,
1362    /// No verdict: the hello stalled, exceeded the inspection window, or never
1363    /// reconciled its own length fields. Passthrough cannot be ruled out.
1364    Undetermined,
1365}
1366
1367/// Read the SNI hostname from a connection's ClientHello *without consuming
1368/// it*, so the same bytes are still available to whichever path handles the
1369/// connection.
1370///
1371/// `timeout` bounds the whole probe, including a read that never completes:
1372/// a peer that opens a connection and then sends nothing more is given up on
1373/// rather than holding the connection task open. A client that sends part of
1374/// a hello and then closes is noticed as soon as its close arrives, even
1375/// though `peek` keeps returning the buffered bytes rather than end of file,
1376/// and is reported as [`SniProbe::Undetermined`] without waiting out the
1377/// timeout.
1378#[cfg(feature = "proxy-tls")]
1379async fn peek_sni_host(stream: &TcpStream, timeout: std::time::Duration) -> SniProbe {
1380    use crate::proxy::sni::{SniPeek, parse_sni};
1381
1382    const MIN_PAUSE: std::time::Duration = std::time::Duration::from_millis(10);
1383    const MAX_PAUSE: std::time::Duration = std::time::Duration::from_millis(200);
1384
1385    let deadline = tokio::time::Instant::now() + timeout;
1386    let mut buf = vec![0u8; 2048];
1387    let mut last_n = 0;
1388    let mut pause = MIN_PAUSE;
1389
1390    loop {
1391        // The deadline has to bound the read itself: a peer that opens a
1392        // connection and then stops sending leaves `peek` waiting forever, and
1393        // the check further down never runs to end it.
1394        let n = match tokio::time::timeout_at(deadline, stream.peek(&mut buf)).await {
1395            // End of file with nothing buffered: the client hung up before
1396            // saying anything, so there is nothing to route and nothing to
1397            // downgrade.
1398            Ok(Ok(0)) => return SniProbe::NoHost,
1399            Ok(Ok(n)) => n,
1400            Ok(Err(e)) => {
1401                log::debug!("Failed to peek at a TLS connection: {e}");
1402                return SniProbe::Undetermined;
1403            }
1404            Err(_elapsed) => {
1405                log::debug!("Timed out waiting for a client that sent no ClientHello");
1406                return SniProbe::Undetermined;
1407            }
1408        };
1409        match parse_sni(&buf[..n]) {
1410            SniPeek::Found(host) => return SniProbe::Host(host),
1411            SniPeek::Absent | SniPeek::NotTls => return SniProbe::NoHost,
1412            SniPeek::Incomplete => {}
1413        }
1414
1415        // The hello may simply be longer than the window we looked at.
1416        if n == buf.len() && buf.len() < SNI_PEEK_MAX_BYTES {
1417            buf.resize((buf.len() * 2).min(SNI_PEEK_MAX_BYTES), 0);
1418            continue;
1419        }
1420        if n >= SNI_PEEK_MAX_BYTES {
1421            log::debug!("Giving up on SNI after {n} bytes without a complete ClientHello");
1422            return SniProbe::Undetermined;
1423        }
1424        if tokio::time::Instant::now() >= deadline {
1425            log::debug!("Timed out waiting for a complete ClientHello ({n} bytes read)");
1426            return SniProbe::Undetermined;
1427        }
1428        // Peeked data stays in the socket buffer, so the socket always reads as
1429        // readable and waiting on that would spin. Its readiness does record
1430        // the peer's close, though, which `peek` cannot report while bytes are
1431        // still buffered: a client that sent part of a hello and hung up will
1432        // never finish it.
1433        match stream.ready(tokio::io::Interest::READABLE).await {
1434            Ok(ready) if ready.is_read_closed() => {
1435                log::debug!("Client closed after {n} bytes of an incomplete ClientHello");
1436                return SniProbe::Undetermined;
1437            }
1438            Ok(_) => {}
1439            Err(e) => {
1440                log::debug!("Failed to poll a TLS connection: {e}");
1441                return SniProbe::Undetermined;
1442            }
1443        }
1444        // Re-peek soon while the hello is still arriving, and back off while
1445        // it has stalled, so a slow client is not polled hundreds of times.
1446        pause = if n > last_n {
1447            MIN_PAUSE
1448        } else {
1449            (pause * 2).min(MAX_PAUSE)
1450        };
1451        last_n = n;
1452        tokio::time::sleep_until((tokio::time::Instant::now() + pause).min(deadline)).await;
1453    }
1454}
1455
1456/// Splice a TLS connection straight through to its daemon.
1457///
1458/// Neither direction is inspected or rewritten, so the daemon terminates TLS
1459/// with its own certificate, negotiates its own ALPN (HTTP/2 and gRPC included)
1460/// and can require client certificates — none of which survive termination at
1461/// the proxy.
1462///
1463/// A stopped daemon is auto-started first and the connection is held until it
1464/// is ready, bounded by `proxy.auto_start_timeout`: a raw TLS stream has no
1465/// equivalent of the HTML "Starting…" page. When routing fails there is
1466/// likewise nothing to reply with, so the connection is closed and the reason
1467/// is logged.
1468#[cfg(feature = "proxy-tls")]
1469async fn serve_passthrough(mut stream: TcpStream, host: &str, tld: &str) {
1470    // Held until the splice ends: an open passthrough connection is activity
1471    // for as long as it is open, since the proxy cannot see the requests
1472    // inside it.
1473    let (port, _activity) = match resolve_passthrough_port(host, tld).await {
1474        Ok(ready) => ready,
1475        Err(msg) => {
1476            log::warn!("TLS passthrough for '{host}' failed: {msg}");
1477            return;
1478        }
1479    };
1480
1481    let addr = SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port));
1482    let mut backend = match connect_backend(addr).await {
1483        Ok(b) => b,
1484        Err(e) => {
1485            log::warn!("TLS passthrough for '{host}': failed to connect to {addr}: {e}");
1486            return;
1487        }
1488    };
1489
1490    log::debug!("TLS passthrough: splicing '{host}' to {addr}");
1491    if let Err(e) = tokio::io::copy_bidirectional(&mut stream, &mut backend).await {
1492        // A client or daemon closing one half mid-stream is ordinary.
1493        log::debug!("TLS passthrough for '{host}' ended: {e}");
1494    }
1495}
1496
1497/// How long to keep retrying a refused connection to a daemon that has just
1498/// been reported ready.
1499///
1500/// A daemon can be recorded as running and holding a port a moment before its
1501/// listener actually accepts. This window covers that gap without making a
1502/// genuinely dead port hang the client for the full auto-start budget.
1503#[cfg(feature = "proxy-tls")]
1504const PASSTHROUGH_CONNECT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
1505
1506/// Connect to a daemon's port, retrying a refused connection briefly.
1507#[cfg(feature = "proxy-tls")]
1508async fn connect_backend(addr: SocketAddr) -> std::io::Result<TcpStream> {
1509    let deadline = tokio::time::Instant::now() + PASSTHROUGH_CONNECT_GRACE;
1510    loop {
1511        match TcpStream::connect(addr).await {
1512            Ok(stream) => return Ok(stream),
1513            Err(e) => {
1514                let retryable = e.kind() == std::io::ErrorKind::ConnectionRefused;
1515                if !retryable || tokio::time::Instant::now() >= deadline {
1516                    return Err(e);
1517                }
1518                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1519            }
1520        }
1521    }
1522}
1523
1524/// Resolve the daemon port a passthrough hostname splices to, starting the
1525/// daemon if it is not running.
1526///
1527/// Waiting replaces the "Starting…" page that the HTTP path shows, and the
1528/// whole resolution — waiting behind another connection's start *and* running
1529/// one of its own — is bounded by a single `proxy.auto_start_timeout` budget,
1530/// which is what the documentation promises.
1531#[cfg(feature = "proxy-tls")]
1532async fn resolve_passthrough_port(
1533    host: &str,
1534    tld: &str,
1535) -> std::result::Result<(u16, Option<ActivityGuard>), String> {
1536    let budget = settings().proxy_auto_start_timeout();
1537    match tokio::time::timeout(budget, resolve_passthrough_port_inner(host, tld)).await {
1538        Ok(result) => result,
1539        Err(_elapsed) => Err(format!(
1540            "no daemon was ready for '{host}' within proxy.auto_start_timeout ({budget:?})"
1541        )),
1542    }
1543}
1544
1545/// Inner loop of [`resolve_passthrough_port`], wrapped by the caller so that
1546/// waiting and starting share one deadline.
1547#[cfg(feature = "proxy-tls")]
1548async fn resolve_passthrough_port_inner(
1549    host: &str,
1550    tld: &str,
1551) -> std::result::Result<(u16, Option<ActivityGuard>), String> {
1552    loop {
1553        match resolve_target(host, tld).await {
1554            ResolveResult::Ready(port, activity) => return Ok((port, activity)),
1555            // Another connection is already auto-starting this daemon, or it
1556            // is being stopped for inactivity; wait for that to finish rather
1557            // than starting a second copy. The caller's timeout ends this
1558            // wait.
1559            ResolveResult::Starting { slug: _ } => {
1560                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
1561            }
1562            ResolveResult::NotFound => {
1563                return Err(
1564                    "no running daemon with a port matched this hostname, and it could not \
1565                     be auto-started"
1566                        .to_string(),
1567                );
1568            }
1569            // A project or stack page is HTML the proxy serves itself, and an
1570            // unknown name has a page listing what does exist. Neither is
1571            // something a spliced TLS stream can carry, so the connection is
1572            // closed with the reason logged.
1573            ResolveResult::Page {
1574                project, worktree, ..
1575            } => {
1576                return Err(match worktree {
1577                    Some(worktree) => {
1578                        format!("'{worktree}' of project '{project}' is a stack page, not a daemon")
1579                    }
1580                    None => format!("'{project}' is a project page, not a daemon"),
1581                });
1582            }
1583            ResolveResult::Unknown { heading, .. } => return Err(heading),
1584            ResolveResult::Error(msg) => return Err(msg),
1585        }
1586    }
1587}
1588
1589/// Fallback when proxy-tls feature is not enabled.
1590#[cfg(not(feature = "proxy-tls"))]
1591#[allow(clippy::too_many_arguments)]
1592async fn serve_https_with_http_fallback(
1593    _app: Router,
1594    _addr: SocketAddr,
1595    _s: &crate::settings::Settings,
1596    _effective_port: u16,
1597    _effective_tld: String,
1598    _plain_proxy_state: ProxyState,
1599    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
1600    _cancel: tokio_util::sync::CancellationToken,
1601) -> crate::Result<()> {
1602    let msg = "HTTPS proxy support requires the `proxy-tls` feature.\n\
1603         Rebuild pitchfork with: cargo build --features proxy-tls"
1604        .to_string();
1605    let _ = bind_tx.send(Err(msg.clone()));
1606    miette::bail!("{msg}");
1607}
1608
1609/// Resolve the CA certificate and key paths from settings.
1610///
1611/// If `tls_cert` / `tls_key` are empty, falls back to the auto-generated
1612/// CA paths in `$PITCHFORK_STATE_DIR/proxy/`.
1613///
1614/// Setting only one of the two is refused. Filling the other half from the
1615/// generated CA would pair a user's key with pitchfork's certificate, or the
1616/// reverse, and when the CA does not exist yet, generating it would write a
1617/// new CA key over the user's file.
1618#[cfg(feature = "proxy-tls")]
1619fn resolve_tls_paths(
1620    s: &crate::settings::Settings,
1621) -> crate::Result<(std::path::PathBuf, std::path::PathBuf)> {
1622    if let Some(problem) = tls_pair_problem(&s.proxy.tls_cert, &s.proxy.tls_key) {
1623        miette::bail!("{problem}");
1624    }
1625    let proxy_dir = crate::env::PITCHFORK_STATE_DIR.join("proxy");
1626    let resolve = |configured: &str, default: &str| {
1627        if configured.is_empty() {
1628            proxy_dir.join(default)
1629        } else {
1630            std::path::PathBuf::from(configured)
1631        }
1632    };
1633    Ok((
1634        resolve(&s.proxy.tls_cert, "ca.pem"),
1635        resolve(&s.proxy.tls_key, "ca-key.pem"),
1636    ))
1637}
1638
1639/// Why `proxy.tls_cert` and `proxy.tls_key` cannot be used as configured:
1640/// they are a pair, so both are set or neither is.
1641pub(crate) fn tls_pair_problem(cert: &str, key: &str) -> Option<String> {
1642    match (cert.is_empty(), key.is_empty()) {
1643        (false, true) => Some(
1644            "proxy.tls_cert is set but proxy.tls_key is empty; set both, or neither to use \
1645             the generated CA"
1646                .to_string(),
1647        ),
1648        (true, false) => Some(
1649            "proxy.tls_key is set but proxy.tls_cert is empty; set both, or neither to use \
1650             the generated CA"
1651                .to_string(),
1652        ),
1653        _ => None,
1654    }
1655}
1656
1657/// Generate the CA pair unless `usable` says the one on disk will do.
1658///
1659/// `proxy setup` and a starting supervisor can both find the CA missing and
1660/// generate one at once. The cert and key are separate files, so two writers
1661/// interleaving can leave one's certificate beside the other's key: a pair
1662/// that trusts fine and then signs nothing that verifies. The check and the
1663/// write happen under one lock so only the first generation happens.
1664///
1665/// Returns whether a new pair was written.
1666#[cfg(feature = "proxy-tls")]
1667pub fn ensure_ca(
1668    cert_path: &std::path::Path,
1669    key_path: &std::path::Path,
1670    usable: impl FnOnce() -> bool,
1671) -> crate::Result<bool> {
1672    let _lock = xx::fslock::get(cert_path, false)
1673        .map_err(|e| miette::miette!("Failed to lock {}: {e}", cert_path.display()))?;
1674    if usable() {
1675        return Ok(false);
1676    }
1677    generate_ca(cert_path, key_path)?;
1678    // Leaves cached from a previous CA would still load — they are only
1679    // checked for expiry — and be served for names the new CA never signed.
1680    // They are all stale now, so they go.
1681    clear_host_certs(&host_certs_dir_for(cert_path), None);
1682    Ok(true)
1683}
1684
1685/// Where leaves signed by the CA at `ca_cert_path` are cached.
1686#[cfg(feature = "proxy-tls")]
1687fn host_certs_dir_for(ca_cert_path: &std::path::Path) -> std::path::PathBuf {
1688    ca_cert_path
1689        .parent()
1690        .unwrap_or(std::path::Path::new("."))
1691        .join("host-certs")
1692}
1693
1694/// Name of the cache subdirectory for leaves signed by the CA `ca_cert_pem`.
1695///
1696/// Leaves are kept per CA so that one signed by a replaced CA is never looked
1697/// up again, even if a running supervisor writes it after the cache was
1698/// cleared: loading only checks expiry, not the issuer. FNV-1a rather than
1699/// `DefaultHasher`, whose output may change between Rust releases and would
1700/// orphan the cache on every upgrade.
1701#[cfg(feature = "proxy-tls")]
1702fn ca_cache_id(ca_cert_pem: &str) -> String {
1703    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1704    for b in ca_cert_pem.bytes() {
1705        hash ^= u64::from(b);
1706        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1707    }
1708    format!("ca-{hash:016x}")
1709}
1710
1711/// Remove cached leaves under `root` that do not belong to the CA `keep`
1712/// names: loose `*.pem` files from the older flat layout, and every other
1713/// CA's subdirectory. Other files are left alone.
1714#[cfg(feature = "proxy-tls")]
1715fn clear_host_certs(root: &std::path::Path, keep: Option<&str>) {
1716    let Ok(entries) = std::fs::read_dir(root) else {
1717        return;
1718    };
1719    for entry in entries.filter_map(|e| e.ok()) {
1720        let path = entry.path();
1721        let name = entry.file_name();
1722        let result = if path.is_dir() {
1723            let is_ca_dir = name.to_str().is_some_and(|n| n.starts_with("ca-"));
1724            if !is_ca_dir || keep.is_some_and(|k| name == k) {
1725                continue;
1726            }
1727            std::fs::remove_dir_all(&path)
1728        } else if path.extension().is_some_and(|x| x == "pem") {
1729            std::fs::remove_file(&path)
1730        } else {
1731            continue;
1732        };
1733        if let Err(e) = result {
1734            log::debug!(
1735                "Could not remove stale cached certs {}: {e}",
1736                path.display()
1737            );
1738        }
1739    }
1740}
1741
1742/// Generate a local root CA certificate and private key using `rcgen`.
1743///
1744/// The CA is used to sign per-domain certificates on demand (SNI).
1745/// Files are written in PEM format to `cert_path` and `key_path`. Callers go
1746/// through [`ensure_ca`], which holds the CA lock around the check and this
1747/// write.
1748#[cfg(feature = "proxy-tls")]
1749fn generate_ca(cert_path: &std::path::Path, key_path: &std::path::Path) -> crate::Result<()> {
1750    use rcgen::{
1751        BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyUsagePurpose,
1752    };
1753
1754    // Create parent directory if needed
1755    if let Some(parent) = cert_path.parent() {
1756        std::fs::create_dir_all(parent)
1757            .map_err(|e| miette::miette!("Failed to create proxy cert directory: {e}"))?;
1758    }
1759
1760    let mut params = CertificateParams::default();
1761    let mut dn = DistinguishedName::new();
1762    dn.push(DnType::CommonName, "Pitchfork Local CA");
1763    dn.push(DnType::OrganizationName, "Pitchfork");
1764    params.distinguished_name = dn;
1765    params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
1766    params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
1767
1768    let key_pair = rcgen::KeyPair::generate()
1769        .map_err(|e| miette::miette!("Failed to generate CA key pair: {e}"))?;
1770    let ca_cert = params
1771        .self_signed(&key_pair)
1772        .map_err(|e| miette::miette!("Failed to self-sign CA certificate: {e}"))?;
1773
1774    // Write the CA certificate (public — 0644 is fine)
1775    std::fs::write(cert_path, ca_cert.pem()).map_err(|e| {
1776        miette::miette!(
1777            "Failed to write CA certificate to {}: {e}",
1778            cert_path.display()
1779        )
1780    })?;
1781
1782    // Write the CA private key with restrictive permissions (0600).
1783    // Using OpenOptions + mode() so the file is never world-readable,
1784    // even briefly before a chmod call.
1785    {
1786        #[cfg(unix)]
1787        {
1788            use std::io::Write;
1789            use std::os::unix::fs::OpenOptionsExt;
1790            std::fs::OpenOptions::new()
1791                .write(true)
1792                .create(true)
1793                .truncate(true)
1794                .mode(0o600)
1795                .open(key_path)
1796                .and_then(|mut f| f.write_all(key_pair.serialize_pem().as_bytes()))
1797                .map_err(|e| {
1798                    miette::miette!("Failed to write CA key to {}: {e}", key_path.display())
1799                })?;
1800        }
1801        #[cfg(not(unix))]
1802        {
1803            std::fs::write(key_path, key_pair.serialize_pem()).map_err(|e| {
1804                miette::miette!("Failed to write CA key to {}: {e}", key_path.display())
1805            })?;
1806            log::debug!(
1807                "CA private key written to {} (file permissions are not restricted \
1808                 on non-Unix platforms — consider restricting access manually)",
1809                key_path.display()
1810            );
1811        }
1812    }
1813
1814    Ok(())
1815}
1816
1817/// Resolver that serves one configured certificate for every connection.
1818///
1819/// Used when `proxy.tls_cert` / `proxy.tls_key` are set. No certificate is
1820/// minted: whatever the user configured is what clients see, for every SNI
1821/// name.
1822#[cfg(feature = "proxy-tls")]
1823struct StaticCertResolver {
1824    certified: Arc<rustls::sign::CertifiedKey>,
1825    /// TLD hostnames are resolved against, so a passthrough hostname is
1826    /// refused here too rather than answered with the configured certificate.
1827    tld: String,
1828}
1829
1830#[cfg(feature = "proxy-tls")]
1831impl std::fmt::Debug for StaticCertResolver {
1832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1833        f.debug_struct("StaticCertResolver").finish_non_exhaustive()
1834    }
1835}
1836
1837#[cfg(feature = "proxy-tls")]
1838impl StaticCertResolver {
1839    fn new(
1840        cert_path: &std::path::Path,
1841        key_path: &std::path::Path,
1842        tld: String,
1843    ) -> crate::Result<Self> {
1844        use rustls::pki_types::CertificateDer;
1845        use rustls_pemfile::{certs, private_key};
1846
1847        let cert_pem = std::fs::read(cert_path).map_err(|e| {
1848            miette::miette!("Failed to read proxy.tls_cert {}: {e}", cert_path.display())
1849        })?;
1850        let key_pem = std::fs::read(key_path).map_err(|e| {
1851            miette::miette!("Failed to read proxy.tls_key {}: {e}", key_path.display())
1852        })?;
1853
1854        let cert_ders: Vec<CertificateDer<'static>> = certs(&mut cert_pem.as_slice())
1855            .collect::<Result<Vec<_>, _>>()
1856            .map_err(|e| miette::miette!("Failed to parse {}: {e}", cert_path.display()))?;
1857        if cert_ders.is_empty() {
1858            miette::bail!("No certificates found in {}", cert_path.display());
1859        }
1860
1861        let key_der = private_key(&mut key_pem.as_slice())
1862            .map_err(|e| miette::miette!("Failed to parse {}: {e}", key_path.display()))?
1863            .ok_or_else(|| miette::miette!("No private key found in {}", key_path.display()))?;
1864        let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
1865            .map_err(|e| miette::miette!("Failed to use the configured private key: {e}"))?;
1866
1867        let certified = rustls::sign::CertifiedKey::new(cert_ders, signing_key);
1868        // `CertifiedKey::new` only packages the two; it does not check that the
1869        // key belongs to the certificate. Without this the listener would start
1870        // cleanly and then fail every single handshake.
1871        certified.keys_match().map_err(|e| {
1872            miette::miette!(
1873                "proxy.tls_key {} does not match proxy.tls_cert {}: {e}",
1874                key_path.display(),
1875                cert_path.display()
1876            )
1877        })?;
1878
1879        Ok(Self {
1880            certified: Arc::new(certified),
1881            tld,
1882        })
1883    }
1884}
1885
1886#[cfg(feature = "proxy-tls")]
1887impl rustls::server::ResolvesServerCert for StaticCertResolver {
1888    fn resolve(
1889        &self,
1890        client_hello: rustls::server::ClientHello<'_>,
1891    ) -> Option<Arc<rustls::sign::CertifiedKey>> {
1892        // As in `SniCertResolver::resolve`: a passthrough hostname whose hello
1893        // could not be read before the handshake must fail rather than be
1894        // answered with a certificate that is not the daemon's.
1895        if let Some(domain) = client_hello.server_name()
1896            && resolve_tls_mode_in(domain, &self.tld, &slug_snapshot(), &registry_snapshot())
1897                .is_passthrough()
1898        {
1899            log::warn!(
1900                "Refusing to terminate TLS for '{domain}', which is configured for \
1901                 proxy_tls = \"passthrough\": its ClientHello could not be inspected before the \
1902                 handshake, so the stream could not be spliced to the daemon."
1903            );
1904            return None;
1905        }
1906        Some(Arc::clone(&self.certified))
1907    }
1908}
1909
1910/// SNI-based certificate resolver.
1911///
1912/// Holds the local CA and a two-level cache of per-domain certificates:
1913/// - L1: in-memory `HashMap` (fastest, process-lifetime)
1914/// - L2: on-disk `host-certs/<safe_name>.pem` (survives restarts)
1915///
1916/// A `pending` set prevents concurrent requests for the same domain from
1917/// triggering multiple simultaneous cert-generation operations.
1918///
1919/// On each new TLS connection, `resolve()` is called with the SNI hostname;
1920/// if no cached cert exists for that domain, one is signed by the CA on the fly.
1921///
1922/// # Locking strategy
1923/// Both `cache` and `pending` use `std::sync::Mutex` paired with a
1924/// `std::sync::Condvar`.  The critical sections are intentionally short
1925/// (hash-map lookups / inserts), so the blocking time is negligible.
1926/// `get_or_create` is only called from the synchronous `ResolvesServerCert`
1927/// trait method (not from an async context), so blocking a thread here is
1928/// acceptable.
1929#[cfg(feature = "proxy-tls")]
1930struct SniCertResolver {
1931    /// The CA issuer (key + parsed cert params, used to sign leaf certs).
1932    issuer: rcgen::Issuer<'static, rcgen::KeyPair>,
1933    /// TLD this CA is willing to sign for; anything else is refused. Also the
1934    /// TLD hostnames are resolved against, so a passthrough hostname can be
1935    /// recognized before a certificate is issued for it.
1936    tld: String,
1937    /// Directory where per-domain PEM files are cached on disk.
1938    host_certs_dir: std::path::PathBuf,
1939    /// L1 cache: domain → certified key (in-memory), with insertion order kept
1940    /// alongside so the oldest can be evicted once the cache is full.
1941    cache: std::sync::Mutex<CertCache>,
1942    /// Pending set: domains currently being generated (dedup concurrent requests).
1943    /// Using a `Condvar` so waiting threads are parked instead of spin-sleeping,
1944    /// which avoids blocking tokio worker threads.
1945    pending: std::sync::Mutex<std::collections::HashSet<String>>,
1946    /// Condvar paired with `pending` — notified when a domain is removed from the set.
1947    pending_cv: std::sync::Condvar,
1948}
1949
1950/// Delete the oldest cached certificates until at most [`MAX_HOST_CERTS`] remain.
1951///
1952/// Called at startup, because eviction during a run only knows about names that
1953/// run has seen. Oldest is by modification time, which is when the certificate
1954/// was minted, so the ones most recently useful survive.
1955#[cfg(feature = "proxy-tls")]
1956fn prune_host_certs(dir: &std::path::Path) {
1957    let Ok(entries) = std::fs::read_dir(dir) else {
1958        return;
1959    };
1960    let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = entries
1961        .filter_map(|e| e.ok())
1962        .filter(|e| e.path().extension().is_some_and(|x| x == "pem"))
1963        .filter_map(|e| {
1964            let modified = e.metadata().and_then(|m| m.modified()).ok()?;
1965            Some((modified, e.path()))
1966        })
1967        .collect();
1968    if files.len() <= MAX_HOST_CERTS {
1969        return;
1970    }
1971    files.sort_by_key(|(t, _)| *t);
1972    let excess = files.len() - MAX_HOST_CERTS;
1973    for (_, path) in files.into_iter().take(excess) {
1974        if let Err(e) = std::fs::remove_file(&path) {
1975            log::debug!("Could not prune cached cert {}: {e}", path.display());
1976        }
1977    }
1978}
1979
1980/// Minted certificates, bounded and evicted oldest-first.
1981#[cfg(feature = "proxy-tls")]
1982#[derive(Default)]
1983struct CertCache {
1984    by_domain: std::collections::HashMap<String, Arc<rustls::sign::CertifiedKey>>,
1985    /// Insertion order, oldest first.
1986    order: std::collections::VecDeque<String>,
1987}
1988
1989#[cfg(feature = "proxy-tls")]
1990impl CertCache {
1991    fn get(&self, domain: &str) -> Option<&Arc<rustls::sign::CertifiedKey>> {
1992        self.by_domain.get(domain)
1993    }
1994
1995    /// Insert `key`, evicting the oldest entry when full.
1996    ///
1997    /// Returns the evicted domain, whose on-disk copy the caller removes.
1998    fn insert(&mut self, domain: String, key: Arc<rustls::sign::CertifiedKey>) -> Option<String> {
1999        if self.by_domain.insert(domain.clone(), key).is_none() {
2000            self.order.push_back(domain);
2001        }
2002        if self.by_domain.len() <= MAX_HOST_CERTS {
2003            return None;
2004        }
2005        let evicted = self.order.pop_front()?;
2006        self.by_domain.remove(&evicted);
2007        Some(evicted)
2008    }
2009}
2010
2011#[cfg(feature = "proxy-tls")]
2012impl std::fmt::Debug for SniCertResolver {
2013    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2014        f.debug_struct("SniCertResolver").finish_non_exhaustive()
2015    }
2016}
2017
2018#[cfg(feature = "proxy-tls")]
2019impl SniCertResolver {
2020    /// Load the CA from disk and prepare the resolver.
2021    fn new(
2022        ca_cert_path: &std::path::Path,
2023        ca_key_path: &std::path::Path,
2024        tld: String,
2025    ) -> crate::Result<Self> {
2026        let ca_key_pem = std::fs::read_to_string(ca_key_path)
2027            .map_err(|e| miette::miette!("Failed to read CA key {}: {e}", ca_key_path.display()))?;
2028        let ca_cert_pem = std::fs::read_to_string(ca_cert_path).map_err(|e| {
2029            miette::miette!("Failed to read CA cert {}: {e}", ca_cert_path.display())
2030        })?;
2031
2032        // Verify the PEM is readable (sanity check)
2033        if !ca_cert_pem.contains("BEGIN CERTIFICATE") {
2034            miette::bail!("CA cert file does not contain a valid PEM certificate");
2035        }
2036
2037        let ca_key = rcgen::KeyPair::from_pem(&ca_key_pem)
2038            .map_err(|e| miette::miette!("Failed to parse CA key: {e}"))?;
2039
2040        // Parse the CA cert + key into an Issuer for signing leaf certs.
2041        let issuer = rcgen::Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)
2042            .map_err(|e| miette::miette!("Failed to parse CA cert: {e}"))?;
2043
2044        // Leaves live in a subdirectory for this CA, and those of any other CA
2045        // are cleared, so a leaf from a replaced CA can never be served.
2046        let host_certs_root = host_certs_dir_for(ca_cert_path);
2047        let cache_id = ca_cache_id(&ca_cert_pem);
2048        clear_host_certs(&host_certs_root, Some(&cache_id));
2049        let host_certs_dir = host_certs_root.join(&cache_id);
2050        std::fs::create_dir_all(&host_certs_dir)
2051            .map_err(|e| miette::miette!("Failed to create host-certs dir: {e}"))?;
2052        // The in-memory cache starts empty on every run, so without this the
2053        // directory would keep files from previous processes for ever and grow
2054        // across restarts however well eviction works within one.
2055        prune_host_certs(&host_certs_dir);
2056
2057        Ok(Self {
2058            issuer,
2059            tld,
2060            host_certs_dir,
2061            cache: std::sync::Mutex::new(CertCache::default()),
2062            pending: std::sync::Mutex::new(std::collections::HashSet::new()),
2063            pending_cv: std::sync::Condvar::new(),
2064        })
2065    }
2066
2067    /// `get_or_create`, but only for names this CA is allowed to sign.
2068    ///
2069    /// The CA is installed in the machine's trust store and the proxy answers on
2070    /// 127.0.0.1, so without this check any name that resolves to loopback — an
2071    /// /etc/hosts line, a rebinding answer, a poisoned resolver — could obtain a
2072    /// browser-trusted certificate for itself and its parent wildcard just by
2073    /// sending it as SNI. Returning `None` fails the handshake and writes
2074    /// nothing to the on-disk cache.
2075    fn get_or_create_checked(&self, domain: &str) -> Option<Arc<rustls::sign::CertifiedKey>> {
2076        if !crate::proxy::owns_name(&self.tld, domain) {
2077            // Throttled for the same reason the refusal exists: anything that
2078            // can reach the listener can ask for any name, repeatedly.
2079            if let Some(suppressed) = REFUSED_SNI.allow(REFUSAL_LOG_INTERVAL) {
2080                log::warn!(
2081                    "Refusing to issue a certificate for {domain:?}: \
2082                     the pitchfork CA only signs names under .{} \
2083                     ({suppressed} similar refusals since the last message)",
2084                    self.tld
2085                );
2086            }
2087            return None;
2088        }
2089        self.get_or_create(domain)
2090    }
2091
2092    /// Get or create a `CertifiedKey` for the given domain.
2093    ///
2094    /// Resolution order:
2095    /// 1. L1 in-memory cache
2096    /// 2. L2 on-disk cache (`host-certs/<safe_name>.pem`)
2097    /// 3. Generate fresh cert, persist to disk, populate both caches
2098    ///
2099    /// Concurrent requests for the same domain are deduplicated: the second
2100    /// thread waits on a `Condvar` until the first thread finishes, then reads
2101    /// from the cache.  This avoids both duplicate cert generation and the
2102    /// spin-sleep anti-pattern that would block tokio worker threads.
2103    ///
2104    /// # Locking discipline
2105    /// `cache` and `pending` are **never held simultaneously**.  The protocol is:
2106    /// 1. Check `cache` (lock, read, unlock).
2107    /// 2. Acquire `pending`; wait if domain is in-progress; re-check `cache`
2108    ///    after waking (unlock `cache` before re-acquiring `pending` is not
2109    ///    needed because we release `cache` before entering the `pending` block).
2110    /// 3. Insert domain into `pending`; release `pending` lock.
2111    /// 4. Generate cert (no locks held).
2112    /// 5. Insert into `cache` (lock, write, unlock).
2113    /// 6. Remove from `pending` and notify (lock, write, unlock).
2114    fn get_or_create(&self, domain: &str) -> Option<Arc<rustls::sign::CertifiedKey>> {
2115        // L1: memory cache (fast path — no pending lock needed)
2116        {
2117            let cache = self.cache.lock().ok()?;
2118            if let Some(ck) = cache.get(domain) {
2119                return Some(Arc::clone(ck));
2120            }
2121        } // cache lock released here
2122
2123        // Dedup: acquire the pending lock, wait if another thread is generating
2124        // this domain, then re-check the cache (without holding pending) before
2125        // deciding to generate.
2126        //
2127        // We deliberately release the pending lock before re-checking the cache
2128        // to avoid holding both locks simultaneously.  The re-check is safe
2129        // because: if the generating thread inserted into the cache and then
2130        // removed from pending, we will see the cert in the cache.  If we miss
2131        // the window (extremely unlikely), we will generate a duplicate cert,
2132        // which is harmless — the last writer wins in the cache.
2133        loop {
2134            {
2135                let mut pending = self.pending.lock().ok()?;
2136                if pending.contains(domain) {
2137                    // Another thread is generating; wait until it finishes.
2138                    pending = self.pending_cv.wait(pending).ok()?;
2139                    // pending lock re-acquired; loop to re-check cache below.
2140                    drop(pending);
2141                } else {
2142                    // No one else is generating; claim the slot and proceed.
2143                    pending.insert(domain.to_string());
2144                    break;
2145                }
2146            } // pending lock released
2147
2148            // Re-check cache after being woken (the generating thread may have
2149            // already populated it).  Cache lock is acquired independently of
2150            // pending lock here — no nesting.
2151            {
2152                let cache = self.cache.lock().ok()?;
2153                if let Some(ck) = cache.get(domain) {
2154                    return Some(Arc::clone(ck));
2155                }
2156            } // cache lock released
2157        } // pending lock released at break
2158
2159        let result = self.get_or_create_inner(domain);
2160
2161        // Always clear the pending flag and wake waiting threads.
2162        // notify_all() is called *inside* the lock scope so that the domain is
2163        // guaranteed to be removed before any waiting thread is woken up.
2164        // If the lock is poisoned we recover it (the data is still valid) so
2165        // that the domain is always removed and waiters are always notified.
2166        {
2167            let mut pending = match self.pending.lock() {
2168                Ok(g) => g,
2169                Err(e) => e.into_inner(),
2170            };
2171            pending.remove(domain);
2172            self.pending_cv.notify_all();
2173        }
2174
2175        result
2176    }
2177
2178    /// Inner implementation: check disk cache, then generate.
2179    fn get_or_create_inner(&self, domain: &str) -> Option<Arc<rustls::sign::CertifiedKey>> {
2180        let disk_path = self.disk_path(domain);
2181
2182        // L2: disk cache — try to load existing cert+key PEM
2183        if disk_path.exists() {
2184            if let Ok(ck) = self.load_from_disk(&disk_path) {
2185                let ck = Arc::new(ck);
2186                self.remember(domain, &ck);
2187                return Some(ck);
2188            }
2189            // Disk cache corrupt/expired — fall through to regenerate
2190            let _ = std::fs::remove_file(&disk_path);
2191        }
2192
2193        // L3: generate fresh cert
2194        let ck = self.sign_for_domain(domain).ok()?;
2195
2196        let ck = Arc::new(ck);
2197        self.remember(domain, &ck);
2198        Some(ck)
2199    }
2200
2201    /// Cache `ck` for `domain`, dropping the oldest entry when full.
2202    ///
2203    /// The evicted certificate's file is removed too, so the on-disk cache
2204    /// stays bounded alongside the in-memory one.
2205    fn remember(&self, domain: &str, ck: &Arc<rustls::sign::CertifiedKey>) {
2206        let evicted = match self.cache.lock() {
2207            Ok(mut cache) => cache.insert(domain.to_string(), Arc::clone(ck)),
2208            Err(_) => return,
2209        };
2210        if let Some(evicted) = evicted {
2211            let path = self.disk_path(&evicted);
2212            if let Err(e) = std::fs::remove_file(&path)
2213                && e.kind() != std::io::ErrorKind::NotFound
2214            {
2215                log::debug!("Could not evict cached cert {}: {e}", path.display());
2216            }
2217        }
2218    }
2219
2220    /// Where the cached certificate for `domain` lives.
2221    fn disk_path(&self, domain: &str) -> std::path::PathBuf {
2222        self.host_certs_dir
2223            .join(format!("{}.pem", cert_cache_file_stem(domain)))
2224    }
2225
2226    /// Load a `CertifiedKey` from a combined cert+key PEM file on disk.
2227    ///
2228    /// Returns an error if the certificate has already expired, so the caller
2229    /// can fall through to regeneration rather than serving a stale cert.
2230    fn load_from_disk(&self, path: &std::path::Path) -> crate::Result<rustls::sign::CertifiedKey> {
2231        use rustls::pki_types::CertificateDer;
2232        use rustls_pemfile::{certs, private_key};
2233
2234        let pem = std::fs::read_to_string(path)
2235            .map_err(|e| miette::miette!("Failed to read disk cert {}: {e}", path.display()))?;
2236
2237        let cert_ders: Vec<CertificateDer<'static>> = certs(&mut pem.as_bytes())
2238            .collect::<Result<Vec<_>, _>>()
2239            .map_err(|e| miette::miette!("Failed to parse certs from {}: {e}", path.display()))?;
2240
2241        if cert_ders.is_empty() {
2242            miette::bail!("No certificates found in {}", path.display());
2243        }
2244
2245        // Check that the first certificate has not expired using x509-parser.
2246        {
2247            let (_, cert) = x509_parser::parse_x509_certificate(&cert_ders[0]).map_err(|e| {
2248                miette::miette!("Failed to parse certificate from {}: {e}", path.display())
2249            })?;
2250            use chrono::Utc;
2251            let now_ts = Utc::now().timestamp();
2252            let not_after_ts = cert.validity().not_after.timestamp();
2253            if not_after_ts < now_ts {
2254                miette::bail!(
2255                    "Cached certificate at {} has expired — will regenerate",
2256                    path.display()
2257                );
2258            }
2259        }
2260
2261        let key_der = private_key(&mut pem.as_bytes())
2262            .map_err(|e| miette::miette!("Failed to parse key from {}: {e}", path.display()))?
2263            .ok_or_else(|| miette::miette!("No private key found in {}", path.display()))?;
2264
2265        let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
2266            .map_err(|e| miette::miette!("Failed to create signing key from disk: {e}"))?;
2267
2268        Ok(rustls::sign::CertifiedKey::new(cert_ders, signing_key))
2269    }
2270
2271    /// Sign a leaf certificate for `domain` using the CA.
2272    ///
2273    /// SANs include:
2274    /// - `DNS:<domain>` (exact match)
2275    /// - `DNS:*.<parent>` (sibling wildcard, e.g. `*.pf.localhost` for `docs.pf.localhost`)
2276    ///
2277    /// Returns both the `CertifiedKey` and the combined PEM for disk caching.
2278    fn sign_for_domain(&self, domain: &str) -> crate::Result<rustls::sign::CertifiedKey> {
2279        use rcgen::date_time_ymd;
2280        use rcgen::{CertificateParams, DistinguishedName, DnType, SanType};
2281        use rustls::pki_types::CertificateDer;
2282        use rustls_pemfile::private_key;
2283
2284        let mut params = CertificateParams::default();
2285        let mut dn = DistinguishedName::new();
2286        dn.push(DnType::CommonName, domain);
2287        params.distinguished_name = dn;
2288
2289        // Set validity dynamically: from yesterday to 10 years from now.
2290        {
2291            use chrono::{Datelike, Duration, Utc};
2292            let yesterday = Utc::now() - Duration::days(1);
2293            // 397 days: stays within Chrome/Safari's 398-day maximum validity limit
2294            // for TLS certificates (including locally-trusted CA leaf certs).
2295            let expiry = Utc::now() + Duration::days(397);
2296            params.not_before = date_time_ymd(
2297                yesterday.year(),
2298                yesterday.month() as u8,
2299                yesterday.day() as u8,
2300            );
2301            params.not_after =
2302                date_time_ymd(expiry.year(), expiry.month() as u8, expiry.day() as u8);
2303        }
2304
2305        // Build SANs: exact domain + sibling wildcard (e.g. *.pf.localhost)
2306        let mut sans =
2307            vec![SanType::DnsName(domain.to_string().try_into().map_err(
2308                |e| miette::miette!("Invalid domain name '{domain}': {e}"),
2309            )?)];
2310        // Sibling wildcard for the parent domain, one level up.
2311        //
2312        // Only when the parent is strictly inside the TLD. `*.<tld>` would cover
2313        // every name the proxy serves, which is far broader than the one host
2314        // this certificate is for, and a parent outside the TLD is not ours to
2315        // claim at all.
2316        if let Some(dot_pos) = domain.find('.') {
2317            let parent = &domain[dot_pos + 1..];
2318            if crate::proxy::is_strictly_under_tld(&self.tld, parent) {
2319                let wildcard = format!("*.{parent}");
2320                if let Ok(wc) = wildcard.try_into() {
2321                    sans.push(SanType::DnsName(wc));
2322                }
2323            }
2324        }
2325        params.subject_alt_names = sans;
2326
2327        let leaf_key = rcgen::KeyPair::generate()
2328            .map_err(|e| miette::miette!("Failed to generate leaf key: {e}"))?;
2329        let leaf_cert = params
2330            .signed_by(&leaf_key, &self.issuer)
2331            .map_err(|e| miette::miette!("Failed to sign leaf cert for '{domain}': {e}"))?;
2332
2333        // Convert to rustls types
2334        let cert_der = CertificateDer::from(leaf_cert.der().to_vec());
2335        let key_pem = leaf_key.serialize_pem();
2336        let key_der = private_key(&mut key_pem.as_bytes())
2337            .map_err(|e| miette::miette!("Failed to parse leaf key PEM: {e}"))?
2338            .ok_or_else(|| miette::miette!("No private key found in generated PEM"))?;
2339
2340        let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
2341            .map_err(|e| miette::miette!("Failed to create signing key: {e}"))?;
2342
2343        // Persist cert + key to disk cache as combined PEM.
2344        // Use 0600 so the private key is not world-readable.
2345        let disk_path = self.disk_path(domain);
2346        let combined_pem = format!("{}{}", leaf_cert.pem(), key_pem);
2347        {
2348            #[cfg(unix)]
2349            {
2350                use std::io::Write;
2351                use std::os::unix::fs::OpenOptionsExt;
2352                if let Err(e) = std::fs::OpenOptions::new()
2353                    .write(true)
2354                    .create(true)
2355                    .truncate(true)
2356                    .mode(0o600)
2357                    .open(&disk_path)
2358                    .and_then(|mut f| f.write_all(combined_pem.as_bytes()))
2359                {
2360                    log::warn!(
2361                        "Failed to persist cert for '{domain}' to {}: {e}",
2362                        disk_path.display()
2363                    );
2364                }
2365            }
2366            #[cfg(not(unix))]
2367            {
2368                if let Err(e) = std::fs::write(&disk_path, combined_pem) {
2369                    log::warn!(
2370                        "Failed to persist cert for '{domain}' to {}: {e}",
2371                        disk_path.display()
2372                    );
2373                } else {
2374                    log::debug!(
2375                        "Leaf cert for '{domain}' written to {} (file permissions are not \
2376                         restricted on non-Unix platforms — consider restricting access manually)",
2377                        disk_path.display()
2378                    );
2379                }
2380            }
2381        }
2382
2383        Ok(rustls::sign::CertifiedKey::new(vec![cert_der], signing_key))
2384    }
2385}
2386
2387#[cfg(feature = "proxy-tls")]
2388impl rustls::server::ResolvesServerCert for SniCertResolver {
2389    fn resolve(
2390        &self,
2391        client_hello: rustls::server::ClientHello<'_>,
2392    ) -> Option<Arc<rustls::sign::CertifiedKey>> {
2393        let domain = client_hello.server_name()?;
2394        // This is the last point where a passthrough hostname can still be
2395        // caught, and the first where the host name is authoritative: rustls
2396        // has reassembled the handshake itself. Issuing here would answer with
2397        // the proxy's certificate in place of the daemon's and drop the
2398        // client-certificate request, so the handshake is failed instead.
2399        // Reached only when the pre-handshake peek could not read the hello.
2400        if resolve_tls_mode_in(domain, &self.tld, &slug_snapshot(), &registry_snapshot())
2401            .is_passthrough()
2402        {
2403            log::warn!(
2404                "Refusing to terminate TLS for '{domain}', which is configured for \
2405                 proxy_tls = \"passthrough\": its ClientHello could not be inspected before the \
2406                 handshake, so the stream could not be spliced to the daemon."
2407            );
2408            return None;
2409        }
2410
2411        // Refuse to sign for a name outside the proxy's own TLD.
2412        //
2413        // The CA is installed in the machine's trust store and the proxy answers
2414        // on 127.0.0.1, so without this check any name that resolves to loopback
2415        // — an /etc/hosts line, a rebinding answer, a poisoned resolver — could
2416        // obtain a browser-trusted certificate for itself and its parent
2417        // wildcard just by sending it as SNI. Returning `None` fails the
2418        // handshake and writes nothing to the on-disk cache.
2419        self.get_or_create_checked(domain)
2420    }
2421}
2422
2423/// Why the CA pair at `cert` and `key` cannot sign certificates, if it cannot.
2424///
2425/// Existence is not enough: a truncated file or a key that belongs to another
2426/// certificate stops the HTTPS listener from starting, and trusting such a
2427/// certificate would install something the proxy never serves from.
2428#[cfg(feature = "proxy-tls")]
2429pub(crate) fn ca_pair_problem(cert: &std::path::Path, key: &std::path::Path) -> Option<String> {
2430    use rcgen::PublicKeyData;
2431
2432    let cert_pem = match std::fs::read_to_string(cert) {
2433        Ok(p) => p,
2434        Err(e) => return Some(format!("cannot read {}: {e}", cert.display())),
2435    };
2436    let key_pem = match std::fs::read_to_string(key) {
2437        Ok(p) => p,
2438        Err(e) => return Some(format!("cannot read {}: {e}", key.display())),
2439    };
2440    let key_pair = match rcgen::KeyPair::from_pem(&key_pem) {
2441        Ok(k) => k,
2442        Err(e) => return Some(format!("cannot parse {}: {e}", key.display())),
2443    };
2444    let Some(Ok(der)) = rustls_pemfile::certs(&mut cert_pem.as_bytes()).next() else {
2445        return Some(format!("no certificate in {}", cert.display()));
2446    };
2447    let parsed = match x509_parser::parse_x509_certificate(&der) {
2448        Ok((_, c)) => c,
2449        Err(e) => return Some(format!("cannot parse {}: {e}", cert.display())),
2450    };
2451    if parsed.public_key().subject_public_key.data.as_ref() != key_pair.der_bytes() {
2452        return Some(format!(
2453            "{} is not the key for {}",
2454            key.display(),
2455            cert.display()
2456        ));
2457    }
2458    if let Err(e) = rcgen::Issuer::from_ca_cert_pem(&cert_pem, key_pair) {
2459        return Some(format!("{} cannot sign certificates: {e}", cert.display()));
2460    }
2461    None
2462}
2463
2464/// A file name for `domain`'s cached certificate, distinct for distinct names.
2465///
2466/// Letters, digits, `-` and `.` are kept, since they are safe in a file name
2467/// and make the cache readable; every other byte is percent-encoded. Mapping
2468/// `.` to `_`, as this once did, sent `a_b.localhost` and `a.b.localhost` to
2469/// the same file, so one name's certificate could be served for the other.
2470#[cfg(feature = "proxy-tls")]
2471fn cert_cache_file_stem(domain: &str) -> String {
2472    let mut out = String::with_capacity(domain.len());
2473    for b in domain.bytes() {
2474        if b.is_ascii_alphanumeric() || b == b'-' || b == b'.' {
2475            out.push(char::from(b));
2476        } else {
2477            out.push_str(&format!("%{b:02X}"));
2478        }
2479    }
2480    out
2481}
2482
2483/// State for the plain-HTTP side of the TLS listener.
2484#[derive(Clone)]
2485struct PlainState {
2486    tld: String,
2487    port: u16,
2488    /// Carried so `CONNECT` can be tunnelled without re-deriving the routing
2489    /// configuration.
2490    proxy: ProxyState,
2491}
2492
2493/// Plain HTTP arriving on the HTTPS port.
2494///
2495/// `CONNECT` is tunnelled, because that is how a PAC-configured browser opens
2496/// an `https://` URL. Everything else is redirected to HTTPS.
2497async fn plain_fallback_handler(State(state): State<PlainState>, req: Request) -> Response {
2498    if req.method() == axum::http::Method::CONNECT {
2499        let raw_host = get_request_host(&req).unwrap_or_default();
2500        return connect_handler(&state.proxy, req, &raw_host).await;
2501    }
2502    redirect_to_https_handler(req).await
2503}
2504
2505/// Render the PAC script, or an error page explaining why it could not be.
2506/// Refuse a write method aimed at the PAC file.
2507///
2508/// A PAC file is fetched, never written, so anything but `GET` or `HEAD` is a
2509/// mistake. Returning `Some` here rather than restricting the route keeps the
2510/// path available to proxied hostnames under every method.
2511fn reject_non_read(method: &axum::http::Method) -> Option<Response> {
2512    if matches!(*method, axum::http::Method::GET | axum::http::Method::HEAD) {
2513        return None;
2514    }
2515    let mut res = error_response(
2516        StatusCode::METHOD_NOT_ALLOWED,
2517        "the proxy auto-config file is read-only\n",
2518    );
2519    res.headers_mut().insert(
2520        axum::http::header::ALLOW,
2521        HeaderValue::from_static("GET, HEAD"),
2522    );
2523    Some(res)
2524}
2525
2526fn pac_response(tld: &str, host: &str, port: u16) -> Response {
2527    match crate::proxy::pac::generate(tld, host, port) {
2528        Ok(body) => (
2529            StatusCode::OK,
2530            [(
2531                axum::http::header::CONTENT_TYPE,
2532                "application/x-ns-proxy-autoconfig",
2533            )],
2534            body,
2535        )
2536            .into_response(),
2537        Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
2538    }
2539}
2540
2541/// Serve `/proxy.pac` on the proxy listener.
2542///
2543/// A request addressed to a name *beneath* the TLD is a normal proxied request
2544/// that happens to use this path, so it is forwarded to the backend instead.
2545/// The TLD apex is not: no slug can route it, so `http://localhost/proxy.pac`
2546/// with `proxy.tld = "localhost"` is a request for the PAC file.
2547async fn pac_handler(State(state): State<ProxyState>, req: Request) -> Response {
2548    let host = get_request_host(&req).unwrap_or_default();
2549    let bare = host.split(':').next().unwrap_or("");
2550    if !bare.is_empty() && crate::proxy::is_strictly_under_tld(&state.tld, bare) {
2551        return proxy_handler(State(state), req).await;
2552    }
2553    // Only now is this known to be a request for the PAC file itself, so the
2554    // method check belongs here rather than on the route.
2555    if let Some(deny) = reject_non_read(req.method()) {
2556        return deny;
2557    }
2558    let port = req
2559        .uri()
2560        .authority()
2561        .and_then(|a| a.port_u16())
2562        .or_else(|| host.rsplit(':').next().and_then(|p| p.parse().ok()))
2563        .unwrap_or(if state.is_tls { 443 } else { 80 });
2564    pac_response(&state.tld, &url_host(state.contact_ip), port)
2565}
2566
2567/// Serve `/proxy.pac` over plain HTTP on the HTTPS listener.
2568async fn plain_pac_handler(State(state): State<PlainState>, req: Request) -> Response {
2569    // Same rule as the TLS side: a name beneath the TLD is a proxied request
2570    // that happens to use this path, so it is redirected to HTTPS like any
2571    // other. The apex is not routable, so it gets the PAC file.
2572    let host = get_request_host(&req).unwrap_or_default();
2573    let bare = host.split(':').next().unwrap_or("");
2574    if !bare.is_empty() && crate::proxy::is_strictly_under_tld(&state.tld, bare) {
2575        return redirect_to_https_handler(req).await;
2576    }
2577    if let Some(deny) = reject_non_read(req.method()) {
2578        return deny;
2579    }
2580    pac_response(&state.tld, &url_host(state.proxy.contact_ip), state.port)
2581}
2582
2583/// The address a client on this machine uses to reach a listener bound to
2584/// `bind_ip`.
2585///
2586/// A wildcard bind is reachable over the loopback address of its own family; a
2587/// specific address is reachable at exactly that address.
2588fn local_contact_ip(bind_ip: std::net::IpAddr) -> std::net::IpAddr {
2589    match bind_ip {
2590        std::net::IpAddr::V4(ip) if ip.is_unspecified() => {
2591            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
2592        }
2593        std::net::IpAddr::V6(ip) if ip.is_unspecified() => {
2594            std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
2595        }
2596        ip => ip,
2597    }
2598}
2599
2600/// Format an address for a URL or PAC directive, bracketing IPv6 literals.
2601fn url_host(ip: std::net::IpAddr) -> String {
2602    match ip {
2603        std::net::IpAddr::V6(ip) => format!("[{ip}]"),
2604        std::net::IpAddr::V4(ip) => ip.to_string(),
2605    }
2606}
2607
2608/// The port in a CONNECT authority (`host:port`, `[v6]:port`), if it names one.
2609fn connect_port(authority: &str) -> Option<u16> {
2610    let (host, port) = authority.rsplit_once(':')?;
2611    // A bare IPv6 literal's last colon is inside the address, not before a port.
2612    if host.contains(':') && !host.ends_with(']') {
2613        return None;
2614    }
2615    port.parse().ok()
2616}
2617
2618/// Handle a `CONNECT` request from a client using the PAC file.
2619///
2620/// A proxy auto-config file routes `*.<tld>` through this listener, and a
2621/// browser opening an `https://` URL asks the proxy to tunnel with `CONNECT
2622/// host:port`. Without this the PAC path would only ever work for plain HTTP.
2623///
2624/// The tunnel's far end is this same listener: the target host is a name only
2625/// pitchfork resolves, and the TLS handshake inside the tunnel carries the SNI
2626/// the certificate resolver needs in order to mint a certificate for it. So the
2627/// connection is spliced back to the proxy's own address, where it arrives as an
2628/// ordinary TLS connection and is routed by `Host` as usual.
2629///
2630/// Only names under the configured TLD are tunnelled. An open CONNECT proxy
2631/// would let anything on the machine reach any host through pitchfork.
2632async fn connect_handler(state: &ProxyState, req: Request, raw_host: &str) -> Response {
2633    let authority = req
2634        .uri()
2635        .authority()
2636        .map(|a| a.as_str().to_string())
2637        .unwrap_or_else(|| raw_host.to_string());
2638    let host = authority
2639        .rsplit_once(':')
2640        .map(|(h, _)| h)
2641        .unwrap_or(&authority)
2642        .trim_start_matches('[')
2643        .trim_end_matches(']')
2644        .to_string();
2645
2646    // The shared rule rather than a hand-rolled pair of conditions; keeping a
2647    // second copy of "is this name ours" is how several of these drifted apart.
2648    if !crate::proxy::owns_name(&state.tld, &host) {
2649        return error_response(
2650            StatusCode::FORBIDDEN,
2651            &format!(
2652                "pitchfork only tunnels CONNECT for names under .{} — refusing {host}",
2653                state.tld
2654            ),
2655        );
2656    }
2657
2658    // With `proxy.https = false` the tunnel lands on a plain HTTP listener.
2659    // That still carries `ws://` and `http://` traffic, which browsers send
2660    // through CONNECT too, but a TLS handshake for an `https://` URL could
2661    // only fail there, and with an error that names neither cause nor fix.
2662    if !state.is_tls && connect_port(&authority) == Some(443) {
2663        return error_response(
2664            StatusCode::BAD_GATEWAY,
2665            &format!(
2666                "proxy.https is false, so pitchfork cannot serve https://{host}; \
2667                 use http:// or enable proxy.https"
2668            ),
2669        );
2670    }
2671
2672    let Some(target) = state.connect_target else {
2673        return error_response(
2674            StatusCode::SERVICE_UNAVAILABLE,
2675            "The proxy listener address is unknown, so CONNECT cannot be tunnelled",
2676        );
2677    };
2678
2679    // Refuse once shutdown has begun, so no tunnel is created after the drain
2680    // has started looking for stragglers.
2681    if state.cancel.is_cancelled() {
2682        return error_response(
2683            StatusCode::SERVICE_UNAVAILABLE,
2684            "The proxy is shutting down",
2685        );
2686    }
2687
2688    // Bounded like the handshake phase and the DNS listener. The permit is
2689    // held for the life of the tunnel, which is the resource being limited.
2690    let Ok(permit) = Arc::clone(&state.tunnel_slots).try_acquire_owned() else {
2691        if let Some(suppressed) = REFUSED_TUNNEL.allow(REFUSAL_LOG_INTERVAL) {
2692            log::warn!(
2693                "Proxy refused a CONNECT tunnel: {MAX_TUNNELS} already open \
2694                 ({suppressed} similar refusals since the last message)"
2695            );
2696        }
2697        return error_response(
2698            StatusCode::SERVICE_UNAVAILABLE,
2699            "Too many CONNECT tunnels are open",
2700        );
2701    };
2702    let cancel = state.cancel.clone();
2703
2704    // Reply 200 first, then take over the upgraded stream. hyper only hands the
2705    // socket over once the response has been sent.
2706    //
2707    // Tracked so shutdown can wait for the splice to stop rather than leaving
2708    // it holding two sockets.
2709    state.tunnels.spawn(async move {
2710        // Dropped with the task, releasing the slot when the tunnel closes.
2711        let _permit = permit;
2712
2713        // Both of these happen before there is a tunnel to speak of, and
2714        // neither is bounded by anything on its own. A client that never
2715        // finishes reading the 200 leaves `upgrade::on` pending forever, and a
2716        // target that accepts nothing leaves `connect` the same way — in each
2717        // case holding one of the tunnel slots for good, so enough stalls
2718        // permanently shrink the proxy's CONNECT capacity. Shutdown does not
2719        // rescue them either: the drain gives up when its own timeout elapses
2720        // and the tracker never aborts what is left.
2721        //
2722        // Racing the cancellation token as well as the clock means shutdown
2723        // reaches a tunnel that is still being set up, not only one that is
2724        // already running.
2725        macro_rules! setup_step {
2726            ($what:literal, $fut:expr) => {
2727                tokio::select! {
2728                    r = tokio::time::timeout(HANDSHAKE_TIMEOUT, $fut) => match r {
2729                        Ok(Ok(v)) => v,
2730                        Ok(Err(e)) => {
2731                            if let Some(n) = ABANDONED_TUNNEL.allow(REFUSAL_LOG_INTERVAL) {
2732                                log::debug!(
2733                                    concat!(
2734                                        "CONNECT {} for {target} failed: {e}",
2735                                        " ({n} similar since the last message)"
2736                                    ),
2737                                    $what,
2738                                    target = target,
2739                                    e = e,
2740                                    n = n
2741                                );
2742                            }
2743                            return;
2744                        }
2745                        Err(_) => {
2746                            if let Some(n) = ABANDONED_TUNNEL.allow(REFUSAL_LOG_INTERVAL) {
2747                                log::debug!(
2748                                    concat!(
2749                                        "CONNECT {} for {target} did not finish within",
2750                                        " {timeout:?}",
2751                                        " ({n} similar since the last message)"
2752                                    ),
2753                                    $what,
2754                                    target = target,
2755                                    timeout = HANDSHAKE_TIMEOUT,
2756                                    n = n
2757                                );
2758                            }
2759                            return;
2760                        }
2761                    },
2762                    // Not throttled: shutdown happens once, so the count is
2763                    // bounded by the tunnels open at that moment.
2764                    _ = cancel.cancelled() => {
2765                        log::debug!("CONNECT {} for {target} abandoned by shutdown", $what);
2766                        return;
2767                    }
2768                }
2769            };
2770        }
2771
2772        let upgraded = setup_step!("upgrade", hyper::upgrade::on(req));
2773        let mut client = hyper_util::rt::TokioIo::new(upgraded);
2774        let mut server = setup_step!("connect", tokio::net::TcpStream::connect(target));
2775
2776        // Straight byte splice: the payload is TLS pitchfork must not and
2777        // cannot read here. Shutdown drops both sockets rather than waiting
2778        // for the peers to finish, since a tunnel can legitimately stay open
2779        // for hours.
2780        tokio::select! {
2781            r = tokio::io::copy_bidirectional(&mut client, &mut server) => {
2782                // Throttled like the setup steps: a client can end tunnels
2783                // with errors as fast as it can open them.
2784                if let Err(e) = r
2785                    && let Some(n) = ABANDONED_TUNNEL.allow(REFUSAL_LOG_INTERVAL)
2786                {
2787                    log::debug!(
2788                        "CONNECT tunnel to {target} ended: {e} ({n} similar since the last message)"
2789                    );
2790                }
2791            }
2792            _ = cancel.cancelled() => {
2793                log::debug!("CONNECT tunnel to {target} closed by shutdown");
2794            }
2795        }
2796    });
2797
2798    Response::builder()
2799        .status(StatusCode::OK)
2800        .header(PITCHFORK_HEADER, "1")
2801        .body(Body::empty())
2802        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
2803}
2804
2805/// Get the effective host from a request.
2806///
2807/// HTTP/2 uses the `:authority` pseudo-header, which hyper exposes via
2808/// `req.uri().authority()` rather than in the `HeaderMap`.
2809/// HTTP/1.1 uses the `Host` header.
2810fn get_request_host(req: &Request) -> Option<String> {
2811    // HTTP/2: :authority is available via the request URI, not the HeaderMap.
2812    let authority = req
2813        .uri()
2814        .authority()
2815        .map(|a| a.as_str().to_string())
2816        .filter(|s| !s.is_empty());
2817
2818    authority.or_else(|| {
2819        req.headers()
2820            .get(HOST)
2821            .and_then(|h| h.to_str().ok())
2822            .map(str::to_string)
2823    })
2824}
2825
2826/// Rejoin a `cookie` header that arrived split across several fields.
2827///
2828/// An HTTP/2 client may send each cookie as its own header field (RFC 9113
2829/// §8.2.3). An HTTP/1.1 backend joins repeated fields with `", "`, which
2830/// corrupts every cookie value, so they must be joined with `"; "` first.
2831fn join_cookie_fields(headers: &mut HeaderMap) {
2832    let fields: Vec<&[u8]> = headers
2833        .get_all(COOKIE)
2834        .iter()
2835        .map(HeaderValue::as_bytes)
2836        .collect();
2837    if fields.len() < 2 {
2838        return;
2839    }
2840
2841    let joined = HeaderValue::from_bytes(&fields.join(b"; ".as_slice()))
2842        .expect("valid header values joined with \"; \" form a valid header value");
2843    headers.insert(COOKIE, joined);
2844}
2845
2846/// Inject `X-Forwarded-*` headers into a proxied request.
2847///
2848/// Because the proxy is a **first-hop** dev tool (not a mid-tier forwarder),
2849/// all four headers are **unconditionally overwritten** with values derived
2850/// from the actual incoming connection.  Any values supplied by the connecting
2851/// client are discarded.
2852///
2853/// Trusting client-supplied `x-forwarded-for` / `x-forwarded-proto` would
2854/// allow a local process to spoof a remote IP or trick a backend's
2855/// HTTPS-detection logic (CSRF checks, secure-cookie flags, redirect rules).
2856fn inject_forwarded_headers(req: &mut Request, is_tls: bool, host_header: &str) {
2857    let remote_addr = req
2858        .extensions()
2859        .get::<axum::extract::ConnectInfo<SocketAddr>>()
2860        .map(|ci| ci.0.ip().to_string())
2861        .unwrap_or_else(|| "127.0.0.1".to_string());
2862
2863    let proto = if is_tls { "https" } else { "http" };
2864    let default_port = if is_tls { "443" } else { "80" };
2865
2866    // Always set fresh values — we are the edge, never a mid-tier forwarder.
2867    // Discard any x-forwarded-* headers supplied by the connecting client.
2868    let forwarded_for = remote_addr.clone();
2869    let forwarded_proto = proto.to_string();
2870    let forwarded_host = host_header.to_string();
2871    let forwarded_port = host_header
2872        .rsplit_once(':')
2873        .map(|(_, port)| port.to_string())
2874        .unwrap_or_else(|| default_port.to_string());
2875
2876    // Strip any client-supplied x-forwarded-* and RFC 7239 Forwarded headers
2877    // before inserting ours, so that no trace of the original values reaches
2878    // the backend.  The RFC 7239 `Forwarded` header is stripped alongside the
2879    // legacy `x-forwarded-*` set because backends that read it (Django, Rails,
2880    // Spring) would otherwise see client-injected spoofed IPs or protocols.
2881    for name in [
2882        "x-forwarded-for",
2883        "x-forwarded-proto",
2884        "x-forwarded-host",
2885        "x-forwarded-port",
2886        "forwarded",
2887    ] {
2888        if let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) {
2889            req.headers_mut().remove(&header_name);
2890        }
2891    }
2892
2893    let headers = [
2894        ("x-forwarded-for", forwarded_for),
2895        ("x-forwarded-proto", forwarded_proto),
2896        ("x-forwarded-host", forwarded_host),
2897        ("x-forwarded-port", forwarded_port),
2898    ];
2899
2900    for (name, value) in headers {
2901        if let Ok(v) = HeaderValue::from_str(&value) {
2902            let header_name = axum::http::HeaderName::from_static(name);
2903            req.headers_mut().insert(header_name, v);
2904        }
2905    }
2906}
2907
2908/// Main proxy request handler.
2909///
2910/// Parses the `Host` header, resolves the target daemon, and forwards the request.
2911/// WebSocket / HTTP upgrade requests are forwarded transparently via hyper's upgrade mechanism.
2912async fn proxy_handler(State(state): State<ProxyState>, mut req: Request) -> Response {
2913    // Extract the host (supports both HTTP/2 :authority and HTTP/1.1 Host)
2914    let Some(raw_host) = get_request_host(&req) else {
2915        return error_response(StatusCode::BAD_REQUEST, "Missing Host header");
2916    };
2917
2918    // A browser configured through the PAC file sends CONNECT for every
2919    // `https://` URL, so this is the entry point for the whole PAC path under
2920    // the default HTTPS configuration.
2921    if req.method() == axum::http::Method::CONNECT {
2922        return connect_handler(&state, req, &raw_host).await;
2923    }
2924    // Strip port from host for routing.
2925    // IPv6 addresses in Host headers are bracketed per RFC 2732: `[::1]:port`.
2926    // Splitting naïvely on ':' would break on the colons inside the address.
2927    let host = if raw_host.starts_with('[') {
2928        // IPv6: "[::1]:port" or "[::1]"
2929        raw_host
2930            .split("]:")
2931            .next()
2932            .unwrap_or(&raw_host)
2933            .trim_start_matches('[')
2934            .trim_end_matches(']')
2935            .to_string()
2936    } else {
2937        // IPv4 / hostname: "host:port" or "host"
2938        raw_host.split(':').next().unwrap_or(&raw_host).to_string()
2939    };
2940    // A fully qualified name may end in the root dot (`api.localhost.`). The
2941    // resolver and the certificate issuer already accept it through
2942    // `owns_name`, so routing has to as well, or the name resolves and
2943    // handshakes only to be answered "not found".
2944    let host = host.trim_end_matches('.').to_string();
2945
2946    // Loop detection: check hop count.
2947    //
2948    // Security: strip (zero out) the hop counter on the very first hop to
2949    // prevent external clients from forging a high value and triggering a
2950    // 508 Loop Detected response (denial-of-service).  A request is
2951    // considered "first hop" when it does not carry the `x-pitchfork-hops`
2952    // request header that pitchfork injects when forwarding — i.e. it did
2953    // not come from another pitchfork proxy instance.
2954    // Note: `x-pitchfork` is a *response* header added by pitchfork and is
2955    // never present on incoming requests, so it cannot be used here.
2956    let is_from_pitchfork = req.headers().contains_key(PROXY_HOPS_HEADER);
2957    let hops: u64 = if is_from_pitchfork {
2958        req.headers()
2959            .get(PROXY_HOPS_HEADER)
2960            .and_then(|v| v.to_str().ok())
2961            .and_then(|s| s.parse().ok())
2962            .unwrap_or(0)
2963    } else {
2964        // External request: ignore any forged hop counter.
2965        0
2966    };
2967    if hops >= MAX_PROXY_HOPS {
2968        return error_response(
2969            StatusCode::LOOP_DETECTED,
2970            &format!(
2971                "Loop detected for '{host}': request has passed through the proxy {hops} times.\n\
2972                 This usually means a backend is proxying back through pitchfork without rewriting \n\
2973                 the Host header. If you use Vite/webpack proxy, set changeOrigin: true."
2974            ),
2975        );
2976    }
2977
2978    let local_client = is_local_client(&req);
2979
2980    // Intercept "pitchfork.<tld>" — route to the built-in web UI
2981    let target_port = if let Some(subdomain) = strip_tld(&host, &state.tld) {
2982        if subdomain.eq_ignore_ascii_case("pitchfork") {
2983            crate::web::port()
2984        } else {
2985            None
2986        }
2987    } else {
2988        None
2989    };
2990
2991    let (target_port, activity) = if let Some(port) = target_port {
2992        (port, None)
2993    } else {
2994        // A passthrough hostname must never be forwarded as plain HTTP: the
2995        // daemon expects a TLS handshake on that port, so the request would
2996        // fail deep inside the daemon with nothing to point at the cause.
2997        if resolve_tls_mode(&host, &state.tld).await.is_passthrough() {
2998            return error_response(
2999                StatusCode::BAD_GATEWAY,
3000                &passthrough_unroutable_message(&host, state.is_tls),
3001            );
3002        }
3003        match resolve_target(&host, &state.tld).await {
3004            ResolveResult::Ready(port, activity) => (port, activity),
3005            ResolveResult::Starting { slug } => {
3006                return starting_html_response(&slug, &raw_host);
3007            }
3008            ResolveResult::Page {
3009                project,
3010                worktree,
3011                daemons,
3012                dir,
3013            } => {
3014                // A reserved name answers 200 while an unknown one answers 404,
3015                // which tells anything on the network which projects exist. Off
3016                // this machine the two look the same.
3017                if !local_client {
3018                    return unknown_host_response(&host, "Not found", &[]);
3019                }
3020                // The project and stack pages live in the web UI, which is
3021                // their canonical location, so this hostname redirects there.
3022                // The page is found by the checkout the hostname resolved
3023                // to, because its URL is built from the registered project
3024                // name and the worktree's own name, neither of which has to
3025                // match the sanitized, overridable labels in the hostname.
3026                if let Some(base) = crate::web::url()
3027                    && let Some(resolved) = dir.clone()
3028                    && let Some(path) = tokio::task::spawn_blocking(move || {
3029                        crate::web::routes::api::projects::page_path_for_dir(&resolved)
3030                    })
3031                    .await
3032                    .ok()
3033                    .flatten()
3034                {
3035                    return page_redirect_response(&base, &path);
3036                }
3037                return page_placeholder_response(
3038                    &project,
3039                    worktree.as_deref(),
3040                    &daemons,
3041                    &state.tld,
3042                    &host_port_suffix(&raw_host),
3043                    crate::web::url().as_deref(),
3044                );
3045            }
3046            ResolveResult::Unknown { heading, known } => {
3047                // The heading says which project was recognised, which is one
3048                // more thing than a remote client needs to learn.
3049                return unknown_host_response(
3050                    &host,
3051                    if local_client { &heading } else { "Not found" },
3052                    if local_client { &known } else { &[] },
3053                );
3054            }
3055            ResolveResult::NotFound => {
3056                return error_response(
3057                    StatusCode::BAD_GATEWAY,
3058                    &format!(
3059                        "No daemon found for host '{host}'.\n\
3060                         A daemon is reachable once it configures a `port` and its project is \
3061                         known to pitchfork; run `pitchfork proxy status` to see the hostnames \
3062                         it serves.\n\
3063                         Expected format: <daemon>.<project>.{tld}",
3064                        tld = state.tld
3065                    ),
3066                );
3067            }
3068            ResolveResult::Error(msg) => {
3069                if local_client {
3070                    return error_response(StatusCode::BAD_GATEWAY, &msg);
3071                }
3072                // The message names directories on this machine.
3073                log::warn!("Refused '{host}' for a non-local client: {msg}");
3074                return error_response(
3075                    StatusCode::BAD_GATEWAY,
3076                    &format!("'{host}' is not available."),
3077                );
3078            }
3079        }
3080    };
3081    // Build the forwarding URI
3082    let path_and_query = req
3083        .uri()
3084        .path_and_query()
3085        .map(|pq| pq.as_str())
3086        .unwrap_or("/");
3087
3088    let forward_uri = match Uri::builder()
3089        .scheme("http")
3090        .authority(format!("localhost:{target_port}"))
3091        .path_and_query(path_and_query)
3092        .build()
3093    {
3094        Ok(uri) => uri,
3095        Err(e) => {
3096            return error_response(
3097                StatusCode::INTERNAL_SERVER_ERROR,
3098                &format!("Failed to build forward URI: {e}"),
3099            );
3100        }
3101    };
3102
3103    // Update the request URI and Host header
3104    *req.uri_mut() = forward_uri;
3105    req.headers_mut().insert(
3106        HOST,
3107        HeaderValue::from_str(&format!("localhost:{target_port}"))
3108            .unwrap_or_else(|_| HeaderValue::from_static("localhost")),
3109    );
3110
3111    // Inject X-Forwarded-* headers
3112    inject_forwarded_headers(&mut req, state.is_tls, &raw_host);
3113
3114    // Increment hop counter
3115    if let Ok(v) = HeaderValue::from_str(&(hops + 1).to_string()) {
3116        req.headers_mut()
3117            .insert(axum::http::HeaderName::from_static(PROXY_HOPS_HEADER), v);
3118    }
3119
3120    // Explicitly strip HTTP/2 pseudo-headers (":authority", ":method", etc.)
3121    // before forwarding to an HTTP/1.1 backend. Although hyper typically does
3122    // not store pseudo-headers in the HeaderMap, some middleware layers or
3123    // future hyper versions might; stripping them here is a defensive measure.
3124    let pseudo_headers: Vec<_> = req
3125        .headers()
3126        .keys()
3127        .filter(|k| k.as_str().starts_with(':'))
3128        .cloned()
3129        .collect();
3130    for key in pseudo_headers {
3131        req.headers_mut().remove(&key);
3132    }
3133
3134    join_cookie_fields(req.headers_mut());
3135
3136    // Downgrade the forwarded request to HTTP/1.1. TLS connections negotiate
3137    // HTTP/2 inbound via ALPN, but the upstream forward client speaks HTTP/1 to
3138    // the daemon. Without this, the still-h2-tagged request is rejected by the
3139    // client with `UserUnsupportedVersion`, surfacing as a 502 to the browser.
3140    *req.version_mut() = axum::http::Version::HTTP_11;
3141
3142    // Extract the client-side OnUpgrade handle *before* consuming req
3143    let client_upgrade = hyper::upgrade::on(&mut req);
3144
3145    // Forward the request with a per-request timeout so that a backend that
3146    // accepts the TCP connection but then stalls (deadlock, blocking I/O, etc.)
3147    // cannot hold the proxy connection open forever and exhaust file descriptors.
3148    //
3149    // 120 s is intentionally generous for a local dev proxy — it covers slow
3150    // test suites, large file uploads, and SSE streams while still bounding
3151    // the worst-case resource leak.
3152    let result = match tokio::time::timeout(
3153        std::time::Duration::from_secs(120),
3154        state.client.request(req),
3155    )
3156    .await
3157    {
3158        Ok(r) => r,
3159        Err(_elapsed) => {
3160            let msg = format!(
3161                "Request to daemon on port {target_port} timed out after 120 s.\n\
3162                 The daemon accepted the connection but did not respond in time."
3163            );
3164            log::warn!("{msg}");
3165            if let Some(ref on_error) = state.on_error {
3166                on_error(&msg);
3167            }
3168            return error_response(StatusCode::GATEWAY_TIMEOUT, &msg);
3169        }
3170    };
3171    match result {
3172        Ok(mut resp) => {
3173            // Extract backend upgrade handle *before* consuming resp
3174            let backend_upgrade = hyper::upgrade::on(&mut resp);
3175            let (mut parts, body) = resp.into_parts();
3176
3177            // Add pitchfork identification header
3178            parts.headers.insert(
3179                axum::http::HeaderName::from_static(PITCHFORK_HEADER),
3180                HeaderValue::from_static("1"),
3181            );
3182
3183            // Strip the internal hop-counter so it is never leaked to external clients.
3184            parts.headers.remove(PROXY_HOPS_HEADER);
3185
3186            // Strip hop-by-hop headers when serving HTTPS (HTTP/2 forbids them).
3187            // Skip 101 Switching Protocols — that response is always HTTP/1.1 and
3188            // the client needs the `Upgrade` header to complete the WS handshake
3189            // (RFC 6455 §4.1 requires `Upgrade: websocket` in the 101 response).
3190            if state.is_tls && parts.status != StatusCode::SWITCHING_PROTOCOLS {
3191                for h in HOP_BY_HOP_HEADERS {
3192                    if let Ok(name) = axum::http::HeaderName::from_bytes(h.as_bytes()) {
3193                        parts.headers.remove(&name);
3194                    }
3195                }
3196            }
3197
3198            // If the backend returned 101 Switching Protocols, pipe the upgraded streams.
3199            if parts.status == StatusCode::SWITCHING_PROTOCOLS {
3200                // Note: loop detection for WebSocket upgrades is already handled at the
3201                // top of proxy_handler (hops >= MAX_PROXY_HOPS check) before the request
3202                // is forwarded.  A 101 response here means the backend accepted the
3203                // upgrade, so the hop count was already within limits.
3204                // Tracked with the CONNECT tunnels and raced against the
3205                // cancellation token, so shutdown drains it instead of leaving
3206                // it splicing to a daemon that is about to be stopped.
3207                let cancel = state.cancel.clone();
3208                state.tunnels.spawn(async move {
3209                    // An open WebSocket is activity until either side closes
3210                    // it, however quiet it is.
3211                    let _activity = activity;
3212                    let splice = async move {
3213                        if let (Ok(client_upgraded), Ok(backend_upgraded)) =
3214                            (client_upgrade.await, backend_upgrade.await)
3215                        {
3216                            let mut client_io = hyper_util::rt::TokioIo::new(client_upgraded);
3217                            let mut backend_io = hyper_util::rt::TokioIo::new(backend_upgraded);
3218                            // No application-level timeout here: tokio::time::timeout would be a
3219                            // hard wall-clock deadline for the entire tunnel, not an idle timeout.
3220                            // Long-lived connections (Vite/webpack HMR, SSE-over-WS) would be
3221                            // silently terminated after the deadline even if data is actively
3222                            // flowing.  The OS TCP keepalive is sufficient to reap truly dead
3223                            // connections; a proper idle timeout would require a custom
3224                            // AsyncRead/AsyncWrite wrapper that resets the timer on each I/O op.
3225                            let _ = tokio::io::copy_bidirectional(&mut client_io, &mut backend_io)
3226                                .await;
3227                        }
3228                    };
3229                    // The whole splice, upgrade waits included: an abandoned
3230                    // handshake leaves those pending, and the tracker never
3231                    // aborts, so only the token ends it at shutdown.
3232                    tokio::select! {
3233                        _ = splice => {}
3234                        _ = cancel.cancelled() => {}
3235                    }
3236                });
3237                return Response::from_parts(parts, Body::empty());
3238            }
3239
3240            // Backend refused the upgrade (returned a non-101 response) — forward it as-is.
3241            // This can happen when the backend rejects a WebSocket handshake with e.g. 400.
3242            //
3243            // The body carries the activity guard, so a streamed response
3244            // (server-sent events, a long download) counts until it ends.
3245            Response::from_parts(parts, Body::new(GuardedBody::new(body, activity)))
3246        }
3247        Err(e) => {
3248            let msg = format!(
3249                "Failed to connect to daemon on port {target_port}: {e}\n\
3250                 The daemon may have stopped or is not yet ready."
3251            );
3252            if let Some(ref on_error) = state.on_error {
3253                on_error(&msg);
3254            } else {
3255                log::warn!("{msg}");
3256            }
3257            error_response(StatusCode::BAD_GATEWAY, &msg)
3258        }
3259    }
3260}
3261
3262/// Explain why a request for a `proxy_tls = "passthrough"` hostname arrived on
3263/// the HTTP path, where it cannot be served.
3264///
3265/// Passthrough routes on the hostname in the TLS ClientHello, so there are two
3266/// ways to end up here: the proxy is not serving TLS at all, or the TLS
3267/// connection named one host and the request inside it named another.
3268///
3269/// A connection with no SNI at all never reaches this point: the certificate
3270/// resolver has no name to issue for, so the handshake fails outright.
3271fn passthrough_unroutable_message(host: &str, is_tls: bool) -> String {
3272    if is_tls {
3273        format!(
3274            "'{host}' uses proxy_tls = \"passthrough\", which routes on the host name \
3275             in the TLS ClientHello.\n\
3276             This connection's TLS handshake named a different host, so it was \
3277             terminated here and the request inside it cannot be spliced to the \
3278             daemon.\n\
3279             Make the connection itself name '{host}' rather than overriding the Host \
3280             header of a connection opened to something else."
3281        )
3282    } else {
3283        format!(
3284            "'{host}' uses proxy_tls = \"passthrough\", but the proxy is serving \
3285             plain HTTP.\n\
3286             Passthrough splices a TLS stream to the daemon, so it requires \
3287             settings.proxy.https = true.\n\
3288             Enable HTTPS on the proxy, or set proxy_tls = \"terminate\" on the daemon."
3289        )
3290    }
3291}
3292
3293/// Resolve the target for a given hostname.
3294///
3295/// Slug-based routing using the global config's `[slugs]` section:
3296/// 1. Strip TLD to get subdomain (the slug)
3297/// 2. Look up slug in global config → find project dir + daemon name
3298/// 3. Check state file for a running daemon with that name → get its port
3299/// 4. If `proxy.auto_start` is enabled and the daemon is not running,
3300///    trigger an automatic start and wait for it to become ready.
3301///
3302/// # Returns
3303/// - `ResolveResult::Ready(port, _)`    — daemon running (or just auto-started), forward to this port
3304/// - `ResolveResult::Starting { slug }` — daemon start in progress (show waiting page)
3305/// - `ResolveResult::NotFound`          — no daemon matched
3306/// - `ResolveResult::Error(msg)`        — routing refused with a descriptive reason
3307///
3308/// # Locking
3309/// The state file lock is held only for the duration of the snapshot copy,
3310/// then released immediately to avoid serialising all proxy requests.
3311async fn resolve_target(host: &str, tld: &str) -> ResolveResult {
3312    // One budget for everything this request may wait on: an idle stop under
3313    // way and then the start after it.
3314    let deadline = auto_start_deadline();
3315    let ctx = match resolve_route_context(host, tld).await {
3316        Ok(ctx) => ctx,
3317        Err(result) => return result,
3318    };
3319
3320    let daemons = {
3321        let state_file = SUPERVISOR.state_file.lock().await;
3322        state_file.daemons.clone()
3323    };
3324
3325    let daemon_name = &ctx.cached.daemon_name;
3326    let running_matches: Vec<(&DaemonId, &crate::daemon::Daemon)> = daemons
3327        .iter()
3328        .filter(|(id, d)| {
3329            id.name() == daemon_name
3330                && d.status.is_running()
3331                && match &ctx.expected_namespace {
3332                    Some(ns) => id.namespace() == ns,
3333                    None => true,
3334                }
3335        })
3336        .collect();
3337
3338    match running_matches.as_slice() {
3339        [] => {
3340            try_auto_start(
3341                &ctx.cached.slug,
3342                &ctx.cached,
3343                ctx.worktree_dir.as_deref(),
3344                ctx.expected_namespace.as_deref(),
3345                &ctx.route,
3346                deadline,
3347            )
3348            .await
3349        }
3350        // With more than one namespace running a daemon of this name and no
3351        // namespace to narrow by, the first match is used — as before.
3352        [(id, d), ..] => match select_daemon_port(&ctx.route, d) {
3353            Some(port) => match begin_running(id, deadline).await {
3354                Running::Yes(activity) => ResolveResult::Ready(port, Some(activity)),
3355                Running::Stopping => ResolveResult::Starting {
3356                    slug: ctx.cached.slug.clone(),
3357                },
3358                Running::No => {
3359                    try_auto_start(
3360                        &ctx.cached.slug,
3361                        &ctx.cached,
3362                        ctx.worktree_dir.as_deref(),
3363                        ctx.expected_namespace.as_deref(),
3364                        &ctx.route,
3365                        deadline,
3366                    )
3367                    .await
3368                }
3369            },
3370            None => ResolveResult::NotFound,
3371        },
3372    }
3373}
3374
3375/// Whether a daemon found running is still running once its request has
3376/// been recorded as activity.
3377enum Running {
3378    /// Still running; forward while holding this.
3379    Yes(ActivityGuard),
3380    /// Still being stopped for inactivity when the request's time ran out.
3381    Stopping,
3382    /// Stopped since the state was read, including by an idle stop the
3383    /// request waited out: start it again.
3384    No,
3385}
3386
3387/// When a request that may auto-start a daemon has to give up:
3388/// `proxy.auto_start_timeout` from now.
3389fn auto_start_deadline() -> tokio::time::Instant {
3390    tokio::time::Instant::now() + settings().proxy_auto_start_timeout()
3391}
3392
3393/// Wait until `deadline` for any stop for inactivity of `ids` to finish.
3394/// Returns whether none is under way any more.
3395///
3396/// A request that arrives while its daemon is being stopped waits here and
3397/// then starts it again, rather than reaching a daemon that is going away or
3398/// being turned away while it goes.
3399async fn wait_out_idle_stops(ids: &[DaemonId], deadline: tokio::time::Instant) -> bool {
3400    while ids.iter().any(|id| ACTIVITY.is_idle_stopping(id)) {
3401        if tokio::time::Instant::now() >= deadline {
3402            return false;
3403        }
3404        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3405    }
3406    true
3407}
3408
3409/// Record a request for a daemon that the state showed running, then check
3410/// that it still is.
3411///
3412/// Recording comes first: once it has, a stop for inactivity can no longer be
3413/// claimed, so a daemon that is still running at the check stays running for
3414/// as long as the request holds the guard.
3415///
3416/// A stop for inactivity under way is waited out until `deadline`. It may have
3417/// been called off rather than carried out, so the daemon is then looked at
3418/// again instead of assumed stopped.
3419async fn begin_running(id: &DaemonId, deadline: tokio::time::Instant) -> Running {
3420    let activity = match ACTIVITY.begin(id) {
3421        Some(activity) => activity,
3422        None => {
3423            if !wait_out_idle_stops(std::slice::from_ref(id), deadline).await {
3424                return Running::Stopping;
3425            }
3426            match ACTIVITY.begin(id) {
3427                Some(activity) => activity,
3428                // Claimed again straight away.
3429                None => return Running::Stopping,
3430            }
3431        }
3432    };
3433    let running = {
3434        let state_file = SUPERVISOR.state_file.lock().await;
3435        state_file
3436            .daemons
3437            .get(id)
3438            .is_some_and(|d| d.status.is_running())
3439    };
3440    if running {
3441        Running::Yes(activity)
3442    } else {
3443        Running::No
3444    }
3445}
3446
3447/// What a hostname resolves to before the daemon's running state is consulted:
3448/// the slug it matched, the namespace and worktree it names, and how its TLS
3449/// is handled.
3450struct RouteContext {
3451    cached: CachedSlugEntry,
3452    expected_namespace: Option<String>,
3453    worktree_dir: Option<std::path::PathBuf>,
3454    route: ProxyTlsRoute,
3455}
3456
3457/// Resolve a hostname to its slug, worktree and TLS route.
3458///
3459/// This is the part of routing that does not depend on whether the daemon is
3460/// running, which is what lets the 443 listener decide between terminating TLS
3461/// and splicing the raw stream before anything is started.
3462///
3463/// Returns `Err(ResolveResult)` when the host does not route at all, carrying
3464/// the response the caller should produce.
3465async fn resolve_route_context(host: &str, tld: &str) -> Result<RouteContext, ResolveResult> {
3466    let Some(subdomain) = strip_tld(host, tld) else {
3467        return Err(ResolveResult::NotFound);
3468    };
3469
3470    let cached = cached_slug_lookup(&subdomain).await.filter(|cached| {
3471        // A slug too long for the configured TLD is not advertised as a URL, so
3472        // it does not take precedence over the daemon's automatic hostname
3473        // here either.
3474        if crate::proxy::hostname::hostname_fits(&cached.slug) {
3475            return true;
3476        }
3477        crate::proxy::hostname::warn_once(&format!(
3478            "Slug '{}' plus the configured proxy.tld is over the DNS length limit, \
3479             so it is not routed.",
3480            cached.slug
3481        ));
3482        false
3483    });
3484    let Some(cached) = cached else {
3485        // No legacy slug matched; fall through to the automatic
3486        // `<daemon>.<worktree>.<project>` hostnames.
3487        return Err(resolve_registry_target(&subdomain).await);
3488    };
3489
3490    // ─── Worktree prefix extraction ──────────────────────────────────────
3491    // When a wildcard subdomain like "feature-a.myapp" matched slug "myapp",
3492    // the prefix "feature-a" may correspond to a git worktree or jj workspace.
3493    let (expected_namespace, worktree_dir, route) = if !subdomain.eq_ignore_ascii_case(&cached.slug)
3494    {
3495        let prefix = strip_dot_suffix_ignore_case(&subdomain, &cached.slug);
3496        match prefix {
3497            Some(ref p) => match match_worktree_prefix(&cached, p) {
3498                PrefixMatch::Worktree(wt) => {
3499                    let ns = wt.namespace.clone().or_else(|| {
3500                        log::warn!(
3501                            "Worktree '{}' has no cached namespace; \
3502                             falling back to parent slug namespace.",
3503                            wt.path.display()
3504                        );
3505                        cached.namespace.clone()
3506                    });
3507                    let route = worktree_route(&cached, &wt.sanitized_branch);
3508                    (ns, Some(wt.path.clone()), route)
3509                }
3510                PrefixMatch::Ambiguous => {
3511                    return Err(ResolveResult::Error(format!(
3512                        "'{host}' is ambiguous: more than one branch or workspace of '{slug}' \
3513                         sanitizes to the prefix '{p}', and host names are case-insensitive.\n\
3514                         Rename one of them so the prefixes differ by more than case, then \
3515                         reload.\n\
3516                         The supervisor log lists the colliding branches.",
3517                        slug = cached.slug,
3518                    )));
3519                }
3520                PrefixMatch::Unknown => (cached.namespace.clone(), None, cached.tls),
3521            },
3522            None => (cached.namespace.clone(), None, cached.tls),
3523        }
3524    } else {
3525        (cached.namespace.clone(), None, cached.tls)
3526    };
3527
3528    Ok(RouteContext {
3529        cached,
3530        expected_namespace,
3531        worktree_dir,
3532        route,
3533    })
3534}
3535
3536/// How the proxy should handle TLS for `host`.
3537///
3538/// Answered from the slug cache alone, so it is cheap enough to call on every
3539/// TLS connection. Anything that does not resolve to a daemon — an unknown
3540/// host, the built-in web UI, a bare IP — keeps today's behavior and
3541/// terminates.
3542pub(crate) async fn resolve_tls_mode(host: &str, tld: &str) -> ProxyTlsMode {
3543    let entries = get_cached_slugs().await;
3544    let registry = get_cached_host_registry().await;
3545    resolve_tls_mode_in(host, tld, &entries, &registry)
3546}
3547
3548/// [`resolve_tls_mode`] against a given slug table, without awaiting.
3549///
3550/// Used by the certificate resolver, which runs in a synchronous trait method
3551/// and reads [`slug_snapshot`].
3552fn resolve_tls_mode_in(
3553    host: &str,
3554    tld: &str,
3555    entries: &std::collections::HashMap<String, CachedSlugEntry>,
3556    registry: &crate::proxy::hostname::HostRegistry,
3557) -> ProxyTlsMode {
3558    let Some(subdomain) = strip_tld(host, tld) else {
3559        return ProxyTlsMode::Terminate;
3560    };
3561    let wildcard = settings().proxy.wildcard;
3562
3563    // Legacy slugs resolve first, exactly as routing resolves them.
3564    if let Some(cached) = wildcard_slug_lookup(&subdomain, entries, wildcard)
3565        && crate::proxy::hostname::hostname_fits(&cached.slug)
3566    {
3567        // A wildcard match may name a worktree, which carries its own setting.
3568        if !subdomain.eq_ignore_ascii_case(&cached.slug)
3569            && let Some(prefix) = strip_dot_suffix_ignore_case(&subdomain, &cached.slug)
3570            && let PrefixMatch::Worktree(wt) = match_worktree_prefix(cached, &prefix)
3571        {
3572            return worktree_route(cached, &wt.sanitized_branch).mode;
3573        }
3574        return cached.tls.mode;
3575    }
3576
3577    // Otherwise the automatic `<daemon>.<worktree>.<project>` hostnames, whose
3578    // mode lives in the config of the checkout the name points at.
3579    match registry.resolve(&subdomain, wildcard) {
3580        crate::proxy::hostname::HostTarget::Daemon { proxy_tls, .. } => {
3581            proxy_tls.unwrap_or_default()
3582        }
3583        // A project or stack page, an unknown name: the proxy answers those
3584        // itself over its own certificate.
3585        _ => ProxyTlsMode::Terminate,
3586    }
3587}
3588
3589/// Pick which of a running daemon's ports a hostname forwards to.
3590///
3591/// Without `proxy_tls_port`, a terminating hostname uses the port the process
3592/// was detected listening on, falling back to the first resolved port — the
3593/// historical behavior, and the right answer for a single-port daemon. A
3594/// passthrough hostname reverses that order, because the first detected
3595/// listener of a multi-port daemon need not be the one speaking TLS.
3596///
3597/// With `proxy_tls_port` set, the configured port is matched by *position* in
3598/// the daemon's `port` list, so the mapping survives auto-bump: a daemon
3599/// configured for `[8443, 9443]` whose ports bumped to `[8444, 9444]` still
3600/// routes `proxy_tls_port = 9443` to 9444. Config validation rejects a
3601/// `proxy_tls_port` the daemon does not declare, so the only way the position
3602/// lookup can miss is a state record that predates the current config; the
3603/// port is then used as written only while the daemon has no resolved ports to
3604/// contradict it.
3605fn select_daemon_port(route: &ProxyTlsRoute, daemon: &crate::daemon::Daemon) -> Option<u16> {
3606    let Some(want) = route.port else {
3607        // A passthrough hostname has to reach the daemon's *TLS* listener.
3608        // `active_port` is whichever port was detected first, which on a
3609        // multi-port daemon can be a secondary plain-HTTP or status listener
3610        // that came up earlier, so the declared first port wins there — as
3611        // documented. A terminating hostname keeps preferring the detected
3612        // port, which is what routes daemons that declare no ports at all.
3613        // Port 0 is what a daemon carries when it asked the operating system
3614        // to choose and nothing has been detected yet; it is not connectable.
3615        let detected = daemon.active_port.filter(|&p| p != 0);
3616        let first_declared = daemon.resolved_port.iter().copied().find(|&p| p != 0);
3617        return if route.mode.is_passthrough() {
3618            first_declared.or(detected)
3619        } else {
3620            detected.or(first_declared)
3621        };
3622    };
3623
3624    let configured = daemon
3625        .port
3626        .as_ref()
3627        .map(|p| p.expect.as_slice())
3628        .unwrap_or(&[]);
3629    if let Some(idx) = configured.iter().position(|&p| p == want)
3630        && let Some(&resolved) = daemon.resolved_port.get(idx)
3631    {
3632        // A recorded 0 is a port the daemon asked the operating system to
3633        // choose and nothing has detected yet, so it is no more connectable
3634        // here than on the path that takes the daemon's first port. That is a
3635        // daemon still starting, not a mismatch, so it is not warned about:
3636        // auto-start polls through this state several times a second.
3637        return (resolved != 0).then_some(resolved);
3638    }
3639    if daemon.resolved_port.contains(&want) {
3640        return Some(want);
3641    }
3642    if daemon.resolved_port.is_empty() {
3643        // Nothing recorded to place the port against — the config is the only
3644        // information there is, so use it.
3645        return Some(want);
3646    }
3647    // Every request to the hostname lands here until the daemon restarts, so
3648    // the warning is logged once per daemon and port set, not per request.
3649    crate::proxy::hostname::warn_once(&format!(
3650        "Daemon {} has proxy_tls_port {want}, which is not among its resolved ports {:?}; \
3651         refusing to route rather than forwarding to a port it never bound. \
3652         Restart the daemon if its ports changed.",
3653        daemon.id, daemon.resolved_port,
3654    ));
3655    None
3656}
3657
3658/// RAII guard that removes a `DaemonId` from `AUTO_START_IN_PROGRESS` on drop.
3659///
3660/// This ensures the in-progress flag is cleared even if the auto-start future
3661/// panics (e.g. an unexpected `unwrap` inside a dependency).  Without this,
3662/// the daemon ID would stay in the set permanently and every subsequent proxy
3663/// request would return "Starting …" forever.
3664struct AutoStartGuard {
3665    daemon_id: DaemonId,
3666}
3667
3668impl Drop for AutoStartGuard {
3669    fn drop(&mut self) {
3670        let daemon_id = self.daemon_id.clone();
3671        // Spawn a cleanup task because `Drop` is synchronous and the mutex is
3672        // async.  If the runtime is shutting down this may not execute, but in
3673        // that case the entire set is being dropped anyway.
3674        tokio::spawn(async move {
3675            AUTO_START_IN_PROGRESS.lock().await.remove(&daemon_id);
3676        });
3677    }
3678}
3679
3680/// Per-daemon locks held while a proxy request starts a dependency graph.
3681///
3682/// `AUTO_START_IN_PROGRESS` only deduplicates requests for the same hostname.
3683/// Two hostnames whose daemons share a dependency would otherwise start it
3684/// concurrently, and the supervisor answers the second start with "already
3685/// running" while the first is still waiting for it to become ready — letting
3686/// the second daemon start against a dependency that is not ready yet. A graph
3687/// holds the lock of every daemon in it until it has finished starting, so an
3688/// overlapping graph waits and then finds its shared dependencies ready.
3689///
3690/// Entries are weak: a lock lives only while a startup holds or awaits it, and
3691/// dead entries are pruned on the next call, so daemons of worktrees that come
3692/// and go do not accumulate for the life of the supervisor.
3693static STARTUP_LOCKS: once_cell::sync::Lazy<
3694    std::sync::Mutex<std::collections::HashMap<DaemonId, std::sync::Weak<tokio::sync::Mutex<()>>>>,
3695> = once_cell::sync::Lazy::new(Default::default);
3696
3697/// Lock every daemon in `ids`, in sorted order so that overlapping graphs
3698/// cannot deadlock.
3699async fn lock_startup_graph(mut ids: Vec<DaemonId>) -> Vec<tokio::sync::OwnedMutexGuard<()>> {
3700    ids.sort();
3701    ids.dedup();
3702    let locks: Vec<Arc<tokio::sync::Mutex<()>>> = {
3703        let mut map = STARTUP_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
3704        map.retain(|_, lock| lock.strong_count() > 0);
3705        ids.into_iter()
3706            .map(|id| {
3707                let entry = map.entry(id).or_default();
3708                entry.upgrade().unwrap_or_else(|| {
3709                    let lock = Arc::new(tokio::sync::Mutex::new(()));
3710                    *entry = Arc::downgrade(&lock);
3711                    lock
3712                })
3713            })
3714            .collect()
3715    };
3716    let mut guards = Vec::with_capacity(locks.len());
3717    for lock in locks {
3718        guards.push(lock.lock_owned().await);
3719    }
3720    guards
3721}
3722
3723/// Attempt to auto-start a daemon for the given slug.
3724///
3725/// If `proxy.auto_start` is disabled, returns `NotFound`.
3726/// Uses a dedup set to prevent concurrent starts for the same daemon.
3727/// Starts the daemon together with its `depends` graph exactly as
3728/// `pitchfork start` does — dependencies first, level by level, each waited on
3729/// until ready — then polls for the daemon's active port.
3730///
3731/// The request's wait — for the whole graph and then for the port — is bounded
3732/// by `proxy_auto_start_timeout`. The start itself runs as its own task and is
3733/// not cancelled when the request gives up: abandoning a graph between levels
3734/// would leave dependencies starting with nothing waiting on them, and the
3735/// next request would then start the daemon before they were ready.
3736async fn try_auto_start(
3737    slug: &str,
3738    cached: &CachedSlugEntry,
3739    worktree_dir: Option<&std::path::Path>,
3740    expected_namespace: Option<&str>,
3741    route: &ProxyTlsRoute,
3742    deadline: tokio::time::Instant,
3743) -> ResolveResult {
3744    let s = settings();
3745    if !s.proxy.auto_start {
3746        return ResolveResult::NotFound;
3747    }
3748
3749    let ns = expected_namespace
3750        .map(|s| s.to_string())
3751        .or_else(|| cached.namespace.clone())
3752        .unwrap_or_else(|| "global".to_string());
3753    let daemon_id = match DaemonId::try_new(&ns, &cached.daemon_name) {
3754        Ok(id) => id,
3755        Err(_) => return ResolveResult::NotFound,
3756    };
3757
3758    {
3759        let mut in_progress = AUTO_START_IN_PROGRESS.lock().await;
3760        if !in_progress.insert(daemon_id.clone()) {
3761            return ResolveResult::Starting {
3762                slug: slug.to_string(),
3763            };
3764        }
3765    }
3766
3767    // Shared by the start task and this request, so the daemon stays marked
3768    // as starting until both the graph and this request's port wait are done.
3769    let guard = Arc::new(AutoStartGuard {
3770        daemon_id: daemon_id.clone(),
3771    });
3772
3773    // The request's deadline, which may already be partly spent waiting out
3774    // an idle stop, so the whole request stays within one timeout.
3775    let timeout = s.proxy_auto_start_timeout();
3776    let timed_out = || {
3777        log::warn!("Auto-start: total timeout ({timeout:?}) exceeded for daemon {daemon_id}");
3778        ResolveResult::Error(format!(
3779            "Auto-start for '{daemon_id}' timed out after {timeout:?}.\n\
3780             The daemon and its dependencies did not all become ready, with the daemon \
3781             bound to a port, within the configured proxy_auto_start_timeout.\n\
3782             Startup continues in the background; reload to check again.\n\
3783             Increase the timeout or check the logs of the daemon and its dependencies \
3784             for slow startup."
3785        ))
3786    };
3787
3788    log::info!("Auto-start: starting daemon {daemon_id} for slug '{slug}'");
3789    let start = tokio::spawn({
3790        let guard = guard.clone();
3791        let daemon_id = daemon_id.clone();
3792        let config_dir = worktree_dir.unwrap_or(&cached.dir).to_path_buf();
3793        async move {
3794            let _guard = guard;
3795            start_with_dependencies(&daemon_id, &config_dir).await
3796        }
3797    });
3798    match tokio::time::timeout_at(deadline, start).await {
3799        Ok(Ok(Ok(()))) => {}
3800        Ok(Ok(Err(result))) => return result,
3801        Ok(Err(e)) => {
3802            log::warn!("Auto-start: start task for {daemon_id} failed: {e}");
3803            return ResolveResult::Error(format!("Failed to start daemon '{daemon_id}': {e}"));
3804        }
3805        Err(_elapsed) => return timed_out(),
3806    }
3807
3808    let result =
3809        tokio::time::timeout_at(deadline, wait_for_active_port(slug, &daemon_id, route)).await;
3810    drop(guard);
3811    result.unwrap_or_else(|_elapsed| timed_out())
3812}
3813
3814/// Start `daemon_id` and everything it depends on, through the same batch
3815/// start `pitchfork start` uses.
3816///
3817/// Configuration is loaded from `config_dir` — the checkout the hostname named
3818/// — together with every registered namespace, so a dependency on a daemon of
3819/// another registered project resolves just as it does from that directory on
3820/// the command line.
3821///
3822/// Returns the response to give the request when the daemon cannot be started.
3823async fn start_with_dependencies(
3824    daemon_id: &DaemonId,
3825    config_dir: &std::path::Path,
3826) -> std::result::Result<(), ResolveResult> {
3827    let loaded =
3828        crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces_from(config_dir).await;
3829    let pt = match loaded {
3830        Ok(pt) => pt,
3831        Err(e) => {
3832            log::warn!(
3833                "Auto-start: failed to load config from {}: {e}",
3834                config_dir.display()
3835            );
3836            return Err(ResolveResult::NotFound);
3837        }
3838    };
3839
3840    if !pt.daemons.contains_key(daemon_id) {
3841        log::debug!(
3842            "Auto-start: daemon {daemon_id} not found in config at {}",
3843            config_dir.display()
3844        );
3845        return Err(ResolveResult::NotFound);
3846    }
3847
3848    // `pitchfork start` refuses a disabled daemon, and so does the proxy.
3849    if SUPERVISOR
3850        .state_file
3851        .lock()
3852        .await
3853        .disabled
3854        .contains(daemon_id)
3855    {
3856        return Err(ResolveResult::Error(format!(
3857            "Daemon '{daemon_id}' is disabled, so it is not started.\n\
3858             Enable it with: pitchfork enable {daemon_id}"
3859        )));
3860    }
3861
3862    let graph: Vec<DaemonId> =
3863        match crate::deps::resolve_dependencies(std::slice::from_ref(daemon_id), &pt.daemons) {
3864            Ok(order) => order.levels.into_iter().flatten().collect(),
3865            Err(e) => {
3866                log::warn!("Auto-start: cannot resolve dependencies of {daemon_id}: {e}");
3867                return Err(ResolveResult::Error(format!(
3868                    "Cannot start '{daemon_id}': {e}"
3869                )));
3870            }
3871        };
3872    // Record the start as activity for every daemon in the graph, for as long
3873    // as the start runs — past the request's own wait, if it gives up. A
3874    // dependency that is already running is skipped by the start, so it must
3875    // not be stopped for inactivity meanwhile; one whose stop is already under
3876    // way is waited out first, and then started again. Taken before waiting
3877    // on the startup locks, so a dependency cannot go while this start queues
3878    // behind another.
3879    let _activity = match ACTIVITY.begin_all(&graph) {
3880        Some(activity) => activity,
3881        None => {
3882            wait_out_idle_stops(&graph, auto_start_deadline()).await;
3883            match ACTIVITY.begin_all(&graph) {
3884                Some(activity) => activity,
3885                None => {
3886                    return Err(ResolveResult::Error(format!(
3887                        "A dependency of '{daemon_id}' is still being stopped for inactivity.\n\
3888                         Reload to start it again."
3889                    )));
3890                }
3891            }
3892        }
3893    };
3894    let proxy_idle = proxy_idle_timeouts(daemon_id, &graph, &pt).await;
3895    let _locks = lock_startup_graph(graph).await;
3896
3897    // The same entry point as `pitchfork start`, over this supervisor's own
3898    // socket as the web UI's controls use it.
3899    let ipc = match crate::ipc::client::IpcClient::connect(false).await {
3900        Ok(ipc) => Arc::new(ipc),
3901        Err(e) => {
3902            log::warn!("Auto-start: failed to connect to the supervisor: {e}");
3903            return Err(ResolveResult::Error(format!(
3904                "Failed to start daemon '{daemon_id}': {e}"
3905            )));
3906        }
3907    };
3908    let opts = crate::ipc::batch::StartOptions {
3909        quiet: true,
3910        proxy_idle: Some(proxy_idle),
3911        ..Default::default()
3912    };
3913    let result = match ipc
3914        .start_daemons_with_config(std::slice::from_ref(daemon_id), opts, pt)
3915        .await
3916    {
3917        Ok(result) => result,
3918        Err(e) => {
3919            log::warn!("Auto-start: failed to start {daemon_id}: {e}");
3920            return Err(ResolveResult::Error(format!(
3921                "Failed to start daemon '{daemon_id}': {e}"
3922            )));
3923        }
3924    };
3925    if !result.any_failed {
3926        return Ok(());
3927    }
3928
3929    let message = match result.failed.first() {
3930        Some((id, reason)) if id == daemon_id => {
3931            format!("Daemon '{daemon_id}' failed to start: {reason}\nCheck its logs for errors.")
3932        }
3933        Some((dep, reason)) => format!(
3934            "Daemon '{daemon_id}' was not started because its dependency '{dep}' failed: \
3935             {reason}\n\
3936             Check the logs of '{dep}' for errors."
3937        ),
3938        None => format!(
3939            "Daemon '{daemon_id}' or one of its dependencies failed to start.\n\
3940             Check the supervisor log for errors."
3941        ),
3942    };
3943    log::warn!("Auto-start: {message}");
3944    Err(ResolveResult::Error(message))
3945}
3946
3947/// Poll the state file until a started daemon reports a port to forward to.
3948async fn wait_for_active_port(
3949    slug: &str,
3950    daemon_id: &DaemonId,
3951    route: &ProxyTlsRoute,
3952) -> ResolveResult {
3953    let poll_interval = std::time::Duration::from_millis(250);
3954
3955    loop {
3956        let daemons = {
3957            let sf = SUPERVISOR.state_file.lock().await;
3958            sf.daemons.clone()
3959        };
3960
3961        if let Some(d) = daemons.get(daemon_id) {
3962            if d.status.is_running() {
3963                // Selected through the same route as an already-running daemon,
3964                // so a hostname with `proxy_tls_port` lands on its configured
3965                // port on the request that started the daemon, not only on
3966                // later ones.
3967                if let Some(port) = select_daemon_port(route, d) {
3968                    // Recorded as the request's activity. Its start held the
3969                    // daemon's activity until it finished, so a stop for
3970                    // inactivity can only have been claimed since if the grace
3971                    // period is shorter than this poll. Then it is on its way
3972                    // down, and the request is answered as for a daemon still
3973                    // starting: the page reloads, and a passthrough connection
3974                    // resolves again, and either starts it once more.
3975                    return match ACTIVITY.begin(daemon_id) {
3976                        Some(activity) => {
3977                            log::info!("Auto-start: daemon {daemon_id} is ready on port {port}");
3978                            ResolveResult::Ready(port, Some(activity))
3979                        }
3980                        None => ResolveResult::Starting {
3981                            slug: slug.to_string(),
3982                        },
3983                    };
3984                }
3985            } else {
3986                log::warn!(
3987                    "Auto-start: daemon {daemon_id} is no longer running (status: {})",
3988                    d.status
3989                );
3990                return ResolveResult::Error(format!(
3991                    "Daemon '{daemon_id}' started but exited unexpectedly.\n\
3992                     Check its logs for errors."
3993                ));
3994            }
3995        } else {
3996            log::warn!("Auto-start: daemon {daemon_id} not found in state file after start");
3997            return ResolveResult::Error(format!(
3998                "Daemon '{daemon_id}' started but disappeared from the state file.\n\
3999                 Check its logs for errors."
4000            ));
4001        }
4002
4003        tokio::time::sleep(poll_interval).await;
4004    }
4005}
4006
4007/// Which daemons of a proxy auto-start may later be stopped for inactivity,
4008/// with their grace periods in milliseconds.
4009///
4010/// The daemon behind the hostname uses its own `proxy_idle_timeout`, or else
4011/// `proxy.idle_timeout` as its project resolves it. A dependency uses its own
4012/// `proxy_idle_timeout` when it sets one, and otherwise inherits the daemon's:
4013/// it was started only because that daemon needed it. Daemons with idle
4014/// shutdown off are left out, and are started as explicit.
4015///
4016/// Only daemons this start actually launches are affected; one that is
4017/// already running keeps whatever ownership it had.
4018async fn proxy_idle_timeouts(
4019    daemon_id: &DaemonId,
4020    closure: &[DaemonId],
4021    pt: &crate::pitchfork_toml::PitchforkToml,
4022) -> std::collections::HashMap<DaemonId, u64> {
4023    let own = |id: &DaemonId| pt.daemons.get(id).and_then(|d| d.proxy_idle_timeout);
4024    let target = match own(daemon_id) {
4025        Some(configured) => configured.duration(),
4026        None => {
4027            // Project settings are read from files, so off the request thread.
4028            let project_dir = crate::ipc::batch::resolve_config_base_dir(
4029                pt.daemons.get(daemon_id).and_then(|d| d.path.as_deref()),
4030            );
4031            tokio::task::spawn_blocking(move || {
4032                crate::settings::Settings::load_from_dir(&project_dir).proxy_idle_timeout()
4033            })
4034            .await
4035            .ok()
4036            .flatten()
4037        }
4038    };
4039    let millis = |d: std::time::Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
4040    closure
4041        .iter()
4042        .filter_map(|id| {
4043            let grace = if id == daemon_id {
4044                target
4045            } else {
4046                own(id).map_or(target, |configured| configured.duration())
4047            };
4048            grace.map(|g| (id.clone(), millis(g)))
4049        })
4050        .collect()
4051}
4052
4053/// Resolve a hostname against the automatic hostname registry.
4054///
4055/// Runs only after the legacy `[slugs]` registry found no match, so a slug
4056/// keeps precedence over an automatic hostname that spells the same thing.
4057async fn resolve_registry_target(subdomain: &str) -> ResolveResult {
4058    let registry = get_cached_host_registry().await;
4059    if !crate::proxy::hostname::hostname_fits(subdomain) {
4060        // Nothing advertises a name this long, so nothing answers to one.
4061        return ResolveResult::Unknown {
4062            heading: "Host name too long".to_string(),
4063            known: registry.project_labels(),
4064        };
4065    }
4066    match registry.resolve(subdomain, settings().proxy.wildcard) {
4067        crate::proxy::hostname::HostTarget::Daemon {
4068            ref dir,
4069            ref namespace,
4070            ref daemon,
4071            proxy_tls,
4072            proxy_tls_port,
4073            ..
4074        } => {
4075            // The mode and port were captured with the hostname, from one read
4076            // of the checkout's config, so a config that has since become
4077            // unreadable cannot turn a passthrough daemon into a terminated
4078            // one.
4079            let route = ProxyTlsRoute {
4080                mode: proxy_tls.unwrap_or_default(),
4081                port: proxy_tls_port,
4082            };
4083            // When several checkouts share this daemon's namespace — in this
4084            // project or in another one, since namespaces come from directory
4085            // names — the ID no longer says which checkout is running, so the
4086            // request has to be matched to the directory it named.
4087            let per_checkout = registry.shares_daemon_id(namespace, daemon);
4088            resolve_registry_daemon(subdomain, dir, namespace, daemon, per_checkout, &route).await
4089        }
4090        crate::proxy::hostname::HostTarget::ProjectPage { project } => {
4091            let entry = registry.projects.get(&project);
4092            ResolveResult::Page {
4093                daemons: entry.map(|p| p.primary.labels()).unwrap_or_default(),
4094                dir: entry.map(|p| p.primary.dir.clone()),
4095                project,
4096                worktree: None,
4097            }
4098        }
4099        crate::proxy::hostname::HostTarget::WorktreePage { project, worktree } => {
4100            let checkout = registry
4101                .projects
4102                .get(&project)
4103                .and_then(|p| p.worktrees.get(&worktree));
4104            ResolveResult::Page {
4105                daemons: checkout.map(|c| c.labels()).unwrap_or_default(),
4106                dir: checkout.map(|c| c.dir.clone()),
4107                project,
4108                worktree: Some(worktree),
4109            }
4110        }
4111        crate::proxy::hostname::HostTarget::UnknownProject { known } => ResolveResult::Unknown {
4112            heading: "Unknown project".to_string(),
4113            known,
4114        },
4115        crate::proxy::hostname::HostTarget::UnknownDaemon {
4116            project,
4117            worktree,
4118            known,
4119        } => ResolveResult::Unknown {
4120            heading: match worktree {
4121                Some(wt) => format!("Unknown daemon in '{wt}' of project '{project}'"),
4122                None => format!("Unknown daemon in project '{project}'"),
4123            },
4124            known,
4125        },
4126    }
4127}
4128
4129/// Find the running daemon behind an automatic hostname, auto-starting it when
4130/// it is not running.
4131///
4132/// Several checkouts of one project can share a namespace when the project
4133/// declares one explicitly, so a daemon running in the matching directory is
4134/// preferred over one that merely shares the name.
4135async fn resolve_registry_daemon(
4136    host: &str,
4137    dir: &std::path::Path,
4138    namespace: &str,
4139    daemon: &str,
4140    per_checkout: bool,
4141    route: &ProxyTlsRoute,
4142) -> ResolveResult {
4143    // One budget for waiting out an idle stop and the start after it.
4144    let deadline = auto_start_deadline();
4145    let daemons = {
4146        let state_file = SUPERVISOR.state_file.lock().await;
4147        state_file.daemons.clone()
4148    };
4149
4150    let mut matches: Vec<crate::daemon::Daemon> = daemons
4151        .iter()
4152        .filter(|(id, d)| {
4153            id.name() == daemon && id.namespace() == namespace && d.status.is_running()
4154        })
4155        .map(|(_, d)| d.clone())
4156        .collect();
4157    // Attributing a daemon to a checkout walks the filesystem, so it happens off
4158    // the request's worker thread.
4159    matches = sort_by_checkout(matches, dir).await;
4160
4161    if let Some(d) = matches.first() {
4162        // A running daemon from another checkout would serve that checkout's
4163        // content under this one's hostname, so say what is wrong instead.
4164        if per_checkout && !runs_in_checkout(d.clone(), dir).await {
4165            return ResolveResult::Error(format!(
4166                "'{host}' belongs to the checkout at {}, but daemon '{namespace}/{daemon}' is \
4167                 running from {}.\n\
4168                 These checkouts share the namespace '{namespace}', so pitchfork cannot run \
4169                 both copies at once.\n\
4170                 Give each checkout its own top-level `namespace`, or stop the other one first.",
4171                dir.display(),
4172                d.dir
4173                    .as_deref()
4174                    .map(|p| p.display().to_string())
4175                    .unwrap_or_else(|| "an unknown directory".to_string()),
4176            ));
4177        }
4178        let Some(port) = select_daemon_port(route, d) else {
4179            return ResolveResult::NotFound;
4180        };
4181        match begin_running(&d.id, deadline).await {
4182            Running::Yes(activity) => return ResolveResult::Ready(port, Some(activity)),
4183            Running::Stopping => {
4184                return ResolveResult::Starting {
4185                    slug: host.to_string(),
4186                };
4187            }
4188            // Stopped since the state was read: start it again below.
4189            Running::No => {}
4190        }
4191    }
4192
4193    let cached = CachedSlugEntry {
4194        slug: host.to_string(),
4195        namespace: Some(namespace.to_string()),
4196        daemon_name: daemon.to_string(),
4197        dir: dir.to_path_buf(),
4198        worktrees: vec![],
4199        rejected_worktree_prefixes: std::collections::HashSet::new(),
4200        tls: *route,
4201        worktree_tls: std::collections::HashMap::new(),
4202    };
4203    let result = try_auto_start(host, &cached, None, Some(namespace), route, deadline).await;
4204
4205    // The start can land on a record another checkout already owns, because the
4206    // supervisor refuses to run a second daemon under the same ID. Serving that
4207    // port would hand this hostname the other checkout's content.
4208    if per_checkout && let ResolveResult::Ready(..) = result {
4209        let started = {
4210            let state_file = SUPERVISOR.state_file.lock().await;
4211            state_file
4212                .daemons
4213                .iter()
4214                .find(|(id, _)| id.name() == daemon && id.namespace() == namespace)
4215                .map(|(_, d)| d.clone())
4216        };
4217        if let Some(d) = started
4218            && !runs_in_checkout(d.clone(), dir).await
4219        {
4220            return ResolveResult::Error(format!(
4221                "'{host}' belongs to the checkout at {}, but daemon '{namespace}/{daemon}' is \
4222                 running from {}.\n\
4223                 These checkouts share the namespace '{namespace}', so pitchfork cannot run \
4224                 both copies at once.\n\
4225                 Give each checkout its own top-level `namespace`, or stop the other one first.",
4226                dir.display(),
4227                d.dir
4228                    .as_deref()
4229                    .map(|p| p.display().to_string())
4230                    .unwrap_or_else(|| "an unknown directory".to_string()),
4231            ));
4232        }
4233    }
4234
4235    result
4236}
4237
4238/// Whether the request came from this machine.
4239///
4240/// Names of other people's projects, daemon labels and absolute paths are
4241/// details of the developer's machine. They help whoever is sitting at it and
4242/// tell a device on the LAN things it has no business knowing, so pages spell
4243/// them out for loopback clients only.
4244fn is_local_client(req: &Request) -> bool {
4245    // A request whose peer is unknown is treated as remote: withholding detail
4246    // from a local client is a small loss, and the reverse is a leak.
4247    req.extensions()
4248        .get::<axum::extract::ConnectInfo<SocketAddr>>()
4249        .is_some_and(|ci| ci.0.ip().is_loopback())
4250}
4251
4252/// Whether a daemon is running from this checkout.
4253///
4254/// The daemon's directory is resolved to the checkout that contains it rather
4255/// than compared as a path prefix, so a worktree nested inside its primary
4256/// checkout is attributed to the worktree, and a symlinked or non-canonical
4257/// directory still matches. A daemon whose explicit `dir` lies outside every
4258/// checkout belongs to none of them, which keeps it reachable as long as its
4259/// hostname is unambiguous.
4260fn daemon_runs_in(daemon: &crate::daemon::Daemon, checkout: &std::path::Path) -> bool {
4261    daemon
4262        .dir
4263        .as_deref()
4264        .is_some_and(|d| crate::proxy::hostname::checkout_root_of(d) == checkout)
4265}
4266
4267/// [`daemon_runs_in`] off the async worker, since it walks the filesystem.
4268async fn runs_in_checkout(daemon: crate::daemon::Daemon, checkout: &std::path::Path) -> bool {
4269    let checkout = checkout.to_path_buf();
4270    tokio::task::spawn_blocking(move || daemon_runs_in(&daemon, &checkout))
4271        .await
4272        .unwrap_or(false)
4273}
4274
4275/// Order the candidates so that daemons running in this checkout come first.
4276///
4277/// Only the attribution runs off-thread, and the candidates stay here, so a
4278/// failure in that task costs the ordering rather than the candidates
4279/// themselves.
4280async fn sort_by_checkout(
4281    daemons: Vec<crate::daemon::Daemon>,
4282    checkout: &std::path::Path,
4283) -> Vec<crate::daemon::Daemon> {
4284    if daemons.len() < 2 {
4285        return daemons;
4286    }
4287    let dirs: Vec<Option<std::path::PathBuf>> = daemons.iter().map(|d| d.dir.clone()).collect();
4288    let checkout = checkout.to_path_buf();
4289    let here = tokio::task::spawn_blocking(move || {
4290        dirs.iter()
4291            .map(|dir| {
4292                dir.as_deref()
4293                    .is_some_and(|d| crate::proxy::hostname::checkout_root_of(d) == checkout)
4294            })
4295            .collect::<Vec<bool>>()
4296    })
4297    .await;
4298
4299    match here {
4300        Ok(here) => {
4301            let mut ordered: Vec<(bool, crate::daemon::Daemon)> =
4302                here.into_iter().zip(daemons).collect();
4303            ordered.sort_by_key(|(here, _)| !here);
4304            ordered.into_iter().map(|(_, d)| d).collect()
4305        }
4306        Err(e) => {
4307            log::warn!("Checkout attribution task failed: {e}");
4308            daemons
4309        }
4310    }
4311}
4312
4313/// Escape the five characters that change the meaning of HTML text.
4314fn escape_html(s: &str) -> String {
4315    s.replace('&', "&amp;")
4316        .replace('<', "&lt;")
4317        .replace('>', "&gt;")
4318        .replace('"', "&quot;")
4319        .replace('\'', "&#x27;")
4320}
4321
4322/// Wrap body markup in the shared pitchfork page chrome.
4323fn html_page(status: StatusCode, title: &str, body: String) -> Response {
4324    let html = format!(
4325        r##"<!DOCTYPE html>
4326<html lang="en">
4327<head>
4328    <meta charset="UTF-8">
4329    <meta name="viewport" content="width=device-width, initial-scale=1">
4330    <title>{title} — pitchfork</title>
4331    <style>
4332        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
4333        body {{
4334            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
4335            background: #0f1117;
4336            color: #e1e4e8;
4337            display: flex;
4338            align-items: center;
4339            justify-content: center;
4340            min-height: 100vh;
4341        }}
4342        .container {{ max-width: 640px; padding: 2rem; }}
4343        h1 {{ font-size: 1.5rem; font-weight: 600; margin-bottom: 0.75rem; }}
4344        p {{ color: #8b949e; font-size: 0.9rem; margin-bottom: 0.75rem; }}
4345        ul {{ list-style: none; margin: 0.5rem 0 1rem; }}
4346        li {{ margin: 0.25rem 0; }}
4347        code, a {{
4348            color: #58a6ff;
4349            font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
4350            text-decoration: none;
4351        }}
4352    </style>
4353</head>
4354<body>
4355    <div class="container">{body}</div>
4356</body>
4357</html>"##
4358    );
4359    Response::builder()
4360        .status(status)
4361        .header("content-type", "text/html; charset=utf-8")
4362        .body(Body::from(html))
4363        .unwrap_or_else(|_| (status, title.to_string()).into_response())
4364}
4365
4366/// The `:port` part of a Host header, or an empty string when it carries none.
4367///
4368/// Links on pitchfork's own pages keep the port the request arrived on, so they
4369/// still work when the proxy listens somewhere other than 80 or 443.
4370fn host_port_suffix(raw_host: &str) -> String {
4371    let port = if raw_host.starts_with('[') {
4372        raw_host.split_once("]:").map(|(_, port)| port)
4373    } else {
4374        raw_host.rsplit_once(':').map(|(_, port)| port)
4375    };
4376    port.filter(|p| p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty())
4377        .map(|p| format!(":{p}"))
4378        .unwrap_or_default()
4379}
4380
4381/// Redirect a reserved project or stack hostname to its page in the web UI.
4382///
4383/// The page lives at `/projects/<project>[/<worktree>]`, which is its canonical
4384/// location. Both labels come from hostname labels, so they are already limited
4385/// to characters that need no escaping in a path.
4386fn page_redirect_response(base: &str, path: &str) -> Response {
4387    let target = format!("{base}{path}");
4388    Response::builder()
4389        .status(StatusCode::FOUND)
4390        .header(axum::http::header::LOCATION, &target)
4391        .header(axum::http::header::CACHE_CONTROL, "no-store")
4392        .body(axum::body::Body::from(format!(
4393            "This page is at {target}\n"
4394        )))
4395        .unwrap_or_else(|_| {
4396            html_page(
4397                StatusCode::INTERNAL_SERVER_ERROR,
4398                "pitchfork",
4399                String::new(),
4400            )
4401        })
4402}
4403
4404/// Serve the placeholder for a reserved project or stack hostname.
4405///
4406/// `<project>.<tld>` and `<worktree>.<project>.<tld>` belong to the project and
4407/// stack pages, which the web UI serves. This stands in when the web UI is not
4408/// running, so the hostname never resolves to whichever daemon shares its
4409/// name.
4410fn page_placeholder_response(
4411    project: &str,
4412    worktree: Option<&str>,
4413    daemons: &[String],
4414    tld: &str,
4415    port_suffix: &str,
4416    web_url: Option<&str>,
4417) -> Response {
4418    let heading = match worktree {
4419        Some(wt) => format!("{} · {}", escape_html(project), escape_html(wt)),
4420        None => escape_html(project),
4421    };
4422    let suffix = match worktree {
4423        Some(wt) => format!(
4424            "{}.{}.{}",
4425            escape_html(wt),
4426            escape_html(project),
4427            escape_html(tld)
4428        ),
4429        None => format!("{}.{}", escape_html(project), escape_html(tld)),
4430    };
4431    let list = if daemons.is_empty() {
4432        "<p>No daemon in this checkout has a port configured.</p>".to_string()
4433    } else {
4434        let items: String = daemons
4435            .iter()
4436            .map(|d| {
4437                let d = escape_html(d);
4438                format!("<li><a href=\"//{d}.{suffix}{port_suffix}\">{d}.{suffix}</a></li>")
4439            })
4440            .collect();
4441        format!("<p>Daemons here:</p><ul>{items}</ul>")
4442    };
4443    let page = if worktree.is_some() {
4444        "stack"
4445    } else {
4446        "project"
4447    };
4448    // The web UI has a page for a checkout only when a registered namespace
4449    // covers its directory, while a hostname is reserved for any checkout the
4450    // proxy knows, including ones known only from a slug or the state file. The
4451    // two cases need different advice.
4452    let explanation = match web_url {
4453        Some(url) => format!(
4454            "<p>This address is reserved for the {page} page, which the web UI serves.              No registered project covers this checkout, so it has no page yet: add its              directory under <code>[namespaces]</code> in your user config, or run              <code>pitchfork proxy add</code> from it.              <a href=\"{url}/projects\">Open the project list</a>.</p>",
4455            url = escape_html(url),
4456        ),
4457        None => format!(
4458            "<p>This address is reserved for the {page} page, which the web UI serves.              Enable it with <code>[settings.web] auto_start = true</code> to open this              address.</p>"
4459        ),
4460    };
4461    let body = format!("<h1>{heading}</h1>{explanation}{list}");
4462    html_page(StatusCode::OK, "pitchfork", body)
4463}
4464
4465/// Serve the 404 page for a hostname whose project or daemon does not exist.
4466fn unknown_host_response(host: &str, heading: &str, known: &[String]) -> Response {
4467    let list = if known.is_empty() {
4468        "<p>Nothing is registered under this name yet.</p>".to_string()
4469    } else {
4470        let items: String = known
4471            .iter()
4472            .map(|k| format!("<li><code>{}</code></li>", escape_html(k)))
4473            .collect();
4474        format!("<p>Known names:</p><ul>{items}</ul>")
4475    };
4476    let body = format!(
4477        "<h1>{heading}</h1><p>No route for <code>{host}</code>.</p>{list}",
4478        heading = escape_html(heading),
4479        host = escape_html(host),
4480    );
4481    html_page(StatusCode::NOT_FOUND, "Not found", body)
4482}
4483
4484/// Strip the TLD suffix from a hostname, returning the subdomain part.
4485///
4486/// Host names are case-insensitive (RFC 4343) and a browser passes on whatever
4487/// the user typed, so `API.MyProject.LOCALHOST` has to lose its TLD like any
4488/// other spelling. A trailing root dot names the same host and is accepted too:
4489/// this is the one place every routing path passes a host name through, whether
4490/// it came from a `Host` header, an HTTP/2 `:authority`, a peeked ClientHello,
4491/// or rustls handing over the SNI name it parsed, and only some of those are
4492/// normalized by the time they arrive.
4493///
4494/// Examples:
4495/// - `api.myproject.localhost` with tld `localhost` → `api.myproject`
4496/// - `API.LocalHost.` with tld `localhost` → `API`
4497/// - `localhost` with tld `localhost` → `None` (no subdomain)
4498fn strip_tld(host: &str, tld: &str) -> Option<String> {
4499    strip_dot_suffix_ignore_case(host.trim_end_matches('.'), tld)
4500}
4501
4502/// Build a human-friendly error message for port binding failures.
4503fn bind_error_message(port: u16, err: &std::io::Error) -> String {
4504    if port < 1024 {
4505        format!(
4506            "Failed to bind proxy server to port {port}: {err}\n\
4507             Hint: ports below 1024 require elevated privileges. Run \
4508             `pitchfork proxy setup`, which grants the bind capability on Linux, \
4509             or set an unprivileged proxy.port and let setup redirect {port} to it."
4510        )
4511    } else {
4512        format!(
4513            "Failed to bind proxy server to port {port}: {err}\n\
4514             Hint: another process may already be using this port."
4515        )
4516    }
4517}
4518
4519/// Build an HTML "Starting…" response that auto-refreshes every 2 seconds.
4520///
4521/// Displayed when a proxy request triggers an auto-start for a stopped daemon.
4522/// Once the daemon is ready, the next refresh will proxy normally to the backend.
4523fn starting_html_response(slug: &str, raw_host: &str) -> Response {
4524    let escaped_slug = slug
4525        .replace('&', "&amp;")
4526        .replace('<', "&lt;")
4527        .replace('>', "&gt;")
4528        .replace('"', "&quot;")
4529        .replace('\'', "&#x27;");
4530    let escaped_host = raw_host
4531        .replace('&', "&amp;")
4532        .replace('<', "&lt;")
4533        .replace('>', "&gt;")
4534        .replace('"', "&quot;")
4535        .replace('\'', "&#x27;");
4536
4537    let html = format!(
4538        r##"<!DOCTYPE html>
4539<html lang="en">
4540<head>
4541    <meta charset="UTF-8">
4542    <meta name="viewport" content="width=device-width, initial-scale=1">
4543    <meta http-equiv="refresh" content="2">
4544    <title>Starting {escaped_slug}… — pitchfork</title>
4545    <style>
4546        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
4547        body {{
4548            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
4549            background: #0f1117;
4550            color: #e1e4e8;
4551            display: flex;
4552            align-items: center;
4553            justify-content: center;
4554            min-height: 100vh;
4555        }}
4556        .container {{
4557            text-align: center;
4558            max-width: 480px;
4559            padding: 2rem;
4560        }}
4561        .spinner {{
4562            width: 48px;
4563            height: 48px;
4564            border: 4px solid rgba(255, 255, 255, 0.1);
4565            border-top-color: #58a6ff;
4566            border-radius: 50%;
4567            animation: spin 0.8s linear infinite;
4568            margin: 0 auto 1.5rem;
4569        }}
4570        @keyframes spin {{
4571            to {{ transform: rotate(360deg); }}
4572        }}
4573        h1 {{
4574            font-size: 1.5rem;
4575            font-weight: 600;
4576            margin-bottom: 0.5rem;
4577        }}
4578        .slug {{
4579            color: #58a6ff;
4580            font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
4581        }}
4582        .host {{
4583            color: #8b949e;
4584            font-size: 0.875rem;
4585            margin-top: 0.25rem;
4586        }}
4587        .hint {{
4588            color: #8b949e;
4589            font-size: 0.8rem;
4590            margin-top: 1.5rem;
4591        }}
4592    </style>
4593</head>
4594<body>
4595    <div class="container">
4596        <div class="spinner"></div>
4597        <h1>Starting <span class="slug">{escaped_slug}</span>…</h1>
4598        <p class="host">{escaped_host}</p>
4599        <p class="hint">This page will refresh automatically when the daemon is ready.</p>
4600    </div>
4601</body>
4602</html>"##
4603    );
4604
4605    Response::builder()
4606        .status(StatusCode::SERVICE_UNAVAILABLE)
4607        .header("content-type", "text/html; charset=utf-8")
4608        .header("retry-after", "2")
4609        .body(Body::from(html))
4610        .unwrap_or_else(|_| (StatusCode::SERVICE_UNAVAILABLE, "Starting…").into_response())
4611}
4612
4613/// Handler that redirects plain-HTTP requests to HTTPS.
4614///
4615/// Used when the proxy is configured for HTTPS but receives a plain-HTTP
4616/// request on the same port (after the first-byte peek determines it is
4617/// not a TLS ClientHello).  Returns a 302 redirect to the HTTPS equivalent.
4618///
4619/// WebSocket upgrade attempts over plain HTTP are rejected with 400
4620/// because WS-over-plain-HTTP to a TLS port is inherently broken.
4621async fn redirect_to_https_handler(req: Request) -> Response {
4622    // Reject WebSocket upgrades over plain HTTP
4623    if req.headers().contains_key("upgrade") {
4624        log::warn!("Dropping plain-HTTP WebSocket upgrade attempt — use wss:// instead of ws://");
4625        return (
4626            StatusCode::BAD_REQUEST,
4627            "WebSocket over plain HTTP is not supported on the HTTPS port. Use wss:// instead.",
4628        )
4629            .into_response();
4630    }
4631
4632    let raw_host = get_request_host(&req);
4633    let Some(raw_host) = raw_host else {
4634        return (StatusCode::BAD_REQUEST, "Missing Host header").into_response();
4635    };
4636
4637    // Strip any incoming port from Host and use the configured HTTPS port.
4638    let hostname = if raw_host.starts_with('[') {
4639        // IPv6: "[::1]:port" or "[::1]"
4640        raw_host
4641            .split_once("]:")
4642            .map(|(host, _)| host)
4643            .unwrap_or(&raw_host)
4644            .trim_start_matches('[')
4645            .trim_end_matches(']')
4646    } else {
4647        // IPv4/hostname: "host:port" or "host"
4648        let mut parts = raw_host.rsplitn(2, ':');
4649        let last = parts.next().unwrap_or(&raw_host);
4650        parts.next().unwrap_or(last)
4651    };
4652
4653    let path = req
4654        .uri()
4655        .path_and_query()
4656        .map(|pq| pq.as_str())
4657        .unwrap_or("/");
4658
4659    let https_port = match u16::try_from(settings().proxy.port).ok().filter(|&p| p > 0) {
4660        Some(443) | None => String::new(),
4661        Some(port) => format!(":{port}"),
4662    };
4663
4664    let host_for_url = if raw_host.starts_with('[') {
4665        format!("[{hostname}]")
4666    } else {
4667        hostname.to_string()
4668    };
4669
4670    let location = format!("https://{host_for_url}{https_port}{path}");
4671    (
4672        StatusCode::FOUND,
4673        [
4674            (axum::http::header::LOCATION, location),
4675            // Identifies the redirect as pitchfork's, so a probe on the port
4676            // can tell this listener apart from an unrelated service.
4677            (
4678                axum::http::HeaderName::from_static(PITCHFORK_HEADER),
4679                "1".to_string(),
4680            ),
4681        ],
4682    )
4683        .into_response()
4684}
4685
4686/// Build a plain-text error response.
4687fn error_response(status: StatusCode, message: &str) -> Response {
4688    // Carries the identification header like any other proxy response: a
4689    // client — `pitchfork proxy doctor` included — should be able to tell that
4690    // pitchfork answered even when the answer is an error.
4691    (
4692        status,
4693        [(
4694            axum::http::HeaderName::from_static(PITCHFORK_HEADER),
4695            HeaderValue::from_static("1"),
4696        )],
4697        message.to_string(),
4698    )
4699        .into_response()
4700}
4701
4702#[cfg(test)]
4703mod tests {
4704    use super::*;
4705
4706    /// An idle stop can be called off rather than carried out. A request
4707    /// that waited it out then finds the daemon still running and forwards to
4708    /// it, instead of starting it all over again.
4709    #[tokio::test]
4710    async fn a_request_forwards_after_an_idle_stop_is_called_off() {
4711        let id = DaemonId::new("calledoff", "api");
4712        SUPERVISOR.state_file.lock().await.daemons.insert(
4713            id.clone(),
4714            crate::daemon::Daemon {
4715                id: id.clone(),
4716                status: crate::daemon_status::DaemonStatus::Running,
4717                ..Default::default()
4718            },
4719        );
4720        assert!(ACTIVITY.claim_idle_stop(&id, std::time::Duration::ZERO));
4721        let releaser = {
4722            let id = id.clone();
4723            tokio::spawn(async move {
4724                tokio::time::sleep(std::time::Duration::from_millis(300)).await;
4725                ACTIVITY.release_idle_stop(&id);
4726            })
4727        };
4728        assert!(matches!(
4729            begin_running(&id, auto_start_deadline()).await,
4730            Running::Yes(_)
4731        ));
4732        releaser.await.unwrap();
4733        SUPERVISOR.state_file.lock().await.daemons.remove(&id);
4734    }
4735
4736    /// A request for a daemon mid idle-stop waits the stop out, so it can
4737    /// start the daemon again, instead of being answered "Starting…".
4738    #[tokio::test]
4739    async fn a_request_waits_out_an_idle_stop() {
4740        let id = DaemonId::new("waitproj", "api");
4741        assert!(ACTIVITY.claim_idle_stop(&id, std::time::Duration::ZERO));
4742        let releaser = {
4743            let id = id.clone();
4744            tokio::spawn(async move {
4745                tokio::time::sleep(std::time::Duration::from_millis(300)).await;
4746                ACTIVITY.release_idle_stop(&id);
4747            })
4748        };
4749        assert!(matches!(
4750            begin_running(&id, auto_start_deadline()).await,
4751            Running::No
4752        ));
4753        releaser.await.unwrap();
4754        assert!(ACTIVITY.begin(&id).is_some());
4755    }
4756
4757    /// Axum answers a path match with no method match itself, so a route
4758    /// registered for `GET` alone takes that path away from `fallback` for
4759    /// every other method. `/proxy.pac` has to stay reachable on a proxied
4760    /// hostname under any method, so it is registered with `any` and the
4761    /// handler decides. This pins the router behaviour that choice rests on.
4762    #[tokio::test]
4763    async fn a_get_only_route_never_reaches_the_fallback() {
4764        async fn routed() -> &'static str {
4765            "routed"
4766        }
4767        async fn fell_through() -> &'static str {
4768            "fell-through"
4769        }
4770
4771        // A hand-written request over a plain socket: no HTTP client, so no
4772        // TLS provider to install for a test that never uses TLS.
4773        async fn post_to(app: Router) -> String {
4774            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4775
4776            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4777            let addr = listener.local_addr().unwrap();
4778            let server = tokio::spawn(async move {
4779                let _ = axum::serve(listener, app).await;
4780            });
4781
4782            let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap();
4783            sock.write_all(
4784                b"POST /proxy.pac HTTP/1.1\r\nHost: example\r\nConnection: close\r\n\r\n",
4785            )
4786            .await
4787            .unwrap();
4788            let mut raw = Vec::new();
4789            sock.read_to_end(&mut raw).await.unwrap();
4790            server.abort();
4791
4792            let text = String::from_utf8_lossy(&raw).into_owned();
4793            let status = text.split_whitespace().nth(1).unwrap_or("").to_string();
4794            if status == "405" {
4795                return status;
4796            }
4797            text.rsplit("\r\n").next().unwrap_or("").to_string()
4798        }
4799
4800        let get_only = Router::new()
4801            .route("/proxy.pac", axum::routing::get(routed))
4802            .fallback(fell_through);
4803        assert_eq!(
4804            post_to(get_only).await,
4805            "405",
4806            "axum reached the fallback on a method mismatch, so `any` is unnecessary"
4807        );
4808
4809        let any_method = Router::new()
4810            .route("/proxy.pac", axum::routing::any(routed))
4811            .fallback(fell_through);
4812        assert_eq!(
4813            post_to(any_method).await,
4814            "routed",
4815            "`any` did not deliver the POST to the handler"
4816        );
4817    }
4818
4819    #[cfg(feature = "proxy-tls")]
4820    #[test]
4821    fn a_ca_pair_is_checked_not_just_found() {
4822        let dir = tempfile::tempdir().unwrap();
4823        let (cert, key) = (dir.path().join("ca.pem"), dir.path().join("ca-key.pem"));
4824        generate_ca(&cert, &key).unwrap();
4825        assert_eq!(ca_pair_problem(&cert, &key), None);
4826
4827        // A key from a different CA.
4828        let (other_cert, other_key) = (dir.path().join("b.pem"), dir.path().join("b-key.pem"));
4829        generate_ca(&other_cert, &other_key).unwrap();
4830        assert!(ca_pair_problem(&cert, &other_key).is_some());
4831
4832        // A truncated certificate.
4833        std::fs::write(&cert, "-----BEGIN CERTIFICATE-----\nAAAA\n").unwrap();
4834        assert!(ca_pair_problem(&cert, &key).is_some());
4835    }
4836
4837    #[cfg(feature = "proxy-tls")]
4838    #[test]
4839    fn a_new_ca_clears_leaves_signed_by_the_old_one() {
4840        // Cached leaves are checked only for expiry when loaded, so after a
4841        // regeneration they would be served for names the new CA never signed.
4842        let dir = tempfile::tempdir().unwrap();
4843        let (cert, key) = (dir.path().join("ca.pem"), dir.path().join("ca-key.pem"));
4844        let host_certs = host_certs_dir_for(&cert);
4845        std::fs::create_dir_all(&host_certs).unwrap();
4846        std::fs::write(host_certs.join("api.localhost.pem"), "old leaf").unwrap();
4847        std::fs::write(host_certs.join("notes.txt"), "keep me").unwrap();
4848
4849        // Kept while the pair is usable.
4850        assert!(!ensure_ca(&cert, &key, || true).unwrap());
4851        assert!(host_certs.join("api.localhost.pem").exists());
4852
4853        // Gone once a new pair is written; unrelated files stay.
4854        assert!(ensure_ca(&cert, &key, || false).unwrap());
4855        assert!(!host_certs.join("api.localhost.pem").exists());
4856        assert!(host_certs.join("notes.txt").exists());
4857    }
4858
4859    #[cfg(feature = "proxy-tls")]
4860    #[test]
4861    fn leaves_are_cached_per_ca_so_a_replaced_ca_is_never_served() {
4862        // A running supervisor can write a leaf after a new CA cleared the
4863        // cache. Loading only checks expiry, so the leaf has to live where the
4864        // new CA never looks.
4865        let dir = tempfile::tempdir().unwrap();
4866        let (cert, key) = (dir.path().join("ca.pem"), dir.path().join("ca-key.pem"));
4867        let _ = rustls::crypto::ring::default_provider().install_default();
4868        ensure_ca(&cert, &key, || false).unwrap();
4869        let first = SniCertResolver::new(&cert, &key, "localhost".into()).unwrap();
4870        assert!(first.get_or_create_checked("api.localhost").is_some());
4871        let old_dir = first.host_certs_dir.clone();
4872        assert!(std::fs::read_dir(&old_dir).unwrap().count() > 0);
4873
4874        ensure_ca(&cert, &key, || false).unwrap();
4875        // The old CA's resolver writes after the rotation.
4876        std::fs::create_dir_all(&old_dir).unwrap();
4877        std::fs::write(old_dir.join("late.localhost.pem"), "old leaf").unwrap();
4878
4879        let second = SniCertResolver::new(&cert, &key, "localhost".into()).unwrap();
4880        assert_ne!(second.host_certs_dir, old_dir);
4881        // Starting under the new CA clears the other CA's leaves outright.
4882        assert!(!old_dir.exists());
4883    }
4884
4885    #[cfg(feature = "proxy-tls")]
4886    #[test]
4887    fn cert_cache_file_names_do_not_collide() {
4888        // `.` used to become `_`, so these two shared a file.
4889        assert_ne!(
4890            cert_cache_file_stem("a_b.localhost"),
4891            cert_cache_file_stem("a.b.localhost")
4892        );
4893        assert_eq!(cert_cache_file_stem("api.localhost"), "api.localhost");
4894        assert_eq!(cert_cache_file_stem("a_b.localhost"), "a%5Fb.localhost");
4895        assert_eq!(
4896            cert_cache_file_stem("*.proj.localhost"),
4897            "%2A.proj.localhost"
4898        );
4899        // No path separators survive.
4900        assert!(!cert_cache_file_stem("../x").contains('/'));
4901    }
4902
4903    #[test]
4904    fn connect_port_reads_the_authority() {
4905        assert_eq!(connect_port("api.localhost:443"), Some(443));
4906        assert_eq!(connect_port("api.localhost:80"), Some(80));
4907        assert_eq!(connect_port("[::1]:443"), Some(443));
4908        assert_eq!(connect_port("api.localhost"), None);
4909        assert_eq!(connect_port("::1"), None);
4910    }
4911
4912    #[test]
4913    fn a_half_configured_certificate_pair_is_refused() {
4914        // Either half alone would be filled in from the generated CA: the
4915        // user's key signing with pitchfork's certificate, or a new CA key
4916        // written over the user's file.
4917        assert!(tls_pair_problem("", "/k.pem").is_some());
4918        assert!(tls_pair_problem("/c.pem", "").is_some());
4919        assert!(tls_pair_problem("", "").is_none());
4920        assert!(tls_pair_problem("/c.pem", "/k.pem").is_none());
4921    }
4922
4923    /// A resolver backed by a freshly generated CA in a temporary directory.
4924    #[cfg(feature = "proxy-tls")]
4925    fn test_resolver(tld: &str) -> (SniCertResolver, tempfile::TempDir) {
4926        let dir = tempfile::tempdir().unwrap();
4927        let cert = dir.path().join("ca.pem");
4928        let key = dir.path().join("ca-key.pem");
4929        generate_ca(&cert, &key).unwrap();
4930        let _ = rustls::crypto::ring::default_provider().install_default();
4931        (
4932            SniCertResolver::new(&cert, &key, tld.to_string()).unwrap(),
4933            dir,
4934        )
4935    }
4936
4937    /// SANs on a certificate the resolver minted for `domain`.
4938    #[cfg(feature = "proxy-tls")]
4939    fn sans_for(resolver: &SniCertResolver, domain: &str) -> Vec<String> {
4940        let ck = resolver.get_or_create(domain).expect("a certificate");
4941        let (_, cert) = x509_parser::parse_x509_certificate(&ck.cert[0]).unwrap();
4942        cert.subject_alternative_name()
4943            .unwrap()
4944            .map(|ext| {
4945                ext.value
4946                    .general_names
4947                    .iter()
4948                    .filter_map(|n| match n {
4949                        x509_parser::extensions::GeneralName::DNSName(d) => Some(d.to_string()),
4950                        _ => None,
4951                    })
4952                    .collect()
4953            })
4954            .unwrap_or_default()
4955    }
4956
4957    #[cfg(feature = "proxy-tls")]
4958    #[test]
4959    fn the_ca_refuses_to_sign_for_a_name_outside_the_tld() {
4960        use rustls::server::ResolvesServerCert;
4961
4962        let (resolver, _dir) = test_resolver("localhost");
4963        let cache = resolver.host_certs_dir.clone();
4964
4965        // The CA is installed in the machine's trust store and the proxy
4966        // answers on loopback, so minting for an arbitrary SNI would hand out a
4967        // browser-trusted certificate for somebody else's name.
4968        for foreign in [
4969            "login.microsoftonline.com",
4970            "example.com",
4971            "notlocalhost",
4972            "localhost.evil.com",
4973        ] {
4974            assert!(
4975                resolver.get_or_create_checked(foreign).is_none(),
4976                "expected {foreign:?} to be refused"
4977            );
4978        }
4979
4980        // And nothing was written to the on-disk cache for them.
4981        let cached: Vec<String> = std::fs::read_dir(&cache)
4982            .map(|rd| {
4983                rd.filter_map(|e| e.ok())
4984                    .map(|e| e.file_name().to_string_lossy().into_owned())
4985                    .collect()
4986            })
4987            .unwrap_or_default();
4988        assert!(
4989            cached.is_empty(),
4990            "unexpected cached certificates: {cached:?}"
4991        );
4992
4993        // Names under the TLD are still served, at any depth.
4994        for ours in ["localhost", "api.localhost", "core.wt.proj.localhost"] {
4995            assert!(
4996                resolver.get_or_create_checked(ours).is_some(),
4997                "expected {ours:?} to be issued"
4998            );
4999        }
5000        // `resolve` is the trait entry point and applies the same rule.
5001        let _ = &resolver as &dyn ResolvesServerCert;
5002    }
5003
5004    #[cfg(feature = "proxy-tls")]
5005    #[test]
5006    fn the_certificate_cache_is_bounded_and_evicts_the_oldest() {
5007        // The set of names under a TLD is unbounded, and each one costs a key
5008        // generation and a file, so the cache must not grow with it.
5009        let (resolver, _dir) = test_resolver("localhost");
5010        let host_certs = resolver.host_certs_dir.clone();
5011
5012        for i in 0..MAX_HOST_CERTS + 8 {
5013            assert!(
5014                resolver
5015                    .get_or_create_checked(&format!("h{i}.localhost"))
5016                    .is_some()
5017            );
5018        }
5019
5020        let cached = resolver.cache.lock().unwrap();
5021        assert_eq!(cached.by_domain.len(), MAX_HOST_CERTS);
5022        assert_eq!(cached.order.len(), MAX_HOST_CERTS);
5023        // Oldest first out, newest retained.
5024        assert!(cached.get("h0.localhost").is_none());
5025        assert!(
5026            cached
5027                .get(&format!("h{}.localhost", MAX_HOST_CERTS + 7))
5028                .is_some()
5029        );
5030        drop(cached);
5031
5032        // The files went with them, so the disk cache is bounded too.
5033        let on_disk = std::fs::read_dir(&host_certs).unwrap().count();
5034        assert!(
5035            on_disk <= MAX_HOST_CERTS,
5036            "{on_disk} files cached, expected at most {MAX_HOST_CERTS}"
5037        );
5038    }
5039
5040    #[cfg(feature = "proxy-tls")]
5041    #[test]
5042    fn the_disk_cache_is_pruned_at_startup() {
5043        // Eviction during a run only knows the names that run has seen, so
5044        // files left by earlier processes have to be cleared on the way in or
5045        // the directory grows across restarts.
5046        let dir = tempfile::tempdir().unwrap();
5047        let host_certs = dir.path().join("host-certs");
5048        std::fs::create_dir_all(&host_certs).unwrap();
5049        for i in 0..MAX_HOST_CERTS + 20 {
5050            std::fs::write(host_certs.join(format!("old{i}.pem")), "stale").unwrap();
5051        }
5052        // A file we do not own is left alone.
5053        std::fs::write(host_certs.join("notes.txt"), "keep me").unwrap();
5054
5055        prune_host_certs(&host_certs);
5056
5057        let pems = std::fs::read_dir(&host_certs)
5058            .unwrap()
5059            .filter_map(|e| e.ok())
5060            .filter(|e| e.path().extension().is_some_and(|x| x == "pem"))
5061            .count();
5062        assert_eq!(pems, MAX_HOST_CERTS);
5063        assert!(host_certs.join("notes.txt").exists());
5064
5065        // Under the limit, nothing is touched.
5066        let small = dir.path().join("small");
5067        std::fs::create_dir_all(&small).unwrap();
5068        std::fs::write(small.join("a.pem"), "x").unwrap();
5069        prune_host_certs(&small);
5070        assert!(small.join("a.pem").exists());
5071    }
5072
5073    #[cfg(feature = "proxy-tls")]
5074    #[test]
5075    fn a_minted_certificate_never_wildcards_the_whole_tld() {
5076        let (resolver, _dir) = test_resolver("localhost");
5077
5078        // One level down: the parent is the TLD itself, so no wildcard.
5079        let sans = sans_for(&resolver, "api.localhost");
5080        assert!(sans.contains(&"api.localhost".to_string()));
5081        assert!(
5082            !sans.iter().any(|s| s.starts_with('*')),
5083            "unexpected wildcard in {sans:?}"
5084        );
5085
5086        // Deeper: the sibling wildcard is inside the TLD, which is fine.
5087        let sans = sans_for(&resolver, "core.wt.proj.localhost");
5088        assert!(sans.contains(&"*.wt.proj.localhost".to_string()));
5089
5090        // A multi-label TLD is still a TLD; `*.dev.internal` would cover it all.
5091        let (resolver, _dir) = test_resolver("dev.internal");
5092        let sans = sans_for(&resolver, "api.dev.internal");
5093        assert!(
5094            !sans.iter().any(|s| s.starts_with('*')),
5095            "unexpected wildcard in {sans:?}"
5096        );
5097    }
5098
5099    /// The placeholder has to say why there is no page: the web UI being off is
5100    /// a different problem from a checkout no registered project covers, and
5101    /// the advice differs.
5102    #[tokio::test]
5103    async fn test_page_placeholder_explains_which_step_is_missing() {
5104        async fn body_of(response: Response) -> String {
5105            let (_, body) = response.into_parts();
5106            let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
5107            String::from_utf8(bytes.to_vec()).unwrap()
5108        }
5109
5110        let disabled = page_placeholder_response("shop", None, &[], "localhost", "", None);
5111        assert_eq!(disabled.status(), StatusCode::OK);
5112        let disabled = body_of(disabled).await;
5113        assert!(disabled.contains("auto_start"), "{disabled}");
5114        assert!(!disabled.contains("[namespaces]"));
5115
5116        let running = page_placeholder_response(
5117            "shop",
5118            Some("feature-a"),
5119            &[],
5120            "localhost",
5121            "",
5122            Some("http://127.0.0.1:3120"),
5123        );
5124        let running = body_of(running).await;
5125        assert!(running.contains("[namespaces]"), "{running}");
5126        assert!(running.contains("http://127.0.0.1:3120/projects"));
5127        assert!(!running.contains("auto_start"));
5128    }
5129
5130    /// A reserved project or stack hostname sends the browser to the page in
5131    /// the web UI, which is where those pages live.
5132    #[test]
5133    fn test_page_redirect_targets_the_web_ui() {
5134        let response = page_redirect_response("http://127.0.0.1:3120", "/projects/shop/feature-a");
5135        assert_eq!(response.status(), StatusCode::FOUND);
5136        assert_eq!(
5137            response
5138                .headers()
5139                .get(axum::http::header::LOCATION)
5140                .unwrap(),
5141            "http://127.0.0.1:3120/projects/shop/feature-a"
5142        );
5143
5144        let project = page_redirect_response("http://127.0.0.1:3120/ps", "/projects/shop");
5145        assert_eq!(
5146            project.headers().get(axum::http::header::LOCATION).unwrap(),
5147            // The base carries the web UI's path prefix when one is set.
5148            "http://127.0.0.1:3120/ps/projects/shop"
5149        );
5150    }
5151
5152    #[tokio::test]
5153    async fn overlapping_startup_graphs_wait_for_each_other() {
5154        use std::time::Duration;
5155        let id = |name: &str| DaemonId::try_new("startup-lock-test", name).unwrap();
5156
5157        let first = lock_startup_graph(vec![id("shared"), id("app-a")]).await;
5158
5159        // Listed in the opposite order to the first graph: sorted acquisition
5160        // is what keeps two overlapping graphs from deadlocking.
5161        let second = tokio::spawn(lock_startup_graph(vec![id("shared"), id("app-b")]));
5162        tokio::time::sleep(Duration::from_millis(50)).await;
5163        assert!(
5164            !second.is_finished(),
5165            "a graph sharing a daemon must wait for the graph starting it"
5166        );
5167
5168        // A graph with nothing in common does not wait.
5169        let disjoint = tokio::time::timeout(
5170            Duration::from_secs(1),
5171            lock_startup_graph(vec![id("other")]),
5172        )
5173        .await;
5174        assert!(disjoint.is_ok());
5175
5176        drop(first);
5177        let second = tokio::time::timeout(Duration::from_secs(1), second)
5178            .await
5179            .expect("the waiting graph proceeds once the first releases")
5180            .unwrap();
5181        assert_eq!(second.len(), 2);
5182        drop(second);
5183        drop(disjoint);
5184
5185        // Once nothing holds or awaits them, the locks are not retained.
5186        drop(lock_startup_graph(vec![id("unrelated")]).await);
5187        let map = STARTUP_LOCKS.lock().unwrap();
5188        assert!(
5189            !map.keys()
5190                .any(|k| k.namespace() == "startup-lock-test" && k.name() != "unrelated"),
5191            "released startup locks must be pruned"
5192        );
5193    }
5194
5195    #[test]
5196    fn test_strip_tld() {
5197        assert_eq!(
5198            strip_tld("api.myproject.localhost", "localhost"),
5199            Some("api.myproject".to_string())
5200        );
5201        // Host names are case-insensitive, and browsers pass on what was typed.
5202        assert_eq!(
5203            strip_tld("API.MyProject.LOCALHOST", "localhost"),
5204            Some("API.MyProject".to_string())
5205        );
5206        assert_eq!(
5207            strip_tld("api.localhost", "LOCALHOST"),
5208            Some("api".to_string())
5209        );
5210        assert_eq!(
5211            strip_tld("api.localhost", "localhost"),
5212            Some("api".to_string())
5213        );
5214        assert_eq!(strip_tld("localhost", "localhost"), None);
5215        // Host names are case-insensitive, and a trailing root dot names the
5216        // same host. rustls hands over the SNI name as the client wrote it, so
5217        // both spellings have to resolve or a passthrough hostname would be
5218        // read as terminating.
5219        assert_eq!(
5220            strip_tld("API.LocalHost", "localhost"),
5221            Some("API".to_string())
5222        );
5223        assert_eq!(
5224            strip_tld("api.localhost.", "localhost"),
5225            Some("api".to_string())
5226        );
5227        assert_eq!(
5228            strip_tld("API.MyProject.LOCALHOST.", "localhost"),
5229            Some("API.MyProject".to_string())
5230        );
5231        assert_eq!(strip_tld("localhost.", "localhost"), None);
5232        assert_eq!(
5233            strip_tld("api.myproject.test", "test"),
5234            Some("api.myproject".to_string())
5235        );
5236        assert_eq!(strip_tld("other.com", "localhost"), None);
5237    }
5238
5239    fn make_entry(name: &str) -> CachedSlugEntry {
5240        CachedSlugEntry {
5241            slug: name.to_string(),
5242            namespace: None,
5243            daemon_name: name.to_string(),
5244            dir: std::path::PathBuf::from(format!("/tmp/{name}")),
5245            worktrees: vec![],
5246            rejected_worktree_prefixes: std::collections::HashSet::new(),
5247            tls: ProxyTlsRoute::default(),
5248            worktree_tls: std::collections::HashMap::new(),
5249        }
5250    }
5251
5252    /// A stopped daemon record with the given configured and resolved ports.
5253    fn make_daemon(
5254        configured: &[u16],
5255        resolved: &[u16],
5256        active: Option<u16>,
5257    ) -> crate::daemon::Daemon {
5258        crate::daemon::Daemon {
5259            id: DaemonId::try_new("proj", "api").unwrap(),
5260            port: crate::config_types::PortConfig::from_parts(
5261                configured.to_vec(),
5262                crate::config_types::PortBump(0),
5263            ),
5264            resolved_port: resolved.to_vec(),
5265            active_port: active,
5266            ..crate::daemon::Daemon::default()
5267        }
5268    }
5269
5270    /// Without `proxy_tls_port`, the detected listening port wins, exactly as
5271    /// before this setting existed.
5272    #[test]
5273    fn test_select_daemon_port_prefers_active_port() {
5274        let route = ProxyTlsRoute::default();
5275        let d = make_daemon(&[8443, 9443], &[8443, 9443], Some(8443));
5276        assert_eq!(select_daemon_port(&route, &d), Some(8443));
5277    }
5278
5279    /// A port of 0 is not connectable — it is what a daemon carries when it
5280    /// asked the operating system to choose and nothing has been detected yet
5281    /// — so it is skipped rather than spliced to.
5282    #[test]
5283    fn test_select_daemon_port_skips_port_zero() {
5284        for mode in [ProxyTlsMode::Passthrough, ProxyTlsMode::Terminate] {
5285            let route = ProxyTlsRoute { mode, port: None };
5286
5287            // A later real port is used in place of the placeholder.
5288            let mixed = make_daemon(&[0, 8443], &[0, 8443], None);
5289            assert_eq!(select_daemon_port(&route, &mixed), Some(8443));
5290
5291            // A placeholder in the detected port must not shadow a real one
5292            // further down: it is skipped, not treated as the answer.
5293            let detected_placeholder = make_daemon(&[0, 8443], &[0, 8443], Some(0));
5294            assert_eq!(
5295                select_daemon_port(&route, &detected_placeholder),
5296                Some(8443)
5297            );
5298
5299            // With nothing but placeholders there is no route.
5300            let unresolved = make_daemon(&[0], &[0], Some(0));
5301            assert_eq!(select_daemon_port(&route, &unresolved), None);
5302        }
5303    }
5304
5305    /// A passthrough hostname without `proxy_tls_port` takes the daemon's
5306    /// declared first port rather than whichever listener was detected first:
5307    /// on a multi-port daemon the detected one can be a secondary plain-HTTP
5308    /// listener that came up before the TLS one.
5309    #[test]
5310    fn test_select_daemon_port_passthrough_prefers_declared_first_port() {
5311        let route = ProxyTlsRoute {
5312            mode: ProxyTlsMode::Passthrough,
5313            port: None,
5314        };
5315        let d = make_daemon(&[8443, 9080], &[8443, 9080], Some(9080));
5316        assert_eq!(select_daemon_port(&route, &d), Some(8443));
5317
5318        // With nothing resolved, the detected port is still better than
5319        // refusing to route.
5320        let detected_only = make_daemon(&[], &[], Some(9080));
5321        assert_eq!(select_daemon_port(&route, &detected_only), Some(9080));
5322    }
5323
5324    /// With no detected port yet, the first resolved port is used.
5325    #[test]
5326    fn test_select_daemon_port_falls_back_to_first_resolved() {
5327        let route = ProxyTlsRoute::default();
5328        let d = make_daemon(&[8443, 9443], &[8443, 9443], None);
5329        assert_eq!(select_daemon_port(&route, &d), Some(8443));
5330
5331        // A daemon with no ports at all does not route.
5332        let none = make_daemon(&[], &[], None);
5333        assert_eq!(select_daemon_port(&route, &none), None);
5334    }
5335
5336    /// `proxy_tls_port` picks a later port of a multi-port daemon, overriding
5337    /// the detected first port.
5338    #[test]
5339    fn test_select_daemon_port_honors_configured_port() {
5340        let route = ProxyTlsRoute {
5341            mode: ProxyTlsMode::Passthrough,
5342            port: Some(9443),
5343        };
5344        let d = make_daemon(&[8443, 9443], &[8443, 9443], Some(8443));
5345        assert_eq!(select_daemon_port(&route, &d), Some(9443));
5346    }
5347
5348    /// Auto-bump shifts the ports a daemon actually binds. The hostname still
5349    /// maps to the same *position* in its port list, so it follows the bump
5350    /// instead of pointing at a port nothing is listening on.
5351    #[test]
5352    fn test_select_daemon_port_follows_auto_bump() {
5353        let route = ProxyTlsRoute {
5354            mode: ProxyTlsMode::Passthrough,
5355            port: Some(9443),
5356        };
5357        let d = make_daemon(&[8443, 9443], &[8444, 9444], Some(8444));
5358        assert_eq!(select_daemon_port(&route, &d), Some(9444));
5359    }
5360
5361    /// A configured `proxy_tls_port` whose resolved port is still the
5362    /// placeholder 0 does not route either: the position matches, but nothing
5363    /// is listening there yet.
5364    #[test]
5365    fn test_select_daemon_port_skips_a_configured_port_resolved_to_zero() {
5366        let route = ProxyTlsRoute {
5367            mode: ProxyTlsMode::Passthrough,
5368            port: Some(9443),
5369        };
5370        // Declared [8443, 9443], but the second slot has not been resolved to
5371        // a real port yet.
5372        let pending = make_daemon(&[8443, 9443], &[8443, 0], Some(8443));
5373        assert_eq!(select_daemon_port(&route, &pending), None);
5374
5375        // Once it resolves, the same hostname routes to it.
5376        let ready = make_daemon(&[8443, 9443], &[8443, 9444], Some(8443));
5377        assert_eq!(select_daemon_port(&route, &ready), Some(9444));
5378    }
5379
5380    /// A daemon whose recorded ports contradict `proxy_tls_port` — a state
5381    /// record left by an older config — is not routed to a port it never
5382    /// bound. Config validation stops this combination from being written in
5383    /// the first place.
5384    #[test]
5385    fn test_select_daemon_port_refuses_a_port_the_daemon_never_bound() {
5386        let route = ProxyTlsRoute {
5387            mode: ProxyTlsMode::Passthrough,
5388            port: Some(9443),
5389        };
5390        let stale = make_daemon(&[8443], &[8443], Some(8443));
5391        assert_eq!(select_daemon_port(&route, &stale), None);
5392
5393        // With nothing recorded to contradict it, the configured port is all
5394        // the information there is, so it is used.
5395        let bare = make_daemon(&[], &[], None);
5396        assert_eq!(select_daemon_port(&route, &bare), Some(9443));
5397    }
5398
5399    /// `proxy_tls_port` selects the forwarded port in terminate mode too, not
5400    /// only for passthrough hostnames.
5401    #[test]
5402    fn test_select_daemon_port_honors_configured_port_when_terminating() {
5403        let route = ProxyTlsRoute {
5404            mode: ProxyTlsMode::Terminate,
5405            port: Some(9080),
5406        };
5407        let d = make_daemon(&[8080, 9080], &[8080, 9080], Some(8080));
5408        assert_eq!(select_daemon_port(&route, &d), Some(9080));
5409
5410        // Following auto-bump by position, as in passthrough mode.
5411        let bumped = make_daemon(&[8080, 9080], &[8081, 9081], Some(8081));
5412        assert_eq!(select_daemon_port(&route, &bumped), Some(9081));
5413    }
5414
5415    /// A directory that says nothing about the daemon reads as no route at
5416    /// all, so a worktree the proxy cannot read inherits its slug's mode
5417    /// instead of being recorded as terminating and quietly ending a splice.
5418    #[test]
5419    fn test_read_proxy_tls_route_absent_without_config() {
5420        let dir = tempfile::tempdir().unwrap();
5421
5422        // A directory with no config at all.
5423        assert_eq!(
5424            read_proxy_tls_route(dir.path(), Some("proj"), "api").unwrap(),
5425            None
5426        );
5427
5428        // A config that describes some other daemon.
5429        std::fs::write(
5430            dir.path().join("pitchfork.toml"),
5431            "[daemons.other]\nrun = \"serve\"\n",
5432        )
5433        .unwrap();
5434        assert_eq!(
5435            read_proxy_tls_route(dir.path(), Some("proj"), "api").unwrap(),
5436            None,
5437            "a config without this daemon says nothing about it"
5438        );
5439
5440        // No namespace to resolve the daemon against.
5441        assert_eq!(read_proxy_tls_route(dir.path(), None, "api").unwrap(), None);
5442    }
5443
5444    /// A config that cannot be read — here because an unrelated daemon is
5445    /// invalid — is an error, not a route of `terminate`, and a refresh keeps
5446    /// the route it already knew instead of downgrading a passthrough daemon.
5447    #[test]
5448    fn test_unreadable_config_keeps_the_last_known_route() {
5449        let dir = tempfile::tempdir().unwrap();
5450        std::fs::write(
5451            dir.path().join("pitchfork.toml"),
5452            "[daemons.api]\nrun = \"serve\"\nport = 8443\nproxy_tls = \"passthrough\"\n\n\
5453             [daemons.broken]\nrun = \"serve\"\nproxy_tls = \"passthrough\"\n",
5454        )
5455        .unwrap();
5456        let read = read_proxy_tls_route(dir.path(), Some("proj"), "api");
5457        assert!(
5458            read.is_err(),
5459            "an invalid sibling makes the config unreadable"
5460        );
5461
5462        let known = ProxyTlsRoute {
5463            mode: ProxyTlsMode::Passthrough,
5464            port: Some(8443),
5465        };
5466        assert_eq!(
5467            route_or_last_known(read, Some(known), dir.path(), "api"),
5468            Some(known)
5469        );
5470
5471        // A readable answer replaces what was known, even when it is silence.
5472        assert_eq!(
5473            route_or_last_known(Ok(None), Some(known), dir.path(), "api"),
5474            None
5475        );
5476    }
5477
5478    /// A route is only carried over while the slug still points at the same
5479    /// daemon, directory and namespace; a repointed slug starts from nothing.
5480    #[test]
5481    fn test_known_route_requires_the_same_target() {
5482        let known = ProxyTlsRoute {
5483            mode: ProxyTlsMode::Passthrough,
5484            port: Some(8443),
5485        };
5486        let mut entry = make_entry("api");
5487        entry.namespace = Some("proj".to_string());
5488        entry.tls = known;
5489        let dir = entry.dir.clone();
5490
5491        assert_eq!(entry.known_route(&dir, Some("proj"), "api"), Some(known));
5492        assert_eq!(entry.known_route(&dir, Some("proj"), "web"), None);
5493        assert_eq!(entry.known_route(&dir, Some("other"), "api"), None);
5494        assert_eq!(
5495            entry.known_route(std::path::Path::new("/elsewhere"), Some("proj"), "api"),
5496            None
5497        );
5498
5499        let wt = make_worktree("feature/x", "feature-x");
5500        entry.worktrees = vec![wt.clone()];
5501        entry.worktree_tls.insert("feature-x".to_string(), known);
5502        assert_eq!(entry.known_worktree_route(&wt, "api"), Some(known));
5503        assert_eq!(entry.known_worktree_route(&wt, "web"), None);
5504        let moved = crate::proxy::worktree::WorktreeEntry {
5505            path: std::path::PathBuf::from("/elsewhere/feature-x"),
5506            ..wt
5507        };
5508        assert_eq!(entry.known_worktree_route(&moved, "api"), None);
5509    }
5510
5511    /// A worktree whose own config describes the daemon is authoritative,
5512    /// including when it leaves `proxy_tls` out, and one the proxy knows
5513    /// nothing about inherits the slug's route.
5514    #[test]
5515    fn test_worktree_route_inherits_when_unknown() {
5516        let mut entry = make_entry("spliced");
5517        entry.tls = ProxyTlsRoute {
5518            mode: ProxyTlsMode::Passthrough,
5519            port: Some(8443),
5520        };
5521        entry.worktrees = vec![
5522            make_worktree("feature/known", "feature-known"),
5523            make_worktree("feature/unknown", "feature-unknown"),
5524        ];
5525        // Only the worktree whose config was readable gets an entry.
5526        entry.worktree_tls.insert(
5527            "feature-known".to_string(),
5528            ProxyTlsRoute {
5529                mode: ProxyTlsMode::Terminate,
5530                port: None,
5531            },
5532        );
5533        let mut entries = std::collections::HashMap::new();
5534        entries.insert("spliced".to_string(), entry);
5535
5536        let mode =
5537            |host: &str| resolve_tls_mode_in(host, "localhost", &entries, &Default::default());
5538        assert_eq!(
5539            mode("feature-known.spliced.localhost"),
5540            ProxyTlsMode::Terminate,
5541            "an explicit worktree setting wins"
5542        );
5543        assert_eq!(
5544            mode("feature-unknown.spliced.localhost"),
5545            ProxyTlsMode::Passthrough,
5546            "a worktree with nothing recorded inherits the slug"
5547        );
5548
5549        // The port is not inherited with the mode: it names a position in one
5550        // daemon's port list, and a worktree hostname reaches a different
5551        // process with its own ports.
5552        let cached = entries.get("spliced").unwrap();
5553        assert_eq!(
5554            worktree_route(cached, "feature-unknown"),
5555            ProxyTlsRoute {
5556                mode: ProxyTlsMode::Passthrough,
5557                port: None,
5558            }
5559        );
5560        // An explicit worktree route is taken whole, port included.
5561        assert_eq!(
5562            worktree_route(cached, "feature-known"),
5563            ProxyTlsRoute {
5564                mode: ProxyTlsMode::Terminate,
5565                port: None,
5566            }
5567        );
5568    }
5569
5570    /// The route of a wildcard worktree host comes from that worktree's own
5571    /// config, and falls back to the slug's when the worktree has none.
5572    #[test]
5573    fn test_worktree_route_lookup() {
5574        let mut entry = make_entry("myapp");
5575        entry.tls = ProxyTlsRoute {
5576            mode: ProxyTlsMode::Terminate,
5577            port: None,
5578        };
5579        entry.worktrees = vec![
5580            make_worktree("feature/b", "feature-b"),
5581            make_worktree("feature/c", "feature-c"),
5582        ];
5583        entry.worktree_tls.insert(
5584            "feature-b".to_string(),
5585            ProxyTlsRoute {
5586                mode: ProxyTlsMode::Passthrough,
5587                port: Some(9443),
5588            },
5589        );
5590
5591        let lookup = |prefix: &str| match match_worktree_prefix(&entry, prefix) {
5592            PrefixMatch::Worktree(wt) => entry
5593                .worktree_tls
5594                .get(&wt.sanitized_branch.to_ascii_lowercase())
5595                .copied()
5596                .unwrap_or(entry.tls),
5597            _ => entry.tls,
5598        };
5599
5600        assert_eq!(lookup("feature-b").mode, ProxyTlsMode::Passthrough);
5601        assert_eq!(lookup("feature-b").port, Some(9443));
5602        // A worktree without its own setting inherits the slug's mode.
5603        assert_eq!(lookup("feature-c").mode, ProxyTlsMode::Terminate);
5604        // So does an ordinary wildcard subdomain.
5605        assert_eq!(lookup("tenant").mode, ProxyTlsMode::Terminate);
5606    }
5607
5608    #[test]
5609    fn test_wildcard_slug_lookup_exact_match() {
5610        let mut entries = std::collections::HashMap::new();
5611        entries.insert("myapp".to_string(), make_entry("myapp"));
5612        // Exact match takes priority.
5613        let result = wildcard_slug_lookup("myapp", &entries, true);
5614        assert!(result.is_some());
5615        assert_eq!(result.unwrap().daemon_name, "myapp");
5616    }
5617
5618    #[test]
5619    fn test_wildcard_slug_lookup_subdomain_fallback() {
5620        let mut entries = std::collections::HashMap::new();
5621        entries.insert("myapp".to_string(), make_entry("myapp"));
5622        // "tenant.myapp" falls back to "myapp".
5623        let result = wildcard_slug_lookup("tenant.myapp", &entries, true);
5624        assert!(result.is_some());
5625        assert_eq!(result.unwrap().daemon_name, "myapp");
5626    }
5627
5628    #[test]
5629    fn test_wildcard_slug_lookup_nested_fallback() {
5630        let mut entries = std::collections::HashMap::new();
5631        entries.insert("myapp".to_string(), make_entry("myapp"));
5632        // "a.b.myapp" falls back to "myapp" through "b.myapp" → "myapp".
5633        let result = wildcard_slug_lookup("a.b.myapp", &entries, true);
5634        assert!(result.is_some());
5635        assert_eq!(result.unwrap().daemon_name, "myapp");
5636    }
5637
5638    #[test]
5639    fn test_wildcard_slug_lookup_no_match() {
5640        let entries = std::collections::HashMap::new();
5641        // Empty entries → no match.
5642        let result = wildcard_slug_lookup("tenant.myapp", &entries, true);
5643        assert!(result.is_none());
5644    }
5645
5646    #[test]
5647    fn test_wildcard_slug_lookup_disabled() {
5648        let mut entries = std::collections::HashMap::new();
5649        entries.insert("myapp".to_string(), make_entry("myapp"));
5650        // With wildcard disabled, "tenant.myapp" does NOT match "myapp".
5651        let result = wildcard_slug_lookup("tenant.myapp", &entries, false);
5652        assert!(result.is_none());
5653        // But exact match still works.
5654        let result = wildcard_slug_lookup("myapp", &entries, false);
5655        assert!(result.is_some());
5656    }
5657
5658    #[test]
5659    fn test_wildcard_slug_lookup_exact_beats_wildcard() {
5660        let mut entries = std::collections::HashMap::new();
5661        entries.insert("myapp".to_string(), make_entry("myapp"));
5662        let mut tenant_entry = make_entry("tenant-daemon");
5663        tenant_entry.slug = "tenant.myapp".to_string();
5664        entries.insert("tenant.myapp".to_string(), tenant_entry);
5665        // "tenant.myapp" should match the exact slug, not fall back to "myapp".
5666        let result = wildcard_slug_lookup("tenant.myapp", &entries, true);
5667        assert!(result.is_some());
5668        assert_eq!(result.unwrap().daemon_name, "tenant-daemon");
5669    }
5670
5671    #[test]
5672    fn test_wildcard_slug_lookup_ignores_case() {
5673        let mut entries = std::collections::HashMap::new();
5674        entries.insert("myapp".to_string(), make_entry("myapp"));
5675        // Browsers lowercase the Host header, so every spelling must resolve.
5676        for host in ["MyApp", "MYAPP", "myapp"] {
5677            let result = wildcard_slug_lookup(host, &entries, true);
5678            assert!(result.is_some(), "exact lookup failed for {host}");
5679            assert_eq!(result.unwrap().daemon_name, "myapp");
5680        }
5681        // ...including through the wildcard fallback.
5682        for host in ["Tenant.MyApp", "tenant.MYAPP", "A.B.MyApp"] {
5683            let result = wildcard_slug_lookup(host, &entries, true);
5684            assert!(result.is_some(), "wildcard lookup failed for {host}");
5685            assert_eq!(result.unwrap().daemon_name, "myapp");
5686        }
5687    }
5688
5689    #[test]
5690    fn test_wildcard_slug_lookup_case_insensitive_registration() {
5691        let mut entries = std::collections::HashMap::new();
5692        let mut entry = make_entry("upper");
5693        entry.slug = "MyApp".to_string();
5694        // build_slug_entries lowercases the key while keeping the configured
5695        // spelling in `slug`, so a capitalized registration stays reachable.
5696        entries.insert("myapp".to_string(), entry);
5697        for host in ["myapp", "MyApp", "tenant.MYAPP"] {
5698            let result = wildcard_slug_lookup(host, &entries, true);
5699            assert!(result.is_some(), "lookup failed for {host}");
5700            assert_eq!(result.unwrap().daemon_name, "upper");
5701        }
5702    }
5703
5704    fn make_worktree(branch: &str, sanitized: &str) -> crate::proxy::worktree::WorktreeEntry {
5705        crate::proxy::worktree::WorktreeEntry {
5706            path: std::path::PathBuf::from(format!("/tmp/{sanitized}")),
5707            branch: branch.to_string(),
5708            sanitized_branch: sanitized.to_string(),
5709            namespace: Some(sanitized.to_string()),
5710        }
5711    }
5712
5713    #[test]
5714    fn test_reject_case_colliding_worktrees_drops_both_sides() {
5715        let wts = vec![
5716            make_worktree("Feature-A", "Feature-A"),
5717            make_worktree("feature-a", "feature-a"),
5718            make_worktree("main", "main"),
5719        ];
5720        let (kept, rejected) = reject_case_colliding_worktrees(wts);
5721        // Neither spelling routes; picking one would send the request to a
5722        // worktree the user did not name.
5723        assert_eq!(kept.len(), 1);
5724        assert_eq!(kept[0].sanitized_branch, "main");
5725        // The prefix is remembered so it is refused rather than treated as an
5726        // unknown wildcard prefix.
5727        assert!(rejected.contains("feature-a"));
5728    }
5729
5730    #[test]
5731    fn test_reject_case_colliding_worktrees_keeps_unambiguous() {
5732        let wts = vec![
5733            make_worktree("main", "main"),
5734            make_worktree("feature/a", "feature-a"),
5735        ];
5736        let (kept, rejected) = reject_case_colliding_worktrees(wts);
5737        assert_eq!(kept.len(), 2);
5738        assert!(rejected.is_empty());
5739    }
5740
5741    #[test]
5742    fn test_reject_case_colliding_worktrees_drops_sanitize_duplicates() {
5743        // Distinct branches can sanitize to the same string without any case
5744        // difference; that is ambiguous for the same reason.
5745        let wts = vec![
5746            make_worktree("feature/a", "feature-a"),
5747            make_worktree("feature.a", "feature-a"),
5748        ];
5749        let (kept, rejected) = reject_case_colliding_worktrees(wts);
5750        assert!(kept.is_empty());
5751        assert!(rejected.contains("feature-a"));
5752    }
5753
5754    #[test]
5755    fn test_match_worktree_prefix() {
5756        let mut entry = make_entry("myapp");
5757        entry.worktrees = vec![make_worktree("feature/b", "feature-b")];
5758        entry
5759            .rejected_worktree_prefixes
5760            .insert("feature-a".to_string());
5761
5762        assert!(matches!(
5763            match_worktree_prefix(&entry, "feature-b"),
5764            PrefixMatch::Worktree(_)
5765        ));
5766        // Host case does not matter for either outcome.
5767        assert!(matches!(
5768            match_worktree_prefix(&entry, "Feature-B"),
5769            PrefixMatch::Worktree(_)
5770        ));
5771        // A rejected prefix is refused, not served by the main checkout.
5772        assert!(matches!(
5773            match_worktree_prefix(&entry, "feature-a"),
5774            PrefixMatch::Ambiguous
5775        ));
5776        assert!(matches!(
5777            match_worktree_prefix(&entry, "FEATURE-A"),
5778            PrefixMatch::Ambiguous
5779        ));
5780        // An unrelated prefix is still an ordinary wildcard subdomain.
5781        assert!(matches!(
5782            match_worktree_prefix(&entry, "tenant"),
5783            PrefixMatch::Unknown
5784        ));
5785    }
5786
5787    /// Each way of reaching the HTTP path with a passthrough hostname names its
5788    /// own remedy, since the fixes are different.
5789    #[test]
5790    fn test_passthrough_unroutable_message() {
5791        let no_tls = passthrough_unroutable_message("api.localhost", false);
5792        assert!(no_tls.contains("api.localhost"), "{no_tls}");
5793        assert!(no_tls.contains("settings.proxy.https = true"), "{no_tls}");
5794
5795        // Over TLS this branch is reached when the handshake named a
5796        // different host than the request did — a connection with no host
5797        // name at all fails in the certificate resolver and never gets here.
5798        let mismatch = passthrough_unroutable_message("api.localhost", true);
5799        assert!(mismatch.contains("named a different host"), "{mismatch}");
5800        assert!(
5801            !mismatch.contains("settings.proxy.https"),
5802            "a host mismatch is not an HTTPS configuration problem: {mismatch}"
5803        );
5804    }
5805
5806    /// Routing and the certificate resolver read one table, so they cannot
5807    /// disagree about a hostname's mode: the synchronous read returns the very
5808    /// same allocation the async one does.
5809    #[tokio::test]
5810    async fn test_slug_snapshot_is_the_cached_table() {
5811        let from_async = get_cached_slugs().await;
5812        let from_sync = slug_snapshot();
5813        assert!(
5814            Arc::ptr_eq(&from_async, &from_sync),
5815            "the synchronous read must see the same table routing does"
5816        );
5817    }
5818
5819    /// Callers arriving together on an expired cache share one table rather
5820    /// than each building and holding its own. A caller holding its own build
5821    /// would route a hostname by a table the certificate resolver does not
5822    /// read.
5823    #[tokio::test]
5824    async fn test_concurrent_refresh_returns_the_published_table() {
5825        // The cache starts expired, so both calls take the refresh path; the
5826        // second waits for the first rather than building a second table.
5827        let (first, second) = tokio::join!(get_cached_slugs(), get_cached_slugs());
5828        assert!(
5829            Arc::ptr_eq(&first, &second),
5830            "overlapping refreshes must agree on one table"
5831        );
5832        assert!(
5833            Arc::ptr_eq(&first, &slug_snapshot()),
5834            "and it must be the published one"
5835        );
5836    }
5837
5838    /// The synchronous mode lookup the certificate resolver uses agrees with
5839    /// the async one: exact hosts, wildcard subdomains, worktree prefixes with
5840    /// their own setting, and anything that is not a slug at all.
5841    #[test]
5842    fn test_resolve_tls_mode_in() {
5843        let mut entries = std::collections::HashMap::new();
5844
5845        let mut spliced = make_entry("spliced");
5846        spliced.tls = ProxyTlsRoute {
5847            mode: ProxyTlsMode::Passthrough,
5848            port: Some(8443),
5849        };
5850        spliced.worktrees = vec![
5851            make_worktree("feature/b", "feature-b"),
5852            make_worktree("feature/c", "feature-c"),
5853        ];
5854        spliced.worktree_tls.insert(
5855            "feature-b".to_string(),
5856            ProxyTlsRoute {
5857                mode: ProxyTlsMode::Terminate,
5858                port: None,
5859            },
5860        );
5861        entries.insert("spliced".to_string(), spliced);
5862        entries.insert("plain".to_string(), make_entry("plain"));
5863
5864        let mode =
5865            |host: &str| resolve_tls_mode_in(host, "localhost", &entries, &Default::default());
5866
5867        assert_eq!(mode("spliced.localhost"), ProxyTlsMode::Passthrough);
5868        // Host names are case-insensitive, including the TLD, and a trailing
5869        // root dot names the same host — rustls passes the SNI name through
5870        // exactly as the client wrote it.
5871        assert_eq!(mode("SPLICED.localhost"), ProxyTlsMode::Passthrough);
5872        assert_eq!(mode("Spliced.LocalHost"), ProxyTlsMode::Passthrough);
5873        assert_eq!(mode("spliced.localhost."), ProxyTlsMode::Passthrough);
5874        assert_eq!(
5875            mode("FEATURE-C.Spliced.LOCALHOST."),
5876            ProxyTlsMode::Passthrough
5877        );
5878        // A wildcard subdomain inherits the slug's mode.
5879        assert_eq!(mode("tenant.spliced.localhost"), ProxyTlsMode::Passthrough);
5880        // A worktree with its own setting overrides the slug's …
5881        assert_eq!(mode("feature-b.spliced.localhost"), ProxyTlsMode::Terminate);
5882        // … and one without it inherits.
5883        assert_eq!(
5884            mode("feature-c.spliced.localhost"),
5885            ProxyTlsMode::Passthrough
5886        );
5887        // Everything else terminates: another slug, an unknown host, the
5888        // bare TLD, and a host outside the TLD.
5889        assert_eq!(mode("plain.localhost"), ProxyTlsMode::Terminate);
5890        assert_eq!(mode("unknown.localhost"), ProxyTlsMode::Terminate);
5891        assert_eq!(mode("localhost"), ProxyTlsMode::Terminate);
5892        assert_eq!(mode("spliced.example.com"), ProxyTlsMode::Terminate);
5893    }
5894
5895    /// A daemon reached through its automatic `<daemon>.<project>` hostname
5896    /// gets the mode from its own config, the same as one reached through a
5897    /// legacy slug. Automatic hostnames are the common case, so passthrough
5898    /// has to work without a slug registration.
5899    #[test]
5900    fn test_resolve_tls_mode_in_uses_the_hostname_registry() {
5901        let dir = tempfile::tempdir().unwrap();
5902        let project = dir.path().join("autoproj");
5903        std::fs::create_dir_all(&project).unwrap();
5904        std::fs::write(
5905            project.join("pitchfork.toml"),
5906            "[daemons.secure]\nrun = \"serve\"\nport = 8443\nproxy_tls = \"passthrough\"\n\
5907             [daemons.plain]\nrun = \"serve\"\nport = 8080\n",
5908        )
5909        .unwrap();
5910
5911        let registry =
5912            crate::proxy::hostname::HostRegistry::from_dirs(std::slice::from_ref(&project));
5913        let slugs = std::collections::HashMap::new();
5914        let mode = |host: &str| resolve_tls_mode_in(host, "localhost", &slugs, &registry);
5915
5916        assert_eq!(mode("secure.autoproj.localhost"), ProxyTlsMode::Passthrough);
5917        assert_eq!(mode("plain.autoproj.localhost"), ProxyTlsMode::Terminate);
5918
5919        // The mode travels with the registry entry, so a config that becomes
5920        // unreadable after the registry was built cannot turn the passthrough
5921        // daemon into a terminated one.
5922        std::fs::write(project.join("pitchfork.toml"), "this is not toml = [\n").unwrap();
5923        assert_eq!(mode("secure.autoproj.localhost"), ProxyTlsMode::Passthrough);
5924        // The project page is served by the proxy itself, over its own
5925        // certificate, as is a name nothing claims.
5926        assert_eq!(mode("autoproj.localhost"), ProxyTlsMode::Terminate);
5927        assert_eq!(mode("nothing.autoproj.localhost"), ProxyTlsMode::Terminate);
5928        assert_eq!(mode("unknown.localhost"), ProxyTlsMode::Terminate);
5929    }
5930
5931    /// An empty table — the snapshot before any refresh — terminates rather
5932    /// than refusing certificates for hosts it knows nothing about.
5933    #[test]
5934    fn test_resolve_tls_mode_in_empty_table() {
5935        let entries = std::collections::HashMap::new();
5936        assert_eq!(
5937            resolve_tls_mode_in(
5938                "spliced.localhost",
5939                "localhost",
5940                &entries,
5941                &Default::default()
5942            ),
5943            ProxyTlsMode::Terminate
5944        );
5945    }
5946
5947    #[test]
5948    fn test_strip_dot_suffix_ignore_case() {
5949        assert_eq!(
5950            strip_dot_suffix_ignore_case("feature-a.myapp", "myapp"),
5951            Some("feature-a".to_string())
5952        );
5953        assert_eq!(
5954            strip_dot_suffix_ignore_case("Feature-A.MyApp", "myapp"),
5955            Some("Feature-A".to_string())
5956        );
5957        assert_eq!(
5958            strip_dot_suffix_ignore_case("feature-a.myapp", "MYAPP"),
5959            Some("feature-a".to_string())
5960        );
5961        // No dot separator, no prefix left, and a non-matching suffix all fail.
5962        assert_eq!(strip_dot_suffix_ignore_case("xmyapp", "myapp"), None);
5963        assert_eq!(strip_dot_suffix_ignore_case(".myapp", "myapp"), None);
5964        assert_eq!(strip_dot_suffix_ignore_case("myapp", "myapp"), None);
5965        assert_eq!(
5966            strip_dot_suffix_ignore_case("feature-a.other", "myapp"),
5967            None
5968        );
5969        // Multi-byte input must not panic on a mid-character split.
5970        assert_eq!(
5971            strip_dot_suffix_ignore_case("café.myapp", "myapp"),
5972            Some("café".to_string())
5973        );
5974        assert_eq!(strip_dot_suffix_ignore_case("café", "afé"), None);
5975    }
5976
5977    /// Build a minimal ClientHello naming `host`, as it goes on the wire.
5978    #[cfg(feature = "proxy-tls")]
5979    fn client_hello_wire(host: &str) -> Vec<u8> {
5980        let mut entry = vec![0u8];
5981        entry.extend_from_slice(&(host.len() as u16).to_be_bytes());
5982        entry.extend_from_slice(host.as_bytes());
5983        let mut sni = (entry.len() as u16).to_be_bytes().to_vec();
5984        sni.extend_from_slice(&entry);
5985
5986        let mut ext = vec![0x00, 0x00];
5987        ext.extend_from_slice(&(sni.len() as u16).to_be_bytes());
5988        ext.extend_from_slice(&sni);
5989
5990        let mut body = vec![0x03, 0x03];
5991        body.extend_from_slice(&[0x22; 32]);
5992        body.push(0);
5993        body.extend_from_slice(&[0x00, 0x02, 0x13, 0x01]);
5994        body.extend_from_slice(&[0x01, 0x00]);
5995        body.extend_from_slice(&(ext.len() as u16).to_be_bytes());
5996        body.extend_from_slice(&ext);
5997
5998        let mut msg = vec![0x01];
5999        let len = body.len() as u32;
6000        msg.extend_from_slice(&[(len >> 16) as u8, (len >> 8) as u8, len as u8]);
6001        msg.extend_from_slice(&body);
6002
6003        let mut record = vec![0x16, 0x03, 0x01];
6004        record.extend_from_slice(&(msg.len() as u16).to_be_bytes());
6005        record.extend_from_slice(&msg);
6006        record
6007    }
6008
6009    /// Accept one connection, hand the peeked verdict back, and report what a
6010    /// reader sees afterwards.
6011    #[cfg(feature = "proxy-tls")]
6012    async fn probe_over_socket(
6013        writes: Vec<Vec<u8>>,
6014        gap: std::time::Duration,
6015        timeout: std::time::Duration,
6016    ) -> (SniProbe, Vec<u8>) {
6017        probe_over_socket_then(writes, gap, timeout, false).await
6018    }
6019
6020    /// [`probe_over_socket`], optionally shutting down the client's write side
6021    /// once everything is sent.
6022    #[cfg(feature = "proxy-tls")]
6023    async fn probe_over_socket_then(
6024        writes: Vec<Vec<u8>>,
6025        gap: std::time::Duration,
6026        timeout: std::time::Duration,
6027        close_after: bool,
6028    ) -> (SniProbe, Vec<u8>) {
6029        use tokio::io::{AsyncReadExt, AsyncWriteExt};
6030
6031        let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
6032            .await
6033            .unwrap();
6034        let addr = listener.local_addr().unwrap();
6035        let total: usize = writes.iter().map(Vec::len).sum();
6036
6037        let client = tokio::spawn(async move {
6038            let mut sock = TcpStream::connect(addr).await.unwrap();
6039            for chunk in writes {
6040                sock.write_all(&chunk).await.unwrap();
6041                sock.flush().await.unwrap();
6042                tokio::time::sleep(gap).await;
6043            }
6044            if close_after {
6045                sock.shutdown().await.unwrap();
6046            }
6047            // Hold the connection open so the server can read back what it
6048            // only peeked at.
6049            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
6050        });
6051
6052        let (stream, _) = listener.accept().await.unwrap();
6053        let probe = peek_sni_host(&stream, timeout).await;
6054
6055        // Whatever was peeked must still be readable, byte for byte.
6056        let mut replayed = vec![0u8; total];
6057        let mut stream = stream;
6058        let read = tokio::time::timeout(
6059            std::time::Duration::from_secs(2),
6060            stream.read_exact(&mut replayed),
6061        )
6062        .await;
6063        let replayed = match read {
6064            Ok(Ok(_)) => replayed,
6065            _ => vec![],
6066        };
6067        client.abort();
6068        (probe, replayed)
6069    }
6070
6071    /// The peek loop reassembles a hello that arrives in many small writes,
6072    /// and leaves every byte in the socket for the path that handles the
6073    /// connection.
6074    #[cfg(feature = "proxy-tls")]
6075    #[tokio::test]
6076    async fn test_peek_sni_host_reads_a_hello_split_across_writes() {
6077        let wire = client_hello_wire("api.localhost");
6078        let writes: Vec<Vec<u8>> = wire.chunks(3).map(<[u8]>::to_vec).collect();
6079        let (probe, replayed) = probe_over_socket(
6080            writes,
6081            std::time::Duration::from_millis(5),
6082            std::time::Duration::from_secs(5),
6083        )
6084        .await;
6085
6086        assert_eq!(probe, SniProbe::Host("api.localhost".to_string()));
6087        assert_eq!(replayed, wire, "peeked bytes must still be readable");
6088    }
6089
6090    /// A hello that stops half way is reported as undetermined once the
6091    /// timeout expires, never as "no hostname": the caller must not terminate
6092    /// a connection that might belong to a passthrough daemon.
6093    #[cfg(feature = "proxy-tls")]
6094    #[tokio::test]
6095    async fn test_peek_sni_host_undetermined_when_a_hello_stalls() {
6096        let wire = client_hello_wire("api.localhost");
6097        let truncated = wire[..wire.len() / 2].to_vec();
6098        let (probe, _) = probe_over_socket(
6099            vec![truncated],
6100            std::time::Duration::ZERO,
6101            std::time::Duration::from_millis(150),
6102        )
6103        .await;
6104
6105        assert_eq!(probe, SniProbe::Undetermined);
6106    }
6107
6108    /// A client that sends part of a hello and then closes is given up on as
6109    /// soon as the close arrives, not after the whole timeout: `peek` keeps
6110    /// returning the buffered bytes, so only the socket's readiness shows it.
6111    #[cfg(feature = "proxy-tls")]
6112    #[tokio::test]
6113    async fn test_peek_sni_host_notices_a_client_that_closes_mid_hello() {
6114        let wire = client_hello_wire("api.localhost");
6115        let truncated = wire[..wire.len() / 2].to_vec();
6116        let started = std::time::Instant::now();
6117        let (probe, _) = probe_over_socket_then(
6118            vec![truncated],
6119            std::time::Duration::ZERO,
6120            std::time::Duration::from_secs(5),
6121            true,
6122        )
6123        .await;
6124
6125        assert_eq!(probe, SniProbe::Undetermined);
6126        assert!(
6127            started.elapsed() < std::time::Duration::from_secs(1),
6128            "the probe must end when the client closes, not at its deadline"
6129        );
6130    }
6131
6132    /// A peer that opens a connection and then sends nothing is given up on
6133    /// at the deadline. Without the read itself being bounded this waits for
6134    /// as long as the peer keeps the socket open.
6135    #[cfg(feature = "proxy-tls")]
6136    #[tokio::test]
6137    async fn test_peek_sni_host_gives_up_on_a_silent_peer() {
6138        let started = std::time::Instant::now();
6139        let (probe, _) = probe_over_socket(
6140            vec![],
6141            std::time::Duration::ZERO,
6142            std::time::Duration::from_millis(150),
6143        )
6144        .await;
6145
6146        assert_eq!(probe, SniProbe::Undetermined);
6147        assert!(
6148            started.elapsed() < std::time::Duration::from_secs(1),
6149            "the probe must end at its deadline, not wait on the peer"
6150        );
6151    }
6152
6153    /// Something that is not a TLS handshake is a definite "no hostname", so
6154    /// the connection is still served rather than dropped.
6155    #[cfg(feature = "proxy-tls")]
6156    #[tokio::test]
6157    async fn test_peek_sni_host_reports_no_host_for_non_tls() {
6158        let (probe, _) = probe_over_socket(
6159            vec![b"GET / HTTP/1.1\r\n\r\n".to_vec()],
6160            std::time::Duration::ZERO,
6161            std::time::Duration::from_secs(5),
6162        )
6163        .await;
6164
6165        assert_eq!(probe, SniProbe::NoHost);
6166    }
6167
6168    #[cfg(feature = "proxy-tls")]
6169    #[test]
6170    fn test_generate_ca() {
6171        let dir = tempfile::tempdir().unwrap();
6172        let cert_path = dir.path().join("ca.pem");
6173        let key_path = dir.path().join("ca-key.pem");
6174
6175        generate_ca(&cert_path, &key_path).unwrap();
6176
6177        assert!(cert_path.exists(), "ca.pem should be created");
6178        assert!(key_path.exists(), "ca-key.pem should be created");
6179
6180        let cert_pem = std::fs::read_to_string(&cert_path).unwrap();
6181        let key_pem = std::fs::read_to_string(&key_path).unwrap();
6182
6183        assert!(cert_pem.contains("BEGIN CERTIFICATE"), "should be PEM cert");
6184        assert!(
6185            key_pem.contains("BEGIN") && key_pem.contains("PRIVATE KEY"),
6186            "should be PEM key"
6187        );
6188    }
6189
6190    /// The raw `cookie` field values, in the order the map holds them.
6191    fn cookie_fields(headers: &HeaderMap) -> Vec<&[u8]> {
6192        headers
6193            .get_all(COOKIE)
6194            .iter()
6195            .map(HeaderValue::as_bytes)
6196            .collect()
6197    }
6198
6199    /// Several fields become one, joined with `"; "`, and a comma inside a
6200    /// value is left alone.
6201    #[test]
6202    fn test_join_cookie_fields_joins_with_semicolon_space() {
6203        let mut headers = HeaderMap::new();
6204        headers.append(COOKIE, HeaderValue::from_static("_session=abc123"));
6205        headers.append(COOKIE, HeaderValue::from_static("consent=ads,stats"));
6206        headers.append(COOKIE, HeaderValue::from_static("theme=dark"));
6207
6208        join_cookie_fields(&mut headers);
6209
6210        assert_eq!(
6211            cookie_fields(&headers),
6212            vec![&b"_session=abc123; consent=ads,stats; theme=dark"[..]]
6213        );
6214    }
6215
6216    /// A UTF-8 cookie value is joined like any other, since the join works on
6217    /// bytes rather than on visible ASCII.
6218    #[test]
6219    fn test_join_cookie_fields_joins_bytes_outside_ascii() {
6220        let mut headers = HeaderMap::new();
6221        headers.append(COOKIE, HeaderValue::from_static("_session=abc123"));
6222        headers.append(
6223            COOKIE,
6224            HeaderValue::from_bytes(b"name=Jos\xc3\xa9").unwrap(),
6225        );
6226
6227        join_cookie_fields(&mut headers);
6228
6229        assert_eq!(
6230            cookie_fields(&headers),
6231            vec![&b"_session=abc123; name=Jos\xc3\xa9"[..]]
6232        );
6233    }
6234
6235    /// A request without cookies gains none.
6236    #[test]
6237    fn test_join_cookie_fields_without_cookies() {
6238        let mut headers = HeaderMap::new();
6239        headers.insert(HOST, HeaderValue::from_static("app.localhost"));
6240
6241        join_cookie_fields(&mut headers);
6242
6243        assert!(headers.get(COOKIE).is_none());
6244    }
6245
6246    #[test]
6247    fn test_host_port_suffix() {
6248        assert_eq!(host_port_suffix("api.myproj.localhost:8088"), ":8088");
6249        assert_eq!(host_port_suffix("api.myproj.localhost"), "");
6250        assert_eq!(host_port_suffix("[::1]:8088"), ":8088");
6251        assert_eq!(host_port_suffix("[::1]"), "");
6252        // A non-numeric tail is not a port and must not reach a link.
6253        assert_eq!(host_port_suffix("host:notaport"), "");
6254    }
6255
6256    /// A daemon belongs to the checkout that contains its working directory,
6257    /// which is the worktree rather than the primary when one is nested inside
6258    /// the other, and no checkout at all when its `dir` points elsewhere.
6259    #[test]
6260    fn test_daemon_runs_in() {
6261        let temp = tempfile::tempdir().unwrap();
6262        let repo = temp.path().join("my-repo");
6263        std::fs::create_dir_all(repo.join(".git/worktrees/feature")).unwrap();
6264        std::fs::create_dir_all(repo.join("sub")).unwrap();
6265        // A worktree checked out *inside* the primary's directory tree.
6266        let nested = repo.join(".worktrees/feature");
6267        std::fs::create_dir_all(&nested).unwrap();
6268        std::fs::write(
6269            nested.join(".git"),
6270            format!(
6271                "gitdir: {}\n",
6272                repo.join(".git/worktrees/feature").display()
6273            ),
6274        )
6275        .unwrap();
6276
6277        let root = |p: &std::path::Path| crate::proxy::hostname::checkout_root_of(p);
6278        let repo_root = root(&repo);
6279        let nested_root = root(&nested);
6280
6281        let mut daemon = crate::daemon::Daemon {
6282            dir: Some(repo.join("sub")),
6283            ..Default::default()
6284        };
6285        assert!(daemon_runs_in(&daemon, &repo_root));
6286        assert!(!daemon_runs_in(&daemon, &nested_root));
6287
6288        // Lexically the nested worktree sits under the primary; by checkout it
6289        // does not, so the primary's hostname must not claim it.
6290        daemon.dir = Some(nested.clone());
6291        assert!(daemon_runs_in(&daemon, &nested_root));
6292        assert!(!daemon_runs_in(&daemon, &repo_root));
6293
6294        // An explicit dir outside every checkout belongs to none of them.
6295        daemon.dir = Some(temp.path().join("elsewhere"));
6296        assert!(!daemon_runs_in(&daemon, &repo_root));
6297
6298        daemon.dir = None;
6299        assert!(!daemon_runs_in(&daemon, &repo_root));
6300    }
6301
6302    /// Only a request known to come from this machine gets the detailed pages;
6303    /// an unknown peer counts as remote.
6304    #[test]
6305    fn test_is_local_client() {
6306        let build = |info: Option<SocketAddr>| {
6307            let mut req = Request::new(Body::empty());
6308            if let Some(addr) = info {
6309                req.extensions_mut()
6310                    .insert(axum::extract::ConnectInfo(addr));
6311            }
6312            req
6313        };
6314
6315        assert!(is_local_client(&build(Some(
6316            "127.0.0.1:5000".parse().unwrap()
6317        ))));
6318        assert!(is_local_client(&build(Some("[::1]:5000".parse().unwrap()))));
6319        assert!(!is_local_client(&build(Some(
6320            "192.168.1.42:5000".parse().unwrap()
6321        ))));
6322        assert!(!is_local_client(&build(None)));
6323    }
6324}