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