Skip to main content

shell_tunnel/api/
router.rs

1//! API router configuration.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5
6use axum::{
7    extract::{
8        connect_info::IntoMakeServiceWithConnectInfo, DefaultBodyLimit, MatchedPath, Request, State,
9    },
10    http::{header::AUTHORIZATION, Method, StatusCode},
11    middleware::{self, Next},
12    response::Response,
13    routing::{any, get, post},
14    Router,
15};
16use tokio::sync::mpsc::UnboundedSender;
17use tower_http::{
18    cors::{Any, CorsLayer},
19    trace::TraceLayer,
20};
21
22use super::handlers::{
23    api_info, create_session, delete_session, execute_command, execute_oneshot, get_session,
24    health, list_sessions, AppState,
25};
26use super::websocket::{ws_handler, ws_oneshot_handler};
27use crate::security::{
28    rate_limit_middleware, ApiKeyStore, AuthConfig, CapabilitySet, RateLimitConfig, RateLimiter,
29};
30
31/// Cross-Origin Resource Sharing (CORS) configuration.
32///
33/// CORS is a browser-enforced mechanism; non-browser clients (`curl`, SDKs)
34/// ignore it entirely. shell-tunnel therefore emits **no** permissive CORS headers
35/// by default: this blocks a malicious web page from reading responses cross-origin
36/// and, because the JSON execute endpoints require a preflight, from issuing the
37/// request at all — with zero impact on the intended non-browser consumers.
38///
39/// Note: CORS does **not** defend against DNS-rebinding (the attacker's host is
40/// rebound to `127.0.0.1`, making the request same-origin); that requires
41/// Host-header validation and is tracked separately.
42#[derive(Debug, Clone, Default)]
43pub struct CorsConfig {
44    /// Allow any origin/method/header (restores the permissive `Any` behavior).
45    /// Off by default; enable only for trusted browser-based UIs.
46    pub allow_any: bool,
47}
48
49/// Security configuration for the server.
50#[derive(Debug, Clone)]
51pub struct SecurityConfig {
52    /// Authentication configuration.
53    pub auth: AuthConfig,
54    /// Rate limiting configuration.
55    pub rate_limit: RateLimitConfig,
56    /// API keys to pre-register.
57    pub api_keys: Vec<String>,
58    /// Capabilities granted to the pre-registered keys and to the
59    /// auto-generated fallback key.
60    ///
61    /// `None` (the default) means **full-control** (wildcard) — the backward-
62    /// compatible behavior for a bare `--api-key` / `--require-auth` (spec §4).
63    /// `Some(set)` issues fine-grained tokens scoped to that set (spec §9).
64    pub capabilities: Option<CapabilitySet>,
65    /// CORS configuration.
66    pub cors: CorsConfig,
67    /// Host names this server answers to, when it is worth checking.
68    ///
69    /// `None` disables the check. It is meant for a loopback-bound server, where
70    /// DNS rebinding is the one attack CORS cannot stop: the attacker's name is
71    /// rebound to `127.0.0.1`, so the browser considers the request same-origin
72    /// and sends it. The `Host` header still carries the attacker's name, which
73    /// is what this compares. A published server is deliberately reachable under
74    /// a name we may not know, so the check does not apply there.
75    pub allowed_hosts: Option<Vec<String>>,
76}
77
78impl Default for SecurityConfig {
79    fn default() -> Self {
80        Self {
81            auth: AuthConfig::disabled(), // Disabled by default for ease of use
82            rate_limit: RateLimitConfig::default(),
83            api_keys: Vec::new(),
84            capabilities: None, // Full-control by default (legacy-compatible)
85            cors: CorsConfig::default(), // Restrictive by default (no permissive CORS)
86            allowed_hosts: None,
87        }
88    }
89}
90
91/// Build a permissive CORS layer when `allow_any` is set, otherwise `None`.
92///
93/// Returning `None` means no CORS headers are emitted — the secure default.
94fn cors_layer(cfg: &CorsConfig) -> Option<CorsLayer> {
95    cfg.allow_any.then(|| {
96        CorsLayer::new()
97            .allow_origin(Any)
98            .allow_methods(Any)
99            .allow_headers(Any)
100    })
101}
102
103impl SecurityConfig {
104    /// Create a secure configuration.
105    pub fn secure() -> Self {
106        Self {
107            auth: AuthConfig::default(),
108            rate_limit: RateLimitConfig::default(),
109            api_keys: Vec::new(),
110            capabilities: None,
111            cors: CorsConfig::default(),
112            allowed_hosts: None,
113        }
114    }
115
116    /// Create a development configuration (no auth, relaxed limits).
117    pub fn development() -> Self {
118        Self {
119            auth: AuthConfig::disabled(),
120            rate_limit: RateLimitConfig::relaxed(),
121            api_keys: Vec::new(),
122            capabilities: None,
123            cors: CorsConfig::default(),
124            allowed_hosts: None,
125        }
126    }
127
128    /// Add an API key.
129    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
130        self.api_keys.push(key.into());
131        self
132    }
133
134    /// Scope the issued tokens to a fine-grained capability set (spec §9).
135    ///
136    /// Applies to the pre-registered keys and to the auto-generated fallback
137    /// key. Without this, tokens are full-control (legacy-compatible).
138    pub fn with_capabilities(mut self, capabilities: CapabilitySet) -> Self {
139        self.capabilities = Some(capabilities);
140        self
141    }
142
143    /// Answer only to these host names.
144    pub fn with_allowed_hosts(mut self, hosts: Vec<String>) -> Self {
145        self.allowed_hosts = Some(hosts);
146        self
147    }
148
149    /// Enable permissive (`Any`) CORS. Opt-in; only for trusted browser UIs.
150    pub fn with_cors_allow_any(mut self) -> Self {
151        self.cors.allow_any = true;
152        self
153    }
154}
155
156/// Register `key` into `store` with `capabilities` (fine-grained), or as a
157/// legacy full-control key when `capabilities` is `None` (spec §4/§9).
158fn register_key(store: &ApiKeyStore, key: &str, capabilities: &Option<CapabilitySet>) {
159    match capabilities {
160        Some(caps) => store.add_key_with_capabilities(key, caps.clone(), "configured"),
161        None => store.add_key(key),
162    }
163}
164
165/// Create the API router with all routes configured.
166pub fn create_router() -> Router {
167    create_router_with_state(AppState::new())
168}
169
170/// Create the API router with custom state (no security).
171pub fn create_router_with_state(state: AppState) -> Router {
172    // Session routes
173    let session_routes = Router::new()
174        .route("/", get(list_sessions).post(create_session))
175        .route("/{id}", get(get_session).delete(delete_session))
176        .route("/{id}/execute", post(execute_command))
177        .route("/{id}/ws", any(ws_handler));
178
179    // API v1 routes
180    let api_v1 = Router::new()
181        .route("/", get(api_info))
182        .route("/execute", post(execute_oneshot))
183        .route("/ws", any(ws_oneshot_handler))
184        .nest("/fs", fs_routes())
185        .nest("/sessions", session_routes);
186
187    // Build main router. This "no security" convenience constructor uses the
188    // restrictive CORS default (no permissive CORS headers emitted).
189    Router::new()
190        .route("/health", get(health))
191        .nest("/api/v1", api_v1)
192        .layer(TraceLayer::new_for_http())
193        .with_state(state)
194}
195
196/// The capability a route requires (Phase A spec §3).
197///
198/// Declared here at the router layer — co-located with the route definitions,
199/// which are the single source of truth for the matched-path strings this maps
200/// against. Not a per-handler attribute.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum RequiredCapability {
203    /// No authentication at all (e.g. `/health`).
204    Public,
205    /// Authenticated only: any valid token passes, no specific capability
206    /// required (spec §3 "인증만" tier, e.g. `GET /api/v1`).
207    Authenticated,
208    /// Requires a specific capability string (set membership, or wildcard).
209    Capability(&'static str),
210}
211
212/// Map a matched route (`method` + axum [`MatchedPath`]) to its required
213/// capability (spec §3 table).
214///
215/// Keyed on the **full nested** `MatchedPath` (confirmed against the real router
216/// structure) plus the HTTP method, because one path can require different
217/// capabilities per method (e.g. `GET /api/v1/sessions` = read, `POST` = manage).
218///
219/// Unknown routes fail **closed** to [`RequiredCapability::Authenticated`]: an
220/// unmapped route still requires a valid token, never less than that.
221pub fn required_capability(method: &Method, matched_path: &str) -> RequiredCapability {
222    use RequiredCapability::{Authenticated, Capability, Public};
223
224    // HEAD is GET without a body, and axum's `get()` serves it automatically, so
225    // its authorization must equal GET's. Keyed separately below, every GET
226    // route would need a twin HEAD arm — and a forgotten twin falls through to
227    // the closed default `Authenticated`, which means any valid token, not the
228    // capability GET requires. Normalising here is the one place it cannot be
229    // forgotten. Matched as `&str` (rather than comparing `Method` values) so
230    // the table below stays exactly as it reads for every other method.
231    let method = match method.as_str() {
232        "HEAD" => "GET",
233        other => other,
234    };
235
236    match (method, matched_path) {
237        (_, "/health") => Public,
238        ("GET", "/api/v1") => Authenticated,
239        ("POST", "/api/v1/execute") => Capability("exec"),
240        (_, "/api/v1/ws") => Capability("exec"),
241        ("GET", "/api/v1/sessions") => Capability("session.read"),
242        ("POST", "/api/v1/sessions") => Capability("session.manage"),
243        ("GET", "/api/v1/sessions/{id}") => Capability("session.read"),
244        ("DELETE", "/api/v1/sessions/{id}") => Capability("session.manage"),
245        ("POST", "/api/v1/sessions/{id}/execute") => Capability("exec"),
246        (_, "/api/v1/sessions/{id}/ws") => Capability("exec"),
247        ("GET", "/api/v1/fs/list") => Capability("fs.read"),
248        ("GET", "/api/v1/fs/stat") => Capability("fs.read"),
249        ("GET", "/api/v1/fs/file") => Capability("fs.read"),
250        ("DELETE", "/api/v1/fs/file") => Capability("fs.write"),
251        ("POST", "/api/v1/fs/uploads") => Capability("fs.write"),
252        ("GET", "/api/v1/fs/uploads/{id}") => Capability("fs.write"),
253        ("PATCH", "/api/v1/fs/uploads/{id}") => Capability("fs.write"),
254        ("POST", "/api/v1/fs/uploads/{id}/complete") => Capability("fs.write"),
255        ("DELETE", "/api/v1/fs/uploads/{id}") => Capability("fs.write"),
256        _ => Authenticated,
257    }
258}
259
260/// The longest raw request path an audit entry will carry, in bytes.
261///
262/// Only an *unmatched* path is recorded raw, and an unmatched path is whatever
263/// the caller asked for — a probe can send four kilobytes of it and did, in the
264/// measurement that set this number. A matched route is a router template and
265/// is never truncated. The same log-flood worry already put the `tracing` line
266/// beside this one at `debug`; the trail cannot be silenced by a log level, so
267/// it needs the bound instead.
268const MAX_AUDITED_PATH: usize = 256;
269
270/// The `route` value for an audit entry.
271///
272/// A matched route is recorded as its template (`/api/v1/sessions/{id}`) so
273/// that entries group rather than exploding into one bucket per id. An
274/// unmatched path has no template, so the raw path is recorded: it is the only
275/// thing that says *what was probed*, and probing is precisely what the trail
276/// is asked about afterwards. Recording only the method left two different
277/// probes with byte-identical entries — five distinct requests produced five
278/// indistinguishable lines, confirmed against a running server, and a majority
279/// of the `denied` entries on an internet-facing deployment were of that shape.
280///
281/// The caller passes `uri().path()`, never `path_and_query()`, and that is a
282/// guarantee rather than a shortcut: a query string is caller-controlled too,
283/// and `USAGE.md` §4 promises the trail never carries a credential. A probe of
284/// `/nope?token=…` is recorded as `/nope` — confirmed by running it.
285///
286/// Truncation is marked with a suffix that begins with a space, which is not a
287/// byte a request path can contain: a space terminates the request target, so
288/// hyper answers `400` long before this function sees it. A caller therefore
289/// cannot forge the marker by asking for a path that ends in it. (Raw control
290/// bytes and invalid UTF-8 are refused at the same layer for the same kind of
291/// reason, so this function is not where they need handling — measured, not
292/// assumed, because "the parser surely rejects that" is the premise this
293/// repository has been wrong about before.)
294fn audited_route(method: &Method, matched: Option<&str>, raw_path: &str) -> String {
295    let Some(template) = matched else {
296        if raw_path.len() <= MAX_AUDITED_PATH {
297            return format!("{method} {raw_path}");
298        }
299        // Back off to a character boundary; index 0 is always one, so this
300        // terminates.
301        let mut end = MAX_AUDITED_PATH;
302        while !raw_path.is_char_boundary(end) {
303            end -= 1;
304        }
305        return format!("{method} {} (truncated)", &raw_path[..end]);
306    };
307    format!("{method} {template}")
308}
309
310/// Scope-aware authentication + authorization middleware (spec §5).
311///
312/// 1. Resolve the route's required capability from `method` + `MatchedPath`.
313/// 2. `Public` route or auth disabled → pass through.
314/// 3. Extract the bearer token; look up its `TokenRecord` in the store.
315///    Missing/invalid token → **401**.
316/// 4. `Authenticated` route → any valid token passes. `Capability(c)` route →
317///    the token's set must satisfy `c` (membership or wildcard), else **403**.
318async fn capability_auth_middleware(
319    State((store, audit)): State<(
320        std::sync::Arc<ApiKeyStore>,
321        std::sync::Arc<crate::audit::AuditSink>,
322    )>,
323    mut request: Request,
324    next: Next,
325) -> Result<Response, StatusCode> {
326    // Auth disabled → open server (existing behavior).
327    if !store.is_enabled() {
328        return Ok(next.run(request).await);
329    }
330
331    let method = request.method().clone();
332    // Owned so it can be used in the rejection logs after `request` is consumed.
333    //
334    // `None` and `Some("")` are not the same thing and are no longer flattened
335    // together: a path the router did not match has no template, and the raw
336    // path is the only description of it that exists. Authorization still sees
337    // the empty string for it (`required_capability` fails closed on it), but
338    // the audit entry does not.
339    let matched = request
340        .extensions()
341        .get::<MatchedPath>()
342        .map(|m| m.as_str().to_owned());
343    let route = audited_route(&method, matched.as_deref(), request.uri().path());
344    let required = required_capability(&method, matched.as_deref().unwrap_or_default());
345
346    // Public routes (e.g. /health) skip auth entirely.
347    if required == RequiredCapability::Public {
348        return Ok(next.run(request).await);
349    }
350
351    // Extract the bearer token and resolve its capabilities.
352    // Missing header, wrong prefix, or unregistered token → 401.
353    let token = request
354        .headers()
355        .get(AUTHORIZATION)
356        .and_then(|v| v.to_str().ok())
357        .and_then(|header| store.extract_key(header));
358
359    let identity = token.as_deref().and_then(|t| store.identity(t));
360
361    let capabilities = match token.as_deref().and_then(|t| store.capabilities(t)) {
362        Some(caps) => caps,
363        None => {
364            // Logged at debug to avoid a log-flood amplifier under probing; the
365            // token value itself is never logged. `missing-token` = no/malformed
366            // Authorization header, `invalid-token` = present but unregistered.
367            let reason = if token.is_none() {
368                "missing-token"
369            } else {
370                "invalid-token"
371            };
372            tracing::debug!(%method, path = %route, reason, "auth rejected (401)");
373            // Probing is exactly what an audit trail is asked about afterwards,
374            // so this layer's refusals are recorded as well as successes.
375            //
376            // "This layer's" is the whole claim, not modesty. Refusals that
377            // happen after this middleware has let a request through — an
378            // extractor turning away an unrecognised field, a malformed body, a
379            // path parameter that will not parse, a body over the size limit —
380            // record nothing, so the trail is not a complete list of every
381            // refusal the server issued. `USAGE.md` §4 names that gap and lists
382            // the measured cases; keep the two in step as the layer grows.
383            audit
384                .record_async(
385                    crate::audit::AuditEvent::new("denied")
386                        .with_route(route)
387                        .with_denial(401, reason),
388                )
389                .await;
390            return Err(StatusCode::UNAUTHORIZED);
391        }
392    };
393
394    // Handlers record what actually ran, and need to know who asked.
395    if let Some(identity) = identity.clone() {
396        request.extensions_mut().insert(identity);
397    }
398
399    match required {
400        // Already handled above, but keep the match exhaustive.
401        RequiredCapability::Public => Ok(next.run(request).await),
402        // Any valid token satisfies an authenticated-only route.
403        RequiredCapability::Authenticated => Ok(next.run(request).await),
404        // Specific capability: set membership (or wildcard) required, else 403.
405        RequiredCapability::Capability(cap) => {
406            if capabilities.satisfies(cap) {
407                Ok(next.run(request).await)
408            } else {
409                audit
410                    .record_async(
411                        crate::audit::AuditEvent::new("denied")
412                            .with_identity(identity)
413                            .with_route(route.clone())
414                            .with_denial(403, format!("missing-capability:{cap}")),
415                    )
416                    .await;
417                tracing::debug!(
418                    %method,
419                    path = %route,
420                    required = cap,
421                    "authorization denied (403): insufficient capability"
422                );
423                Err(StatusCode::FORBIDDEN)
424            }
425        }
426    }
427}
428
429/// Whether `header` names a host this server answers to.
430///
431/// Compared without the port, since the port is not what an attacker controls
432/// in a rebinding attack, and a legitimate caller may reach the same server
433/// through different ports.
434fn host_is_allowed(header: Option<&str>, allowed: &[String]) -> bool {
435    let Some(value) = header else {
436        // HTTP/1.1 requires a Host header; its absence is not a shape any
437        // ordinary client produces.
438        return false;
439    };
440
441    let host = value
442        .rsplit_once(':')
443        .map_or(value, |(host, port)| {
444            // Only strip a trailing port, not part of a bare IPv6 address.
445            if port.chars().all(|c| c.is_ascii_digit()) {
446                host
447            } else {
448                value
449            }
450        })
451        .trim_matches(|c| c == '[' || c == ']');
452
453    allowed
454        .iter()
455        .any(|candidate| candidate.eq_ignore_ascii_case(host))
456}
457
458/// Reject requests carrying a `Host` this server does not answer to.
459async fn host_check_middleware(
460    State(allowed): State<Arc<Vec<String>>>,
461    request: Request,
462    next: Next,
463) -> Result<Response, (StatusCode, String)> {
464    let header = request
465        .headers()
466        .get(axum::http::header::HOST)
467        .and_then(|value| value.to_str().ok());
468
469    if host_is_allowed(header, &allowed) {
470        return Ok(next.run(request).await);
471    }
472
473    // Named explicitly: an operator hitting this from a container or behind a
474    // proxy needs to know which name was refused and how to permit it.
475    let seen = header.unwrap_or("(none)").to_string();
476    tracing::debug!(host = %seen, "request refused: host not allowed");
477    Err((
478        StatusCode::FORBIDDEN,
479        format!(
480            "host {seen} is not allowed; pass --allow-host {seen} to permit it
481"
482        ),
483    ))
484}
485
486/// Headers a reverse proxy adds, in the order operators are likeliest to meet.
487///
488/// `Forwarded` is the standardised one; the two `X-` names predate it and are
489/// what nginx, Apache, IIS and every cloud load balancer actually send.
490const PROXY_HEADERS: [&str; 3] = ["x-forwarded-for", "x-real-ip", "forwarded"];
491
492/// Say so, once, if a request arrives through a proxy while auth is off.
493///
494/// The posture this server picks is decided by its bind address: loopback is
495/// read as "not reachable", and authentication, the `operator` scope and
496/// automatic audit are all released on that reading. A reverse proxy breaks the
497/// premise without changing the bind address — every request then appears to
498/// come from `127.0.0.1` — so the server goes on believing it is private while
499/// being as reachable as the proxy is. This product *recommends* that
500/// arrangement: putting a reverse proxy in front is what its own error message
501/// tells an operator to do when they ask a gateway for TLS.
502///
503/// Documentation already says this at each place the arrangement is suggested,
504/// but documentation only protects whoever reads it. This is the observation
505/// that needs no reading: a request carrying `X-Forwarded-For` is evidence,
506/// arriving on the very path that matters, that something is in front.
507///
508/// Only ever a warning, which is what makes the forgeability of these headers
509/// irrelevant: anyone who can reach an unauthenticated exec API has no use for
510/// making it warn about itself. And only where auth is off — a server that
511/// authenticates is behind a proxy on purpose and needs no telling.
512///
513/// The default is deliberately not changed. Loopback-means-no-auth is the
514/// local-development experience, and trading it away is a product decision, not
515/// one to slip in beside a warning.
516async fn proxy_evidence_middleware(
517    State((warned, audited)): State<(Arc<std::sync::atomic::AtomicBool>, bool)>,
518    request: Request,
519    next: Next,
520) -> Response {
521    use std::sync::atomic::Ordering;
522
523    // The common case is the already-warned one; keep it to a single load.
524    if !warned.load(Ordering::Relaxed) {
525        if let Some(header) = PROXY_HEADERS
526            .iter()
527            .find(|name| request.headers().contains_key(**name))
528        {
529            // Whoever loses the race warned; the other stays quiet.
530            if !warned.swap(true, Ordering::Relaxed) {
531                // Written as separate calls, never one literal split with a
532                // backslash: `cargo fmt` folds that into a line with a gap in
533                // the middle, and it has shipped that way here four times.
534                //
535                // Each line asserts only what was checked. Whether the bind
536                // address is loopback is not checked — a library consumer can
537                // disable auth on any address — and whether a trail is being
538                // written is read rather than assumed, because an operator who
539                // passed --audit-log has one.
540                tracing::warn!("A request arrived carrying {header}, which a reverse proxy adds.");
541                tracing::warn!("Authentication is off on this server, so whatever reaches it can run commands — and something in front means that is wherever the proxy is reachable, not just this machine.");
542                if !audited {
543                    tracing::warn!(
544                        "No audit trail is configured either, so none of it is being recorded."
545                    );
546                    tracing::warn!("Restart with --require-auth, and --audit-log <FILE> to keep a record. This warning is printed once.");
547                } else {
548                    tracing::warn!("Restart with --require-auth. This warning is printed once.");
549                }
550            }
551        }
552    }
553
554    next.run(request).await
555}
556
557/// Create the API router with security enabled.
558pub fn create_secure_router(
559    state: AppState,
560    security: SecurityConfig,
561) -> (Router, Arc<ApiKeyStore>, Arc<RateLimiter>) {
562    // Create security components
563    let auth_store = Arc::new(ApiKeyStore::new(security.auth));
564    let rate_limiter = Arc::new(RateLimiter::new(security.rate_limit));
565
566    // Register API keys with their configured capabilities.
567    for key in &security.api_keys {
568        register_key(&auth_store, key, &security.capabilities);
569    }
570
571    // Session routes
572    let session_routes = Router::new()
573        .route("/", get(list_sessions).post(create_session))
574        .route("/{id}", get(get_session).delete(delete_session))
575        .route("/{id}/execute", post(execute_command))
576        .route("/{id}/ws", any(ws_handler));
577
578    // API v1 routes
579    let api_v1 = Router::new()
580        .route("/", get(api_info))
581        .route("/execute", post(execute_oneshot))
582        .route("/ws", any(ws_oneshot_handler))
583        .nest("/fs", fs_routes())
584        .nest("/sessions", session_routes);
585
586    let allowed_hosts = security.allowed_hosts.clone();
587
588    // Build main router with security layers
589    let mut router = Router::new()
590        .route("/health", get(health))
591        .nest("/api/v1", api_v1)
592        .layer(middleware::from_fn_with_state(
593            (Arc::clone(&auth_store), Arc::clone(&state.audit)),
594            capability_auth_middleware,
595        ))
596        .layer(middleware::from_fn_with_state(
597            Arc::clone(&rate_limiter),
598            rate_limit_middleware,
599        ))
600        .layer(TraceLayer::new_for_http());
601
602    // Only where there is something to warn about: a server that authenticates
603    // is behind whatever is in front of it on purpose.
604    if !auth_store.is_enabled() {
605        router = router.layer(middleware::from_fn_with_state(
606            (
607                Arc::new(std::sync::atomic::AtomicBool::new(false)),
608                state.audit.is_enabled(),
609            ),
610            proxy_evidence_middleware,
611        ));
612    }
613
614    // Outermost, so a rebound request is refused before it reaches the token
615    // store or the rate limiter's bookkeeping.
616    if let Some(hosts) = allowed_hosts {
617        router = router.layer(middleware::from_fn_with_state(
618            Arc::new(hosts),
619            host_check_middleware,
620        ));
621    }
622
623    // Permissive CORS only when explicitly opted in (default: restrictive).
624    if let Some(cors) = cors_layer(&security.cors) {
625        router = router.layer(cors);
626    }
627
628    let router = router.with_state(state);
629
630    (router, auth_store, rate_limiter)
631}
632
633/// Server configuration.
634#[derive(Debug, Clone)]
635pub struct ServerConfig {
636    /// Host address to bind to.
637    pub host: String,
638    /// Port to listen on.
639    pub port: u16,
640    /// Security configuration.
641    pub security: SecurityConfig,
642    /// Enable graceful shutdown on SIGTERM/SIGINT.
643    pub graceful_shutdown: bool,
644    /// Where to report an API key this server generated for itself.
645    ///
646    /// [`serve_on`] generates one when authentication is on and no key was
647    /// registered. It used to write that key to the log, which handed an
648    /// embedding consumer a plaintext secret in whatever its logs go to. It now
649    /// sends the key here instead: the library reports the fact, the caller
650    /// decides where it goes — the arrangement
651    /// [`crate::relay::client::RelayClientConfig`]'s `enrolled` already uses.
652    ///
653    /// `None` leaves the key unreported, and [`serve_on`] warns when it lands
654    /// there — a key nobody can read authenticates nobody. Register a key with
655    /// [`SecurityConfig::with_api_key`] if you have one; set this if you want
656    /// the generated one.
657    ///
658    /// The send happens before the listener starts accepting, and the channel
659    /// is unbounded, so the key waits in it: spawn [`serve_on`] and receive
660    /// afterwards. Nothing needs to be draining it first.
661    pub generated_key: Option<UnboundedSender<String>>,
662}
663
664impl ServerConfig {
665    pub fn new(host: impl Into<String>, port: u16) -> Self {
666        Self {
667            host: host.into(),
668            port,
669            security: SecurityConfig::default(),
670            graceful_shutdown: true,
671            generated_key: None,
672        }
673    }
674
675    pub fn bind_address(&self) -> String {
676        format!("{}:{}", self.host, self.port)
677    }
678
679    /// Enable security with the given configuration.
680    pub fn with_security(mut self, security: SecurityConfig) -> Self {
681        self.security = security;
682        self
683    }
684
685    /// Disable graceful shutdown.
686    pub fn without_graceful_shutdown(mut self) -> Self {
687        self.graceful_shutdown = false;
688        self
689    }
690
691    /// Report a key this server generates for itself on `tx`.
692    ///
693    /// See [`ServerConfig::generated_key`] for when that happens and what
694    /// leaving it unset costs.
695    pub fn report_generated_key_to(mut self, tx: UnboundedSender<String>) -> Self {
696        self.generated_key = Some(tx);
697        self
698    }
699}
700
701impl Default for ServerConfig {
702    fn default() -> Self {
703        Self {
704            host: "127.0.0.1".to_string(),
705            port: 3000,
706            security: SecurityConfig::default(),
707            graceful_shutdown: true,
708            generated_key: None,
709        }
710    }
711}
712
713/// Start the API server.
714pub async fn serve(config: ServerConfig) -> crate::Result<()> {
715    serve_with_state(config, AppState::new()).await
716}
717
718/// Bind the API server's port without starting to serve.
719///
720/// Callers that need to know the port before traffic flows — anything binding
721/// port 0, where the OS chooses — take the listener from here and hand it to
722/// [`serve_on`]. Splitting bind from serve is what makes an ephemeral port
723/// usable: the alternative is binding twice and racing whoever grabs it in
724/// between.
725pub async fn bind(config: &ServerConfig) -> crate::Result<tokio::net::TcpListener> {
726    tokio::net::TcpListener::bind(config.bind_address())
727        .await
728        .map_err(crate::error::ShellTunnelError::Io)
729}
730
731/// Start the API server with custom state.
732pub async fn serve_with_state(config: ServerConfig, state: AppState) -> crate::Result<()> {
733    let listener = bind(&config).await?;
734    serve_on(listener, config, state).await
735}
736
737/// Serve on an already-bound listener.
738pub async fn serve_on(
739    listener: tokio::net::TcpListener,
740    config: ServerConfig,
741    state: AppState,
742) -> crate::Result<()> {
743    let addr = config.bind_address();
744
745    // Create router with security
746    let (router, auth_store, _rate_limiter) = create_secure_router(state, config.security.clone());
747
748    // Log API key if auth is enabled and keys are registered
749    if auth_store.is_enabled() {
750        if auth_store.count() == 0 {
751            // Generate and register a key if none provided, scoped to the
752            // configured capabilities (full-control when unset).
753            let key = crate::security::generate_api_key();
754            register_key(&auth_store, &key, &config.security.capabilities);
755            match &config.generated_key {
756                Some(tx) => {
757                    let _ = tx.send(key);
758                }
759                // Reported nowhere, and deliberately not logged: this used to
760                // put a plaintext secret in the consumer's log. Saying so is
761                // the difference between "the caller chose not to collect it"
762                // and "the server is now holding a key nobody can present".
763                None => tracing::warn!("authentication is on with no API key registered, so one was generated — but ServerConfig::generated_key is unset and the key is not logged, so nothing can read it. Register a key with SecurityConfig::with_api_key, or set that channel."),
764            }
765        }
766        tracing::info!(
767            "Authentication enabled with {} API key(s)",
768            auth_store.count()
769        );
770    } else {
771        tracing::warn!("Authentication is DISABLED - server is open to all requests");
772    }
773
774    let _ = addr;
775    tracing::info!(
776        "Starting shell-tunnel API server on {}",
777        listener
778            .local_addr()
779            .map(|a| a.to_string())
780            .unwrap_or_else(|_| config.bind_address())
781    );
782
783    // Create service with connection info for rate limiting
784    let service: IntoMakeServiceWithConnectInfo<Router, SocketAddr> =
785        router.into_make_service_with_connect_info::<SocketAddr>();
786
787    if config.graceful_shutdown {
788        // Serve with graceful shutdown
789        axum::serve(listener, service)
790            .with_graceful_shutdown(shutdown_signal())
791            .await
792            .map_err(|e| {
793                crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
794            })?;
795
796        tracing::info!("Server shutdown complete");
797    } else {
798        // Serve without graceful shutdown
799        axum::serve(listener, service).await.map_err(|e| {
800            crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
801        })?;
802    }
803
804    Ok(())
805}
806
807/// Wait for shutdown signal (Ctrl+C or SIGTERM).
808async fn shutdown_signal() {
809    let ctrl_c = async {
810        tokio::signal::ctrl_c()
811            .await
812            .expect("Failed to install Ctrl+C handler");
813    };
814
815    #[cfg(unix)]
816    let terminate = async {
817        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
818            .expect("Failed to install SIGTERM handler")
819            .recv()
820            .await;
821    };
822
823    #[cfg(not(unix))]
824    let terminate = std::future::pending::<()>();
825
826    tokio::select! {
827        _ = ctrl_c => {
828            tracing::info!("Received Ctrl+C, initiating graceful shutdown...");
829        }
830        _ = terminate => {
831            tracing::info!("Received SIGTERM, initiating graceful shutdown...");
832        }
833    }
834}
835
836/// Filesystem routes, shared by the plain and the secured router.
837///
838/// Built in one place so the two constructors cannot drift apart — a route
839/// present in only one of them is reachable in only one deployment shape.
840fn fs_routes() -> Router<AppState> {
841    // The chunk-upload route carries request bodies up to `MAX_CHUNK_SIZE` (8
842    // MiB) — well above axum-core's own default body limit (2 MiB, axum-core
843    // 0.5.6's `DEFAULT_LIMIT`), which this app otherwise never overrides. A
844    // client sending a chunk at the server's own advertised `chunk_size` (4
845    // MiB default) would have it rejected before `append_chunk` ever ran.
846    //
847    // Raised only for this one path, via a merged sub-router, rather than
848    // `.layer()` on the whole `fs_routes` router: the latter would also raise
849    // the limit for `/list`, `/stat`, and `/file`, none of which need an 8
850    // MiB body, and a limit set any higher up would reach `/api/v1/execute`
851    // too. Set at the hard ceiling (`MAX_CHUNK_SIZE`) rather than the
852    // *configured* `chunk_size`, so a chunk larger than configured but still
853    // under the ceiling reaches `append_chunk`'s own `TooLarge` check and
854    // gets a 413 with a machine-readable body — if axum's limit cut it off
855    // first, the caller would get a bodyless 413 with no way to tell why.
856    let upload_session_routes = Router::new()
857        .route(
858            "/uploads/{id}",
859            get(super::fs::upload_status)
860                .patch(super::fs::append_chunk)
861                .delete(super::fs::cancel_upload),
862        )
863        .route_layer(DefaultBodyLimit::max(crate::fs::MAX_CHUNK_SIZE));
864
865    Router::new()
866        .route("/list", get(super::fs::list))
867        .route("/stat", get(super::fs::stat))
868        .route(
869            "/file",
870            get(super::fs::download).delete(super::fs::delete_file),
871        )
872        .route("/uploads", post(super::fs::create_upload))
873        .merge(upload_session_routes)
874        .route("/uploads/{id}/complete", post(super::fs::complete_upload))
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    #[test]
882    fn test_server_config_default() {
883        let config = ServerConfig::default();
884        assert_eq!(config.host, "127.0.0.1");
885        assert_eq!(config.port, 3000);
886        assert_eq!(config.bind_address(), "127.0.0.1:3000");
887        assert!(config.graceful_shutdown);
888    }
889
890    #[test]
891    fn test_server_config_custom() {
892        let config = ServerConfig::new("0.0.0.0", 8080);
893        assert_eq!(config.bind_address(), "0.0.0.0:8080");
894    }
895
896    #[test]
897    fn test_server_config_with_security() {
898        let config = ServerConfig::new("0.0.0.0", 8080)
899            .with_security(SecurityConfig::secure().with_api_key("test-key"));
900
901        assert!(config.security.auth.enabled);
902        assert_eq!(config.security.api_keys.len(), 1);
903    }
904
905    #[test]
906    fn test_security_config_default() {
907        let config = SecurityConfig::default();
908        assert!(!config.auth.enabled); // Disabled by default
909        assert!(config.rate_limit.enabled);
910    }
911
912    #[test]
913    fn test_security_config_secure() {
914        let config = SecurityConfig::secure();
915        assert!(config.auth.enabled);
916        assert!(config.rate_limit.enabled);
917    }
918
919    #[test]
920    fn test_cors_restrictive_by_default() {
921        assert!(!SecurityConfig::default().cors.allow_any);
922        assert!(!SecurityConfig::secure().cors.allow_any);
923        assert!(cors_layer(&CorsConfig::default()).is_none());
924    }
925
926    #[test]
927    fn test_cors_allow_any_opt_in() {
928        let config = SecurityConfig::development().with_cors_allow_any();
929        assert!(config.cors.allow_any);
930        assert!(cors_layer(&config.cors).is_some());
931    }
932
933    #[test]
934    fn test_security_config_development() {
935        let config = SecurityConfig::development();
936        assert!(!config.auth.enabled);
937        assert!(config.rate_limit.enabled);
938    }
939
940    #[test]
941    fn test_router_creation() {
942        let _router = create_router();
943        // Router created successfully
944    }
945
946    #[test]
947    fn test_required_capability_mapping() {
948        use RequiredCapability::{Authenticated, Capability, Public};
949
950        // Public + authenticated-only tiers.
951        assert_eq!(required_capability(&Method::GET, "/health"), Public);
952        assert_eq!(required_capability(&Method::GET, "/api/v1"), Authenticated);
953
954        // exec routes (oneshot + session-scoped + WS).
955        assert_eq!(
956            required_capability(&Method::POST, "/api/v1/execute"),
957            Capability("exec")
958        );
959        assert_eq!(
960            required_capability(&Method::GET, "/api/v1/ws"),
961            Capability("exec")
962        );
963        assert_eq!(
964            required_capability(&Method::POST, "/api/v1/sessions/{id}/execute"),
965            Capability("exec")
966        );
967        assert_eq!(
968            required_capability(&Method::GET, "/api/v1/sessions/{id}/ws"),
969            Capability("exec")
970        );
971
972        // read vs manage split on the same path, keyed by method.
973        assert_eq!(
974            required_capability(&Method::GET, "/api/v1/sessions"),
975            Capability("session.read")
976        );
977        assert_eq!(
978            required_capability(&Method::POST, "/api/v1/sessions"),
979            Capability("session.manage")
980        );
981        assert_eq!(
982            required_capability(&Method::GET, "/api/v1/sessions/{id}"),
983            Capability("session.read")
984        );
985        assert_eq!(
986            required_capability(&Method::DELETE, "/api/v1/sessions/{id}"),
987            Capability("session.manage")
988        );
989    }
990
991    #[test]
992    fn test_required_capability_unknown_fails_closed() {
993        // An unmapped route requires at least a valid token (never less).
994        assert_eq!(
995            required_capability(&Method::GET, "/api/v1/unknown"),
996            RequiredCapability::Authenticated
997        );
998    }
999
1000    // A direct unit-level check of the `HEAD` normalisation lived here once,
1001    // hardcoding three fs paths. It is superseded by
1002    // `every_get_fs_route_authorizes_head_identically` in `tests/fs_api.rs`,
1003    // which derives the same check from the one authoritative route table
1004    // (shared with `every_fs_route_declares_a_capability`) instead of a
1005    // second, hand-maintained list that could drift from it.
1006
1007    #[test]
1008    fn test_secure_router_creation() {
1009        let state = AppState::new();
1010        let security = SecurityConfig::secure().with_api_key("test-key");
1011        let (router, auth_store, rate_limiter) = create_secure_router(state, security);
1012
1013        assert_eq!(auth_store.count(), 1);
1014        assert!(auth_store.is_valid("test-key"));
1015        assert!(rate_limiter.is_enabled());
1016
1017        // Router should be created
1018        drop(router);
1019    }
1020}