Skip to main content

trusty_console/proxy/
routes.rs

1//! Reverse-proxy handlers for `/api/{service}/{*path}` (primary) and the
2//! deprecated `/proxy/{daemon}/{*path}` alias (#1849 Phase 2).
3//!
4//! Why: Provides a single handler that forwards every HTTP method to the live
5//! upstream daemon URL resolved from the background health-poll cache, enabling
6//! all daemon APIs and UIs to be reached through the console port without knowing
7//! per-daemon port numbers.
8//! What: `proxy_handler` resolves the service key, normalises the upstream base
9//! URL (double-scheme guard), forwards the request (method, allowed headers,
10//! body) via `reqwest`, and streams the response back.  Returns 400 for unknown
11//! service keys and 503/502 when the daemon cache is cold or unreachable.
12//! `deprecated_proxy_handler` wraps `proxy_handler` with a debug deprecation
13//! note to nudge callers toward the new `/api/{service}/…` prefix.
14//! Test: `tests::test_build_upstream_url_*` and `test_normalize_base_url_*` below.
15
16use axum::{
17    body::{Body, Bytes},
18    extract::{Path, Request, State},
19    http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
20    response::{IntoResponse, Response},
21};
22use futures_util::TryStreamExt;
23use reqwest::Method;
24use tracing::{debug, trace, warn};
25
26use crate::server::AppState;
27
28/// How long a body may take when the caller asked for a stream and the upstream
29/// did not return one (#6155).
30///
31/// Matches the default client's whole-request timeout, so claiming
32/// `Accept: text/event-stream` buys no more time than an ordinary call.
33const NON_STREAM_BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
34
35// Hop-by-hop headers that must not be forwarded in either direction.
36// RFC 7230 §6.1 and common proxy practice.
37static HOP_BY_HOP: &[&str] = &[
38    "connection",
39    "keep-alive",
40    "proxy-authenticate",
41    "proxy-authorization",
42    "te",
43    "trailers",
44    "transfer-encoding",
45    "upgrade",
46    // Console-specific: do not forward the host header (reqwest sets its own).
47    "host",
48];
49
50/// Map a short service key (as it appears in the URL) to the full service ID
51/// stored in `CachedSnapshot.services`.
52///
53/// Why: The URL uses short names (`search`, `memory`, …) while `ServiceInfo.id`
54/// uses the full `trusty-*` prefix.  This function is the single source of
55/// truth for the proxy allowlist: `None` means the key is not permitted.
56/// `mpm` is in the allowlist (#1849 Phase 1) so `/api/mpm/*path` forwards to
57/// the live trusty-mpm daemon URL resolved from the connector's `ServiceInfo`.
58/// What: Returns the full service ID, or `None` for unknown/disallowed keys.
59/// Test: `test_service_key_mapping` below.
60fn full_id(service_key: &str) -> Option<&'static str> {
61    match service_key {
62        // #6285: NO `search` row. trusty-search moved to UDS (ADR-0032) and
63        // stopped writing the `http_addr` file this proxy resolves a base URL
64        // from — the same reason the memory and analyze rows below are gone,
65        // with the same hazard: that file predates the migration on every
66        // existing machine, so a kept row would forward `/api/search/*` to
67        // whatever now holds 7878. `/api/search/{*path}` is served by
68        // `crate::search_uds::routes` instead, which translates each request
69        // into an RPC call on the socket.
70        //
71        // #6286: NO `memory` row. trusty-memory moved to UDS (ADR-0032) and
72        // this proxy resolves a target's base URL from its `http_addr` file,
73        // which the daemon no longer writes — and which is still on disk from
74        // before the migration on every existing machine, so the row could only
75        // forward `/api/memory/*` to whatever now holds 7070. Deleted for the
76        // same reason the analyze row was, not kept inert like review's.
77        //
78        // #6287: NO `analyze` row. trusty-analyze moved to UDS (ADR-0032) and
79        // this proxy resolves a target's base URL from its `http_addr` file,
80        // which the daemon no longer writes — so the row could only resolve
81        // nothing, or worse, resolve a STALE file left by the pre-migration
82        // daemon and forward `/api/analyze/*` to whatever now holds 7879.
83        //
84        // #6277: INERT since trusty-review moved to UDS (ADR-0032), for the
85        // same reason. Kept rather than deleted because ADR-0035's console-side
86        // aggregator is where review's surface comes back and that work re-uses
87        // this key; the analyze row is deleted instead because its
88        // `http_addr` file predates the migration on every existing machine and
89        // an inert row that can resolve a stale address is not inert.
90        "review" => Some("trusty-review"),
91        // #1849 Phase 1: mpm added to the proxy allowlist so the console can
92        // forward requests to the live trusty-mpm HTTP daemon via its base URL
93        // resolved from the standard http_addr discovery file.
94        "mpm" => Some("trusty-mpm"),
95        // #3331: agents added so the trusty-agents API surface is reachable via
96        // `/api/agents/*`. Under the loopback-only doctrine (#3328) the agents
97        // daemon binds 127.0.0.1 by default, so this console proxy is the
98        // intended remote path to it — resolved from the same `http_addr`
99        // discovery file the other entries use (see `detect::AgentsConnector`).
100        "agents" => Some("trusty-agents"),
101        _ => None,
102    }
103}
104
105/// Guard that rejects any upstream URL that is not a local loopback address.
106///
107/// Why: The console is a strictly local tool.  If a bug or compromise caused a
108/// non-loopback URL to enter the poller cache, forwarding to it would turn the
109/// console into an SSRF vector.  This guard prevents that by enforcing that the
110/// resolved base URL is always a local address before any bytes are sent.
111/// What: Returns `true` if `url` starts with `http://127.`, `http://[::1]`, or
112/// `http://localhost`; `false` for anything else.
113/// Test: `test_is_local_upstream_*` below.
114// #6360: `pub(crate)` so the console's delete routes apply the same
115// loopback predicate before dialling a daemon, rather than minting a second
116// answer to "is this upstream local".
117pub(crate) fn is_local_upstream(url: &str) -> bool {
118    url.starts_with("http://127.")
119        || url.starts_with("http://[::1]")
120        || url.starts_with("http://localhost")
121}
122
123/// Normalize a service base URL to strip any accidental double-scheme prefix.
124///
125/// Why: Defense in depth against a misconfigured discovery file that already
126/// contains a scheme (`http://127.0.0.1:7788`).  The connector prepends
127/// `http://` via `detect_service`, which would produce `http://http://127.0.0.1:7788`.
128/// Stripping all leading scheme prefixes and re-adding exactly one `http://`
129/// ensures a single well-formed `http://host:port` URL regardless of how many
130/// schemes were stacked.  Idempotent on a correctly-formed URL.
131/// What: Delegates to `crate::url_util::strip_schemes` (the single shared
132/// implementation) so the loop logic cannot drift between this site and the
133/// connector layer.  Emits a `warn!` when `https://` is present — that
134/// indicates a misconfigured discovery file (all upstream connections are
135/// loopback HTTP only).
136/// Test: `test_normalize_base_url_*` below.
137pub fn normalize_base_url(url: &str) -> String {
138    if url.contains("https://") {
139        warn!(
140            "proxy: upstream base URL contains https:// — stripping to http:// \
141             (loopback upstream connections are HTTP only). \
142             Check the service's http_addr discovery file."
143        );
144    }
145    format!("http://{}", crate::url_util::strip_schemes(url))
146}
147
148/// Build the upstream URL from a base URL, sub-path, and optional query string.
149///
150/// Why: Centrally-tested URL construction keeps the proxy handler clean.
151/// What: Appends `subpath` (with a leading slash) to `base_url`, then appends
152/// `?{query}` if the query string is non-empty.
153/// Test: `test_build_upstream_url_*` below.
154pub fn build_upstream_url(base_url: &str, subpath: &str, query: Option<&str>) -> String {
155    let base = base_url.trim_end_matches('/');
156    let path = subpath.trim_start_matches('/');
157    let url = if path.is_empty() {
158        format!("{base}/")
159    } else {
160        format!("{base}/{path}")
161    };
162    match query {
163        Some(q) if !q.is_empty() => format!("{url}?{q}"),
164        _ => url,
165    }
166}
167
168/// Strip hop-by-hop headers and copy the remainder into a new `HeaderMap`.
169///
170/// Why: Forwarding hop-by-hop headers to the upstream or back to the client
171/// violates HTTP/1.1 proxy semantics and can cause connection reuse failures.
172/// What: Iterates `headers`, skips any name in `HOP_BY_HOP`, and copies the
173/// rest.
174/// Test: Exercised implicitly by proxy round-trip tests.
175fn filter_headers(headers: &HeaderMap) -> HeaderMap {
176    let mut out = HeaderMap::new();
177    for (name, value) in headers {
178        if !HOP_BY_HOP.contains(&name.as_str()) {
179            out.append(name.clone(), value.clone());
180        }
181    }
182    out
183}
184
185/// Whether any value of `name` mentions the SSE media type (#6155).
186///
187/// One helper for both sides of the exchange: the request's `Accept` says what
188/// the caller wants, the response's `Content-Type` says what the upstream
189/// actually sent, and the two are compared. A media type is case-insensitive
190/// and may sit in a list beside a `q=` parameter.
191/// Test: `test_wants_event_stream_*` and `test_event_stream_response_*` below.
192fn mentions_event_stream(headers: &HeaderMap, name: header::HeaderName) -> bool {
193    headers.get_all(name).iter().any(|v| {
194        v.to_str()
195            .is_ok_and(|s| s.to_ascii_lowercase().contains("text/event-stream"))
196    })
197}
198
199/// Whether the caller is opening a Server-Sent Events stream (#6155).
200///
201/// Why: an SSE response never ends, so it must not be proxied under a
202/// whole-request deadline. The browser's `EventSource` always sends
203/// `Accept: text/event-stream`, which is the only signal available before the
204/// upstream has answered — a claim, not proof, which is why the response is
205/// checked too (`event_stream_response`).
206/// What: `true` when any `Accept` header value mentions `text/event-stream`.
207/// Test: `test_wants_event_stream_*` below.
208fn wants_event_stream(headers: &HeaderMap) -> bool {
209    mentions_event_stream(headers, header::ACCEPT)
210}
211
212/// Whether the upstream actually answered with a Server-Sent Events body.
213///
214/// Why: this is the half the caller cannot forge. Only a response the upstream
215/// labelled `text/event-stream` is streamed under the deadline-free client;
216/// anything else is read under `NON_STREAM_BODY_TIMEOUT`, so naming that Accept
217/// type on an ordinary route buys no extra connection time.
218/// What: `true` when the response `Content-Type` mentions `text/event-stream`.
219/// Test: `test_event_stream_response_*` below.
220fn event_stream_response(headers: &HeaderMap) -> bool {
221    mentions_event_stream(headers, header::CONTENT_TYPE)
222}
223
224/// Build a plain-text error response.
225///
226/// Why: Centralises error body construction so callers are one-liners.
227/// What: Returns a `Response` with the given status and a UTF-8 text body.
228/// Test: Exercised by error-path coverage.
229fn error_response(status: StatusCode, body: &'static str) -> Response {
230    Response::builder()
231        .status(status)
232        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
233        .body(Body::from(body))
234        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
235}
236
237/// Bounded backoff schedule for connect-retries after a proxied request fails
238/// to reach the upstream.
239///
240/// Why: An upstream daemon restart (`launchctl bootout`/`bootstrap`) leaves the
241/// port unbound for a short window (empirically up to ~1 s to re-listen).  A
242/// single short pause does not reliably cover that window — a request landing
243/// early in a restart would retry while the port is still dead and surface the
244/// exact 502 this fix targets (#1984).  So we retry with a bounded escalating
245/// backoff whose cumulative wait (~1 s) spans a typical restart.  The delays are
246/// only ever paid when a connect actually fails, so the overwhelmingly common
247/// success path is never slowed.
248/// What: Two entries — 300 ms then 700 ms — i.e. at most two extra attempts
249/// (three total), consumed by `proxy_handler`.
250/// Test: `test_connect_retry_recovers` / `test_connect_retry_gives_up` pass
251/// shorter test-local schedules.
252const CONNECT_RETRY_DELAYS: &[std::time::Duration] = &[
253    std::time::Duration::from_millis(300),
254    std::time::Duration::from_millis(700),
255];
256
257/// Send the proxied upstream request, retrying on connect errors per a bounded
258/// backoff schedule.
259///
260/// Why: When the upstream daemon restarts (#1984), its port is momentarily not
261/// accepting connections; a proxied request landing in that window fails to
262/// connect and would surface as a 502 to the caller (the first `tm session new`
263/// after an mpm restart).  Because the proxy client no longer pools idle
264/// keep-alive connections (see `AppState::new`), a *connect* error is proof the
265/// request was never transmitted — so retrying it is safe even for a
266/// non-idempotent POST (no risk of a duplicate spawn), for any bounded number of
267/// attempts.
268/// What: Sends the request; while it fails with a connect error (`is_connect()`)
269/// and the schedule is not exhausted, waits the next `retry_delays` entry and
270/// rebuilds+resends from the cloned method/url/headers/body.  Any other transport
271/// error is returned as-is, an HTTP error *status* (which reqwest reports as
272/// `Ok`) is never retried, and the last connect error is returned once the
273/// schedule is exhausted.  Attempts are strictly bounded to `retry_delays.len()`
274/// retries (no loop past the schedule).
275/// Test: `test_connect_retry_recovers` and `test_connect_retry_gives_up` below.
276async fn send_with_connect_retry(
277    client: &reqwest::Client,
278    method: Method,
279    url: &str,
280    headers: HeaderMap,
281    body: Bytes,
282    retry_delays: &[std::time::Duration],
283) -> Result<reqwest::Response, reqwest::Error> {
284    let mut attempt = 0usize;
285    loop {
286        match client
287            .request(method.clone(), url)
288            .headers(headers.clone())
289            .body(body.clone())
290            .send()
291            .await
292        {
293            Ok(resp) => return Ok(resp),
294            Err(e) if e.is_connect() && attempt < retry_delays.len() => {
295                let delay = retry_delays[attempt];
296                debug!(
297                    "proxy: upstream connect failed (attempt {}), retrying after {delay:?}: {e}",
298                    attempt + 1
299                );
300                tokio::time::sleep(delay).await;
301                attempt += 1;
302            }
303            Err(e) => return Err(e),
304        }
305    }
306}
307
308/// `ANY /api/{service}/{*path}` — reverse-proxy to the service's live URL.
309///
310/// Why: Lets operators and the console SPA reach every daemon API through the
311/// console port without knowing per-daemon port numbers.
312/// What: Resolves the service's base URL from the background health-poll cache,
313/// normalises the URL (double-scheme guard), forwards the request (method, safe
314/// headers, body) via reqwest, and streams the upstream response back.
315/// Unknown service keys → 400; daemon not reachable → 502.
316/// Test: URL construction is unit-tested in `tests` below.  End-to-end proxy
317/// behaviour requires a live daemon and is not tested in CI.
318pub async fn proxy_handler(
319    State(state): State<AppState>,
320    Path((service_key, subpath)): Path<(String, String)>,
321    req: Request,
322) -> Response {
323    // Defensive guard: "console" is a reserved service key that routes to the
324    // console's own /api/console/* namespace.  Reject it explicitly here as a
325    // routing-independent second layer so the proxy can never target itself,
326    // even if axum's literal-segment priority were somehow bypassed.
327    if service_key.as_str() == "console" {
328        warn!(
329            "proxy: service_key 'console' is reserved and cannot be proxied (routing invariant violated)"
330        );
331        return error_response(StatusCode::BAD_REQUEST, "reserved service key");
332    }
333
334    // Map short key → full id via the exhaustive match in full_id(), which is
335    // the single source of truth for the proxy allowlist.
336    let Some(full_service_id) = full_id(&service_key) else {
337        warn!("proxy: unknown service key '{service_key}'");
338        return error_response(StatusCode::BAD_REQUEST, "unknown daemon");
339    };
340
341    let base_url = {
342        let snap = state.poller_cache().snapshot().await;
343        match snap {
344            None => {
345                warn!("proxy: cache not yet populated for '{service_key}'");
346                return error_response(StatusCode::SERVICE_UNAVAILABLE, "cache not ready");
347            }
348            Some(s) => {
349                let map = s.url_map();
350                match map.get(full_service_id).cloned() {
351                    Some(url) => url,
352                    None => {
353                        warn!("proxy: service '{service_key}' is not running");
354                        return error_response(StatusCode::BAD_GATEWAY, "daemon not running");
355                    }
356                }
357            }
358        }
359    };
360
361    // Normalize base URL — strips any accidental double-scheme prefix produced
362    // by a malformed discovery file (#1849 Phase 2 hardening).
363    let base_url = normalize_base_url(&base_url);
364
365    // SSRF guard: the console is a local-only tool; reject any upstream that is
366    // not a loopback address.  A non-local URL in the cache would be a bug or
367    // compromise — fail closed rather than forward.
368    if !is_local_upstream(&base_url) {
369        warn!("proxy: upstream '{base_url}' is not a local address — rejecting (SSRF guard)");
370        return error_response(StatusCode::BAD_GATEWAY, "upstream not local");
371    }
372
373    // Decompose request into parts so we can access headers and body.
374    let (parts, body) = req.into_parts();
375
376    // Build the upstream URL.
377    let query = parts.uri.query();
378    let upstream_url = build_upstream_url(&base_url, &subpath, query);
379    debug!("proxy: {service_key} → {upstream_url}");
380
381    // Convert axum Method to reqwest Method.
382    let method = match Method::from_bytes(parts.method.as_str().as_bytes()) {
383        Ok(m) => m,
384        Err(_) => {
385            return error_response(StatusCode::BAD_REQUEST, "unsupported method");
386        }
387    };
388
389    // Filter headers before consuming body.
390    let safe_headers = filter_headers(&parts.headers);
391
392    // Collect body bytes (64 MiB cap).
393    const BODY_LIMIT: usize = 64 * 1024 * 1024;
394    let body_bytes: Bytes = match axum::body::to_bytes(body, BODY_LIMIT).await {
395        Ok(b) => b,
396        Err(e) => {
397            warn!("proxy: failed to read request body: {e}");
398            return error_response(
399                StatusCode::PAYLOAD_TOO_LARGE,
400                "request body exceeds proxy limit of 64 MiB",
401            );
402        }
403    };
404
405    // Build & execute the upstream request with a bounded connect-retry backoff.
406    // The proxy client does not pool idle keep-alive connections (see
407    // `AppState::new`), so a connect error means nothing was transmitted; retry
408    // across the ~1 s window in which a restarted upstream daemon is not yet
409    // accepting connections (#1984) rather than failing the caller's first
410    // request outright.
411    //
412    // #6155: an `EventSource` asks for `text/event-stream` and expects the
413    // connection to stay open indefinitely. The default client's 30-second
414    // whole-request timeout would cut it, so those requests go through the
415    // stream client instead.
416    let asked_for_event_stream = wants_event_stream(&parts.headers);
417    let client = if asked_for_event_stream {
418        state.stream_client()
419    } else {
420        state.http_client()
421    };
422    let upstream_resp = match send_with_connect_retry(
423        &client,
424        method,
425        &upstream_url,
426        safe_headers,
427        body_bytes,
428        CONNECT_RETRY_DELAYS,
429    )
430    .await
431    {
432        Ok(r) => r,
433        Err(e) => {
434            warn!("proxy: upstream request failed for '{service_key}': {e}");
435            return error_response(StatusCode::BAD_GATEWAY, "upstream request failed");
436        }
437    };
438
439    // Map the upstream response back.
440    let status = StatusCode::from_u16(upstream_resp.status().as_u16())
441        .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
442
443    let mut resp_builder = Response::builder().status(status);
444
445    // Copy allowed upstream response headers.
446    for (name, value) in upstream_resp.headers() {
447        if !HOP_BY_HOP.contains(&name.as_str())
448            && let Ok(n) = HeaderName::from_bytes(name.as_str().as_bytes())
449            && let Ok(v) = HeaderValue::from_bytes(value.as_bytes())
450        {
451            resp_builder = resp_builder.header(n, v);
452        }
453    }
454
455    // #6155: only a response the upstream actually labelled `text/event-stream`
456    // earns the deadline-free client it was fetched with. `Accept` is a caller
457    // claim, so without this an ordinary proxied GET sent with
458    // `Accept: text/event-stream` would hold a connection open indefinitely by
459    // trickling bytes, since `stream_client` bounds silence and not duration.
460    // Bounding the client instead would be the wrong trade: `/reindex/stream`
461    // runs as long as the reindex does, and the SPA reads a closed stream as
462    // "complete" (`ui/src/lib/views/Indexes.svelte`), so a total cap would
463    // report a long reindex finished while it was still running.
464    if asked_for_event_stream && !event_stream_response(upstream_resp.headers()) {
465        warn!(
466            "proxy: {service_key} asked for an event stream but upstream answered \
467             a non-stream body — reading it under a bounded deadline"
468        );
469        let collected = tokio::time::timeout(NON_STREAM_BODY_TIMEOUT, upstream_resp.bytes()).await;
470        let body = match collected {
471            Ok(Ok(b)) => Body::from(b),
472            Ok(Err(e)) => {
473                warn!("proxy: failed to read upstream body: {e}");
474                Body::from("upstream body error")
475            }
476            Err(_) => {
477                warn!("proxy: upstream body exceeded the non-stream deadline");
478                return error_response(StatusCode::GATEWAY_TIMEOUT, "upstream body timeout");
479            }
480        };
481        return resp_builder
482            .body(body)
483            .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response());
484    }
485
486    // #6155: hand the upstream body back as a stream rather than collecting it
487    // first. Collecting never returns for a Server-Sent Events response — the
488    // search SPA's `/status/stream` and `/reindex/stream` produced no bytes at
489    // all through this proxy until the whole-request timeout fired. Streaming
490    // also stops a large search response being held twice in memory.
491    //
492    // `inspect_err` restores the log the pre-streaming `.bytes().await` path
493    // had: a mid-stream failure otherwise reaches the browser as a truncated
494    // body with nothing on the console side saying why.
495    let key_for_log = service_key.clone();
496    let resp_body = Body::from_stream(upstream_resp.bytes_stream().inspect_err(move |e| {
497        warn!("proxy: {key_for_log}: upstream body failed mid-stream: {e}");
498    }));
499
500    resp_builder
501        .body(resp_body)
502        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
503}
504
505/// `ANY /proxy/{daemon}/{*path}` — deprecated alias for `proxy_handler`.
506///
507/// Why: The `/proxy/` prefix was renamed to `/api/` in #1849 Phase 2 to align
508/// with the console's `/api/console/…` namespace.  This alias keeps old callers
509/// working without a hard break while nudging them toward the new path.
510/// What: Logs a debug-level deprecation note then delegates to `proxy_handler`
511/// with the same extracted path components.
512/// Test: `test_deprecated_proxy_alias_*` in `server.rs`.
513pub async fn deprecated_proxy_handler(
514    State(state): State<AppState>,
515    Path((service_key, subpath)): Path<(String, String)>,
516    req: Request,
517) -> Response {
518    trace!("proxy: DEPRECATED /proxy/{service_key}/… — use /api/{service_key}/… instead (#1849)");
519    proxy_handler(State(state), Path((service_key, subpath)), req).await
520}
521
522// ─── tests ───────────────────────────────────────────────────────────────────
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    /// Why: URL must be built correctly for a subpath with no query string.
529    /// What: asserts build_upstream_url("http://127.0.0.1:7878", "health", None)
530    /// → "http://127.0.0.1:7878/health".
531    /// Test: this test itself.
532    #[test]
533    fn test_build_upstream_url_simple_path() {
534        assert_eq!(
535            build_upstream_url("http://127.0.0.1:7878", "health", None),
536            "http://127.0.0.1:7878/health"
537        );
538    }
539
540    /// Why: a query string must be appended after `?`.
541    /// What: asserts build_upstream_url with query "top_k=5" → correct URL.
542    /// Test: this test itself.
543    #[test]
544    fn test_build_upstream_url_with_query() {
545        assert_eq!(
546            build_upstream_url(
547                "http://127.0.0.1:7879",
548                "indexes/abc/complexity_hotspots",
549                Some("top_k=5")
550            ),
551            "http://127.0.0.1:7879/indexes/abc/complexity_hotspots?top_k=5"
552        );
553    }
554
555    /// Why: an empty subpath must still produce a valid URL with trailing slash.
556    /// What: asserts build_upstream_url with empty subpath.
557    /// Test: this test itself.
558    #[test]
559    fn test_build_upstream_url_empty_path() {
560        assert_eq!(
561            build_upstream_url("http://127.0.0.1:7070", "", None),
562            "http://127.0.0.1:7070/"
563        );
564    }
565
566    /// Why: base URL with trailing slash must not produce a double slash.
567    /// What: passes base URL with trailing slash, asserts no double slash.
568    /// Test: this test itself.
569    #[test]
570    fn test_build_upstream_url_base_trailing_slash() {
571        assert_eq!(
572            build_upstream_url("http://127.0.0.1:7878/", "health", None),
573            "http://127.0.0.1:7878/health"
574        );
575    }
576
577    /// Why: an empty query string must not append a `?`.
578    /// What: passes Some("") as query; asserts no trailing `?`.
579    /// Test: this test itself.
580    #[test]
581    fn test_build_upstream_url_empty_query_omitted() {
582        assert_eq!(
583            build_upstream_url("http://127.0.0.1:7878", "health", Some("")),
584            "http://127.0.0.1:7878/health"
585        );
586    }
587
588    /// Why: normalize_base_url must be idempotent on a correctly-formed URL.
589    /// What: passes `http://127.0.0.1:7878`; asserts it is returned unchanged.
590    /// Test: this test itself.
591    #[test]
592    fn test_normalize_base_url_idempotent_on_correct_url() {
593        assert_eq!(
594            normalize_base_url("http://127.0.0.1:7878"),
595            "http://127.0.0.1:7878"
596        );
597    }
598
599    /// Why: a double-scheme URL (produced when a discovery file already contains
600    /// `http://` and detect_service prepends another) must be collapsed to one
601    /// scheme (#1849 Phase 2 double-scheme hardening).
602    /// What: passes `http://http://127.0.0.1:7878`; asserts `http://127.0.0.1:7878`.
603    /// Test: this test itself.
604    #[test]
605    fn test_normalize_base_url_collapses_double_http_scheme() {
606        assert_eq!(
607            normalize_base_url("http://http://127.0.0.1:7878"),
608            "http://127.0.0.1:7878"
609        );
610    }
611
612    /// Why: an https-prefixed discovery URL must also be normalised to http://
613    /// (all upstream connections are loopback HTTP only).
614    /// What: passes `https://127.0.0.1:7878`; asserts `http://127.0.0.1:7878`.
615    /// Test: this test itself.
616    #[test]
617    fn test_normalize_base_url_replaces_https_with_http() {
618        assert_eq!(
619            normalize_base_url("https://127.0.0.1:7878"),
620            "http://127.0.0.1:7878"
621        );
622    }
623
624    /// Why: the SSRF guard must accept loopback IPv4, IPv6, and localhost but
625    /// reject any other URL including external hosts and non-loopback RFC-1918.
626    /// What: calls is_local_upstream with accepted and rejected URLs.
627    /// Test: this test itself.
628    #[test]
629    fn test_is_local_upstream_accepted() {
630        assert!(is_local_upstream("http://127.0.0.1:7878"));
631        assert!(is_local_upstream("http://127.0.0.1:7878/health"));
632        assert!(is_local_upstream("http://127.1.2.3:9000"));
633        assert!(is_local_upstream("http://[::1]:8080"));
634        assert!(is_local_upstream("http://localhost:7070"));
635        assert!(is_local_upstream("http://localhost"));
636    }
637
638    /// Why: non-local URLs must be rejected to prevent SSRF.
639    /// What: calls is_local_upstream with external and RFC-1918 URLs.
640    /// Test: this test itself.
641    #[test]
642    fn test_is_local_upstream_rejected() {
643        assert!(!is_local_upstream("http://192.168.1.1:7878"));
644        assert!(!is_local_upstream("http://10.0.0.1:7879"));
645        assert!(!is_local_upstream("http://evil.example.com/steal"));
646        assert!(!is_local_upstream("https://127.0.0.1:7878")); // https, not http
647        assert!(!is_local_upstream("http://0.0.0.0:7878"));
648    }
649
650    /// Why: full_id must map all known short service keys to their trusty-* IDs.
651    /// What: calls full_id for each known key and the unknown key.
652    /// Test: this test itself.
653    #[test]
654    fn test_service_key_mapping() {
655        // #6285: `search` is no longer allowlisted. trusty-search serves UDS
656        // and writes no `http_addr`, so an allowlisted key could only resolve a
657        // stale one and forward `/api/search/*` to whatever now holds 7878.
658        // `crate::search_uds::routes` owns that prefix instead.
659        assert_eq!(full_id("search"), None);
660        assert_eq!(
661            full_id("memory"),
662            None,
663            "trusty-memory serves a socket since #6286; a stale http_addr would forward to whatever holds 7070"
664        );
665        // #6287: `analyze` is no longer allowlisted. An allowlisted key whose
666        // target serves UDS resolves its base URL from a stale `http_addr`
667        // file, which forwards `/api/analyze/*` to whatever now holds 7879.
668        assert_eq!(full_id("analyze"), None);
669        // #6277: still allowlisted, but inert — trusty-review serves UDS and
670        // writes no `http_addr` for the proxy to resolve. See `full_id`.
671        assert_eq!(full_id("review"), Some("trusty-review"));
672        // #1849 Phase 1: mpm must be in the allowlist.
673        assert_eq!(full_id("mpm"), Some("trusty-mpm"));
674        // #3331: agents must be in the allowlist so `/api/agents/*` proxies.
675        assert_eq!(full_id("agents"), Some("trusty-agents"));
676        assert_eq!(full_id("unknown"), None);
677    }
678
679    /// Why: a double-scheme URL like `http://http://127.0.0.1:7878` must NOT
680    /// pass the SSRF guard — it starts with `http://http://`, not `http://127.`,
681    /// `http://[::1]`, or `http://localhost`.  This locks in the ordering
682    /// safety: normalize_base_url must run before is_local_upstream so the
683    /// guard only ever sees a clean single-scheme URL.
684    /// What: asserts is_local_upstream("http://http://127.0.0.1:7878") is false.
685    /// Test: this test itself (#1849 Phase 2 double-scheme SSRF regression guard).
686    #[test]
687    fn test_is_local_upstream_rejects_double_scheme() {
688        assert!(
689            !is_local_upstream("http://http://127.0.0.1:7878"),
690            "double-scheme URL must not pass the loopback guard"
691        );
692    }
693
694    /// Why: the "console" service key is reserved; full_id returns None for it
695    /// so it would be caught by the unknown-key guard — but the explicit
696    /// console check must fire first (defensive depth).
697    /// What: asserts full_id("console") is None (the allowlist does not list it).
698    /// Test: this test itself (unit-level guard; HTTP-level guard tested in
699    /// server.rs::test_api_proxy_console_key_returns_400).
700    #[test]
701    fn test_console_key_not_in_allowlist() {
702        assert_eq!(
703            full_id("console"),
704            None,
705            "console must never appear in the proxy allowlist"
706        );
707    }
708
709    /// Why: hop-by-hop headers must be stripped; safe headers must pass through.
710    /// What: builds a HeaderMap with a hop-by-hop ("connection") and a safe
711    /// header ("x-custom"), calls filter_headers, asserts only safe one remains.
712    /// Test: this test itself.
713    #[test]
714    fn test_filter_headers_strips_hop_by_hop() {
715        let mut h = HeaderMap::new();
716        h.insert("connection", HeaderValue::from_static("keep-alive"));
717        h.insert("x-custom", HeaderValue::from_static("hello"));
718        let filtered = filter_headers(&h);
719        assert!(!filtered.contains_key("connection"));
720        assert!(filtered.contains_key("x-custom"));
721    }
722
723    use std::time::Duration;
724    use tokio::io::{AsyncReadExt, AsyncWriteExt};
725    use tokio::net::TcpListener;
726
727    /// Reserve a loopback port, then release it so nothing is listening.
728    ///
729    /// Why: The restart tests need a port that is *initially* refusing
730    /// connections (mimicking a daemon that is down) but that a test server can
731    /// later bind.  Binding then dropping a listener yields such a port.
732    /// What: Binds `127.0.0.1:0`, reads the assigned port, drops the listener,
733    /// and returns the port.
734    /// Test: Used by the two connect-retry tests below.
735    async fn reserve_free_port() -> u16 {
736        let l = TcpListener::bind("127.0.0.1:0").await.expect("bind");
737        let port = l.local_addr().expect("local_addr").port();
738        drop(l);
739        port
740    }
741
742    /// Accept exactly one connection on `port`, drain the request, and reply 200.
743    ///
744    /// Why: A minimal HTTP/1.1 responder is enough to prove the proxy client can
745    /// reach a freshly-(re)bound upstream; pulling in a full server would add
746    /// noise.  `connection: close` lets reqwest read the body to EOF.  The
747    /// optional `ready` sender is the #2634 readiness handshake: it fires the
748    /// instant `bind()` returns Ok (i.e. the kernel is provably accepting on
749    /// `port`), so a caller can assert the upstream truly came up instead of
750    /// hoping a bare sleep landed before the retry connected.
751    /// What: Binds `port`; if `ready` is `Some`, signals it once the listener is
752    /// accepting; then accepts one socket, reads one buffer of the request
753    /// (headers + tiny body), writes a fixed `200 OK` response and closes.
754    /// Test: Used by both connect-retry recovery tests below.
755    async fn serve_one_ok(port: u16, ready: Option<tokio::sync::oneshot::Sender<()>>) {
756        let l = TcpListener::bind(("127.0.0.1", port))
757            .await
758            .expect("rebind test server");
759        // `bind()` returned Ok → socket()+bind()+listen() have completed and the
760        // kernel now accepts connections on `port`.  Signalling here (before the
761        // blocking `accept()`) lets the caller prove readiness deterministically.
762        if let Some(tx) = ready {
763            let _ = tx.send(());
764        }
765        let (mut sock, _) = l.accept().await.expect("accept");
766        let mut buf = [0u8; 4096];
767        let _ = sock.read(&mut buf).await;
768        let resp = b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok";
769        let _ = sock.write_all(resp).await;
770        let _ = sock.shutdown().await;
771    }
772
773    /// Why: A proxied POST that lands while the upstream is mid-restart (port not
774    /// yet listening) must succeed via a connect-retry once the daemon comes
775    /// back — the #1984 root-cause regression guard.
776    /// What: Points the client at a port that is refused for ~120 ms, then bound
777    /// by a one-shot 200 server; with a 300 ms retry delay the retry connects and
778    /// returns 200.  A no-idle-pool client mirrors production (`AppState::new`).
779    /// Test: this test itself.
780    #[tokio::test]
781    async fn test_connect_retry_recovers() {
782        let port = reserve_free_port().await;
783        let url = format!("http://127.0.0.1:{port}/api/v1/sessions/managed");
784
785        // Bring the upstream up shortly after the first attempt will have failed.
786        tokio::spawn(async move {
787            tokio::time::sleep(Duration::from_millis(120)).await;
788            serve_one_ok(port, None).await;
789        });
790
791        let client = reqwest::Client::builder()
792            .pool_max_idle_per_host(0)
793            .build()
794            .expect("client");
795        let resp = send_with_connect_retry(
796            &client,
797            Method::POST,
798            &url,
799            HeaderMap::new(),
800            Bytes::from_static(b"{}"),
801            &[Duration::from_millis(300), Duration::from_millis(700)],
802        )
803        .await
804        .expect("retry should recover once upstream is back");
805        assert_eq!(resp.status().as_u16(), 200);
806    }
807
808    /// Why: A restart whose port stays dead past the first retry must still
809    /// recover on a *later* scheduled retry — this guards the bounded-backoff
810    /// widening (300 ms + 700 ms) added so the schedule spans the ~1 s restart
811    /// window, not just its tail (#1984 review follow-up).
812    /// What: Keeps the port refused past the first 100 ms retry, binds a one-shot
813    /// 200 upstream at the 200 ms mark, and asserts the second scheduled retry
814    /// (at 100 + 400 = 500 ms) connects and returns 200.  Determinism comes from a
815    /// readiness handshake plus provably-ordered deadlines rather than sleep
816    /// alignment: tokio fires timers in deadline order, so the 200 ms bind lands
817    /// ~300 ms before the 500 ms retry connects — a margin dwarfing any
818    /// bind/listen latency.  This replaces the #2634 zero-margin schedule where a
819    /// 400 ms bind coincided with a 400 ms retry and intermittently lost the race
820    /// (ConnectionRefused).  `ready_rx` then *proves* the upstream actually bound
821    /// and accepted, so a future timing regression fails loudly instead of flaking.
822    /// Test: this test itself.
823    #[tokio::test]
824    async fn test_connect_retry_recovers_on_second_attempt() {
825        let port = reserve_free_port().await;
826        let url = format!("http://127.0.0.1:{port}/api/v1/sessions/managed");
827
828        // Bind the upstream after the first retry has already failed (100 ms) but
829        // well before the second retry connects (500 ms).  The handshake sender
830        // fires the moment the listener is accepting.
831        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
832        tokio::spawn(async move {
833            tokio::time::sleep(Duration::from_millis(200)).await;
834            serve_one_ok(port, Some(ready_tx)).await;
835        });
836
837        let client = reqwest::Client::builder()
838            .pool_max_idle_per_host(0)
839            .build()
840            .expect("client");
841        let resp = send_with_connect_retry(
842            &client,
843            Method::POST,
844            &url,
845            HeaderMap::new(),
846            Bytes::from_static(b"{}"),
847            &[Duration::from_millis(100), Duration::from_millis(400)],
848        )
849        .await
850        .expect("second scheduled retry should recover once upstream is back");
851        assert_eq!(resp.status().as_u16(), 200);
852        // The success above is only reachable after the upstream bound and
853        // signalled readiness; assert the handshake explicitly so the test never
854        // silently degrades into a coincidental pass.
855        ready_rx
856            .await
857            .expect("upstream should have signalled readiness before responding");
858    }
859
860    /// Why: If the upstream never returns, the retry must give up after exactly
861    /// the scheduled number of extra attempts and surface a connect error (not
862    /// hang, not loop) so the handler can map it to a 502.
863    /// What: Targets a permanently-unbound port with a two-entry schedule; asserts
864    /// the result is an error that `is_connect()` (proving the loop exits after
865    /// exhausting the schedule rather than spinning).
866    /// Test: this test itself.
867    #[tokio::test]
868    async fn test_connect_retry_gives_up() {
869        let port = reserve_free_port().await;
870        let url = format!("http://127.0.0.1:{port}/health");
871        let client = reqwest::Client::builder()
872            .pool_max_idle_per_host(0)
873            .build()
874            .expect("client");
875        let err = send_with_connect_retry(
876            &client,
877            Method::POST,
878            &url,
879            HeaderMap::new(),
880            Bytes::from_static(b"{}"),
881            &[Duration::from_millis(20), Duration::from_millis(20)],
882        )
883        .await
884        .expect_err("no upstream should yield an error");
885        assert!(err.is_connect(), "expected a connect error, got: {err}");
886    }
887
888    /// Why: an SSE request must not be proxied under the whole-request
889    /// deadline, and `Accept` is the only pre-response signal (#6155).
890    /// What: asserts the header match is case-insensitive, tolerates a full
891    /// `Accept` list, and stays false for an ordinary JSON call.
892    /// Test: this test itself.
893    #[test]
894    fn test_wants_event_stream_matches_accept_header() {
895        let mut h = HeaderMap::new();
896        h.insert(
897            header::ACCEPT,
898            HeaderValue::from_static("text/event-stream"),
899        );
900        assert!(wants_event_stream(&h));
901
902        let mut h = HeaderMap::new();
903        h.insert(
904            header::ACCEPT,
905            HeaderValue::from_static("Text/Event-Stream"),
906        );
907        assert!(wants_event_stream(&h), "match must be case-insensitive");
908
909        let mut h = HeaderMap::new();
910        h.insert(
911            header::ACCEPT,
912            HeaderValue::from_static("text/event-stream, application/json;q=0.9"),
913        );
914        assert!(wants_event_stream(&h), "must match inside an Accept list");
915    }
916
917    #[test]
918    fn test_wants_event_stream_false_for_ordinary_requests() {
919        assert!(!wants_event_stream(&HeaderMap::new()));
920        let mut h = HeaderMap::new();
921        h.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
922        assert!(!wants_event_stream(&h));
923    }
924
925    /// Why: the response header is the half a caller cannot forge, and it is
926    /// what decides whether the deadline-free client's body is handed straight
927    /// to the caller (#6155).
928    /// What: asserts the SSE content type is recognised with and without a
929    /// charset parameter, and that an ordinary JSON response is not.
930    /// Test: this test itself.
931    #[test]
932    fn test_event_stream_response_matches_content_type() {
933        let mut h = HeaderMap::new();
934        h.insert(
935            header::CONTENT_TYPE,
936            HeaderValue::from_static("text/event-stream"),
937        );
938        assert!(event_stream_response(&h));
939
940        let mut h = HeaderMap::new();
941        h.insert(
942            header::CONTENT_TYPE,
943            HeaderValue::from_static("text/event-stream; charset=utf-8"),
944        );
945        assert!(event_stream_response(&h), "must tolerate a charset param");
946    }
947
948    /// An upstream that answered JSON must NOT be treated as a stream, however
949    /// the request's `Accept` was written — that pairing is the one the
950    /// bounded-body path exists for.
951    #[test]
952    fn test_event_stream_response_false_for_ordinary_responses() {
953        assert!(!event_stream_response(&HeaderMap::new()));
954        let mut h = HeaderMap::new();
955        h.insert(
956            header::CONTENT_TYPE,
957            HeaderValue::from_static("application/json"),
958        );
959        assert!(!event_stream_response(&h));
960
961        // The shape the guard catches: caller claims SSE, upstream sends JSON.
962        let mut req = HeaderMap::new();
963        req.insert(
964            header::ACCEPT,
965            HeaderValue::from_static("text/event-stream"),
966        );
967        assert!(wants_event_stream(&req));
968        assert!(
969            !event_stream_response(&h),
970            "an Accept claim must not make a JSON response a stream"
971        );
972    }
973}