diff --git a/Dockerfile b/Dockerfile index d42c87d..b62afa6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -111,6 +111,6 @@ ENV HOME=/workspace \ # through GET /tools/attach/{session}; the CLI then finds them at the URL in # OPENAB_TOOLS_MCP_URL. Off means: no listener, and /tools/attach refuses. PTY_TOOLS_LISTEN="" \ - PTY_TOOLS_ATTACH_TTL=1h + PTY_TOOLS_ATTACH_TTL=24h ENTRYPOINT ["/usr/local/bin/openab-pty-entrypoint"] diff --git a/deploy/ecs/entrypoint.sh b/deploy/ecs/entrypoint.sh index 2759688..c23823d 100644 --- a/deploy/ecs/entrypoint.sh +++ b/deploy/ecs/entrypoint.sh @@ -74,7 +74,7 @@ seed_dir = "${PTY_SEED_DIR:-}" # Reverse-attached tools (a Mac lends its instance-mcp tools to one session). # Empty disables the plane; the runtime refuses anything but loopback here. tools_listen = "${PTY_TOOLS_LISTEN:-}" -tools_attach_ttl = "${PTY_TOOLS_ATTACH_TTL:-1h}" +tools_attach_ttl = "${PTY_TOOLS_ATTACH_TTL:-24h}" EOF # Fail before serving rather than after: the same validator the runtime applies diff --git a/docs/k8s-howto.md b/docs/k8s-howto.md index 7c112a2..50a5a9f 100644 --- a/docs/k8s-howto.md +++ b/docs/k8s-howto.md @@ -149,10 +149,14 @@ The pod initiates nothing and stores only a hash. Design: wire contract: §9 of [`../runtime/CLIENT-CONTRACT.md`](../runtime/CLIENT-CONTRACT.md). ```bash -# Mint a one-hour attach secret for session "laptop" (admin credential required). +# Mint a four-hour attach secret for session "laptop" (admin credential required). +# Omit the body for the backwards-compatible one-hour default. The image allows +# up to 24h (`PTY_TOOLS_ATTACH_TTL` is the operator ceiling). curl -s -X POST -H "Authorization: Bearer $CRED" \ + -H "Content-Type: application/json" -d '{"ttl_secs":14400}' \ http://:8090/admin/sessions/laptop/tools-attach -# → {"secret":"…","verifier":"sha256:…","expires_in_secs":3600,"attach":"/tools/attach/laptop"} +# → {"secret":"…","verifier":"sha256:…","expires_in_secs":14400, +# "ttl_secs":14400,"attach":"/tools/attach/laptop"} # Hand the secret to the Mac; it dials ws://:8090/tools/attach/laptop # with `Authorization: Bearer `. Revoke any time: curl -s -X DELETE -H "Authorization: Bearer $CRED" \ diff --git a/runtime/CLIENT-CONTRACT.md b/runtime/CLIENT-CONTRACT.md index 294c987..35c095b 100644 --- a/runtime/CLIENT-CONTRACT.md +++ b/runtime/CLIENT-CONTRACT.md @@ -287,6 +287,7 @@ every upgrade with `401`, and the mint endpoint returns `501`. ``` POST /admin/sessions/{session}/tools-attach Authorization: Bearer + optional JSON: {"ttl_secs": 14400} DELETE /admin/sessions/{session}/tools-attach Authorization: Bearer ``` @@ -296,7 +297,8 @@ DELETE /admin/sessions/{session}/tools-attach Authorization: Bearer Result { admin_credential_hash: raw_hash, seed_dir: string_with_default(pty, "seed_dir", "")?.to_string(), tools_listen: string_with_default(pty, "tools_listen", "")?.to_string(), - tools_attach_ttl: duration_with_default(pty, "tools_attach_ttl", "1h")?, + tools_attach_ttl: duration_with_default(pty, "tools_attach_ttl", "24h")?, kill_domain_requirement: parse_kill_domain(string_with_default( pty, "kill_domain_tier", @@ -384,6 +386,12 @@ fn validate_lifecycle(config: &PtyConfig) -> Result<(), Error> { if config.tools_attach_ttl.is_zero() { return Err(Error::Config("tools_attach_ttl must be non-zero".into())); } + if config.tools_attach_ttl > crate::tools::DEFAULT_TOOLS_ATTACH_MAX_TTL { + return Err(Error::Config(format!( + "tools_attach_ttl must not exceed the hard 24h lease maximum ({:?})", + crate::tools::DEFAULT_TOOLS_ATTACH_MAX_TTL + ))); + } if !config.tools_listen.is_empty() && !crate::server::bind_is_loopback(&config.tools_listen) { // Fail closed. The tools listener carries no credential of its own: its // whole boundary is "only this pod can reach it", which a non-loopback @@ -536,6 +544,14 @@ admin_credential_hash = "{HASH}" ), "must not be shorter than detached_idle_ttl", ), + ( + format!("{}tools_attach_ttl = \"0s\"\n", valid()), + "tools_attach_ttl must be greater than zero", + ), + ( + format!("{}tools_attach_ttl = \"25h\"\n", valid()), + "hard 24h lease maximum", + ), ] { let error = validate_projection(&projection).expect_err("incompatible TTLs must fail closed"); diff --git a/runtime/src/server.rs b/runtime/src/server.rs index ee2f88a..138611a 100644 --- a/runtime/src/server.rs +++ b/runtime/src/server.rs @@ -461,7 +461,8 @@ pub struct ServerConfig { pub drain_grace: Duration, /// TTL maintenance interval. pub tick_interval: Duration, - /// Lifetime of a tools-attach grant (`POST /admin/sessions/{s}/tools-attach`). + /// Upper bound on a tools-attach grant (`POST /admin/sessions/{s}/tools-attach`). + /// The endpoint defaults an omitted `ttl_secs` to one hour. pub tools_attach_ttl: Duration, } @@ -472,7 +473,7 @@ impl Default for ServerConfig { tls_terminated_upstream: true, drain_grace: Duration::from_secs(3), tick_interval: Duration::from_secs(1), - tools_attach_ttl: tools::DEFAULT_TOOLS_ATTACH_TTL, + tools_attach_ttl: tools::DEFAULT_TOOLS_ATTACH_MAX_TTL, } } } @@ -1244,6 +1245,12 @@ async fn tools_attach( }) } +#[derive(Debug, Default, Deserialize)] +struct ToolsMintRequest { + /// Optional lease requested by Connect/Remote. Missing stays one hour; + /// zero or above the operator ceiling is a 400, never silently capped. + ttl_secs: Option, +} /// `POST /admin/sessions/{session}/tools-attach` — mint the secret a Mac will /// present on `/tools/attach/{session}`. Returned once; the runtime keeps the /// hash. Minting again rotates the secret and leaves a live attach in place. @@ -1252,6 +1259,7 @@ async fn admin_tools_mint( ConnectInfo(Peer(peer)): ConnectInfo, Path(session): Path, headers: HeaderMap, + request: Option>, ) -> Response { if let Some(response) = admin_gate(&state, &headers, &peer) { return response; @@ -1270,7 +1278,24 @@ async fn admin_tools_mint( ) .into_response(); } - match state.tools.mint(&name) { + let max_ttl = state.tools.max_ttl(); + let ttl = request + .and_then(|Json(body)| body.ttl_secs) + .map(Duration::from_secs) + .unwrap_or_else(|| tools::DEFAULT_TOOLS_ATTACH_TTL.min(max_ttl)); + if ttl.is_zero() || ttl > max_ttl { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!( + "ttl_secs must be between 1 and {} (configured tools_attach_ttl maximum)", + max_ttl.as_secs() + ) + })), + ) + .into_response(); + } + match state.tools.mint_with_ttl(&name, ttl) { Ok(mut minted) => { let secret = String::from_utf8_lossy(minted.plaintext.as_bytes()).into_owned(); minted.plaintext.zeroize(); @@ -1284,6 +1309,7 @@ async fn admin_tools_mint( .expires_at .saturating_duration_since(Instant::now()) .as_secs(), + "ttl_secs": ttl.as_secs(), "attach": format!("/tools/attach/{}", name.as_str()), })), ) diff --git a/runtime/src/tools.rs b/runtime/src/tools.rs index f08c796..3590d05 100644 --- a/runtime/src/tools.rs +++ b/runtime/src/tools.rs @@ -65,9 +65,11 @@ use std::time::{Duration, Instant}; use subtle::ConstantTimeEq; use tokio::sync::{mpsc, oneshot}; -/// Default lifetime of a tools-attach grant. One hour is the "lend my Mac to -/// this session" unit the Connect / Remote UI offers. +/// Default lifetime when the mint request omits `ttl_secs`. pub const DEFAULT_TOOLS_ATTACH_TTL: Duration = Duration::from_secs(60 * 60); +/// Default configured upper bound. Connect/Remote offer up to 24 hours; the +/// operator can lower this with `[pty].tools_attach_ttl` / `PTY_TOOLS_ATTACH_TTL`. +pub const DEFAULT_TOOLS_ATTACH_MAX_TTL: Duration = Duration::from_secs(24 * 60 * 60); /// Bound on requests a session may have in flight toward its Mac. Past it the /// loopback caller gets a JSON-RPC error immediately rather than queueing. pub const MAX_INFLIGHT_PER_SESSION: usize = 64; @@ -281,23 +283,25 @@ pub struct ToolsHub { /// when a session's child is spawned, so it exists before the CLI can ask. loopback_keys: Mutex>, attached: Mutex>>, - ttl: Duration, + max_ttl: Duration, audit: AuditLogger, } impl ToolsHub { - pub fn new(ttl: Duration, audit: AuditLogger) -> Self { + /// `max_ttl` is an operator ceiling, not the default lease. Requests that + /// omit `ttl_secs` remain one hour for backwards compatibility. + pub fn new(max_ttl: Duration, audit: AuditLogger) -> Self { Self { grants: Mutex::new(HashMap::new()), loopback_keys: Mutex::new(HashMap::new()), attached: Mutex::new(HashMap::new()), - ttl, + max_ttl, audit, } } - pub fn ttl(&self) -> Duration { - self.ttl + pub fn max_ttl(&self) -> Duration { + self.max_ttl } // -- grants ------------------------------------------------------------- @@ -306,12 +310,29 @@ impl ToolsHub { /// keeps its socket: renewal is "new secret, same connection", so a Mac that /// redials after the old secret expires is not evicted mid-grant. pub fn mint(&self, session: &SessionName) -> Result { - if self.ttl.is_zero() { + self.mint_with_ttl(session, DEFAULT_TOOLS_ATTACH_TTL.min(self.max_ttl)) + } + + /// Mint with the admin caller's requested lifetime. Refuse rather than cap: + /// a silent cap is exactly how a 12-hour Connect lease used to become one + /// hour while every layer reported success. + pub fn mint_with_ttl( + &self, + session: &SessionName, + ttl: Duration, + ) -> Result { + if ttl.is_zero() { return Err(Error::Other("tools attach TTL must be non-zero".into())); } + if ttl > self.max_ttl { + return Err(Error::Other(format!( + "tools attach TTL exceeds configured maximum of {} seconds", + self.max_ttl.as_secs() + ))); + } let encoded = random_hex()?; let hash = sha256(&encoded); - let expires_at = Instant::now() + self.ttl; + let expires_at = Instant::now() + ttl; self.grants .lock() .insert(session.clone(), Grant { hash, expires_at }); @@ -319,7 +340,7 @@ impl ToolsHub { AuditEvent::new(AuditKind::ToolsGrantMinted) .session_name(session) .fingerprint(hash_fingerprint(&hash)) - .detail(format!("ttl_secs={}", self.ttl.as_secs())), + .detail(format!("ttl_secs={}", ttl.as_secs())), ); Ok(MintedToolsAttach { plaintext: SecretBytes::new(encoded), @@ -859,12 +880,40 @@ mod tests { hub.verify(&s, &mut again, "test").unwrap(); } + #[test] + fn mint_uses_one_hour_by_default_and_honors_a_requested_ttl() { + let hub = ToolsHub::new(DEFAULT_TOOLS_ATTACH_MAX_TTL, AuditLogger); + let before = Instant::now(); + let defaulted = hub.mint(&session("defaulted")).unwrap(); + let default_lifetime = defaulted.expires_at.duration_since(before); + assert!(default_lifetime >= Duration::from_secs(3599)); + assert!(default_lifetime <= Duration::from_secs(3601)); + + let before = Instant::now(); + let requested = hub + .mint_with_ttl(&session("requested"), Duration::from_secs(2 * 60 * 60)) + .unwrap(); + let requested_lifetime = requested.expires_at.duration_since(before); + assert!(requested_lifetime >= Duration::from_secs(7199)); + assert!(requested_lifetime <= Duration::from_secs(7201)); + } + + #[test] + fn mint_refuses_zero_and_over_the_operator_maximum_instead_of_capping() { + let hub = ToolsHub::new(Duration::from_secs(24 * 60 * 60), AuditLogger); + assert!(hub.mint_with_ttl(&session("zero"), Duration::ZERO).is_err()); + assert!(hub + .mint_with_ttl(&session("over"), Duration::from_secs(24 * 60 * 60 + 1),) + .is_err()); + } + #[test] fn a_secret_for_session_a_does_not_open_session_b() { let hub = ToolsHub::new(Duration::from_secs(60), AuditLogger); let a = session("a"); let b = session("b"); let minted = hub.mint(&a).unwrap(); + hub.mint(&b).unwrap(); let mut presented = SecretBytes::new(minted.plaintext.as_bytes().to_vec()); assert!(hub.verify(&b, &mut presented, "test").is_err()); diff --git a/runtime/tests/tools_e2e.rs b/runtime/tests/tools_e2e.rs index e09ea95..548b1c3 100644 --- a/runtime/tests/tools_e2e.rs +++ b/runtime/tests/tools_e2e.rs @@ -138,14 +138,18 @@ tools_attach_ttl = "1h" body["token"].as_str().unwrap().to_owned() } + async fn mint_tools_response(&self, name: &str, ttl_secs: Option) -> (u16, Value) { + let body = ttl_secs.map(|ttl| format!(r#"{{"ttl_secs":{ttl}}}"#)); + self.admin( + "POST", + &format!("/admin/sessions/{name}/tools-attach"), + body.as_deref(), + ) + .await + } + async fn mint_tools(&self, name: &str) -> String { - let (status, body) = self - .admin( - "POST", - &format!("/admin/sessions/{name}/tools-attach"), - None, - ) - .await; + let (status, body) = self.mint_tools_response(name, None).await; assert_eq!(status, 201, "tools mint failed: {body}"); assert!(body["verifier"].as_str().unwrap().starts_with("sha256:")); body["secret"].as_str().unwrap().to_owned() @@ -388,6 +392,30 @@ async fn fake_mac( // Tests // --------------------------------------------------------------------------- +#[tokio::test] +#[ignore = "binds sockets and spawns a PTY child"] +async fn tools_mint_honors_requested_ttl_and_rejects_invalid_values() { + let h = Harness::start(Duration::from_secs(24 * 60 * 60)).await; + h.create("lease").await; + + let (status, defaulted) = h.mint_tools_response("lease", None).await; + assert_eq!(status, 201, "{defaulted}"); + assert_eq!(defaulted["ttl_secs"], json!(3600)); + assert!(defaulted["expires_in_secs"].as_u64().unwrap() >= 3599); + + let (status, four_hours) = h.mint_tools_response("lease", Some(4 * 60 * 60)).await; + assert_eq!(status, 201, "{four_hours}"); + assert_eq!(four_hours["ttl_secs"], json!(4 * 60 * 60)); + assert!(four_hours["expires_in_secs"].as_u64().unwrap() >= 4 * 60 * 60 - 1); + + for invalid in [0, 24 * 60 * 60 + 1] { + let (status, body) = h.mint_tools_response("lease", Some(invalid)).await; + assert_eq!(status, 400, "invalid ttl {invalid} was accepted: {body}"); + assert!(body["error"].as_str().unwrap().contains("ttl_secs")); + } + h.shutdown().await; +} + #[tokio::test] #[ignore = "binds sockets and spawns a PTY child"] async fn the_shell_reaches_a_dialled_in_mac_through_its_loopback_url() {