diff --git a/Cargo.lock b/Cargo.lock index c5c7f72..3e44ba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1776,6 +1776,7 @@ dependencies = [ "cron", "crossterm", "fuzzy-matcher", + "getrandom 0.4.2", "ratatui", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index ae0cb5e..e92a2e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ wait-timeout = "0.2" tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "signal", "macros"] } cron = "0.12" tiny_http = "0.12" +getrandom = "0.4.2" [dev-dependencies] serial_test = "3" diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index a0e1406..4495212 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -620,6 +620,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 31b1bbf..23cda34 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -274,6 +274,14 @@ pub enum Commands { /// Probe/scheduler tick interval in seconds #[arg(long, default_value = "15")] probe_interval: u64, + + /// Bind to all interfaces (0.0.0.0) with bearer-token auth for remote access + #[arg(long)] + remote: bool, + + /// Custom bind address (default: 127.0.0.1, or 0.0.0.0 with --remote) + #[arg(long)] + bind: Option, }, /// Fuzzy picker for running agents; attach with Enter diff --git a/src/cli/serve.rs b/src/cli/serve.rs index abcf44c..20bf937 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -38,6 +38,8 @@ pub fn run( all: bool, port: Option, probe_interval_secs: u64, + remote: bool, + bind_override: Option<&str>, ) -> Result<()> { let targets = resolve_targets(workspace, all)?; if targets.is_empty() { @@ -50,10 +52,52 @@ pub fn run( } let selected_port = port.unwrap_or_else(resolve_default_port); - let host = "127.0.0.1"; - let http_targets = Arc::new(targets.clone()); - start_control_http_server(http_targets, host, selected_port)?; + + // Resolve bind address: --bind flag > --remote (0.0.0.0) > global config > 127.0.0.1 let global = GlobalConfig::load().ok(); + let host = if let Some(b) = bind_override { + b.to_string() + } else if remote { + "0.0.0.0".to_string() + } else { + global + .as_ref() + .and_then(|g| g.serve.as_ref()) + .map(|s| s.bind.clone()) + .unwrap_or_else(|| "127.0.0.1".to_string()) + }; + + // Determine if auth is required + let auth_mode = if remote { + crate::config::ServeAuthMode::Bearer + } else { + global + .as_ref() + .and_then(|g| g.serve.as_ref()) + .map(|s| s.auth.clone()) + .unwrap_or_default() + }; + + // Block non-localhost bind without auth to prevent accidental unauthenticated exposure + if !is_localhost_addr(&host) && auth_mode == crate::config::ServeAuthMode::None { + return Err(TuttiError::ConfigValidation( + "refusing to bind to non-localhost address without authentication; \ + use --remote to enable bearer-token auth, or set [serve] auth = \"bearer\" in config" + .to_string(), + )); + } + + // Generate/load bearer token when auth is enabled + let auth_token: Option> = if auth_mode == crate::config::ServeAuthMode::Bearer { + let token = load_or_generate_serve_token()?; + println!("serve: bearer token: {token}"); + Some(Arc::new(token)) + } else { + None + }; + + let http_targets = Arc::new(targets.clone()); + start_control_http_server(http_targets, &host, selected_port, auth_token)?; let resilience = global.as_ref().and_then(|g| g.resilience.as_ref()); let recovery_cooldown = Duration::from_secs(90); let mut last_recovery_attempt = HashMap::::new(); @@ -289,6 +333,7 @@ fn start_control_http_server( targets: Arc>, host: &str, port: u16, + auth_token: Option>, ) -> Result<()> { let server = Server::http((host, port)).map_err(|e| { TuttiError::ConfigValidation(format!("failed to bind health HTTP server: {e}")) @@ -296,16 +341,67 @@ fn start_control_http_server( thread::spawn(move || { for request in server.incoming_requests() { let request_targets = targets.clone(); + let token = auth_token.clone(); thread::spawn(move || { - handle_http_request(request, &request_targets); + handle_http_request( + request, + &request_targets, + token.as_deref().map(|s| s.as_str()), + ); }); } }); Ok(()) } +/// Validate a bearer token from an Authorization header value. +/// Returns `true` if auth is not required (`expected` is `None`) or if the +/// header contains a valid `Bearer ` matching `expected`. +fn validate_bearer_auth(auth_header: Option<&str>, expected: Option<&str>) -> bool { + let Some(token) = expected else { + return true; + }; + auth_header + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|t| t == token) + .unwrap_or(false) +} + +/// Check whether a bind address refers to the local machine. +fn is_localhost_addr(addr: &str) -> bool { + addr == "127.0.0.1" || addr == "localhost" || addr == "::1" +} + /// Dispatch an incoming HTTP request to the appropriate handler -fn handle_http_request(request: Request, targets: &[WorkspaceTarget]) { +fn handle_http_request( + request: Request, + targets: &[WorkspaceTarget], + expected_token: Option<&str>, +) { + // Auth middleware: reject if bearer token is required but missing/invalid + if expected_token.is_some() { + let auth_header = request + .headers() + .iter() + .find(|h| h.field.equiv("Authorization")) + .map(|h| h.value.as_str().to_string()); + let valid = validate_bearer_auth(auth_header.as_deref(), expected_token); + if !valid { + let body = api_err( + "auth", + "unauthorized", + "invalid or missing bearer token".to_string(), + ) + .to_string(); + let mut response = Response::from_string(body).with_status_code(StatusCode(401)); + if let Ok(h) = Header::from_bytes("Content-Type", "application/json") { + response = response.with_header(h); + } + let _ = request.respond(response); + return; + } + } + let is_stream = request.method() == &Method::Get && request.url().split('?').next() == Some("/v1/events/stream"); if is_stream { @@ -1183,7 +1279,43 @@ fn api_err(action: &str, code: &str, message: String) -> Value { }) } -/// Resolve the default port from global config, falling back to 4040 +/// Load an existing serve token or generate and persist a new one. +/// Token is stored at ~/.config/tutti/serve-token. +fn load_or_generate_serve_token() -> Result { + let home = std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + let config_dir = home.join(".config").join("tutti"); + let token_path = config_dir.join("serve-token"); + + // Try loading an existing token + if token_path.exists() { + let contents = std::fs::read_to_string(&token_path)?; + let token = contents.trim().to_string(); + if token.len() >= 32 { + return Ok(token); + } + } + + // Generate a 256-bit (32-byte) random hex token + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|e| { + TuttiError::ConfigValidation(format!("failed to generate random token: {e}")) + })?; + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + + std::fs::create_dir_all(&config_dir)?; + std::fs::write(&token_path, &token)?; + // Restrict permissions to owner only + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(token) +} + fn resolve_default_port() -> u16 { match GlobalConfig::load() { Ok(global) => global.dashboard.map(|d| d.port).unwrap_or(4040), @@ -1309,4 +1441,101 @@ mod tests { )); assert!(recovery_cooldown_elapsed(Some(&now), Duration::ZERO)); } + + // ── Remote-serve auth tests ── + + #[test] + fn validate_bearer_auth_accepts_valid_token() { + assert!(validate_bearer_auth(Some("Bearer abc123"), Some("abc123"))); + } + + #[test] + fn validate_bearer_auth_rejects_missing_header() { + assert!(!validate_bearer_auth(None, Some("abc123"))); + } + + #[test] + fn validate_bearer_auth_rejects_wrong_token() { + assert!(!validate_bearer_auth(Some("Bearer wrong"), Some("abc123"))); + } + + #[test] + fn validate_bearer_auth_rejects_malformed_header() { + // Missing "Bearer " prefix + assert!(!validate_bearer_auth(Some("abc123"), Some("abc123"))); + // Basic auth instead of bearer + assert!(!validate_bearer_auth( + Some("Basic dXNlcjpwYXNz"), + Some("abc123") + )); + } + + #[test] + fn validate_bearer_auth_skips_when_not_required() { + assert!(validate_bearer_auth(None, None)); + assert!(validate_bearer_auth(Some("Bearer anything"), None)); + } + + #[test] + fn is_localhost_addr_identifies_local_addresses() { + assert!(is_localhost_addr("127.0.0.1")); + assert!(is_localhost_addr("localhost")); + assert!(is_localhost_addr("::1")); + assert!(!is_localhost_addr("0.0.0.0")); + assert!(!is_localhost_addr("192.168.1.1")); + assert!(!is_localhost_addr("10.0.0.1")); + } + + #[test] + fn token_generation_produces_valid_hex_and_reloads() { + let temp = std::env::temp_dir().join(format!("tutti-test-token-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".config").join("tutti")).unwrap(); + + // Temporarily override HOME so token writes to temp + let original_home = std::env::var("HOME").ok(); + unsafe { std::env::set_var("HOME", &temp) }; + + let token1 = load_or_generate_serve_token().expect("first generation should succeed"); + assert_eq!(token1.len(), 64, "256-bit token should be 64 hex chars"); + assert!( + token1.chars().all(|c| c.is_ascii_hexdigit()), + "token should be valid hex" + ); + + // Second call should reload the same token + let token2 = load_or_generate_serve_token().expect("reload should succeed"); + assert_eq!(token1, token2, "reloaded token should match original"); + + // Restore HOME + if let Some(h) = original_home { + unsafe { std::env::set_var("HOME", h) }; + } + let _ = std::fs::remove_dir_all(&temp); + } + + #[test] + fn serve_config_toml_round_trip() { + let toml_str = r#" +[serve] +bind = "0.0.0.0" +auth = "bearer" +"#; + #[derive(Debug, Deserialize)] + struct Wrapper { + serve: crate::config::ServeConfig, + } + let parsed: Wrapper = toml::from_str(toml_str).expect("should parse"); + assert_eq!(parsed.serve.bind, "0.0.0.0"); + assert_eq!(parsed.serve.auth, crate::config::ServeAuthMode::Bearer); + + // Default values + let toml_default = "[serve]\n"; + let parsed_default: Wrapper = toml::from_str(toml_default).expect("should parse defaults"); + assert_eq!(parsed_default.serve.bind, "127.0.0.1"); + assert_eq!( + parsed_default.serve.auth, + crate::config::ServeAuthMode::None + ); + } } diff --git a/src/cli/up.rs b/src/cli/up.rs index cac1e97..73ab12c 100644 --- a/src/cli/up.rs +++ b/src/cli/up.rs @@ -1901,6 +1901,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; let limit = resolve_profile_limit(&config, &global).unwrap(); @@ -1949,6 +1950,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; assert!(resolve_profile_limit(&config, &global).is_none()); @@ -2009,6 +2011,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; assert_eq!( @@ -2058,6 +2061,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; assert_eq!( @@ -2140,6 +2144,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; let attempts = resolve_runtime_launch_attempts(&config, Some(&global), "codex", true); diff --git a/src/cli/usage.rs b/src/cli/usage.rs index 433c532..3f90f7c 100644 --- a/src/cli/usage.rs +++ b/src/cli/usage.rs @@ -337,6 +337,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; let map = HashMap::from([("a".to_string(), "personal".to_string())]); @@ -364,6 +365,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; let map = HashMap::from([("a".to_string(), "work".to_string())]); diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 9e7a864..3c941fa 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -862,6 +862,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; assert_eq!(resolve_workspace_plan_label(&config, &global), "API"); } @@ -897,6 +898,7 @@ mod tests { dashboard: None, resilience: None, permissions: None, + serve: None, }; let cache = build_plan_cache_with_global(&config, Some(&global)); diff --git a/src/config/mod.rs b/src/config/mod.rs index 7e3ecb4..8141914 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -326,6 +326,31 @@ pub struct GlobalConfig { pub resilience: Option, #[serde(default)] pub permissions: Option, + #[serde(default)] + pub serve: Option, +} + +/// Configuration for `tt serve` remote access +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServeConfig { + /// Bind address (default: 127.0.0.1) + #[serde(default = "default_serve_bind")] + pub bind: String, + /// Authentication mode: "none" or "bearer" + #[serde(default)] + pub auth: ServeAuthMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ServeAuthMode { + #[default] + None, + Bearer, +} + +fn default_serve_bind() -> String { + "127.0.0.1".to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/main.rs b/src/main.rs index a0b4550..2e23606 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,7 +108,16 @@ fn main() { all, port, probe_interval, - } => cli::serve::run(workspace.as_deref(), all, port, probe_interval), + remote, + ref bind, + } => cli::serve::run( + workspace.as_deref(), + all, + port, + probe_interval, + remote, + bind.as_deref(), + ), Commands::Switch => cli::switch::run(), Commands::Handoff { command } => cli::handoff::run(command), Commands::Run {