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::{connect_info::IntoMakeServiceWithConnectInfo, MatchedPath, Request, State},
8    http::{header::AUTHORIZATION, Method, StatusCode},
9    middleware::{self, Next},
10    response::Response,
11    routing::{any, get, post},
12    Router,
13};
14use tower_http::{
15    cors::{Any, CorsLayer},
16    trace::TraceLayer,
17};
18
19use super::handlers::{
20    api_info, create_session, delete_session, execute_command, execute_oneshot, get_session,
21    health, list_sessions, AppState,
22};
23use super::websocket::{ws_handler, ws_oneshot_handler};
24use crate::security::{
25    rate_limit_middleware, ApiKeyStore, AuthConfig, CapabilitySet, RateLimitConfig, RateLimiter,
26};
27
28/// Cross-Origin Resource Sharing (CORS) configuration.
29///
30/// CORS is a browser-enforced mechanism; non-browser clients (AI agents, `curl`)
31/// ignore it entirely. shell-tunnel therefore emits **no** permissive CORS headers
32/// by default: this blocks a malicious web page from reading responses cross-origin
33/// and, because the JSON execute endpoints require a preflight, from issuing the
34/// request at all — with zero impact on the intended non-browser consumers.
35///
36/// Note: CORS does **not** defend against DNS-rebinding (the attacker's host is
37/// rebound to `127.0.0.1`, making the request same-origin); that requires
38/// Host-header validation and is tracked separately.
39#[derive(Debug, Clone, Default)]
40pub struct CorsConfig {
41    /// Allow any origin/method/header (restores the permissive `Any` behavior).
42    /// Off by default; enable only for trusted browser-based UIs.
43    pub allow_any: bool,
44}
45
46/// Security configuration for the server.
47#[derive(Debug, Clone)]
48pub struct SecurityConfig {
49    /// Authentication configuration.
50    pub auth: AuthConfig,
51    /// Rate limiting configuration.
52    pub rate_limit: RateLimitConfig,
53    /// API keys to pre-register.
54    pub api_keys: Vec<String>,
55    /// Capabilities granted to the pre-registered keys and to the
56    /// auto-generated fallback key.
57    ///
58    /// `None` (the default) means **full-control** (wildcard) — the backward-
59    /// compatible behavior for a bare `--api-key` / `--require-auth` (spec §4).
60    /// `Some(set)` issues fine-grained tokens scoped to that set (spec §9).
61    pub capabilities: Option<CapabilitySet>,
62    /// CORS configuration.
63    pub cors: CorsConfig,
64}
65
66impl Default for SecurityConfig {
67    fn default() -> Self {
68        Self {
69            auth: AuthConfig::disabled(), // Disabled by default for ease of use
70            rate_limit: RateLimitConfig::default(),
71            api_keys: Vec::new(),
72            capabilities: None, // Full-control by default (legacy-compatible)
73            cors: CorsConfig::default(), // Restrictive by default (no permissive CORS)
74        }
75    }
76}
77
78/// Build a permissive CORS layer when `allow_any` is set, otherwise `None`.
79///
80/// Returning `None` means no CORS headers are emitted — the secure default.
81fn cors_layer(cfg: &CorsConfig) -> Option<CorsLayer> {
82    cfg.allow_any.then(|| {
83        CorsLayer::new()
84            .allow_origin(Any)
85            .allow_methods(Any)
86            .allow_headers(Any)
87    })
88}
89
90impl SecurityConfig {
91    /// Create a secure configuration.
92    pub fn secure() -> Self {
93        Self {
94            auth: AuthConfig::default(),
95            rate_limit: RateLimitConfig::default(),
96            api_keys: Vec::new(),
97            capabilities: None,
98            cors: CorsConfig::default(),
99        }
100    }
101
102    /// Create a development configuration (no auth, relaxed limits).
103    pub fn development() -> Self {
104        Self {
105            auth: AuthConfig::disabled(),
106            rate_limit: RateLimitConfig::relaxed(),
107            api_keys: Vec::new(),
108            capabilities: None,
109            cors: CorsConfig::default(),
110        }
111    }
112
113    /// Add an API key.
114    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
115        self.api_keys.push(key.into());
116        self
117    }
118
119    /// Scope the issued tokens to a fine-grained capability set (spec §9).
120    ///
121    /// Applies to the pre-registered keys and to the auto-generated fallback
122    /// key. Without this, tokens are full-control (legacy-compatible).
123    pub fn with_capabilities(mut self, capabilities: CapabilitySet) -> Self {
124        self.capabilities = Some(capabilities);
125        self
126    }
127
128    /// Enable permissive (`Any`) CORS. Opt-in; only for trusted browser UIs.
129    pub fn with_cors_allow_any(mut self) -> Self {
130        self.cors.allow_any = true;
131        self
132    }
133}
134
135/// Register `key` into `store` with `capabilities` (fine-grained), or as a
136/// legacy full-control key when `capabilities` is `None` (spec §4/§9).
137fn register_key(store: &ApiKeyStore, key: &str, capabilities: &Option<CapabilitySet>) {
138    match capabilities {
139        Some(caps) => store.add_key_with_capabilities(key, caps.clone(), "configured"),
140        None => store.add_key(key),
141    }
142}
143
144/// Create the API router with all routes configured.
145pub fn create_router() -> Router {
146    create_router_with_state(AppState::new())
147}
148
149/// Create the API router with custom state (no security).
150pub fn create_router_with_state(state: AppState) -> Router {
151    // Session routes
152    let session_routes = Router::new()
153        .route("/", get(list_sessions).post(create_session))
154        .route("/{id}", get(get_session).delete(delete_session))
155        .route("/{id}/execute", post(execute_command))
156        .route("/{id}/ws", any(ws_handler));
157
158    // API v1 routes
159    let api_v1 = Router::new()
160        .route("/", get(api_info))
161        .route("/execute", post(execute_oneshot))
162        .route("/ws", any(ws_oneshot_handler))
163        .nest("/sessions", session_routes);
164
165    // Build main router. This "no security" convenience constructor uses the
166    // restrictive CORS default (no permissive CORS headers emitted).
167    Router::new()
168        .route("/health", get(health))
169        .nest("/api/v1", api_v1)
170        .layer(TraceLayer::new_for_http())
171        .with_state(state)
172}
173
174/// The capability a route requires (Phase A spec §3).
175///
176/// Declared here at the router layer — co-located with the route definitions,
177/// which are the single source of truth for the matched-path strings this maps
178/// against. Not a per-handler attribute.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum RequiredCapability {
181    /// No authentication at all (e.g. `/health`).
182    Public,
183    /// Authenticated only: any valid token passes, no specific capability
184    /// required (spec §3 "인증만" tier, e.g. `GET /api/v1`).
185    Authenticated,
186    /// Requires a specific capability string (set membership, or wildcard).
187    Capability(&'static str),
188}
189
190/// Map a matched route (`method` + axum [`MatchedPath`]) to its required
191/// capability (spec §3 table).
192///
193/// Keyed on the **full nested** `MatchedPath` (confirmed against the real router
194/// structure) plus the HTTP method, because one path can require different
195/// capabilities per method (e.g. `GET /api/v1/sessions` = read, `POST` = manage).
196///
197/// Unknown routes fail **closed** to [`RequiredCapability::Authenticated`]: an
198/// unmapped route still requires a valid token, never less than that.
199pub fn required_capability(method: &Method, matched_path: &str) -> RequiredCapability {
200    use RequiredCapability::{Authenticated, Capability, Public};
201
202    match (method, matched_path) {
203        (_, "/health") => Public,
204        (&Method::GET, "/api/v1") => Authenticated,
205        (&Method::POST, "/api/v1/execute") => Capability("exec"),
206        (_, "/api/v1/ws") => Capability("exec"),
207        (&Method::GET, "/api/v1/sessions") => Capability("session.read"),
208        (&Method::POST, "/api/v1/sessions") => Capability("session.manage"),
209        (&Method::GET, "/api/v1/sessions/{id}") => Capability("session.read"),
210        (&Method::DELETE, "/api/v1/sessions/{id}") => Capability("session.manage"),
211        (&Method::POST, "/api/v1/sessions/{id}/execute") => Capability("exec"),
212        (_, "/api/v1/sessions/{id}/ws") => Capability("exec"),
213        _ => Authenticated,
214    }
215}
216
217/// Scope-aware authentication + authorization middleware (spec §5).
218///
219/// 1. Resolve the route's required capability from `method` + `MatchedPath`.
220/// 2. `Public` route or auth disabled → pass through.
221/// 3. Extract the bearer token; look up its `TokenRecord` in the store.
222///    Missing/invalid token → **401**.
223/// 4. `Authenticated` route → any valid token passes. `Capability(c)` route →
224///    the token's set must satisfy `c` (membership or wildcard), else **403**.
225async fn capability_auth_middleware(
226    State(store): State<std::sync::Arc<ApiKeyStore>>,
227    request: Request,
228    next: Next,
229) -> Result<Response, StatusCode> {
230    // Auth disabled → open server (existing behavior).
231    if !store.is_enabled() {
232        return Ok(next.run(request).await);
233    }
234
235    let method = request.method().clone();
236    // Owned so it can be used in the rejection logs after `request` is consumed.
237    let matched = request
238        .extensions()
239        .get::<MatchedPath>()
240        .map(|m| m.as_str().to_owned())
241        .unwrap_or_default();
242    let required = required_capability(&method, &matched);
243
244    // Public routes (e.g. /health) skip auth entirely.
245    if required == RequiredCapability::Public {
246        return Ok(next.run(request).await);
247    }
248
249    // Extract the bearer token and resolve its capabilities.
250    // Missing header, wrong prefix, or unregistered token → 401.
251    let token = request
252        .headers()
253        .get(AUTHORIZATION)
254        .and_then(|v| v.to_str().ok())
255        .and_then(|header| store.extract_key(header));
256
257    let capabilities = match token.as_deref().and_then(|t| store.capabilities(t)) {
258        Some(caps) => caps,
259        None => {
260            // Logged at debug to avoid a log-flood amplifier under probing; the
261            // token value itself is never logged. `missing-token` = no/malformed
262            // Authorization header, `invalid-token` = present but unregistered.
263            let reason = if token.is_none() {
264                "missing-token"
265            } else {
266                "invalid-token"
267            };
268            tracing::debug!(%method, path = %matched, reason, "auth rejected (401)");
269            return Err(StatusCode::UNAUTHORIZED);
270        }
271    };
272
273    match required {
274        // Already handled above, but keep the match exhaustive.
275        RequiredCapability::Public => Ok(next.run(request).await),
276        // Any valid token satisfies an authenticated-only route.
277        RequiredCapability::Authenticated => Ok(next.run(request).await),
278        // Specific capability: set membership (or wildcard) required, else 403.
279        RequiredCapability::Capability(cap) => {
280            if capabilities.satisfies(cap) {
281                Ok(next.run(request).await)
282            } else {
283                tracing::debug!(
284                    %method,
285                    path = %matched,
286                    required = cap,
287                    "authorization denied (403): insufficient capability"
288                );
289                Err(StatusCode::FORBIDDEN)
290            }
291        }
292    }
293}
294
295/// Create the API router with security enabled.
296pub fn create_secure_router(
297    state: AppState,
298    security: SecurityConfig,
299) -> (Router, Arc<ApiKeyStore>, Arc<RateLimiter>) {
300    // Create security components
301    let auth_store = Arc::new(ApiKeyStore::new(security.auth));
302    let rate_limiter = Arc::new(RateLimiter::new(security.rate_limit));
303
304    // Register API keys with their configured capabilities.
305    for key in &security.api_keys {
306        register_key(&auth_store, key, &security.capabilities);
307    }
308
309    // Session routes
310    let session_routes = Router::new()
311        .route("/", get(list_sessions).post(create_session))
312        .route("/{id}", get(get_session).delete(delete_session))
313        .route("/{id}/execute", post(execute_command))
314        .route("/{id}/ws", any(ws_handler));
315
316    // API v1 routes
317    let api_v1 = Router::new()
318        .route("/", get(api_info))
319        .route("/execute", post(execute_oneshot))
320        .route("/ws", any(ws_oneshot_handler))
321        .nest("/sessions", session_routes);
322
323    // Build main router with security layers
324    let mut router = Router::new()
325        .route("/health", get(health))
326        .nest("/api/v1", api_v1)
327        .layer(middleware::from_fn_with_state(
328            Arc::clone(&auth_store),
329            capability_auth_middleware,
330        ))
331        .layer(middleware::from_fn_with_state(
332            Arc::clone(&rate_limiter),
333            rate_limit_middleware,
334        ))
335        .layer(TraceLayer::new_for_http());
336
337    // Permissive CORS only when explicitly opted in (default: restrictive).
338    if let Some(cors) = cors_layer(&security.cors) {
339        router = router.layer(cors);
340    }
341
342    let router = router.with_state(state);
343
344    (router, auth_store, rate_limiter)
345}
346
347/// Server configuration.
348#[derive(Debug, Clone)]
349pub struct ServerConfig {
350    /// Host address to bind to.
351    pub host: String,
352    /// Port to listen on.
353    pub port: u16,
354    /// Security configuration.
355    pub security: SecurityConfig,
356    /// Enable graceful shutdown on SIGTERM/SIGINT.
357    pub graceful_shutdown: bool,
358}
359
360impl ServerConfig {
361    pub fn new(host: impl Into<String>, port: u16) -> Self {
362        Self {
363            host: host.into(),
364            port,
365            security: SecurityConfig::default(),
366            graceful_shutdown: true,
367        }
368    }
369
370    pub fn bind_address(&self) -> String {
371        format!("{}:{}", self.host, self.port)
372    }
373
374    /// Enable security with the given configuration.
375    pub fn with_security(mut self, security: SecurityConfig) -> Self {
376        self.security = security;
377        self
378    }
379
380    /// Disable graceful shutdown.
381    pub fn without_graceful_shutdown(mut self) -> Self {
382        self.graceful_shutdown = false;
383        self
384    }
385}
386
387impl Default for ServerConfig {
388    fn default() -> Self {
389        Self {
390            host: "127.0.0.1".to_string(),
391            port: 3000,
392            security: SecurityConfig::default(),
393            graceful_shutdown: true,
394        }
395    }
396}
397
398/// Start the API server.
399pub async fn serve(config: ServerConfig) -> crate::Result<()> {
400    serve_with_state(config, AppState::new()).await
401}
402
403/// Start the API server with custom state.
404pub async fn serve_with_state(config: ServerConfig, state: AppState) -> crate::Result<()> {
405    let addr = config.bind_address();
406
407    // Create router with security
408    let (router, auth_store, _rate_limiter) = create_secure_router(state, config.security.clone());
409
410    // Log API key if auth is enabled and keys are registered
411    if auth_store.is_enabled() {
412        if auth_store.count() == 0 {
413            // Generate and register a key if none provided, scoped to the
414            // configured capabilities (full-control when unset).
415            let key = crate::security::generate_api_key();
416            register_key(&auth_store, &key, &config.security.capabilities);
417            tracing::info!("Generated API key: {}", key);
418        }
419        tracing::info!(
420            "Authentication enabled with {} API key(s)",
421            auth_store.count()
422        );
423    } else {
424        tracing::warn!("Authentication is DISABLED - server is open to all requests");
425    }
426
427    tracing::info!("Starting shell-tunnel API server on {}", addr);
428
429    let listener = tokio::net::TcpListener::bind(&addr)
430        .await
431        .map_err(crate::error::ShellTunnelError::Io)?;
432
433    // Create service with connection info for rate limiting
434    let service: IntoMakeServiceWithConnectInfo<Router, SocketAddr> =
435        router.into_make_service_with_connect_info::<SocketAddr>();
436
437    if config.graceful_shutdown {
438        // Serve with graceful shutdown
439        axum::serve(listener, service)
440            .with_graceful_shutdown(shutdown_signal())
441            .await
442            .map_err(|e| {
443                crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
444            })?;
445
446        tracing::info!("Server shutdown complete");
447    } else {
448        // Serve without graceful shutdown
449        axum::serve(listener, service).await.map_err(|e| {
450            crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
451        })?;
452    }
453
454    Ok(())
455}
456
457/// Wait for shutdown signal (Ctrl+C or SIGTERM).
458async fn shutdown_signal() {
459    let ctrl_c = async {
460        tokio::signal::ctrl_c()
461            .await
462            .expect("Failed to install Ctrl+C handler");
463    };
464
465    #[cfg(unix)]
466    let terminate = async {
467        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
468            .expect("Failed to install SIGTERM handler")
469            .recv()
470            .await;
471    };
472
473    #[cfg(not(unix))]
474    let terminate = std::future::pending::<()>();
475
476    tokio::select! {
477        _ = ctrl_c => {
478            tracing::info!("Received Ctrl+C, initiating graceful shutdown...");
479        }
480        _ = terminate => {
481            tracing::info!("Received SIGTERM, initiating graceful shutdown...");
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_server_config_default() {
492        let config = ServerConfig::default();
493        assert_eq!(config.host, "127.0.0.1");
494        assert_eq!(config.port, 3000);
495        assert_eq!(config.bind_address(), "127.0.0.1:3000");
496        assert!(config.graceful_shutdown);
497    }
498
499    #[test]
500    fn test_server_config_custom() {
501        let config = ServerConfig::new("0.0.0.0", 8080);
502        assert_eq!(config.bind_address(), "0.0.0.0:8080");
503    }
504
505    #[test]
506    fn test_server_config_with_security() {
507        let config = ServerConfig::new("0.0.0.0", 8080)
508            .with_security(SecurityConfig::secure().with_api_key("test-key"));
509
510        assert!(config.security.auth.enabled);
511        assert_eq!(config.security.api_keys.len(), 1);
512    }
513
514    #[test]
515    fn test_security_config_default() {
516        let config = SecurityConfig::default();
517        assert!(!config.auth.enabled); // Disabled by default
518        assert!(config.rate_limit.enabled);
519    }
520
521    #[test]
522    fn test_security_config_secure() {
523        let config = SecurityConfig::secure();
524        assert!(config.auth.enabled);
525        assert!(config.rate_limit.enabled);
526    }
527
528    #[test]
529    fn test_cors_restrictive_by_default() {
530        assert!(!SecurityConfig::default().cors.allow_any);
531        assert!(!SecurityConfig::secure().cors.allow_any);
532        assert!(cors_layer(&CorsConfig::default()).is_none());
533    }
534
535    #[test]
536    fn test_cors_allow_any_opt_in() {
537        let config = SecurityConfig::development().with_cors_allow_any();
538        assert!(config.cors.allow_any);
539        assert!(cors_layer(&config.cors).is_some());
540    }
541
542    #[test]
543    fn test_security_config_development() {
544        let config = SecurityConfig::development();
545        assert!(!config.auth.enabled);
546        assert!(config.rate_limit.enabled);
547    }
548
549    #[test]
550    fn test_router_creation() {
551        let _router = create_router();
552        // Router created successfully
553    }
554
555    #[test]
556    fn test_required_capability_mapping() {
557        use RequiredCapability::{Authenticated, Capability, Public};
558
559        // Public + authenticated-only tiers.
560        assert_eq!(required_capability(&Method::GET, "/health"), Public);
561        assert_eq!(required_capability(&Method::GET, "/api/v1"), Authenticated);
562
563        // exec routes (oneshot + session-scoped + WS).
564        assert_eq!(
565            required_capability(&Method::POST, "/api/v1/execute"),
566            Capability("exec")
567        );
568        assert_eq!(
569            required_capability(&Method::GET, "/api/v1/ws"),
570            Capability("exec")
571        );
572        assert_eq!(
573            required_capability(&Method::POST, "/api/v1/sessions/{id}/execute"),
574            Capability("exec")
575        );
576        assert_eq!(
577            required_capability(&Method::GET, "/api/v1/sessions/{id}/ws"),
578            Capability("exec")
579        );
580
581        // read vs manage split on the same path, keyed by method.
582        assert_eq!(
583            required_capability(&Method::GET, "/api/v1/sessions"),
584            Capability("session.read")
585        );
586        assert_eq!(
587            required_capability(&Method::POST, "/api/v1/sessions"),
588            Capability("session.manage")
589        );
590        assert_eq!(
591            required_capability(&Method::GET, "/api/v1/sessions/{id}"),
592            Capability("session.read")
593        );
594        assert_eq!(
595            required_capability(&Method::DELETE, "/api/v1/sessions/{id}"),
596            Capability("session.manage")
597        );
598    }
599
600    #[test]
601    fn test_required_capability_unknown_fails_closed() {
602        // An unmapped route requires at least a valid token (never less).
603        assert_eq!(
604            required_capability(&Method::GET, "/api/v1/unknown"),
605            RequiredCapability::Authenticated
606        );
607    }
608
609    #[test]
610    fn test_secure_router_creation() {
611        let state = AppState::new();
612        let security = SecurityConfig::secure().with_api_key("test-key");
613        let (router, auth_store, rate_limiter) = create_secure_router(state, security);
614
615        assert_eq!(auth_store.count(), 1);
616        assert!(auth_store.is_valid("test-key"));
617        assert!(rate_limiter.is_enabled());
618
619        // Router should be created
620        drop(router);
621    }
622}