Skip to main content

trusty_console/proxy/
routes.rs

1//! Reverse-proxy handler for `/proxy/{daemon}/{*path}`.
2//!
3//! Why: Provides a single handler that forwards every HTTP method to the live
4//! upstream daemon URL resolved from the background health-poll cache, enabling
5//! all daemon APIs and UIs to be reached through the console port.
6//! What: `proxy_handler` strips the `/proxy/{daemon}/` prefix, resolves the
7//! daemon's base URL from the cached snapshot, forwards the request (method,
8//! allowed headers, body) via `reqwest`, and streams the response (status,
9//! allowed response headers, body) back to the caller.  Returns 400 for unknown
10//! daemon IDs and 502 when the daemon is not reachable.
11//! Test: `tests::test_build_upstream_url_*` below exercise URL construction.
12
13use axum::{
14    body::{Body, Bytes},
15    extract::{Path, Request, State},
16    http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
17    response::{IntoResponse, Response},
18};
19use reqwest::Method;
20use tracing::{debug, warn};
21
22use crate::server::AppState;
23
24// Hop-by-hop headers that must not be forwarded in either direction.
25// RFC 7230 §6.1 and common proxy practice.
26static HOP_BY_HOP: &[&str] = &[
27    "connection",
28    "keep-alive",
29    "proxy-authenticate",
30    "proxy-authorization",
31    "te",
32    "trailers",
33    "transfer-encoding",
34    "upgrade",
35    // Console-specific: do not forward the host header (reqwest sets its own).
36    "host",
37];
38
39/// Map a short daemon key (as it appears in the URL) to the full service ID
40/// stored in `CachedSnapshot.services`.
41///
42/// Why: The URL uses short names (`search`, `memory`, …) while `ServiceInfo.id`
43/// uses the full `trusty-*` prefix.  This function is the single source of
44/// truth for the proxy allowlist: `None` means the key is not permitted.
45/// What: Returns the full service ID, or `None` for unknown/disallowed keys.
46/// Test: `test_daemon_key_mapping` below.
47fn full_id(daemon_key: &str) -> Option<&'static str> {
48    match daemon_key {
49        "search" => Some("trusty-search"),
50        "memory" => Some("trusty-memory"),
51        "analyze" => Some("trusty-analyze"),
52        "review" => Some("trusty-review"),
53        _ => None,
54    }
55}
56
57/// Guard that rejects any upstream URL that is not a local loopback address.
58///
59/// Why: The console is a strictly local tool.  If a bug or compromise caused a
60/// non-loopback URL to enter the poller cache, forwarding to it would turn the
61/// console into an SSRF vector.  This guard prevents that by enforcing that the
62/// resolved base URL is always a local address before any bytes are sent.
63/// What: Returns `true` if `url` starts with `http://127.`, `http://[::1]`, or
64/// `http://localhost`; `false` for anything else.
65/// Test: `test_is_local_upstream_*` below.
66fn is_local_upstream(url: &str) -> bool {
67    url.starts_with("http://127.")
68        || url.starts_with("http://[::1]")
69        || url.starts_with("http://localhost")
70}
71
72/// Build the upstream URL from a base URL, sub-path, and optional query string.
73///
74/// Why: Centrally-tested URL construction keeps the proxy handler clean.
75/// What: Appends `subpath` (with a leading slash) to `base_url`, then appends
76/// `?{query}` if the query string is non-empty.
77/// Test: `test_build_upstream_url_*` below.
78pub fn build_upstream_url(base_url: &str, subpath: &str, query: Option<&str>) -> String {
79    let base = base_url.trim_end_matches('/');
80    let path = subpath.trim_start_matches('/');
81    let url = if path.is_empty() {
82        format!("{base}/")
83    } else {
84        format!("{base}/{path}")
85    };
86    match query {
87        Some(q) if !q.is_empty() => format!("{url}?{q}"),
88        _ => url,
89    }
90}
91
92/// Strip hop-by-hop headers and copy the remainder into a new `HeaderMap`.
93///
94/// Why: Forwarding hop-by-hop headers to the upstream or back to the client
95/// violates HTTP/1.1 proxy semantics and can cause connection reuse failures.
96/// What: Iterates `headers`, skips any name in `HOP_BY_HOP`, and copies the
97/// rest.
98/// Test: Exercised implicitly by proxy round-trip tests.
99fn filter_headers(headers: &HeaderMap) -> HeaderMap {
100    let mut out = HeaderMap::new();
101    for (name, value) in headers {
102        if !HOP_BY_HOP.contains(&name.as_str()) {
103            out.append(name.clone(), value.clone());
104        }
105    }
106    out
107}
108
109/// Build a plain-text error response.
110///
111/// Why: Centralises error body construction so callers are one-liners.
112/// What: Returns a `Response` with the given status and a UTF-8 text body.
113/// Test: Exercised by error-path coverage.
114fn error_response(status: StatusCode, body: &'static str) -> Response {
115    Response::builder()
116        .status(status)
117        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
118        .body(Body::from(body))
119        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
120}
121
122/// `ANY /proxy/{daemon}/{*path}` — reverse-proxy to the daemon's live URL.
123///
124/// Why: Lets operators and the console SPA reach every daemon API through the
125/// console port without knowing per-daemon port numbers.
126/// What: Resolves the daemon's base URL from the background health-poll cache,
127/// forwards the request (method, safe headers, body) via reqwest, and streams
128/// the upstream response back.  Unknown daemon IDs → 400; daemon not reachable
129/// → 502.
130/// Test: URL construction is unit-tested in `tests` below.  End-to-end proxy
131/// behaviour requires a live daemon and is not tested in CI.
132pub async fn proxy_handler(
133    State(state): State<AppState>,
134    Path((daemon_key, subpath)): Path<(String, String)>,
135    req: Request,
136) -> Response {
137    // Map short key → full id via the exhaustive match in full_id(), which is
138    // the single source of truth for the proxy allowlist.
139    let Some(full_daemon_id) = full_id(&daemon_key) else {
140        warn!("proxy: unknown daemon key '{daemon_key}'");
141        return error_response(StatusCode::BAD_REQUEST, "unknown daemon");
142    };
143
144    let base_url = {
145        let snap = state.poller_cache().snapshot().await;
146        match snap {
147            None => {
148                warn!("proxy: cache not yet populated for '{daemon_key}'");
149                return error_response(StatusCode::SERVICE_UNAVAILABLE, "cache not ready");
150            }
151            Some(s) => {
152                let map = s.url_map();
153                match map.get(full_daemon_id).cloned() {
154                    Some(url) => url,
155                    None => {
156                        warn!("proxy: daemon '{daemon_key}' is not running");
157                        return error_response(StatusCode::BAD_GATEWAY, "daemon not running");
158                    }
159                }
160            }
161        }
162    };
163
164    // SSRF guard: the console is a local-only tool; reject any upstream that is
165    // not a loopback address.  A non-local URL in the cache would be a bug or
166    // compromise — fail closed rather than forward.
167    if !is_local_upstream(&base_url) {
168        warn!("proxy: upstream '{base_url}' is not a local address — rejecting (SSRF guard)");
169        return error_response(StatusCode::BAD_GATEWAY, "upstream not local");
170    }
171
172    // Decompose request into parts so we can access headers and body.
173    let (parts, body) = req.into_parts();
174
175    // Build the upstream URL.
176    let query = parts.uri.query();
177    let upstream_url = build_upstream_url(&base_url, &subpath, query);
178    debug!("proxy: {daemon_key} → {upstream_url}");
179
180    // Convert axum Method to reqwest Method.
181    let method = match Method::from_bytes(parts.method.as_str().as_bytes()) {
182        Ok(m) => m,
183        Err(_) => {
184            return error_response(StatusCode::BAD_REQUEST, "unsupported method");
185        }
186    };
187
188    // Filter headers before consuming body.
189    let safe_headers = filter_headers(&parts.headers);
190
191    // Collect body bytes (64 MiB cap).
192    const BODY_LIMIT: usize = 64 * 1024 * 1024;
193    let body_bytes: Bytes = match axum::body::to_bytes(body, BODY_LIMIT).await {
194        Ok(b) => b,
195        Err(e) => {
196            warn!("proxy: failed to read request body: {e}");
197            return error_response(
198                StatusCode::PAYLOAD_TOO_LARGE,
199                "request body exceeds proxy limit of 64 MiB",
200            );
201        }
202    };
203
204    // Build the upstream request with safe headers and body.
205    let client = state.http_client();
206    let upstream_req = client
207        .request(method, &upstream_url)
208        .headers(safe_headers)
209        .body(body_bytes);
210
211    // Execute.
212    let upstream_resp = match upstream_req.send().await {
213        Ok(r) => r,
214        Err(e) => {
215            warn!("proxy: upstream request failed for '{daemon_key}': {e}");
216            return error_response(StatusCode::BAD_GATEWAY, "upstream request failed");
217        }
218    };
219
220    // Map the upstream response back.
221    let status = StatusCode::from_u16(upstream_resp.status().as_u16())
222        .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
223
224    let mut resp_builder = Response::builder().status(status);
225
226    // Copy allowed upstream response headers.
227    for (name, value) in upstream_resp.headers() {
228        if !HOP_BY_HOP.contains(&name.as_str())
229            && let Ok(n) = HeaderName::from_bytes(name.as_str().as_bytes())
230            && let Ok(v) = HeaderValue::from_bytes(value.as_bytes())
231        {
232            resp_builder = resp_builder.header(n, v);
233        }
234    }
235
236    // Stream the body.
237    let resp_body = match upstream_resp.bytes().await {
238        Ok(b) => Body::from(b),
239        Err(e) => {
240            warn!("proxy: failed to read upstream body: {e}");
241            Body::from("upstream body error")
242        }
243    };
244
245    resp_builder
246        .body(resp_body)
247        .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
248}
249
250// ─── tests ───────────────────────────────────────────────────────────────────
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Why: URL must be built correctly for a subpath with no query string.
257    /// What: asserts build_upstream_url("http://127.0.0.1:7878", "health", None)
258    /// → "http://127.0.0.1:7878/health".
259    /// Test: this test itself.
260    #[test]
261    fn test_build_upstream_url_simple_path() {
262        assert_eq!(
263            build_upstream_url("http://127.0.0.1:7878", "health", None),
264            "http://127.0.0.1:7878/health"
265        );
266    }
267
268    /// Why: a query string must be appended after `?`.
269    /// What: asserts build_upstream_url with query "top_k=5" → correct URL.
270    /// Test: this test itself.
271    #[test]
272    fn test_build_upstream_url_with_query() {
273        assert_eq!(
274            build_upstream_url(
275                "http://127.0.0.1:7879",
276                "indexes/abc/complexity_hotspots",
277                Some("top_k=5")
278            ),
279            "http://127.0.0.1:7879/indexes/abc/complexity_hotspots?top_k=5"
280        );
281    }
282
283    /// Why: an empty subpath must still produce a valid URL with trailing slash.
284    /// What: asserts build_upstream_url with empty subpath.
285    /// Test: this test itself.
286    #[test]
287    fn test_build_upstream_url_empty_path() {
288        assert_eq!(
289            build_upstream_url("http://127.0.0.1:7070", "", None),
290            "http://127.0.0.1:7070/"
291        );
292    }
293
294    /// Why: base URL with trailing slash must not produce a double slash.
295    /// What: passes base URL with trailing slash, asserts no double slash.
296    /// Test: this test itself.
297    #[test]
298    fn test_build_upstream_url_base_trailing_slash() {
299        assert_eq!(
300            build_upstream_url("http://127.0.0.1:7878/", "health", None),
301            "http://127.0.0.1:7878/health"
302        );
303    }
304
305    /// Why: an empty query string must not append a `?`.
306    /// What: passes Some("") as query; asserts no trailing `?`.
307    /// Test: this test itself.
308    #[test]
309    fn test_build_upstream_url_empty_query_omitted() {
310        assert_eq!(
311            build_upstream_url("http://127.0.0.1:7878", "health", Some("")),
312            "http://127.0.0.1:7878/health"
313        );
314    }
315
316    /// Why: the SSRF guard must accept loopback IPv4, IPv6, and localhost but
317    /// reject any other URL including external hosts and non-loopback RFC-1918.
318    /// What: calls is_local_upstream with accepted and rejected URLs.
319    /// Test: this test itself.
320    #[test]
321    fn test_is_local_upstream_accepted() {
322        assert!(is_local_upstream("http://127.0.0.1:7878"));
323        assert!(is_local_upstream("http://127.0.0.1:7878/health"));
324        assert!(is_local_upstream("http://127.1.2.3:9000"));
325        assert!(is_local_upstream("http://[::1]:8080"));
326        assert!(is_local_upstream("http://localhost:7070"));
327        assert!(is_local_upstream("http://localhost"));
328    }
329
330    /// Why: non-local URLs must be rejected to prevent SSRF.
331    /// What: calls is_local_upstream with external and RFC-1918 URLs.
332    /// Test: this test itself.
333    #[test]
334    fn test_is_local_upstream_rejected() {
335        assert!(!is_local_upstream("http://192.168.1.1:7878"));
336        assert!(!is_local_upstream("http://10.0.0.1:7879"));
337        assert!(!is_local_upstream("http://evil.example.com/steal"));
338        assert!(!is_local_upstream("https://127.0.0.1:7878")); // https, not http
339        assert!(!is_local_upstream("http://0.0.0.0:7878"));
340    }
341
342    /// Why: full_id must map all known short keys to their trusty-* IDs.
343    /// What: calls full_id for each known key and the unknown key.
344    /// Test: this test itself.
345    #[test]
346    fn test_daemon_key_mapping() {
347        assert_eq!(full_id("search"), Some("trusty-search"));
348        assert_eq!(full_id("memory"), Some("trusty-memory"));
349        assert_eq!(full_id("analyze"), Some("trusty-analyze"));
350        assert_eq!(full_id("review"), Some("trusty-review"));
351        assert_eq!(full_id("unknown"), None);
352    }
353
354    /// Why: hop-by-hop headers must be stripped; safe headers must pass through.
355    /// What: builds a HeaderMap with a hop-by-hop ("connection") and a safe
356    /// header ("x-custom"), calls filter_headers, asserts only safe one remains.
357    /// Test: this test itself.
358    #[test]
359    fn test_filter_headers_strips_hop_by_hop() {
360        let mut h = HeaderMap::new();
361        h.insert("connection", HeaderValue::from_static("keep-alive"));
362        h.insert("x-custom", HeaderValue::from_static("hello"));
363        let filtered = filter_headers(&h);
364        assert!(!filtered.contains_key("connection"));
365        assert!(filtered.contains_key("x-custom"));
366    }
367}