From 59ccf6c74865fb99ee262b3af4308ed8320e9e83 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Thu, 21 May 2026 01:49:48 +0200 Subject: [PATCH] Add restricted SSH admin shell --- AGENTS.md | 5 +- README.md | 8 +- crates/geth-cli/src/lib.rs | 38 +++++ crates/geth-control/src/lib.rs | 106 ++++++++++++ crates/geth-node/src/lib.rs | 195 ++++++++++++++++++++++- crates/geth-ssh-proxy/src/lib.rs | 2 +- docs/adr/0009-ssh-trust-and-ssh-proxy.md | 6 +- docs/architecture.md | 8 +- docs/roadmap.md | 4 +- 9 files changed, 358 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ca1017..bd70982 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,10 @@ Roadmap items should be actionable and checkable: CLI streams through the local daemon, the daemon uses `/geth/ssh-proxy/1` over Iroh, the remote daemon requires `ssh_proxy.connect` on `resource:ssh-proxy:local`, and only then connects to `127.0.0.1:22`. SSH is - still not a geth transport backend. + still not a geth transport backend. `geth ssh admin-shell + ` is a restricted geth admin workflow over the protected + Iroh control path, requires `ssh_proxy.admin_shell`, and never executes host + shell commands. - Resource secret epoch metadata can be created, rotated, and listed locally. Bearer access metadata can be created/listed/revoked as resource-scoped auth ops and must not allow trust graph mutation capabilities. Bearer diff --git a/README.md b/README.md index 729344d..bf5c5a8 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,9 @@ The bootstrap implementation provides: - `geth ssh revocation export --out [--format jsonl|openssh-krl-spec|openssh-krl] [--subject ]` - `geth ssh revocation import [--format jsonl|openssh-krl-spec] [--subject ]` - `geth ssh revocation sync [--bearer-secret ]` -- SSH proxy over Iroh: `geth ssh proxy [--bearer-secret ]` +- SSH proxy/admin over Iroh: + `geth ssh proxy [--bearer-secret ]` and + `geth ssh admin-shell [--bearer-secret ]` - pipe registry/message commands: `geth pipe listen [--node ] [--bearer-secret ]`, `geth pipe connect [--node ] [--bearer-secret ]`, @@ -242,6 +244,10 @@ ALPN, the remote daemon validates the caller's endpoint/card binding and requires `ssh_proxy.connect` on `resource:ssh-proxy:local`, and only then connects the stream to `127.0.0.1:22`. SSH remains normal OpenSSH on top of that byte stream; SSH is not a geth transport backend. +`geth ssh admin-shell ` is a restricted geth admin workflow +over the protected Iroh control path. It requires `ssh_proxy.admin_shell` on the +same resource and supports only built-in commands (`help`, `status`, `node-id`); +it does not execute host shell commands. Document sync is a bootstrap JSON last-writer-wins path before Automerge: manual `geth document sync ` and background live-sync require `document.read` on `resource:document:` and import only state that is not diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 275723e..8ed762d 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -500,6 +500,12 @@ pub enum SshCommand { #[arg(long)] bearer_secret: Option, }, + AdminShell { + node: String, + command: String, + #[arg(long)] + bearer_secret: Option, + }, Cert { #[command(subcommand)] command: SshCertCommand, @@ -1050,6 +1056,15 @@ fn request_for_command(command: Command) -> Result { node, bearer_secret, }, + SshCommand::AdminShell { + node, + command, + bearer_secret, + } => ControlRequest::SshAdminShell { + node, + command, + bearer_secret, + }, SshCommand::Cert { command } => match command { SshCertCommand::Request { public_key, @@ -2209,6 +2224,29 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("reason: {reason}"); println!("note: {note}"); } + ControlResponse::SshAdminShellOutput { + peer_node_id, + peer_agent_id, + endpoint_id, + command, + output, + allowed, + reason, + note, + } => { + if allowed { + println!("{output}"); + } else { + println!("ssh admin shell denied by {peer_node_id}"); + } + println!("command: {command}"); + println!("peer: {peer_node_id}"); + println!("agent: {peer_agent_id}"); + println!("endpoint: {endpoint_id}"); + println!("allowed: {allowed}"); + println!("reason: {reason}"); + println!("note: {note}"); + } ControlResponse::NotImplemented { module, command } => { println!("{module} {command}: not implemented yet"); } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index d2aea31..0fc28fd 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -236,6 +236,11 @@ pub enum ControlRequest { node: String, bearer_secret: Option, }, + SshAdminShell { + node: String, + command: String, + bearer_secret: Option, + }, DbAdd { name: String, path: PathBuf, @@ -724,6 +729,16 @@ pub enum ControlResponse { reason: String, note: String, }, + SshAdminShellOutput { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + command: String, + output: String, + allowed: bool, + reason: String, + note: String, + }, NotImplemented { module: String, command: String, @@ -857,6 +872,12 @@ pub enum PeerControlRequest { nonce: String, bearer_proof: Option, }, + SshAdminShell { + peer_card: PeerCard, + command: String, + nonce: String, + bearer_proof: Option, + }, DocumentSync { peer_card: PeerCard, name: String, @@ -1038,6 +1059,19 @@ pub enum PeerControlResponse { nonce: String, note: String, }, + SshAdminShellOutput { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + command: String, + output: String, + allowed: bool, + reason: String, + evaluated_ops: usize, + nonce: String, + note: String, + }, DocumentSynced { node_id: String, agent_id: String, @@ -1571,6 +1605,16 @@ mod tests { request ); + let request = ControlRequest::SshAdminShell { + node: "node:peer".to_owned(), + command: "status".to_owned(), + bearer_secret: None, + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let response = ControlResponse::SshProxyConnected { peer_node_id: "node:peer".to_owned(), peer_agent_id: "agent:peer".to_owned(), @@ -1591,6 +1635,21 @@ mod tests { response ); + let response = ControlResponse::SshAdminShellOutput { + peer_node_id: "node:peer".to_owned(), + peer_agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + command: "status".to_owned(), + output: "node_id=node:peer".to_owned(), + allowed: true, + reason: "direct grant".to_owned(), + note: "restricted admin shell".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let request = ControlRequest::DbChanges { name: "notes".to_owned(), after_db_version: Some(7), @@ -2039,6 +2098,34 @@ mod tests { request ); + let request = PeerControlRequest::SshAdminShell { + peer_card: PeerCard { + node_id: "node:caller".into(), + agent_id: "agent:caller".into(), + endpoints: Vec::new(), + issued_at: geth_types::UnixMillis(1), + signature: geth_discovery::SignatureMetadata { + namespace: "geth.peer-card.v1@geth.local".to_owned(), + signer: "agent:caller".to_owned(), + public_key: "key".to_owned(), + signature: "sig".to_owned(), + }, + }, + command: "status".to_owned(), + nonce: "nonce".to_owned(), + bearer_proof: Some(BearerProof { + secret: "bearer:test".into(), + resource: "resource:ssh-proxy:local".into(), + capabilities: vec!["ssh_proxy.admin_shell".into()], + nonce: "nonce".to_owned(), + response: "response".to_owned(), + }), + }; + assert_eq!( + decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"), + request + ); + let request = PeerControlRequest::PipeListen { peer_card: PeerCard { node_id: "node:caller".into(), @@ -2209,6 +2296,25 @@ mod tests { response ); + let response = PeerControlResponse::SshAdminShellOutput { + node_id: "node:peer".to_owned(), + agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + remote_endpoint_id: "endpoint:caller".to_owned(), + command: "status".to_owned(), + output: "node_id=node:peer".to_owned(), + allowed: true, + reason: "direct grant".to_owned(), + evaluated_ops: 1, + nonce: "nonce".to_owned(), + note: "restricted admin shell".to_owned(), + }; + assert_eq!( + decode_peer_response(&encode_peer_response(&response).expect("encode")) + .expect("decode"), + response + ); + let response = PeerControlResponse::DbSynced { node_id: "node:peer".to_owned(), agent_id: "agent:peer".to_owned(), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 49ceeac..807a6f1 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -552,6 +552,11 @@ pub async fn handle_request_async( node: peer_node, bearer_secret, } => ssh_proxy_connect_to_peer(node, &peer_node, bearer_secret).await, + ControlRequest::SshAdminShell { + node: peer_node, + command, + bearer_secret, + } => ssh_admin_shell_to_peer(node, &peer_node, command, bearer_secret).await, ControlRequest::DocumentSync { node: peer_node, name, @@ -869,6 +874,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result Err(NodeError::IrohPeer( "peer returned wrong response type to ping request".to_owned(), @@ -986,6 +992,7 @@ async fn peer_auth_check( | PeerControlResponse::PipeConnected { .. } | PeerControlResponse::PipeListening { .. } | PeerControlResponse::SshProxyConnected { .. } + | PeerControlResponse::SshAdminShellOutput { .. } | PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to auth-check request".to_owned(), @@ -1135,6 +1142,7 @@ async fn cas_fetch_from_peer( | PeerControlResponse::PipeConnected { .. } | PeerControlResponse::PipeListening { .. } | PeerControlResponse::SshProxyConnected { .. } + | PeerControlResponse::SshAdminShellOutput { .. } | PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to CAS fetch".to_owned(), @@ -2114,6 +2122,54 @@ async fn ssh_proxy_connect_to_peer( } } +async fn ssh_admin_shell_to_peer( + node: &LocalNode, + peer_node: &str, + command: String, + bearer_secret: Option, +) -> Result { + let response = request_peer_control(node, peer_node, "ssh-admin-shell", |peer_card, nonce| { + PeerControlRequest::SshAdminShell { + peer_card, + command: command.clone(), + nonce: nonce.clone(), + bearer_proof: bearer_proof( + bearer_secret, + "resource:ssh-proxy:local", + "ssh_proxy.admin_shell", + &nonce, + ), + } + }) + .await?; + match response { + PeerControlResponse::SshAdminShellOutput { + node_id, + agent_id, + endpoint_id, + command, + output, + allowed, + reason, + note, + .. + } => Ok(ControlResponse::SshAdminShellOutput { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + command, + output, + allowed, + reason, + note, + }), + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + _ => Err(NodeError::IrohPeer( + "peer returned wrong response type to SSH admin shell".to_owned(), + )), + } +} + async fn handle_local_ssh_proxy_stream( node: LocalNode, peer_node: &str, @@ -3706,7 +3762,7 @@ async fn handle_iroh_control_connection( target_node: NodeId::new(node.node_id.clone()), connected_at: UnixMillis(geth_store::now_ms()), local_sshd_target: Some("127.0.0.1:22".to_owned()), - admin_shell_available: false, + admin_shell_available: true, note: "authorized SSH proxy metadata check passed; run geth ssh proxy for the dedicated byte stream".to_owned(), }) } else { @@ -3725,6 +3781,54 @@ async fn handle_iroh_control_connection( note: "SSH proxy authenticated endpoint/card binding and required ssh_proxy.connect on resource:ssh-proxy:local; SSH is not a geth transport, and byte proxying uses the dedicated /geth/ssh-proxy/1 stream".to_owned(), } } + PeerControlRequest::SshAdminShell { + peer_card, + command, + nonce, + bearer_proof, + } => { + peer_card.validate_candidate()?; + ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; + let discovered = DiscoveredPeer::candidate( + peer_card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::PeerExchange, + )?; + let store = Store::open(&node.paths.metadata_db())?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: peer_card.node_id.to_string(), + card_json: serde_json::to_string(&peer_card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + let resource = "resource:ssh-proxy:local".to_owned(); + let capability = "ssh_proxy.admin_shell".to_owned(); + let explanation = explain_peer_or_bearer( + &store, + peer_card.node_id.as_str(), + &resource, + &capability, + &nonce, + bearer_proof.as_ref(), + )?; + let output = if explanation.allowed { + restricted_admin_shell_output(&node, &command)? + } else { + String::new() + }; + PeerControlResponse::SshAdminShellOutput { + node_id: node.node_id.clone(), + agent_id: node.agent_id.clone(), + endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), + remote_endpoint_id, + command, + output, + allowed: explanation.allowed, + reason: explanation.reason, + evaluated_ops: explanation.evaluated_ops, + nonce, + note: "restricted geth admin shell authenticated endpoint/card binding and required ssh_proxy.admin_shell; no host shell commands are executed".to_owned(), + } + } PeerControlRequest::DocumentSync { peer_card, name, @@ -3925,7 +4029,7 @@ async fn handle_ssh_proxy_wire_connection( target_node: node.node_id.clone().into(), connected_at: UnixMillis(geth_store::now_ms()), local_sshd_target: Some("127.0.0.1:22".to_owned()), - admin_shell_available: false, + admin_shell_available: true, note: "authorized SSH proxy byte stream over Iroh; SSH is not a geth transport" .to_owned(), }) @@ -4410,6 +4514,26 @@ fn ensure_peer_card_matches_endpoint(card: &PeerCard, endpoint_id: &str) -> Resu } } +fn restricted_admin_shell_output(node: &LocalNode, command: &str) -> Result { + match command { + "help" => Ok("commands: help, status, node-id".to_owned()), + "node-id" => Ok(node.node_id.clone()), + "status" => Ok(format!( + "node_id={}\nagent_id={}\nendpoint_id={}\nhome={}", + node.node_id, + node.agent_id, + node.iroh_status + .endpoint_id + .as_deref() + .unwrap_or("unavailable"), + node.paths.home().display() + )), + other => Err(NodeError::IrohPeer(format!( + "unsupported restricted admin shell command {other:?}; supported commands: help, status, node-id" + ))), + } +} + fn display_alpn(alpn: Vec) -> String { String::from_utf8_lossy(&alpn).into_owned() } @@ -5819,9 +5943,9 @@ pub fn handle_request( note: geth_pipe::local_pipe_runtime_note().to_owned(), }) } - ControlRequest::SshProxyConnect { .. } | ControlRequest::SshProxyStream { .. } => { - Err(NodeError::IrohEndpointUnavailable) - } + ControlRequest::SshProxyConnect { .. } + | ControlRequest::SshProxyStream { .. } + | ControlRequest::SshAdminShell { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -7924,6 +8048,30 @@ mod tests { other => panic!("unexpected denied SSH proxy response: {other:?}"), } + let denied_admin_shell = handle_request_async( + &left, + ControlRequest::SshAdminShell { + node: right_card.node_id.to_string(), + command: "status".to_owned(), + bearer_secret: None, + }, + ) + .await + .expect("denied SSH admin shell"); + match denied_admin_shell { + ControlResponse::SshAdminShellOutput { + allowed, + output, + reason, + .. + } => { + assert!(!allowed); + assert!(output.is_empty()); + assert!(reason.contains("no active direct or group grant")); + } + other => panic!("unexpected denied SSH admin shell response: {other:?}"), + } + let denied_document = handle_request_async( &left, ControlRequest::DocumentSync { @@ -8099,6 +8247,16 @@ mod tests { }, ) .expect("grant left ssh proxy connect"); + handle_request( + &right, + ControlRequest::AuthGrant { + subject: left.node_id.clone(), + resource: "resource:ssh-proxy:local".to_owned(), + capability: "ssh_proxy.admin_shell".to_owned(), + grant_id: Some("grant:left-ssh-admin-shell".to_owned()), + }, + ) + .expect("grant left ssh admin shell"); handle_request( &right, ControlRequest::AuthGrant { @@ -8634,6 +8792,33 @@ mod tests { other => panic!("unexpected allowed SSH proxy response: {other:?}"), } + let admin_shell = handle_request_async( + &left, + ControlRequest::SshAdminShell { + node: right_card.node_id.to_string(), + command: "status".to_owned(), + bearer_secret: None, + }, + ) + .await + .expect("allowed SSH admin shell"); + match admin_shell { + ControlResponse::SshAdminShellOutput { + allowed, + output, + reason, + note, + .. + } => { + assert!(allowed); + assert!(output.contains(&format!("node_id={}", right.node_id))); + assert!(output.contains("agent_id=")); + assert!(reason.contains("direct grant")); + assert!(note.contains("no host shell commands")); + } + other => panic!("unexpected allowed SSH admin shell response: {other:?}"), + } + let document_sync = handle_request_async( &left, ControlRequest::DocumentSync { diff --git a/crates/geth-ssh-proxy/src/lib.rs b/crates/geth-ssh-proxy/src/lib.rs index 578c373..6e11a78 100644 --- a/crates/geth-ssh-proxy/src/lib.rs +++ b/crates/geth-ssh-proxy/src/lib.rs @@ -18,5 +18,5 @@ pub struct SshProxyConnection { #[must_use] pub fn ssh_proxy_roadmap() -> &'static str { - "future SSH proxy carries SSH protocol bytes over authorized Iroh streams; SSH is not a geth transport" + "SSH proxy carries SSH protocol bytes over authorized Iroh streams, and the restricted geth admin shell uses built-in control commands; SSH is not a geth transport" } diff --git a/docs/adr/0009-ssh-trust-and-ssh-proxy.md b/docs/adr/0009-ssh-trust-and-ssh-proxy.md index 9947c01..db34f4a 100644 --- a/docs/adr/0009-ssh-trust-and-ssh-proxy.md +++ b/docs/adr/0009-ssh-trust-and-ssh-proxy.md @@ -7,8 +7,10 @@ Accepted. ## Decision SSH keys are admin signing identities and ecosystem integration points. SSH is -not a geth transport. Future SSH proxy support will carry SSH protocol bytes over -authorized Iroh streams, and OpenSSH will still perform normal login auth. +not a geth transport. SSH proxy support carries SSH protocol bytes over +authorized Iroh streams, and OpenSSH still performs normal login auth. The +restricted geth admin shell is a separate built-in command set over protected +Iroh control requests; it does not execute host shell commands. Geth also manages OpenSSH certificate request, renewal, approval, import, and revocation-list metadata. Signing is explicit: an approved request yields a diff --git a/docs/architecture.md b/docs/architecture.md index e368415..11ee5b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,8 +77,12 @@ proxy ` command performs an authorized Iroh control-plane handshake: the remote daemon validates the caller's signed peer card against the observed Iroh EndpointID and requires `ssh_proxy.connect` on `resource:ssh-proxy:local`. It returns connection metadata only. Carrying SSH -bytes over an Iroh stream and connecting to remote sshd or a restricted admin -shell remain future work, and will not make SSH a geth transport backend. +bytes over an Iroh stream is implemented on the dedicated `/geth/ssh-proxy/1` +ALPN and only connects to remote `127.0.0.1:22` after authorization. +`geth ssh admin-shell ` is a separate restricted +geth admin workflow over the protected Iroh control path. It requires +`ssh_proxy.admin_shell` and executes only built-in geth commands, never host +shell commands. Neither path makes SSH a geth transport backend. SSH certificate flows use the same split. Nodes can request new OpenSSH certificates or renewals through geth metadata. A machine with the CA key or diff --git a/docs/roadmap.md b/docs/roadmap.md index c3b7d53..76435b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -406,7 +406,7 @@ Goal: add authorized stream-oriented management workflows over Iroh. - `[ ]` Unsupported platforms return clear errors. - `[x]` Tests cover a full two-node Unix socket forwarding exchange. -- `[~]` SSH proxy over Iroh. +- `[x]` SSH proxy over Iroh. Acceptance criteria: - `[x]` `geth ssh proxy ` contacts an imported peer over the dedicated `/geth/ssh-proxy/1` Iroh ALPN. @@ -417,7 +417,7 @@ Goal: add authorized stream-oriented management workflows over Iroh. - `[x]` The proxy opens a dedicated authorized Iroh byte stream. - `[x]` Remote daemon connects that stream to local sshd at `127.0.0.1:22` only after authorization. - - `[ ]` Future completion adds a restricted built-in geth admin shell option. + - `[x]` Future completion adds a restricted built-in geth admin shell option. - `[~]` SSH certificate and revocation distribution. Acceptance criteria: