diff --git a/docs/linux-setup.md b/docs/linux-setup.md index 16cc951..4a681e8 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -287,8 +287,12 @@ sudo tailscale serve --https=8444 off # stop exposing WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) wlr-randr # outputs the tools see ``` -Restarting the daemon drops every grant (registry is in memory); the runtime notices the socket -close and the agent's `instance_status` says nothing is attached until you `POST /attach` again. +Grants survive a daemon restart (#12): live grants are kept in +`~/.local/state/oab-instance-mcp/grants.json` (mode 600; override with `MCP_GRANTS_FILE`, or +`MCP_GRANTS_FILE=off` to disable), and on start every grant still inside its deadline is re-dialled +under its original id — `journalctl --user -u oab-instance-mcp` shows `grants: resuming `. The +file holds the attach secrets, at the same trust level as the bearer token file. A grant whose +runtime has forgotten it (pod replaced) ends with `handshake_rejected_401`; lend again. ## Headless server notes (Ubuntu 24.04 on `black`) diff --git a/poc/reverse-attach-linux/README.md b/poc/reverse-attach-linux/README.md index f19a040..3c4f61e 100644 --- a/poc/reverse-attach-linux/README.md +++ b/poc/reverse-attach-linux/README.md @@ -106,6 +106,14 @@ next call re-initializes and still lists 37. Lent to `kiro-1040` session `mac` ( `browser_snapshot` → `heading "Example Domain"`, `link "Learn more"`. The Chromium window is on rpi1's desktop, so `screenshot` / Connect's Screens pane show what the agent is doing. +## Grant persistence (#12) + +Live grants are written to `MCP_GRANTS_FILE` (default `$XDG_STATE_HOME/oab-instance-mcp/grants.json`, +`off` disables), mode 600 in a 0700 directory, replaced atomically on every create / replace / +`DELETE` / terminal end. On start, grants still inside their deadline are re-dialled under the same +id. Smoke covers `kill -9` → restart → redial, same id, secret absent from `GET /attach`, and a +revoked grant not resumed. + ## Not yet (vs the Swift implementation) - `/mcp` has no real session table (an `Mcp-Session-Id` is issued but not checked) and no SSE stream. diff --git a/poc/reverse-attach-linux/smoke.sh b/poc/reverse-attach-linux/smoke.sh index ae69ed1..bfdbf3e 100755 --- a/poc/reverse-attach-linux/smoke.sh +++ b/poc/reverse-attach-linux/smoke.sh @@ -13,6 +13,9 @@ check(){ if eval "$2"; then ok "$1"; else bad "$1 :: $2"; fi; } pkill -x reverse-attach 2>/dev/null; pkill -f mock_runtime.py 2>/dev/null; sleep 0.3 rm -f "$LOG" "$RA_LOG" +# Never touch the real ~/.local/state grants file from a test run. +GRANTS_DIR=$(mktemp -d) +export MCP_GRANTS_FILE="$GRANTS_DIR/grants.json" # 1000 → redial, then 4010 → stop(revoked). Third attach (if any) would be 4010 again. PORT=18090 ADMIN=admin-secret CLOSES=1000,4010 SECRETS=pre=preminted-xyz LOG=$LOG \ @@ -21,7 +24,7 @@ MOCK=$! BIND=127.0.0.1:8790 MCP_INSECURE_LOCAL=1 MCP_UPSTREAM=browser=http://127.0.0.1:1/mcp $BIN > "$RA_LOG" 2>&1 & RA=$! sleep 0.5 -trap 'kill $MOCK $RA 2>/dev/null' EXIT +trap 'kill $MOCK $RA 2>/dev/null; rm -rf "$GRANTS_DIR"' EXIT C="curl -s -m 5" state_of() { python3 -c 'import sys,json;print([g["state"] for g in json.load(sys.stdin)["grants"] if g["session"]==sys.argv[1]][0])' "$1"; } @@ -129,6 +132,37 @@ sleep 5.5 st=$($C 127.0.0.1:8790/attach) check "unreachable runtime ends at deadline" "[[ \$(echo '$st' | state_of dead) == ended && \$(echo '$st' | ended_of dead) == deadline ]]" + +echo "== grants survive a restart (#12) ==" +kill $RA $MOCK 2>/dev/null; wait $RA $MOCK 2>/dev/null +rm -f "$MCP_GRANTS_FILE" "$LOG" +# CLOSES=1000: the mock closes after each scripted turn and the node redials, so the +# grant stays live and every (re)attach shows up as a new "attach" event. +PORT=18090 ADMIN=admin-secret CLOSES=1000 SECRETS=keep=k1 LOG=$LOG \ + python3 mock_runtime.py > /tmp/mock-runtime.out 2>&1 & +MOCK=$! +start_ra() { BIND=127.0.0.1:8790 MCP_INSECURE_LOCAL=1 $BIN >> "$RA_LOG" 2>&1 & RA=$!; sleep 0.5; } +attaches() { grep -c '"ev": "attach", "session": "keep", "status": 101' $LOG 2>/dev/null || echo 0; } +start_ra +r=$($C -X POST 127.0.0.1:8790/attach -d '{"runtime":"ws://127.0.0.1:18090","session":"keep","profile":"sandbox","ttl_secs":120,"secret":"k1"}') +GID=$(echo "$r" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])') +for i in $(seq 1 20); do [[ $(attaches) -ge 1 ]] && break; sleep 0.3; done +check "grants file written with mode 600" "[[ \$(stat -c %a \"$MCP_GRANTS_FILE\" 2>/dev/null || stat -f %Lp \"$MCP_GRANTS_FILE\") == 600 ]]" +check "grants file holds the grant" "grep -q \"$GID\" \"$MCP_GRANTS_FILE\"" +check "secret never appears in GET /attach" "! $C 127.0.0.1:8790/attach | grep -q k1" +before=$(attaches) +kill -9 $RA 2>/dev/null; wait $RA 2>/dev/null +start_ra +for i in $(seq 1 30); do [[ $(attaches) -gt $before ]] && break; sleep 0.3; done +check "after kill -9 + restart the node redialled on its own" "[[ \$(attaches) -gt $before ]]" +check "resumed under the same grant id" "$C 127.0.0.1:8790/attach | grep -q \"$GID\"" +check "restart logged the resume" "grep -q \"grants: resuming $GID\" $RA_LOG" +$C -o /dev/null -X DELETE 127.0.0.1:8790/attach/$GID +check "DELETE removes it from the file" "! grep -q \"$GID\" \"$MCP_GRANTS_FILE\"" +kill $RA 2>/dev/null; wait $RA 2>/dev/null +start_ra +check "a revoked grant is not resumed" "[[ \$($C 127.0.0.1:8790/attach | python3 -c 'import sys,json;print(len(json.load(sys.stdin)[\"grants\"]))') == 0 ]]" + echo echo "reverse-attach stderr:"; sed 's/^/ /' $RA_LOG echo "RESULT: $pass passed, $fail failed" diff --git a/poc/reverse-attach-linux/src/attach/mod.rs b/poc/reverse-attach-linux/src/attach/mod.rs index cb84b2c..4a6ca60 100644 --- a/poc/reverse-attach-linux/src/attach/mod.rs +++ b/poc/reverse-attach-linux/src/attach/mod.rs @@ -8,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; pub mod client; +pub mod store; // Shared grant registry // --------------------------------------------------------------------------- @@ -23,6 +24,9 @@ pub(crate) struct GrantInfo { pub(crate) ended: Option, pub(crate) expires_at_epoch_secs: u64, pub(crate) cancelled: Arc, + /// The attach secret this grant dials with. Held so the grant can be + /// persisted and resumed after a restart (#12); never in `grant_json`. + pub(crate) secret: String, } pub(crate) type Registry = Arc>>; @@ -57,6 +61,8 @@ pub(crate) fn set_ended(registry: &Registry, grant_id: &str, reason: &str) { g.ended = Some(reason.to_string()); } } + // A terminal grant must not be resumed after a restart. + store::save(registry); } /// Exact Swift `MacGrant` shape consumed by OpenAB Connect/Remote. Fields not diff --git a/poc/reverse-attach-linux/src/attach/store.rs b/poc/reverse-attach-linux/src/attach/store.rs new file mode 100644 index 0000000..f9ee0b9 --- /dev/null +++ b/poc/reverse-attach-linux/src/attach/store.rs @@ -0,0 +1,303 @@ +//! Grants survive a daemon restart (#12). +//! +//! Live grants are written to a mode-600 JSON file so a restart (deploy, crash, +//! `systemctl --user restart`) re-dials every grant whose deadline has not passed, +//! with its original id — instead of the human having to lend again. +//! +//! What is stored: id, runtime, session, profile, principal, absolute expiry and +//! the attach **secret** the node dials with. The secret is TTL-bounded and sits +//! at the same trust level as the bearer token file beside it (both 600 in the +//! daemon user's home). Ended, cancelled and expired grants are never written. +//! +//! Path: `MCP_GRANTS_FILE`, else `$XDG_STATE_HOME/oab-instance-mcp/grants.json`, +//! else `~/.local/state/oab-instance-mcp/grants.json`. `MCP_GRANTS_FILE=off` +//! disables persistence. + +use std::fs; +use std::io::Write; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use serde_json::{json, Value}; + +use super::{now_epoch_secs, GrantInfo, Registry}; + +const VERSION: u64 = 1; + +/// A grant as persisted: everything needed to resume dialling it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Persisted { + pub(crate) id: String, + pub(crate) runtime: String, + pub(crate) session: String, + pub(crate) profile: String, + pub(crate) principal: String, + pub(crate) expires_at_epoch_secs: u64, + pub(crate) secret: String, +} + +pub(crate) struct GrantStore { + path: PathBuf, + /// Serialises writers; the file is always replaced whole. + write: Mutex<()>, +} + +static STORE: OnceLock> = OnceLock::new(); + +/// Configure persistence once at startup. Returns the path in use, if any. +pub(crate) fn init_from_env() -> Option { + let store = STORE.get_or_init(|| default_path().map(GrantStore::new)); + store.as_ref().map(|s| s.path.clone()) +} + +fn default_path() -> Option { + match std::env::var("MCP_GRANTS_FILE") { + Ok(v) if v == "off" => return None, + Ok(v) if !v.is_empty() => return Some(PathBuf::from(v)), + _ => {} + } + let base = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?; + Some(base.join("oab-instance-mcp/grants.json")) +} + +/// Snapshot the registry's live grants to disk. No-op when persistence is off. +/// Errors are logged, never fatal: losing persistence must not stop lending. +pub(crate) fn save(registry: &Registry) { + let Some(Some(store)) = STORE.get() else { + return; + }; + let grants: Vec = match registry.lock() { + Ok(map) => map.values().filter_map(persistable).collect(), + Err(_) => return, + }; + if let Err(e) = store.write(&grants) { + eprintln!("grants: could not persist to {}: {e}", store.path.display()); + } +} + +/// Load grants that are still within their deadline. Empty when persistence is +/// off or the file is absent/unreadable (the reason is logged). +pub(crate) fn load() -> Vec { + let Some(Some(store)) = STORE.get() else { + return Vec::new(); + }; + match store.read() { + Ok(grants) => grants, + Err(e) => { + eprintln!("grants: ignoring {}: {e}", store.path.display()); + Vec::new() + } + } +} + +fn persistable(g: &GrantInfo) -> Option { + let live = g.state != "ended" + && !g.cancelled.load(std::sync::atomic::Ordering::Acquire) + && g.expires_at_epoch_secs > now_epoch_secs(); + live.then(|| Persisted { + id: g.id.clone(), + runtime: g.runtime.clone(), + session: g.session.clone(), + profile: g.profile.clone(), + principal: g.principal.clone(), + expires_at_epoch_secs: g.expires_at_epoch_secs, + secret: g.secret.clone(), + }) +} + +impl GrantStore { + pub(crate) fn new(path: PathBuf) -> Self { + Self { + path, + write: Mutex::new(()), + } + } + + pub(crate) fn write(&self, grants: &[Persisted]) -> std::io::Result<()> { + let _guard = self.write.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(dir) = self.path.parent() { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + } + let body = json!({ + "version": VERSION, + "grants": grants.iter().map(|g| json!({ + "id": g.id, + "runtime": g.runtime, + "session": g.session, + "profile": g.profile, + "principal": g.principal, + "expires_at_epoch_secs": g.expires_at_epoch_secs, + "secret": g.secret, + })).collect::>(), + }); + let tmp = tmp_path(&self.path); + let _ = fs::remove_file(&tmp); + { + let mut f = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)?; + f.write_all(body.to_string().as_bytes())?; + f.sync_all()?; + } + fs::rename(&tmp, &self.path) + } + + pub(crate) fn read(&self) -> Result, String> { + let meta = match fs::metadata(&self.path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.to_string()), + }; + // The file holds attach secrets. If something widened it, narrow it back + // before trusting it, and say so. + let mode = meta.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + eprintln!( + "grants: {} was mode {mode:o}; resetting to 600", + self.path.display() + ); + fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600)) + .map_err(|e| e.to_string())?; + } + let text = fs::read_to_string(&self.path).map_err(|e| e.to_string())?; + parse(&text, now_epoch_secs()) + } +} + +fn tmp_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(".tmp"); + path.with_file_name(name) +} + +/// Parse a grants file, keeping only well-formed grants still inside their +/// deadline at `now`. +pub(crate) fn parse(text: &str, now: u64) -> Result, String> { + let doc: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?; + if doc["version"].as_u64() != Some(VERSION) { + return Err(format!("unsupported version {}", doc["version"])); + } + let s = |g: &Value, k: &str| g[k].as_str().map(String::from); + Ok(doc["grants"] + .as_array() + .map(|a| a.as_slice()) + .unwrap_or_default() + .iter() + .filter_map(|g| { + Some(Persisted { + id: s(g, "id")?, + runtime: s(g, "runtime")?, + session: s(g, "session")?, + profile: s(g, "profile")?, + principal: s(g, "principal")?, + expires_at_epoch_secs: g["expires_at_epoch_secs"].as_u64()?, + secret: s(g, "secret")?, + }) + }) + .filter(|g| g.expires_at_epoch_secs > now) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grant(id: &str, expires: u64) -> Persisted { + Persisted { + id: id.into(), + runtime: "ws://127.0.0.1:1".into(), + session: "s".into(), + profile: "sandbox".into(), + principal: "you@example.com".into(), + expires_at_epoch_secs: expires, + secret: "sec".into(), + } + } + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ra-store-{name}-{}-{}", + std::process::id(), + now_epoch_secs() + )); + let _ = fs::remove_dir_all(&dir); + dir.join("sub/grants.json") + } + + #[test] + fn round_trips_with_owner_only_permissions() { + let path = scratch("rt"); + let store = GrantStore::new(path.clone()); + let later = now_epoch_secs() + 3600; + store + .write(&[grant("a", later), grant("b", later)]) + .unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + store.read().unwrap(), + vec![grant("a", later), grant("b", later)] + ); + // Replacing is whole-file and leaves no temp file behind. + store.write(&[grant("b", later)]).unwrap(); + assert_eq!(store.read().unwrap(), vec![grant("b", later)]); + assert!(!tmp_path(&path).exists()); + let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap()); + } + + #[test] + fn expired_and_malformed_grants_are_dropped_on_load() { + let now = 1_000_000; + let text = json!({"version": 1, "grants": [ + {"id":"live","runtime":"ws://x","session":"s","profile":"owner","principal":"p","expires_at_epoch_secs": now + 5,"secret":"k"}, + {"id":"expired","runtime":"ws://x","session":"s","profile":"owner","principal":"p","expires_at_epoch_secs": now,"secret":"k"}, + {"id":"no-secret","runtime":"ws://x","session":"s","profile":"owner","principal":"p","expires_at_epoch_secs": now + 5} + ]}) + .to_string(); + let got = parse(&text, now).unwrap(); + assert_eq!( + got.iter().map(|g| g.id.as_str()).collect::>(), + vec!["live"] + ); + assert!(parse("{\"version\":2,\"grants\":[]}", now).is_err()); + assert!(parse("not json", now).is_err()); + } + + #[test] + fn a_widened_file_is_narrowed_before_it_is_read() { + let path = scratch("mode"); + let store = GrantStore::new(path.clone()); + store.write(&[grant("a", now_epoch_secs() + 60)]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(store.read().unwrap().len(), 1); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap()); + } + + #[test] + fn a_missing_file_is_simply_empty() { + let store = GrantStore::new(scratch("missing")); + assert!(store.read().unwrap().is_empty()); + } +} diff --git a/poc/reverse-attach-linux/src/http.rs b/poc/reverse-attach-linux/src/http.rs index a579521..ca215f7 100644 --- a/poc/reverse-attach-linux/src/http.rs +++ b/poc/reverse-attach-linux/src/http.rs @@ -11,6 +11,7 @@ use std::time::Duration; use serde_json::{json, Value}; use crate::attach::client::{dial_loop, mint, DialGrant}; +use crate::attach::store; use crate::attach::{ grant_json, new_grant_id, now_epoch_secs, valid_session, GrantInfo, Registry, GRANT_COUNTER, }; @@ -146,6 +147,10 @@ pub(crate) fn parse_mcp_body(body: &str) -> Result { pub fn serve() { let bind = std::env::var("BIND").unwrap_or_else(|_| "127.0.0.1:8790".to_string()); let registry: Registry = Arc::new(Mutex::new(HashMap::new())); + match store::init_from_env() { + Some(path) => eprintln!("grants: persisted at {}", path.display()), + None => eprintln!("grants: persistence off (MCP_GRANTS_FILE=off)"), + } let policy: Arc = match AuthPolicy::from_env() { Ok(p) => Arc::new(p), Err(e) => { @@ -170,6 +175,7 @@ pub fn serve() { } }; eprintln!("reverse-attach control server listening on {bind}"); + resume_grants(®istry); for stream in listener.incoming() { match stream { @@ -374,6 +380,87 @@ fn write_response(stream: &mut TcpStream, status: u16, reason: &str, body: &str) let _ = stream.flush(); } +/// Start the dial loop for one grant on its own thread. +#[allow(clippy::too_many_arguments)] +fn spawn_dial( + registry: &Registry, + grant_id: String, + runtime: String, + session: String, + profile: String, + secret: String, + deadline_epoch_secs: u64, + cancelled: Arc, +) { + let registry = registry.clone(); + thread::spawn(move || { + dial_loop(DialGrant { + runtime, + session, + secret, + profile, + deadline_epoch_secs, + registry, + grant_id, + cancelled, + }); + }); +} + +/// Re-dial every persisted grant still inside its deadline, under its original +/// id (#12). A runtime that has since forgotten the grant (pod replaced) answers +/// the handshake with 401 and the grant ends through the normal disposition. +fn resume_grants(registry: &Registry) { + let grants = store::load(); + if grants.is_empty() { + return; + } + if let Ok(mut map) = registry.lock() { + for g in &grants { + map.insert( + g.id.clone(), + GrantInfo { + id: g.id.clone(), + runtime: g.runtime.clone(), + session: g.session.clone(), + profile: g.profile.clone(), + principal: g.principal.clone(), + state: "idle".to_string(), + ended: None, + expires_at_epoch_secs: g.expires_at_epoch_secs, + cancelled: Arc::new(AtomicBool::new(false)), + secret: g.secret.clone(), + }, + ); + } + } + for g in grants { + eprintln!( + "grants: resuming {} (session {}, {}s left)", + g.id, + g.session, + g.expires_at_epoch_secs.saturating_sub(now_epoch_secs()) + ); + let cancelled = registry + .lock() + .ok() + .and_then(|m| m.get(&g.id).map(|x| x.cancelled.clone())) + .unwrap_or_default(); + spawn_dial( + registry, + g.id, + g.runtime, + g.session, + g.profile, + g.secret, + g.expires_at_epoch_secs, + cancelled, + ); + } + // Rewrite without whatever expired while we were down. + store::save(registry); +} + fn handle_conn( mut stream: TcpStream, registry: Registry, @@ -544,28 +631,22 @@ fn handle_attach( ended: None, expires_at_epoch_secs: deadline, cancelled: cancelled.clone(), + secret: effective_secret.clone(), }, ); } - - // Spawn the dial loop thread. - let reg = registry.clone(); - let runtime_owned = runtime.to_string(); - let session_owned = session.to_string(); - let profile_owned = profile.clone(); - let gid = grant_id.clone(); - thread::spawn(move || { - dial_loop(DialGrant { - runtime: runtime_owned, - session: session_owned, - secret: effective_secret, - profile: profile_owned, - deadline_epoch_secs: deadline, - registry: reg, - grant_id: gid, - cancelled, - }); - }); + store::save(®istry); + + spawn_dial( + ®istry, + grant_id.clone(), + runtime.to_string(), + session.to_string(), + profile.clone(), + effective_secret, + deadline, + cancelled, + ); let response = { let map = registry @@ -622,6 +703,7 @@ fn handle_delete_attachment( match removed { Some(grant) => { grant.cancelled.store(true, Ordering::Release); + store::save(®istry); write_raw(stream, 204, "No Content", "text/plain", b"", &[]); } None => write_response(stream, 404, "Not Found", "{\"error\":\"no such grant\"}"),