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::{HeaderValue, StatusCode, Uri};
17use axum::response::{IntoResponse, Response};
18use hyper::header::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/// HTTP/1.1 hop-by-hop headers that are forbidden in HTTP/2 responses.
31/// These must be stripped when proxying an HTTP/1.1 backend response back to an HTTP/2 client.
32const HOP_BY_HOP_HEADERS: &[&str] = &[
33    "connection",
34    "keep-alive",
35    "proxy-connection",
36    "transfer-encoding",
37    "upgrade",
38];
39
40use hyper_util::client::legacy::Client;
41use hyper_util::client::legacy::connect::HttpConnector;
42use hyper_util::rt::TokioExecutor;
43use tokio::net::TcpListener;
44
45use crate::daemon_id::DaemonId;
46use crate::settings::settings;
47use crate::supervisor::SUPERVISOR;
48
49// ─── Slug resolution cache ──────────────────────────────────────────────────
50//
51// `read_global_slugs()` reads ~/.config/pitchfork/config.toml from disk on every
52// call, and `namespace_for_dir()` traverses the filesystem upward to find the
53// nearest pitchfork.toml.  Both are called from `resolve_target_port()` which
54// sits in the hot path of every proxied HTTP request.
55//
56// This cache stores the resolved slug → (namespace, daemon_name) mapping
57// in memory with a short TTL so that the proxy does zero disk I/O for the vast
58// majority of requests while still picking up config changes within seconds.
59
60/// How long to cache the slug resolution table before re-reading from disk.
61const SLUG_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
62
63/// Cached slug entry: pre-resolved namespace + daemon name for a slug.
64#[derive(Clone, Debug)]
65struct CachedSlugEntry {
66    /// The slug key as registered in config (needed for display in auto-start pages).
67    slug: String,
68    /// Expected namespace derived from `entry.dir` (None if derivation failed).
69    namespace: Option<String>,
70    /// Daemon short name (defaults to slug name when not explicitly set).
71    daemon_name: String,
72    /// Project directory for this slug (needed for auto-start).
73    dir: std::path::PathBuf,
74}
75
76/// In-memory cache for the global slug registry + derived namespaces.
77struct SlugCache {
78    entries: Arc<std::collections::HashMap<String, CachedSlugEntry>>,
79    expires_at: std::time::Instant,
80}
81
82static SLUG_CACHE: once_cell::sync::Lazy<tokio::sync::Mutex<SlugCache>> =
83    once_cell::sync::Lazy::new(|| {
84        tokio::sync::Mutex::new(SlugCache {
85            entries: Arc::new(std::collections::HashMap::new()),
86            expires_at: std::time::Instant::now(), // expired → will be populated on first access
87        })
88    });
89
90/// Build the slug lookup table from disk (expensive — involves file I/O).
91/// Called outside the cache lock to avoid blocking concurrent proxy requests.
92fn build_slug_entries() -> std::collections::HashMap<String, CachedSlugEntry> {
93    let global_slugs = crate::pitchfork_toml::PitchforkToml::read_global_slugs();
94    let mut entries = std::collections::HashMap::with_capacity(global_slugs.len());
95    for (slug, entry) in &global_slugs {
96        let ns = crate::pitchfork_toml::PitchforkToml::namespace_for_dir(&entry.dir).ok();
97        let daemon_name = entry.daemon.as_deref().unwrap_or(slug).to_string();
98        entries.insert(
99            slug.clone(),
100            CachedSlugEntry {
101                slug: slug.clone(),
102                namespace: ns,
103                daemon_name,
104                dir: entry.dir.clone(),
105            },
106        );
107    }
108    entries
109}
110
111/// Return a snapshot of the cached slug table, refreshing from disk if expired.
112///
113/// The disk I/O happens *outside* the mutex to avoid blocking concurrent requests
114/// during the refresh.  A short race window exists where two threads may both
115/// refresh, but that is harmless (last writer wins with identical data).
116async fn get_cached_slugs() -> Arc<std::collections::HashMap<String, CachedSlugEntry>> {
117    // Fast path: cache still valid — just clone the Arc.
118    {
119        let cache = SLUG_CACHE.lock().await;
120        if std::time::Instant::now() < cache.expires_at {
121            return Arc::clone(&cache.entries);
122        }
123    } // lock released before disk I/O
124
125    // Slow path: refresh from disk (no lock held).
126    let new_entries = Arc::new(build_slug_entries());
127
128    // Store the refreshed entries.
129    {
130        let mut cache = SLUG_CACHE.lock().await;
131        cache.entries = Arc::clone(&new_entries);
132        cache.expires_at = std::time::Instant::now() + SLUG_CACHE_TTL;
133    }
134
135    new_entries
136}
137
138/// Try to match a subdomain against a slug table, with optional wildcard fallback.
139///
140/// When `wildcard` is true and no exact match is found, progressively strips
141/// subdomain prefixes from the left until a match is found or no dots remain.
142/// For example, with slug "myapp" registered, `tenant.myapp` matches "myapp".
143fn wildcard_slug_lookup<'a>(
144    subdomain: &str,
145    entries: &'a std::collections::HashMap<String, CachedSlugEntry>,
146    wildcard: bool,
147) -> Option<&'a CachedSlugEntry> {
148    entries.get(subdomain).or_else(|| {
149        if !wildcard {
150            return None;
151        }
152        // "a.b.myapp" has dots at 1,3 → "b.myapp", "myapp"
153        subdomain
154            .match_indices('.')
155            .map(|(i, _)| &subdomain[i + 1..])
156            .find_map(|candidate| entries.get(candidate))
157    })
158}
159
160/// Look up a slug in the cached table.
161///
162/// With wildcard enabled (default), falls back to progressively shorter
163/// subdomain suffixes when an exact match is not found.  For example,
164/// `tenant.myapp` will match slug `myapp` if no slug named `tenant.myapp`
165/// exists.
166async fn cached_slug_lookup(subdomain: &str) -> Option<CachedSlugEntry> {
167    let entries = get_cached_slugs().await;
168    wildcard_slug_lookup(subdomain, &entries, settings().proxy.wildcard).cloned()
169}
170
171// ─── Auto-start deduplication ───────────────────────────────────────────────
172//
173// When auto_start is enabled, concurrent proxy requests for the same stopped
174// daemon must not trigger multiple start operations.  This set tracks daemon
175// IDs that are currently being auto-started.
176
177static AUTO_START_IN_PROGRESS: once_cell::sync::Lazy<
178    tokio::sync::Mutex<std::collections::HashSet<DaemonId>>,
179> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(std::collections::HashSet::new()));
180
181/// Result of resolving a proxy target for a given host.
182enum ResolveResult {
183    /// Daemon is running and ready — forward to this port.
184    /// Covers both already-running daemons and freshly auto-started ones.
185    Ready(u16),
186    /// Daemon is currently starting (auto-start in progress or just triggered).
187    Starting { slug: String },
188    /// No matching slug or daemon found.
189    NotFound,
190    /// Routing refused with a descriptive reason.
191    Error(String),
192}
193
194/// Shared proxy state passed to each request handler.
195/// Callback type invoked on proxy errors (e.g. for logging/alerting).
196type OnErrorFn = Arc<dyn Fn(&str) + Send + Sync>;
197
198#[derive(Clone)]
199struct ProxyState {
200    /// HTTP client used to forward requests to daemon backends.
201    client: Arc<Client<HttpConnector, Body>>,
202    /// The configured TLD (e.g. "localhost").
203    tld: String,
204    /// Whether the proxy is serving HTTPS.
205    is_tls: bool,
206    /// Optional error callback invoked on proxy errors (e.g. for logging/alerting).
207    on_error: Option<OnErrorFn>,
208}
209
210/// Start the reverse proxy server.
211///
212/// Binds to the configured port and serves until the process exits.
213/// When `proxy.https = true`, TLS is terminated here using a self-signed
214/// certificate (auto-generated if not present).
215///
216/// This function is intended to be spawned as a background task.
217pub async fn serve(
218    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
219    cancel: tokio_util::sync::CancellationToken,
220) -> crate::Result<()> {
221    let s = settings();
222    let lan_enabled = s.proxy.lan || !s.proxy.lan_ip.is_empty();
223
224    let effective_tld = if lan_enabled {
225        "local".to_string()
226    } else {
227        s.proxy.tld.clone()
228    };
229
230    let Some(effective_port) = u16::try_from(s.proxy.port).ok().filter(|&p| p > 0) else {
231        let msg = format!(
232            "proxy.port {} is out of valid port range (1-65535), proxy server cannot start",
233            s.proxy.port
234        );
235        let _ = bind_tx.send(Err(msg.clone()));
236        miette::bail!("{msg}");
237    };
238
239    let mut connector = HttpConnector::new();
240    // Limit how long the proxy waits to establish a TCP connection to a backend.
241    // Without this, a daemon that accepts the SYN but never completes the handshake
242    // would stall the proxy indefinitely.
243    connector.set_connect_timeout(Some(std::time::Duration::from_secs(10)));
244
245    let client = Client::builder(TokioExecutor::new())
246        // Reclaim idle keep-alive connections after 30 s so that file descriptors
247        // are not held open forever when a backend goes quiet.
248        .pool_idle_timeout(std::time::Duration::from_secs(30))
249        .build(connector);
250
251    let state = ProxyState {
252        client: Arc::new(client),
253        tld: effective_tld.clone(),
254        is_tls: s.proxy.https,
255        on_error: None,
256    };
257
258    let app = Router::new().fallback(proxy_handler).with_state(state);
259
260    // Resolve bind address from settings.
261    // In LAN mode, default to 0.0.0.0 so the proxy is reachable from other
262    // devices on the network.  Users can still override with proxy.host.
263    let bind_ip: std::net::IpAddr = if lan_enabled && s.proxy.host == "127.0.0.1" {
264        std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
265    } else {
266        match s.proxy.host.parse() {
267            Ok(ip) => ip,
268            Err(_) => {
269                log::warn!(
270                    "proxy.host {:?} is not a valid IP address — falling back to 127.0.0.1. \
271                     The proxy will only be reachable on the loopback interface.",
272                    s.proxy.host
273                );
274                std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
275            }
276        }
277    };
278    let addr = SocketAddr::from((bind_ip, effective_port));
279
280    if s.proxy.https {
281        serve_https_with_http_fallback(app, addr, s, effective_port, bind_tx, cancel).await
282    } else {
283        serve_http(app, addr, effective_port, bind_tx, cancel).await
284    }
285}
286
287/// Serve plain HTTP.
288async fn serve_http(
289    app: Router,
290    addr: SocketAddr,
291    effective_port: u16,
292    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
293    cancel: tokio_util::sync::CancellationToken,
294) -> crate::Result<()> {
295    let listener = match TcpListener::bind(addr).await {
296        Ok(l) => {
297            if settings().proxy.sync_hosts {
298                crate::proxy::hosts::sync_hosts_from_settings();
299            }
300            let _ = bind_tx.send(Ok(()));
301            l
302        }
303        Err(e) => {
304            let msg = bind_error_message(effective_port, &e);
305            let _ = bind_tx.send(Err(msg.clone()));
306            return Err(miette::miette!("{msg}"));
307        }
308    };
309
310    log::info!("Proxy server listening on http://{addr}");
311    if effective_port < 1024 {
312        log::info!(
313            "Note: port {effective_port} is a privileged port. \
314             The supervisor must be started with sudo to bind to this port."
315        );
316    }
317    let shutdown_signal = cancel.clone().cancelled_owned();
318    axum::serve(
319        listener,
320        app.into_make_service_with_connect_info::<SocketAddr>(),
321    )
322    .with_graceful_shutdown(shutdown_signal)
323    .await
324    .map_err(|e| miette::miette!("Proxy server error: {e}"))?;
325    Ok(())
326}
327
328/// Serve HTTPS with automatic HTTP detection on the same port.
329///
330/// Peeks at the first byte of each incoming TCP connection:
331/// - `0x16` (TLS ClientHello) → hand off to the TLS acceptor (HTTP/2 + HTTP/1.1 via ALPN)
332/// - anything else → 302 redirect to HTTPS
333#[cfg(feature = "proxy-tls")]
334async fn serve_https_with_http_fallback(
335    app: Router,
336    addr: SocketAddr,
337    s: &crate::settings::Settings,
338    effective_port: u16,
339    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
340    cancel: tokio_util::sync::CancellationToken,
341) -> crate::Result<()> {
342    use rustls::ServerConfig;
343    use tokio_rustls::TlsAcceptor;
344
345    let (ca_cert_path, ca_key_path) = resolve_tls_paths(s);
346
347    // Generate CA if not present
348    if !ca_cert_path.exists() || !ca_key_path.exists() {
349        generate_ca(&ca_cert_path, &ca_key_path)?;
350        log::info!(
351            "Generated local CA certificate at {}",
352            ca_cert_path.display()
353        );
354        log::info!("To trust the CA in your browser, run: pitchfork proxy trust");
355    }
356
357    // Install ring as the default CryptoProvider if none has been set yet.
358    let _ = rustls::crypto::ring::default_provider().install_default();
359
360    // Build the SNI resolver (loads CA, caches per-domain certs)
361    let resolver = SniCertResolver::new(&ca_cert_path, &ca_key_path)?;
362
363    let mut tls_config = ServerConfig::builder()
364        .with_no_client_auth()
365        .with_cert_resolver(Arc::new(resolver));
366    // Advertise HTTP/2 and HTTP/1.1 via ALPN so browsers negotiate HTTP/2
367    // for multiplexed requests (eliminates the 6-connection-per-host limit).
368    tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
369
370    let acceptor = TlsAcceptor::from(Arc::new(tls_config));
371
372    let listener = match TcpListener::bind(addr).await {
373        Ok(l) => {
374            if settings().proxy.sync_hosts {
375                crate::proxy::hosts::sync_hosts_from_settings();
376            }
377            let _ = bind_tx.send(Ok(()));
378            l
379        }
380        Err(e) => {
381            let msg = bind_error_message(effective_port, &e);
382            let _ = bind_tx.send(Err(msg.clone()));
383            return Err(miette::miette!("{msg}"));
384        }
385    };
386
387    log::info!("Proxy server listening on https://{addr} (HTTP also accepted)");
388    if effective_port < 1024 {
389        log::info!(
390            "Note: port {effective_port} is a privileged port. \
391             The supervisor must be started with sudo to bind to this port."
392        );
393    }
394
395    // Build a lightweight redirect app for plain-HTTP requests.
396    let redirect_app = Router::new().fallback(redirect_to_https_handler);
397
398    // Accept connections and sniff the first byte to decide TLS vs plain HTTP.
399    let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
400    loop {
401        // Reap finished connection tasks during normal operation so the JoinSet
402        // does not retain one entry per historical connection.
403        while conn_tasks.try_join_next().is_some() {}
404
405        tokio::select! {
406            accept_result = listener.accept() => {
407                let (stream, _peer_addr) = match accept_result {
408                    Ok(conn) => conn,
409                    Err(e) => {
410                        log::warn!("Accept error (will retry): {e}");
411                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
412                        continue;
413                    }
414                };
415
416                let acceptor = acceptor.clone();
417                let app = app.clone();
418                let redirect_app = redirect_app.clone();
419
420                conn_tasks.spawn(async move {
421                    // Peek at the first byte without consuming it.
422                    // TLS ClientHello always starts with 0x16 (content type "handshake").
423                    let mut peek_buf = [0u8; 1];
424                    match stream.peek(&mut peek_buf).await {
425                        Ok(0) | Err(_) => return,
426                        _ => {}
427                    }
428
429                    if peek_buf[0] == 0x16 {
430                        // TLS handshake → HTTP/2 or HTTP/1.1 (negotiated via ALPN)
431                        match acceptor.accept(stream).await {
432                            Ok(tls_stream) => {
433                                let io = hyper_util::rt::TokioIo::new(tls_stream);
434                                let svc = hyper_util::service::TowerToHyperService::new(app);
435                                if let Err(e) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
436                                    .serve_connection_with_upgrades(io, svc)
437                                    .await
438                                {
439                                    // HTTP/2 RST_STREAM errors from cancelled browser requests
440                                    // (navigation, HMR) are normal — log at debug to avoid noise.
441                                    log::debug!("Connection error: {e}");
442                                }
443                            }
444                            Err(e) => {
445                                log::debug!("TLS handshake error: {e}");
446                            }
447                        }
448                    } else {
449                        // Plain HTTP on the TLS port → 302 redirect to HTTPS
450                        let io = hyper_util::rt::TokioIo::new(stream);
451                        let svc = hyper_util::service::TowerToHyperService::new(redirect_app);
452                        let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
453                            .serve_connection_with_upgrades(io, svc)
454                            .await;
455                    }
456                });
457
458                while conn_tasks.try_join_next().is_some() {}
459            }
460            _ = cancel.cancelled() => {
461                log::info!("Proxy server shutting down (cancel signal received)");
462                break;
463            }
464        }
465    }
466
467    // Drain in-flight connections with a timeout.
468    let drain_timeout = std::time::Duration::from_secs(10);
469    let _ = tokio::time::timeout(drain_timeout, async {
470        while conn_tasks.join_next().await.is_some() {}
471    })
472    .await;
473
474    Ok(())
475}
476
477/// Fallback when proxy-tls feature is not enabled.
478#[cfg(not(feature = "proxy-tls"))]
479async fn serve_https_with_http_fallback(
480    _app: Router,
481    _addr: SocketAddr,
482    _s: &crate::settings::Settings,
483    _effective_port: u16,
484    bind_tx: tokio::sync::oneshot::Sender<std::result::Result<(), String>>,
485    _cancel: tokio_util::sync::CancellationToken,
486) -> crate::Result<()> {
487    let msg = "HTTPS proxy support requires the `proxy-tls` feature.\n\
488         Rebuild pitchfork with: cargo build --features proxy-tls"
489        .to_string();
490    let _ = bind_tx.send(Err(msg.clone()));
491    miette::bail!("{msg}")
492}
493
494/// Resolve the CA certificate and key paths from settings.
495///
496/// If `tls_cert` / `tls_key` are empty, falls back to the auto-generated
497/// CA paths in `$PITCHFORK_STATE_DIR/proxy/`.
498#[cfg(feature = "proxy-tls")]
499fn resolve_tls_paths(s: &crate::settings::Settings) -> (std::path::PathBuf, std::path::PathBuf) {
500    let proxy_dir = crate::env::PITCHFORK_STATE_DIR.join("proxy");
501    let resolve = |configured: &str, default: &str| {
502        if configured.is_empty() {
503            proxy_dir.join(default)
504        } else {
505            std::path::PathBuf::from(configured)
506        }
507    };
508    (
509        resolve(&s.proxy.tls_cert, "ca.pem"),
510        resolve(&s.proxy.tls_key, "ca-key.pem"),
511    )
512}
513
514/// Generate a local root CA certificate and private key using `rcgen`.
515///
516/// The CA is used to sign per-domain certificates on demand (SNI).
517/// Files are written in PEM format to `cert_path` and `key_path`.
518#[cfg(feature = "proxy-tls")]
519pub fn generate_ca(cert_path: &std::path::Path, key_path: &std::path::Path) -> crate::Result<()> {
520    use rcgen::{
521        BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyUsagePurpose,
522    };
523
524    // Create parent directory if needed
525    if let Some(parent) = cert_path.parent() {
526        std::fs::create_dir_all(parent)
527            .map_err(|e| miette::miette!("Failed to create proxy cert directory: {e}"))?;
528    }
529
530    let mut params = CertificateParams::default();
531    let mut dn = DistinguishedName::new();
532    dn.push(DnType::CommonName, "Pitchfork Local CA");
533    dn.push(DnType::OrganizationName, "Pitchfork");
534    params.distinguished_name = dn;
535    params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
536    params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
537
538    let key_pair = rcgen::KeyPair::generate()
539        .map_err(|e| miette::miette!("Failed to generate CA key pair: {e}"))?;
540    let ca_cert = params
541        .self_signed(&key_pair)
542        .map_err(|e| miette::miette!("Failed to self-sign CA certificate: {e}"))?;
543
544    // Write the CA certificate (public — 0644 is fine)
545    std::fs::write(cert_path, ca_cert.pem()).map_err(|e| {
546        miette::miette!(
547            "Failed to write CA certificate to {}: {e}",
548            cert_path.display()
549        )
550    })?;
551
552    // Write the CA private key with restrictive permissions (0600).
553    // Using OpenOptions + mode() so the file is never world-readable,
554    // even briefly before a chmod call.
555    {
556        #[cfg(unix)]
557        {
558            use std::io::Write;
559            use std::os::unix::fs::OpenOptionsExt;
560            std::fs::OpenOptions::new()
561                .write(true)
562                .create(true)
563                .truncate(true)
564                .mode(0o600)
565                .open(key_path)
566                .and_then(|mut f| f.write_all(key_pair.serialize_pem().as_bytes()))
567                .map_err(|e| {
568                    miette::miette!("Failed to write CA key to {}: {e}", key_path.display())
569                })?;
570        }
571        #[cfg(not(unix))]
572        {
573            std::fs::write(key_path, key_pair.serialize_pem()).map_err(|e| {
574                miette::miette!("Failed to write CA key to {}: {e}", key_path.display())
575            })?;
576            log::debug!(
577                "CA private key written to {} (file permissions are not restricted \
578                 on non-Unix platforms — consider restricting access manually)",
579                key_path.display()
580            );
581        }
582    }
583
584    Ok(())
585}
586
587/// SNI-based certificate resolver.
588///
589/// Holds the local CA and a two-level cache of per-domain certificates:
590/// - L1: in-memory `HashMap` (fastest, process-lifetime)
591/// - L2: on-disk `host-certs/<safe_name>.pem` (survives restarts)
592///
593/// A `pending` set prevents concurrent requests for the same domain from
594/// triggering multiple simultaneous cert-generation operations.
595///
596/// On each new TLS connection, `resolve()` is called with the SNI hostname;
597/// if no cached cert exists for that domain, one is signed by the CA on the fly.
598///
599/// # Locking strategy
600/// Both `cache` and `pending` use `std::sync::Mutex` paired with a
601/// `std::sync::Condvar`.  The critical sections are intentionally short
602/// (hash-map lookups / inserts), so the blocking time is negligible.
603/// `get_or_create` is only called from the synchronous `ResolvesServerCert`
604/// trait method (not from an async context), so blocking a thread here is
605/// acceptable.
606#[cfg(feature = "proxy-tls")]
607struct SniCertResolver {
608    /// The CA issuer (key + parsed cert params, used to sign leaf certs).
609    issuer: rcgen::Issuer<'static, rcgen::KeyPair>,
610    /// Directory where per-domain PEM files are cached on disk.
611    host_certs_dir: std::path::PathBuf,
612    /// L1 cache: domain → certified key (in-memory).
613    cache: std::sync::Mutex<std::collections::HashMap<String, Arc<rustls::sign::CertifiedKey>>>,
614    /// Pending set: domains currently being generated (dedup concurrent requests).
615    /// Using a `Condvar` so waiting threads are parked instead of spin-sleeping,
616    /// which avoids blocking tokio worker threads.
617    pending: std::sync::Mutex<std::collections::HashSet<String>>,
618    /// Condvar paired with `pending` — notified when a domain is removed from the set.
619    pending_cv: std::sync::Condvar,
620}
621
622#[cfg(feature = "proxy-tls")]
623impl std::fmt::Debug for SniCertResolver {
624    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625        f.debug_struct("SniCertResolver").finish_non_exhaustive()
626    }
627}
628
629#[cfg(feature = "proxy-tls")]
630impl SniCertResolver {
631    /// Load the CA from disk and prepare the resolver.
632    fn new(ca_cert_path: &std::path::Path, ca_key_path: &std::path::Path) -> crate::Result<Self> {
633        let ca_key_pem = std::fs::read_to_string(ca_key_path)
634            .map_err(|e| miette::miette!("Failed to read CA key {}: {e}", ca_key_path.display()))?;
635        let ca_cert_pem = std::fs::read_to_string(ca_cert_path).map_err(|e| {
636            miette::miette!("Failed to read CA cert {}: {e}", ca_cert_path.display())
637        })?;
638
639        // Verify the PEM is readable (sanity check)
640        if !ca_cert_pem.contains("BEGIN CERTIFICATE") {
641            miette::bail!("CA cert file does not contain a valid PEM certificate");
642        }
643
644        let ca_key = rcgen::KeyPair::from_pem(&ca_key_pem)
645            .map_err(|e| miette::miette!("Failed to parse CA key: {e}"))?;
646
647        // Parse the CA cert + key into an Issuer for signing leaf certs.
648        let issuer = rcgen::Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)
649            .map_err(|e| miette::miette!("Failed to parse CA cert: {e}"))?;
650
651        // Ensure the host-certs directory exists
652        let host_certs_dir = ca_cert_path
653            .parent()
654            .unwrap_or(std::path::Path::new("."))
655            .join("host-certs");
656        std::fs::create_dir_all(&host_certs_dir)
657            .map_err(|e| miette::miette!("Failed to create host-certs dir: {e}"))?;
658
659        Ok(Self {
660            issuer,
661            host_certs_dir,
662            cache: std::sync::Mutex::new(std::collections::HashMap::new()),
663            pending: std::sync::Mutex::new(std::collections::HashSet::new()),
664            pending_cv: std::sync::Condvar::new(),
665        })
666    }
667
668    /// Get or create a `CertifiedKey` for the given domain.
669    ///
670    /// Resolution order:
671    /// 1. L1 in-memory cache
672    /// 2. L2 on-disk cache (`host-certs/<safe_name>.pem`)
673    /// 3. Generate fresh cert, persist to disk, populate both caches
674    ///
675    /// Concurrent requests for the same domain are deduplicated: the second
676    /// thread waits on a `Condvar` until the first thread finishes, then reads
677    /// from the cache.  This avoids both duplicate cert generation and the
678    /// spin-sleep anti-pattern that would block tokio worker threads.
679    ///
680    /// # Locking discipline
681    /// `cache` and `pending` are **never held simultaneously**.  The protocol is:
682    /// 1. Check `cache` (lock, read, unlock).
683    /// 2. Acquire `pending`; wait if domain is in-progress; re-check `cache`
684    ///    after waking (unlock `cache` before re-acquiring `pending` is not
685    ///    needed because we release `cache` before entering the `pending` block).
686    /// 3. Insert domain into `pending`; release `pending` lock.
687    /// 4. Generate cert (no locks held).
688    /// 5. Insert into `cache` (lock, write, unlock).
689    /// 6. Remove from `pending` and notify (lock, write, unlock).
690    fn get_or_create(&self, domain: &str) -> Option<Arc<rustls::sign::CertifiedKey>> {
691        // L1: memory cache (fast path — no pending lock needed)
692        {
693            let cache = self.cache.lock().ok()?;
694            if let Some(ck) = cache.get(domain) {
695                return Some(Arc::clone(ck));
696            }
697        } // cache lock released here
698
699        // Dedup: acquire the pending lock, wait if another thread is generating
700        // this domain, then re-check the cache (without holding pending) before
701        // deciding to generate.
702        //
703        // We deliberately release the pending lock before re-checking the cache
704        // to avoid holding both locks simultaneously.  The re-check is safe
705        // because: if the generating thread inserted into the cache and then
706        // removed from pending, we will see the cert in the cache.  If we miss
707        // the window (extremely unlikely), we will generate a duplicate cert,
708        // which is harmless — the last writer wins in the cache.
709        loop {
710            {
711                let mut pending = self.pending.lock().ok()?;
712                if pending.contains(domain) {
713                    // Another thread is generating; wait until it finishes.
714                    pending = self.pending_cv.wait(pending).ok()?;
715                    // pending lock re-acquired; loop to re-check cache below.
716                    drop(pending);
717                } else {
718                    // No one else is generating; claim the slot and proceed.
719                    pending.insert(domain.to_string());
720                    break;
721                }
722            } // pending lock released
723
724            // Re-check cache after being woken (the generating thread may have
725            // already populated it).  Cache lock is acquired independently of
726            // pending lock here — no nesting.
727            {
728                let cache = self.cache.lock().ok()?;
729                if let Some(ck) = cache.get(domain) {
730                    return Some(Arc::clone(ck));
731                }
732            } // cache lock released
733        } // pending lock released at break
734
735        let result = self.get_or_create_inner(domain);
736
737        // Always clear the pending flag and wake waiting threads.
738        // notify_all() is called *inside* the lock scope so that the domain is
739        // guaranteed to be removed before any waiting thread is woken up.
740        // If the lock is poisoned we recover it (the data is still valid) so
741        // that the domain is always removed and waiters are always notified.
742        {
743            let mut pending = match self.pending.lock() {
744                Ok(g) => g,
745                Err(e) => e.into_inner(),
746            };
747            pending.remove(domain);
748            self.pending_cv.notify_all();
749        }
750
751        result
752    }
753
754    /// Inner implementation: check disk cache, then generate.
755    fn get_or_create_inner(&self, domain: &str) -> Option<Arc<rustls::sign::CertifiedKey>> {
756        let safe_name = domain.replace('.', "_").replace('*', "wildcard");
757        let disk_path = self.host_certs_dir.join(format!("{safe_name}.pem"));
758
759        // L2: disk cache — try to load existing cert+key PEM
760        if disk_path.exists() {
761            if let Ok(ck) = self.load_from_disk(&disk_path) {
762                let ck = Arc::new(ck);
763                if let Ok(mut cache) = self.cache.lock() {
764                    cache.insert(domain.to_string(), Arc::clone(&ck));
765                }
766                return Some(ck);
767            }
768            // Disk cache corrupt/expired — fall through to regenerate
769            let _ = std::fs::remove_file(&disk_path);
770        }
771
772        // L3: generate fresh cert
773        let ck = self.sign_for_domain(domain).ok()?;
774
775        let ck = Arc::new(ck);
776        if let Ok(mut cache) = self.cache.lock() {
777            cache.insert(domain.to_string(), Arc::clone(&ck));
778        }
779        Some(ck)
780    }
781
782    /// Load a `CertifiedKey` from a combined cert+key PEM file on disk.
783    ///
784    /// Returns an error if the certificate has already expired, so the caller
785    /// can fall through to regeneration rather than serving a stale cert.
786    fn load_from_disk(&self, path: &std::path::Path) -> crate::Result<rustls::sign::CertifiedKey> {
787        use rustls::pki_types::CertificateDer;
788        use rustls_pemfile::{certs, private_key};
789
790        let pem = std::fs::read_to_string(path)
791            .map_err(|e| miette::miette!("Failed to read disk cert {}: {e}", path.display()))?;
792
793        let cert_ders: Vec<CertificateDer<'static>> = certs(&mut pem.as_bytes())
794            .collect::<Result<Vec<_>, _>>()
795            .map_err(|e| miette::miette!("Failed to parse certs from {}: {e}", path.display()))?;
796
797        if cert_ders.is_empty() {
798            miette::bail!("No certificates found in {}", path.display());
799        }
800
801        // Check that the first certificate has not expired using x509-parser.
802        {
803            let (_, cert) = x509_parser::parse_x509_certificate(&cert_ders[0]).map_err(|e| {
804                miette::miette!("Failed to parse certificate from {}: {e}", path.display())
805            })?;
806            use chrono::Utc;
807            let now_ts = Utc::now().timestamp();
808            let not_after_ts = cert.validity().not_after.timestamp();
809            if not_after_ts < now_ts {
810                miette::bail!(
811                    "Cached certificate at {} has expired — will regenerate",
812                    path.display()
813                );
814            }
815        }
816
817        let key_der = private_key(&mut pem.as_bytes())
818            .map_err(|e| miette::miette!("Failed to parse key from {}: {e}", path.display()))?
819            .ok_or_else(|| miette::miette!("No private key found in {}", path.display()))?;
820
821        let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
822            .map_err(|e| miette::miette!("Failed to create signing key from disk: {e}"))?;
823
824        Ok(rustls::sign::CertifiedKey::new(cert_ders, signing_key))
825    }
826
827    /// Sign a leaf certificate for `domain` using the CA.
828    ///
829    /// SANs include:
830    /// - `DNS:<domain>` (exact match)
831    /// - `DNS:*.<parent>` (sibling wildcard, e.g. `*.pf.localhost` for `docs.pf.localhost`)
832    ///
833    /// Returns both the `CertifiedKey` and the combined PEM for disk caching.
834    fn sign_for_domain(&self, domain: &str) -> crate::Result<rustls::sign::CertifiedKey> {
835        use rcgen::date_time_ymd;
836        use rcgen::{CertificateParams, DistinguishedName, DnType, SanType};
837        use rustls::pki_types::CertificateDer;
838        use rustls_pemfile::private_key;
839
840        let mut params = CertificateParams::default();
841        let mut dn = DistinguishedName::new();
842        dn.push(DnType::CommonName, domain);
843        params.distinguished_name = dn;
844
845        // Set validity dynamically: from yesterday to 10 years from now.
846        {
847            use chrono::{Datelike, Duration, Utc};
848            let yesterday = Utc::now() - Duration::days(1);
849            // 397 days: stays within Chrome/Safari's 398-day maximum validity limit
850            // for TLS certificates (including locally-trusted CA leaf certs).
851            let expiry = Utc::now() + Duration::days(397);
852            params.not_before = date_time_ymd(
853                yesterday.year(),
854                yesterday.month() as u8,
855                yesterday.day() as u8,
856            );
857            params.not_after =
858                date_time_ymd(expiry.year(), expiry.month() as u8, expiry.day() as u8);
859        }
860
861        // Build SANs: exact domain + sibling wildcard (e.g. *.pf.localhost)
862        let mut sans =
863            vec![SanType::DnsName(domain.to_string().try_into().map_err(
864                |e| miette::miette!("Invalid domain name '{domain}': {e}"),
865            )?)];
866        // Add wildcard SAN for the parent domain (one level up)
867        if let Some(dot_pos) = domain.find('.') {
868            let parent = &domain[dot_pos + 1..];
869            // Only add wildcard if parent has at least one dot (not a bare TLD)
870            if parent.contains('.') {
871                let wildcard = format!("*.{parent}");
872                if let Ok(wc) = wildcard.try_into() {
873                    sans.push(SanType::DnsName(wc));
874                }
875            }
876        }
877        params.subject_alt_names = sans;
878
879        let leaf_key = rcgen::KeyPair::generate()
880            .map_err(|e| miette::miette!("Failed to generate leaf key: {e}"))?;
881        let leaf_cert = params
882            .signed_by(&leaf_key, &self.issuer)
883            .map_err(|e| miette::miette!("Failed to sign leaf cert for '{domain}': {e}"))?;
884
885        // Convert to rustls types
886        let cert_der = CertificateDer::from(leaf_cert.der().to_vec());
887        let key_pem = leaf_key.serialize_pem();
888        let key_der = private_key(&mut key_pem.as_bytes())
889            .map_err(|e| miette::miette!("Failed to parse leaf key PEM: {e}"))?
890            .ok_or_else(|| miette::miette!("No private key found in generated PEM"))?;
891
892        let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der)
893            .map_err(|e| miette::miette!("Failed to create signing key: {e}"))?;
894
895        // Persist cert + key to disk cache as combined PEM.
896        // Use 0600 so the private key is not world-readable.
897        let safe_name = domain.replace('.', "_").replace('*', "wildcard");
898        let disk_path = self.host_certs_dir.join(format!("{safe_name}.pem"));
899        let combined_pem = format!("{}{}", leaf_cert.pem(), key_pem);
900        {
901            #[cfg(unix)]
902            {
903                use std::io::Write;
904                use std::os::unix::fs::OpenOptionsExt;
905                if let Err(e) = std::fs::OpenOptions::new()
906                    .write(true)
907                    .create(true)
908                    .truncate(true)
909                    .mode(0o600)
910                    .open(&disk_path)
911                    .and_then(|mut f| f.write_all(combined_pem.as_bytes()))
912                {
913                    log::warn!(
914                        "Failed to persist cert for '{domain}' to {}: {e}",
915                        disk_path.display()
916                    );
917                }
918            }
919            #[cfg(not(unix))]
920            {
921                if let Err(e) = std::fs::write(&disk_path, combined_pem) {
922                    log::warn!(
923                        "Failed to persist cert for '{domain}' to {}: {e}",
924                        disk_path.display()
925                    );
926                } else {
927                    log::debug!(
928                        "Leaf cert for '{domain}' written to {} (file permissions are not \
929                         restricted on non-Unix platforms — consider restricting access manually)",
930                        disk_path.display()
931                    );
932                }
933            }
934        }
935
936        Ok(rustls::sign::CertifiedKey::new(vec![cert_der], signing_key))
937    }
938}
939
940#[cfg(feature = "proxy-tls")]
941impl rustls::server::ResolvesServerCert for SniCertResolver {
942    fn resolve(
943        &self,
944        client_hello: rustls::server::ClientHello<'_>,
945    ) -> Option<Arc<rustls::sign::CertifiedKey>> {
946        let domain = client_hello.server_name()?;
947        self.get_or_create(domain)
948    }
949}
950
951/// Get the effective host from a request.
952///
953/// HTTP/2 uses the `:authority` pseudo-header, which hyper exposes via
954/// `req.uri().authority()` rather than in the `HeaderMap`.
955/// HTTP/1.1 uses the `Host` header.
956fn get_request_host(req: &Request) -> Option<String> {
957    // HTTP/2: :authority is available via the request URI, not the HeaderMap.
958    let authority = req
959        .uri()
960        .authority()
961        .map(|a| a.as_str().to_string())
962        .filter(|s| !s.is_empty());
963
964    authority.or_else(|| {
965        req.headers()
966            .get(HOST)
967            .and_then(|h| h.to_str().ok())
968            .map(str::to_string)
969    })
970}
971
972/// Inject `X-Forwarded-*` headers into a proxied request.
973///
974/// Because the proxy is a **first-hop** dev tool (not a mid-tier forwarder),
975/// all four headers are **unconditionally overwritten** with values derived
976/// from the actual incoming connection.  Any values supplied by the connecting
977/// client are discarded.
978///
979/// Trusting client-supplied `x-forwarded-for` / `x-forwarded-proto` would
980/// allow a local process to spoof a remote IP or trick a backend's
981/// HTTPS-detection logic (CSRF checks, secure-cookie flags, redirect rules).
982fn inject_forwarded_headers(req: &mut Request, is_tls: bool, host_header: &str) {
983    let remote_addr = req
984        .extensions()
985        .get::<axum::extract::ConnectInfo<SocketAddr>>()
986        .map(|ci| ci.0.ip().to_string())
987        .unwrap_or_else(|| "127.0.0.1".to_string());
988
989    let proto = if is_tls { "https" } else { "http" };
990    let default_port = if is_tls { "443" } else { "80" };
991
992    // Always set fresh values — we are the edge, never a mid-tier forwarder.
993    // Discard any x-forwarded-* headers supplied by the connecting client.
994    let forwarded_for = remote_addr.clone();
995    let forwarded_proto = proto.to_string();
996    let forwarded_host = host_header.to_string();
997    let forwarded_port = host_header
998        .rsplit_once(':')
999        .map(|(_, port)| port.to_string())
1000        .unwrap_or_else(|| default_port.to_string());
1001
1002    // Strip any client-supplied x-forwarded-* and RFC 7239 Forwarded headers
1003    // before inserting ours, so that no trace of the original values reaches
1004    // the backend.  The RFC 7239 `Forwarded` header is stripped alongside the
1005    // legacy `x-forwarded-*` set because backends that read it (Django, Rails,
1006    // Spring) would otherwise see client-injected spoofed IPs or protocols.
1007    for name in [
1008        "x-forwarded-for",
1009        "x-forwarded-proto",
1010        "x-forwarded-host",
1011        "x-forwarded-port",
1012        "forwarded",
1013    ] {
1014        if let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) {
1015            req.headers_mut().remove(&header_name);
1016        }
1017    }
1018
1019    let headers = [
1020        ("x-forwarded-for", forwarded_for),
1021        ("x-forwarded-proto", forwarded_proto),
1022        ("x-forwarded-host", forwarded_host),
1023        ("x-forwarded-port", forwarded_port),
1024    ];
1025
1026    for (name, value) in headers {
1027        if let Ok(v) = HeaderValue::from_str(&value) {
1028            let header_name = axum::http::HeaderName::from_static(name);
1029            req.headers_mut().insert(header_name, v);
1030        }
1031    }
1032}
1033
1034/// Main proxy request handler.
1035///
1036/// Parses the `Host` header, resolves the target daemon, and forwards the request.
1037/// WebSocket / HTTP upgrade requests are forwarded transparently via hyper's upgrade mechanism.
1038async fn proxy_handler(State(state): State<ProxyState>, mut req: Request) -> Response {
1039    // Extract the host (supports both HTTP/2 :authority and HTTP/1.1 Host)
1040    let Some(raw_host) = get_request_host(&req) else {
1041        return error_response(StatusCode::BAD_REQUEST, "Missing Host header");
1042    };
1043    // Strip port from host for routing.
1044    // IPv6 addresses in Host headers are bracketed per RFC 2732: `[::1]:port`.
1045    // Splitting naïvely on ':' would break on the colons inside the address.
1046    let host = if raw_host.starts_with('[') {
1047        // IPv6: "[::1]:port" or "[::1]"
1048        raw_host
1049            .split("]:")
1050            .next()
1051            .unwrap_or(&raw_host)
1052            .trim_start_matches('[')
1053            .trim_end_matches(']')
1054            .to_string()
1055    } else {
1056        // IPv4 / hostname: "host:port" or "host"
1057        raw_host.split(':').next().unwrap_or(&raw_host).to_string()
1058    };
1059
1060    // Loop detection: check hop count.
1061    //
1062    // Security: strip (zero out) the hop counter on the very first hop to
1063    // prevent external clients from forging a high value and triggering a
1064    // 508 Loop Detected response (denial-of-service).  A request is
1065    // considered "first hop" when it does not carry the `x-pitchfork-hops`
1066    // request header that pitchfork injects when forwarding — i.e. it did
1067    // not come from another pitchfork proxy instance.
1068    // Note: `x-pitchfork` is a *response* header added by pitchfork and is
1069    // never present on incoming requests, so it cannot be used here.
1070    let is_from_pitchfork = req.headers().contains_key(PROXY_HOPS_HEADER);
1071    let hops: u64 = if is_from_pitchfork {
1072        req.headers()
1073            .get(PROXY_HOPS_HEADER)
1074            .and_then(|v| v.to_str().ok())
1075            .and_then(|s| s.parse().ok())
1076            .unwrap_or(0)
1077    } else {
1078        // External request: ignore any forged hop counter.
1079        0
1080    };
1081    if hops >= MAX_PROXY_HOPS {
1082        return error_response(
1083            StatusCode::LOOP_DETECTED,
1084            &format!(
1085                "Loop detected for '{host}': request has passed through the proxy {hops} times.\n\
1086                 This usually means a backend is proxying back through pitchfork without rewriting \n\
1087                 the Host header. If you use Vite/webpack proxy, set changeOrigin: true."
1088            ),
1089        );
1090    }
1091
1092    // Resolve the target port from the host
1093    let target_port = match resolve_target(&host, &state.tld).await {
1094        ResolveResult::Ready(port) => port,
1095        ResolveResult::Starting { slug } => {
1096            return starting_html_response(&slug, &raw_host);
1097        }
1098        ResolveResult::NotFound => {
1099            return error_response(
1100                StatusCode::BAD_GATEWAY,
1101                &format!(
1102                    "No daemon found for host '{host}'.\n\
1103                     Make sure the daemon has a slug, is running, and has a port configured.\n\
1104                     Expected format: <slug>.{tld}",
1105                    tld = state.tld
1106                ),
1107            );
1108        }
1109        ResolveResult::Error(msg) => {
1110            return error_response(StatusCode::BAD_GATEWAY, &msg);
1111        }
1112    };
1113
1114    // Build the forwarding URI
1115    let path_and_query = req
1116        .uri()
1117        .path_and_query()
1118        .map(|pq| pq.as_str())
1119        .unwrap_or("/");
1120
1121    let forward_uri = match Uri::builder()
1122        .scheme("http")
1123        .authority(format!("localhost:{target_port}"))
1124        .path_and_query(path_and_query)
1125        .build()
1126    {
1127        Ok(uri) => uri,
1128        Err(e) => {
1129            return error_response(
1130                StatusCode::INTERNAL_SERVER_ERROR,
1131                &format!("Failed to build forward URI: {e}"),
1132            );
1133        }
1134    };
1135
1136    // Update the request URI and Host header
1137    *req.uri_mut() = forward_uri;
1138    req.headers_mut().insert(
1139        HOST,
1140        HeaderValue::from_str(&format!("localhost:{target_port}"))
1141            .unwrap_or_else(|_| HeaderValue::from_static("localhost")),
1142    );
1143
1144    // Inject X-Forwarded-* headers
1145    inject_forwarded_headers(&mut req, state.is_tls, &raw_host);
1146
1147    // Increment hop counter
1148    if let Ok(v) = HeaderValue::from_str(&(hops + 1).to_string()) {
1149        req.headers_mut()
1150            .insert(axum::http::HeaderName::from_static(PROXY_HOPS_HEADER), v);
1151    }
1152
1153    // Explicitly strip HTTP/2 pseudo-headers (":authority", ":method", etc.)
1154    // before forwarding to an HTTP/1.1 backend. Although hyper typically does
1155    // not store pseudo-headers in the HeaderMap, some middleware layers or
1156    // future hyper versions might; stripping them here is a defensive measure.
1157    let pseudo_headers: Vec<_> = req
1158        .headers()
1159        .keys()
1160        .filter(|k| k.as_str().starts_with(':'))
1161        .cloned()
1162        .collect();
1163    for key in pseudo_headers {
1164        req.headers_mut().remove(&key);
1165    }
1166
1167    // Extract the client-side OnUpgrade handle *before* consuming req
1168    let client_upgrade = hyper::upgrade::on(&mut req);
1169
1170    // Forward the request with a per-request timeout so that a backend that
1171    // accepts the TCP connection but then stalls (deadlock, blocking I/O, etc.)
1172    // cannot hold the proxy connection open forever and exhaust file descriptors.
1173    //
1174    // 120 s is intentionally generous for a local dev proxy — it covers slow
1175    // test suites, large file uploads, and SSE streams while still bounding
1176    // the worst-case resource leak.
1177    let result = match tokio::time::timeout(
1178        std::time::Duration::from_secs(120),
1179        state.client.request(req),
1180    )
1181    .await
1182    {
1183        Ok(r) => r,
1184        Err(_elapsed) => {
1185            let msg = format!(
1186                "Request to daemon on port {target_port} timed out after 120 s.\n\
1187                 The daemon accepted the connection but did not respond in time."
1188            );
1189            log::warn!("{msg}");
1190            if let Some(ref on_error) = state.on_error {
1191                on_error(&msg);
1192            }
1193            return error_response(StatusCode::GATEWAY_TIMEOUT, &msg);
1194        }
1195    };
1196    match result {
1197        Ok(mut resp) => {
1198            // Extract backend upgrade handle *before* consuming resp
1199            let backend_upgrade = hyper::upgrade::on(&mut resp);
1200            let (mut parts, body) = resp.into_parts();
1201
1202            // Add pitchfork identification header
1203            parts.headers.insert(
1204                axum::http::HeaderName::from_static(PITCHFORK_HEADER),
1205                HeaderValue::from_static("1"),
1206            );
1207
1208            // Strip the internal hop-counter so it is never leaked to external clients.
1209            parts.headers.remove(PROXY_HOPS_HEADER);
1210
1211            // Strip hop-by-hop headers when serving HTTPS (HTTP/2 forbids them).
1212            // Skip 101 Switching Protocols — that response is always HTTP/1.1 and
1213            // the client needs the `Upgrade` header to complete the WS handshake
1214            // (RFC 6455 §4.1 requires `Upgrade: websocket` in the 101 response).
1215            if state.is_tls && parts.status != StatusCode::SWITCHING_PROTOCOLS {
1216                for h in HOP_BY_HOP_HEADERS {
1217                    if let Ok(name) = axum::http::HeaderName::from_bytes(h.as_bytes()) {
1218                        parts.headers.remove(&name);
1219                    }
1220                }
1221            }
1222
1223            // If the backend returned 101 Switching Protocols, pipe the upgraded streams.
1224            if parts.status == StatusCode::SWITCHING_PROTOCOLS {
1225                // Note: loop detection for WebSocket upgrades is already handled at the
1226                // top of proxy_handler (hops >= MAX_PROXY_HOPS check) before the request
1227                // is forwarded.  A 101 response here means the backend accepted the
1228                // upgrade, so the hop count was already within limits.
1229                tokio::spawn(async move {
1230                    if let (Ok(client_upgraded), Ok(backend_upgraded)) =
1231                        (client_upgrade.await, backend_upgrade.await)
1232                    {
1233                        let mut client_io = hyper_util::rt::TokioIo::new(client_upgraded);
1234                        let mut backend_io = hyper_util::rt::TokioIo::new(backend_upgraded);
1235                        // No application-level timeout here: tokio::time::timeout would be a
1236                        // hard wall-clock deadline for the entire tunnel, not an idle timeout.
1237                        // Long-lived connections (Vite/webpack HMR, SSE-over-WS) would be
1238                        // silently terminated after the deadline even if data is actively
1239                        // flowing.  The OS TCP keepalive is sufficient to reap truly dead
1240                        // connections; a proper idle timeout would require a custom
1241                        // AsyncRead/AsyncWrite wrapper that resets the timer on each I/O op.
1242                        let _ =
1243                            tokio::io::copy_bidirectional(&mut client_io, &mut backend_io).await;
1244                    }
1245                });
1246                return Response::from_parts(parts, Body::empty());
1247            }
1248
1249            // Backend refused the upgrade (returned a non-101 response) — forward it as-is.
1250            // This can happen when the backend rejects a WebSocket handshake with e.g. 400.
1251            Response::from_parts(parts, Body::new(body))
1252        }
1253        Err(e) => {
1254            let msg = format!(
1255                "Failed to connect to daemon on port {target_port}: {e}\n\
1256                 The daemon may have stopped or is not yet ready."
1257            );
1258            if let Some(ref on_error) = state.on_error {
1259                on_error(&msg);
1260            } else {
1261                log::warn!("{msg}");
1262            }
1263            error_response(StatusCode::BAD_GATEWAY, &msg)
1264        }
1265    }
1266}
1267
1268/// Resolve the target for a given hostname.
1269///
1270/// Slug-based routing using the global config's `[slugs]` section:
1271/// 1. Strip TLD to get subdomain (the slug)
1272/// 2. Look up slug in global config → find project dir + daemon name
1273/// 3. Check state file for a running daemon with that name → get its port
1274/// 4. If `proxy.auto_start` is enabled and the daemon is not running,
1275///    trigger an automatic start and wait for it to become ready.
1276///
1277/// # Returns
1278/// - `ResolveResult::Ready(port)`       — daemon running (or just auto-started), forward to this port
1279/// - `ResolveResult::Starting { slug }` — daemon start in progress (show waiting page)
1280/// - `ResolveResult::NotFound`          — no daemon matched
1281/// - `ResolveResult::Error(msg)`        — routing refused with a descriptive reason
1282///
1283/// # Locking
1284/// The state file lock is held only for the duration of the snapshot copy,
1285/// then released immediately to avoid serialising all proxy requests.
1286async fn resolve_target(host: &str, tld: &str) -> ResolveResult {
1287    // Strip the TLD suffix to get the subdomain part.
1288    let Some(subdomain) = strip_tld(host, tld) else {
1289        return ResolveResult::NotFound;
1290    };
1291
1292    // Look up the slug via the in-memory cache (refreshed every SLUG_CACHE_TTL).
1293    let Some(cached) = cached_slug_lookup(&subdomain).await else {
1294        return ResolveResult::NotFound;
1295    };
1296
1297    let daemon_name = &cached.daemon_name;
1298    let expected_namespace = &cached.namespace;
1299
1300    // Find matching daemons in the state file.
1301    let daemons = {
1302        let state_file = SUPERVISOR.state_file.lock().await;
1303        state_file.daemons.clone()
1304    };
1305
1306    // Find running daemons whose short name matches the slug's daemon name,
1307    // scoped to the slug's registered project namespace when available.
1308    let running_matches: Vec<(&DaemonId, &crate::daemon::Daemon)> = daemons
1309        .iter()
1310        .filter(|(id, d)| {
1311            id.name() == daemon_name
1312                && d.status.is_running()
1313                && match expected_namespace {
1314                    Some(ns) => id.namespace() == ns,
1315                    None => true,
1316                }
1317        })
1318        .collect();
1319
1320    match running_matches.as_slice() {
1321        [] => {
1322            // Daemon not running — try auto-start if enabled.
1323            // Use cached.slug (not subdomain) so wildcard matches show the
1324            // actual slug name in the "Starting…" page, not the full subdomain.
1325            try_auto_start(&cached.slug, &cached).await
1326        }
1327        [(_, d)] => {
1328            if let Some(port) = d.active_port.or_else(|| d.resolved_port.first().copied()) {
1329                ResolveResult::Ready(port)
1330            } else {
1331                ResolveResult::NotFound
1332            }
1333        }
1334        _ => {
1335            let d = running_matches[0].1;
1336            if let Some(port) = d.active_port.or_else(|| d.resolved_port.first().copied()) {
1337                ResolveResult::Ready(port)
1338            } else {
1339                ResolveResult::NotFound
1340            }
1341        }
1342    }
1343}
1344
1345/// RAII guard that removes a `DaemonId` from `AUTO_START_IN_PROGRESS` on drop.
1346///
1347/// This ensures the in-progress flag is cleared even if the auto-start future
1348/// panics (e.g. an unexpected `unwrap` inside a dependency).  Without this,
1349/// the daemon ID would stay in the set permanently and every subsequent proxy
1350/// request would return "Starting …" forever.
1351struct AutoStartGuard {
1352    daemon_id: DaemonId,
1353}
1354
1355impl Drop for AutoStartGuard {
1356    fn drop(&mut self) {
1357        let daemon_id = self.daemon_id.clone();
1358        // Spawn a cleanup task because `Drop` is synchronous and the mutex is
1359        // async.  If the runtime is shutting down this may not execute, but in
1360        // that case the entire set is being dropped anyway.
1361        tokio::spawn(async move {
1362            AUTO_START_IN_PROGRESS.lock().await.remove(&daemon_id);
1363        });
1364    }
1365}
1366
1367/// Attempt to auto-start a daemon for the given slug.
1368///
1369/// If `proxy.auto_start` is disabled, returns `NotFound`.
1370/// Uses a dedup set to prevent concurrent starts for the same daemon.
1371/// Calls `SUPERVISOR.run()` with `wait_ready = true` so the daemon goes
1372/// through the same readiness lifecycle as `pf start`, then polls for the
1373/// active port.
1374///
1375/// The entire operation — including `SUPERVISOR.run()` and the port-polling
1376/// loop — is bounded by `proxy_auto_start_timeout`.
1377async fn try_auto_start(slug: &str, cached: &CachedSlugEntry) -> ResolveResult {
1378    let s = settings();
1379    if !s.proxy.auto_start {
1380        return ResolveResult::NotFound;
1381    }
1382
1383    // Resolve the daemon ID from the slug's project directory.
1384    // Fall back to "global" when no namespace is resolved so that global
1385    // daemons can also benefit from auto-start (matching the name-only
1386    // routing fallback in `resolve_target`).
1387    let ns = cached
1388        .namespace
1389        .clone()
1390        .unwrap_or_else(|| "global".to_string());
1391    let daemon_id = match DaemonId::try_new(&ns, &cached.daemon_name) {
1392        Ok(id) => id,
1393        Err(_) => return ResolveResult::NotFound,
1394    };
1395
1396    // Atomically check-and-mark the daemon as in-progress so that concurrent
1397    // requests for the same stopped daemon don't trigger multiple starts.
1398    {
1399        let mut in_progress = AUTO_START_IN_PROGRESS.lock().await;
1400        if !in_progress.insert(daemon_id.clone()) {
1401            return ResolveResult::Starting {
1402                slug: slug.to_string(),
1403            };
1404        }
1405    }
1406
1407    // RAII guard: ensures the in-progress flag is cleared even on panic.
1408    let _guard = AutoStartGuard {
1409        daemon_id: daemon_id.clone(),
1410    };
1411
1412    // Apply proxy_auto_start_timeout to the *entire* auto-start operation,
1413    // including SUPERVISOR.run() (which waits for the daemon's readiness
1414    // signal) and the subsequent port-detection polling loop.
1415    let timeout = s.proxy_auto_start_timeout();
1416
1417    match tokio::time::timeout(timeout, try_auto_start_inner(slug, cached, &daemon_id)).await {
1418        Ok(result) => result,
1419        Err(_elapsed) => {
1420            log::warn!("Auto-start: total timeout ({timeout:?}) exceeded for daemon {daemon_id}");
1421            ResolveResult::Error(format!(
1422                "Auto-start for '{daemon_id}' timed out after {timeout:?}.\n\
1423                 The daemon did not become ready and bind a port within the configured \
1424                 proxy_auto_start_timeout.\n\
1425                 Increase the timeout or check the daemon's logs for slow startup."
1426            ))
1427        }
1428    }
1429}
1430
1431/// Inner implementation of [`try_auto_start`] extracted so that the caller can
1432/// wrap it with `tokio::time::timeout` and unconditionally clean up
1433/// `AUTO_START_IN_PROGRESS` regardless of the outcome.
1434async fn try_auto_start_inner(
1435    slug: &str,
1436    cached: &CachedSlugEntry,
1437    daemon_id: &DaemonId,
1438) -> ResolveResult {
1439    // Load config from the slug's project directory to find the daemon definition.
1440    let pt = match crate::pitchfork_toml::PitchforkToml::all_merged_from(&cached.dir) {
1441        Ok(pt) => pt,
1442        Err(e) => {
1443            log::warn!(
1444                "Auto-start: failed to load config from {}: {e}",
1445                cached.dir.display()
1446            );
1447            return ResolveResult::NotFound;
1448        }
1449    };
1450
1451    let daemon_config = match pt.daemons.get(daemon_id) {
1452        Some(cfg) => cfg,
1453        None => {
1454            log::debug!(
1455                "Auto-start: daemon {daemon_id} not found in config at {}",
1456                cached.dir.display()
1457            );
1458            return ResolveResult::NotFound;
1459        }
1460    };
1461
1462    // Build run options — keep wait_ready=true (set by build_run_options) so
1463    // SUPERVISOR.run() waits for the daemon's readiness signal before returning,
1464    // matching the same lifecycle as `pf start` via IPC.
1465    let opts = crate::ipc::batch::StartOptions::default();
1466    let mut run_opts = match crate::ipc::batch::build_run_options(daemon_id, daemon_config, &opts) {
1467        Ok(o) => o,
1468        Err(e) => {
1469            log::warn!("Auto-start: failed to build run options for {daemon_id}: {e}");
1470            return ResolveResult::Error(format!("Failed to build run options: {e}"));
1471        }
1472    };
1473
1474    if run_opts.dir.0.as_os_str().is_empty() {
1475        run_opts.dir = crate::config_types::Dir(cached.dir.clone());
1476    }
1477
1478    log::info!("Auto-start: starting daemon {daemon_id} for slug '{slug}'");
1479
1480    // Trigger the start and wait for daemon readiness.
1481    // This call is bounded by the tokio::time::timeout in try_auto_start().
1482    let run_result = SUPERVISOR.run(run_opts).await;
1483
1484    if let Err(e) = run_result {
1485        log::warn!("Auto-start: failed to start daemon {daemon_id}: {e}");
1486        return ResolveResult::Error(format!("Failed to start daemon: {e}"));
1487    }
1488
1489    // Daemon is ready. Poll briefly for the active_port to be detected
1490    // (detect_and_store_active_port runs asynchronously after readiness).
1491    // No per-loop timeout needed — the outer tokio::time::timeout covers this.
1492    let poll_interval = std::time::Duration::from_millis(250);
1493
1494    loop {
1495        let daemons = {
1496            let sf = SUPERVISOR.state_file.lock().await;
1497            sf.daemons.clone()
1498        };
1499
1500        if let Some(d) = daemons.get(daemon_id) {
1501            if d.status.is_running() {
1502                if let Some(port) = d.active_port.or_else(|| d.resolved_port.first().copied()) {
1503                    log::info!("Auto-start: daemon {daemon_id} is ready on port {port}");
1504                    return ResolveResult::Ready(port);
1505                }
1506            } else {
1507                log::warn!(
1508                    "Auto-start: daemon {daemon_id} is no longer running (status: {})",
1509                    d.status
1510                );
1511                return ResolveResult::Error(format!(
1512                    "Daemon '{daemon_id}' started but exited unexpectedly.\n\
1513                     Check its logs for errors."
1514                ));
1515            }
1516        } else {
1517            // Daemon not found in state file after a successful run() —
1518            // it was likely cleaned up immediately.  Don't spin until timeout.
1519            log::warn!("Auto-start: daemon {daemon_id} not found in state file after start");
1520            return ResolveResult::Error(format!(
1521                "Daemon '{daemon_id}' started but disappeared from the state file.\n\
1522                 Check its logs for errors."
1523            ));
1524        }
1525
1526        tokio::time::sleep(poll_interval).await;
1527    }
1528}
1529
1530/// Strip the TLD suffix from a hostname, returning the subdomain part.
1531///
1532/// Examples:
1533/// - `api.myproject.localhost` with tld `localhost` → `api.myproject`
1534/// - `api.localhost` with tld `localhost` → `api`
1535/// - `localhost` with tld `localhost` → `None` (no subdomain)
1536fn strip_tld(host: &str, tld: &str) -> Option<String> {
1537    host.strip_suffix(&format!(".{tld}"))
1538        .filter(|s| !s.is_empty())
1539        .map(str::to_string)
1540}
1541
1542/// Build a human-friendly error message for port binding failures.
1543fn bind_error_message(port: u16, err: &std::io::Error) -> String {
1544    if port < 1024 {
1545        format!(
1546            "Failed to bind proxy server to port {port}: {err}\n\
1547             Hint: ports below 1024 require elevated privileges. \
1548             Try: sudo pitchfork supervisor start"
1549        )
1550    } else {
1551        format!(
1552            "Failed to bind proxy server to port {port}: {err}\n\
1553             Hint: another process may already be using this port."
1554        )
1555    }
1556}
1557
1558/// Build an HTML "Starting…" response that auto-refreshes every 2 seconds.
1559///
1560/// Displayed when a proxy request triggers an auto-start for a stopped daemon.
1561/// Once the daemon is ready, the next refresh will proxy normally to the backend.
1562fn starting_html_response(slug: &str, raw_host: &str) -> Response {
1563    let escaped_slug = slug
1564        .replace('&', "&amp;")
1565        .replace('<', "&lt;")
1566        .replace('>', "&gt;")
1567        .replace('"', "&quot;")
1568        .replace('\'', "&#x27;");
1569    let escaped_host = raw_host
1570        .replace('&', "&amp;")
1571        .replace('<', "&lt;")
1572        .replace('>', "&gt;")
1573        .replace('"', "&quot;")
1574        .replace('\'', "&#x27;");
1575
1576    let html = format!(
1577        r##"<!DOCTYPE html>
1578<html lang="en">
1579<head>
1580    <meta charset="UTF-8">
1581    <meta name="viewport" content="width=device-width, initial-scale=1">
1582    <meta http-equiv="refresh" content="2">
1583    <title>Starting {escaped_slug}… — pitchfork</title>
1584    <style>
1585        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
1586        body {{
1587            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1588            background: #0f1117;
1589            color: #e1e4e8;
1590            display: flex;
1591            align-items: center;
1592            justify-content: center;
1593            min-height: 100vh;
1594        }}
1595        .container {{
1596            text-align: center;
1597            max-width: 480px;
1598            padding: 2rem;
1599        }}
1600        .spinner {{
1601            width: 48px;
1602            height: 48px;
1603            border: 4px solid rgba(255, 255, 255, 0.1);
1604            border-top-color: #58a6ff;
1605            border-radius: 50%;
1606            animation: spin 0.8s linear infinite;
1607            margin: 0 auto 1.5rem;
1608        }}
1609        @keyframes spin {{
1610            to {{ transform: rotate(360deg); }}
1611        }}
1612        h1 {{
1613            font-size: 1.5rem;
1614            font-weight: 600;
1615            margin-bottom: 0.5rem;
1616        }}
1617        .slug {{
1618            color: #58a6ff;
1619            font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
1620        }}
1621        .host {{
1622            color: #8b949e;
1623            font-size: 0.875rem;
1624            margin-top: 0.25rem;
1625        }}
1626        .hint {{
1627            color: #8b949e;
1628            font-size: 0.8rem;
1629            margin-top: 1.5rem;
1630        }}
1631    </style>
1632</head>
1633<body>
1634    <div class="container">
1635        <div class="spinner"></div>
1636        <h1>Starting <span class="slug">{escaped_slug}</span>…</h1>
1637        <p class="host">{escaped_host}</p>
1638        <p class="hint">This page will refresh automatically when the daemon is ready.</p>
1639    </div>
1640</body>
1641</html>"##
1642    );
1643
1644    Response::builder()
1645        .status(StatusCode::SERVICE_UNAVAILABLE)
1646        .header("content-type", "text/html; charset=utf-8")
1647        .header("retry-after", "2")
1648        .body(Body::from(html))
1649        .unwrap_or_else(|_| (StatusCode::SERVICE_UNAVAILABLE, "Starting…").into_response())
1650}
1651
1652/// Handler that redirects plain-HTTP requests to HTTPS.
1653///
1654/// Used when the proxy is configured for HTTPS but receives a plain-HTTP
1655/// request on the same port (after the first-byte peek determines it is
1656/// not a TLS ClientHello).  Returns a 302 redirect to the HTTPS equivalent.
1657///
1658/// WebSocket upgrade attempts over plain HTTP are rejected with 400
1659/// because WS-over-plain-HTTP to a TLS port is inherently broken.
1660async fn redirect_to_https_handler(req: Request) -> Response {
1661    // Reject WebSocket upgrades over plain HTTP
1662    if req.headers().contains_key("upgrade") {
1663        log::warn!("Dropping plain-HTTP WebSocket upgrade attempt — use wss:// instead of ws://");
1664        return (
1665            StatusCode::BAD_REQUEST,
1666            "WebSocket over plain HTTP is not supported on the HTTPS port. Use wss:// instead.",
1667        )
1668            .into_response();
1669    }
1670
1671    let raw_host = get_request_host(&req);
1672    let Some(raw_host) = raw_host else {
1673        return (StatusCode::BAD_REQUEST, "Missing Host header").into_response();
1674    };
1675
1676    // Strip any incoming port from Host and use the configured HTTPS port.
1677    let hostname = if raw_host.starts_with('[') {
1678        // IPv6: "[::1]:port" or "[::1]"
1679        raw_host
1680            .split_once("]:")
1681            .map(|(host, _)| host)
1682            .unwrap_or(&raw_host)
1683            .trim_start_matches('[')
1684            .trim_end_matches(']')
1685    } else {
1686        // IPv4/hostname: "host:port" or "host"
1687        let mut parts = raw_host.rsplitn(2, ':');
1688        let last = parts.next().unwrap_or(&raw_host);
1689        parts.next().unwrap_or(last)
1690    };
1691
1692    let path = req
1693        .uri()
1694        .path_and_query()
1695        .map(|pq| pq.as_str())
1696        .unwrap_or("/");
1697
1698    let https_port = match u16::try_from(settings().proxy.port).ok().filter(|&p| p > 0) {
1699        Some(443) | None => String::new(),
1700        Some(port) => format!(":{port}"),
1701    };
1702
1703    let host_for_url = if raw_host.starts_with('[') {
1704        format!("[{hostname}]")
1705    } else {
1706        hostname.to_string()
1707    };
1708
1709    let location = format!("https://{host_for_url}{https_port}{path}");
1710    (
1711        StatusCode::FOUND,
1712        [(axum::http::header::LOCATION, location)],
1713    )
1714        .into_response()
1715}
1716
1717/// Build a plain-text error response.
1718fn error_response(status: StatusCode, message: &str) -> Response {
1719    (status, message.to_string()).into_response()
1720}
1721
1722#[cfg(test)]
1723mod tests {
1724    use super::*;
1725
1726    #[test]
1727    fn test_strip_tld() {
1728        assert_eq!(
1729            strip_tld("api.myproject.localhost", "localhost"),
1730            Some("api.myproject".to_string())
1731        );
1732        assert_eq!(
1733            strip_tld("api.localhost", "localhost"),
1734            Some("api".to_string())
1735        );
1736        assert_eq!(strip_tld("localhost", "localhost"), None);
1737        assert_eq!(
1738            strip_tld("api.myproject.test", "test"),
1739            Some("api.myproject".to_string())
1740        );
1741        assert_eq!(strip_tld("other.com", "localhost"), None);
1742    }
1743
1744    fn make_entry(name: &str) -> CachedSlugEntry {
1745        CachedSlugEntry {
1746            slug: name.to_string(),
1747            namespace: None,
1748            daemon_name: name.to_string(),
1749            dir: std::path::PathBuf::from(format!("/tmp/{name}")),
1750        }
1751    }
1752
1753    #[test]
1754    fn test_wildcard_slug_lookup_exact_match() {
1755        let mut entries = std::collections::HashMap::new();
1756        entries.insert("myapp".to_string(), make_entry("myapp"));
1757        // Exact match takes priority.
1758        let result = wildcard_slug_lookup("myapp", &entries, true);
1759        assert!(result.is_some());
1760        assert_eq!(result.unwrap().daemon_name, "myapp");
1761    }
1762
1763    #[test]
1764    fn test_wildcard_slug_lookup_subdomain_fallback() {
1765        let mut entries = std::collections::HashMap::new();
1766        entries.insert("myapp".to_string(), make_entry("myapp"));
1767        // "tenant.myapp" falls back to "myapp".
1768        let result = wildcard_slug_lookup("tenant.myapp", &entries, true);
1769        assert!(result.is_some());
1770        assert_eq!(result.unwrap().daemon_name, "myapp");
1771    }
1772
1773    #[test]
1774    fn test_wildcard_slug_lookup_nested_fallback() {
1775        let mut entries = std::collections::HashMap::new();
1776        entries.insert("myapp".to_string(), make_entry("myapp"));
1777        // "a.b.myapp" falls back to "myapp" through "b.myapp" → "myapp".
1778        let result = wildcard_slug_lookup("a.b.myapp", &entries, true);
1779        assert!(result.is_some());
1780        assert_eq!(result.unwrap().daemon_name, "myapp");
1781    }
1782
1783    #[test]
1784    fn test_wildcard_slug_lookup_no_match() {
1785        let entries = std::collections::HashMap::new();
1786        // Empty entries → no match.
1787        let result = wildcard_slug_lookup("tenant.myapp", &entries, true);
1788        assert!(result.is_none());
1789    }
1790
1791    #[test]
1792    fn test_wildcard_slug_lookup_disabled() {
1793        let mut entries = std::collections::HashMap::new();
1794        entries.insert("myapp".to_string(), make_entry("myapp"));
1795        // With wildcard disabled, "tenant.myapp" does NOT match "myapp".
1796        let result = wildcard_slug_lookup("tenant.myapp", &entries, false);
1797        assert!(result.is_none());
1798        // But exact match still works.
1799        let result = wildcard_slug_lookup("myapp", &entries, false);
1800        assert!(result.is_some());
1801    }
1802
1803    #[test]
1804    fn test_wildcard_slug_lookup_exact_beats_wildcard() {
1805        let mut entries = std::collections::HashMap::new();
1806        entries.insert("myapp".to_string(), make_entry("myapp"));
1807        let mut tenant_entry = make_entry("tenant-daemon");
1808        tenant_entry.slug = "tenant.myapp".to_string();
1809        entries.insert("tenant.myapp".to_string(), tenant_entry);
1810        // "tenant.myapp" should match the exact slug, not fall back to "myapp".
1811        let result = wildcard_slug_lookup("tenant.myapp", &entries, true);
1812        assert!(result.is_some());
1813        assert_eq!(result.unwrap().daemon_name, "tenant-daemon");
1814    }
1815
1816    #[cfg(feature = "proxy-tls")]
1817    #[test]
1818    fn test_generate_ca() {
1819        let dir = tempfile::tempdir().unwrap();
1820        let cert_path = dir.path().join("ca.pem");
1821        let key_path = dir.path().join("ca-key.pem");
1822
1823        generate_ca(&cert_path, &key_path).unwrap();
1824
1825        assert!(cert_path.exists(), "ca.pem should be created");
1826        assert!(key_path.exists(), "ca-key.pem should be created");
1827
1828        let cert_pem = std::fs::read_to_string(&cert_path).unwrap();
1829        let key_pem = std::fs::read_to_string(&key_path).unwrap();
1830
1831        assert!(cert_pem.contains("BEGIN CERTIFICATE"), "should be PEM cert");
1832        assert!(
1833            key_pem.contains("BEGIN") && key_pem.contains("PRIVATE KEY"),
1834            "should be PEM key"
1835        );
1836    }
1837}