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}