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}