diff --git a/AGENTS.md b/AGENTS.md index 3726484..6f469c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,10 @@ Roadmap items should be actionable and checkable: `geth pipe connect --node ` uses the protected Iroh control ALPN and requires `pipe.connect` on `resource:pipe:`. Iroh byte streams, TCP/Unix forwarding, and remote listener creation are still roadmap work. +- `geth ssh proxy ` performs an authorized control-plane handshake over + the protected Iroh control ALPN and requires `ssh_proxy.connect` on + `resource:ssh-proxy:local` before returning proxy metadata. It does not carry + SSH bytes or connect to sshd/admin shell yet. - 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. Payload encryption, diff --git a/Cargo.lock b/Cargo.lock index 88ae890..90a312b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,6 +1135,7 @@ dependencies = [ "geth-resource", "geth-secrets", "geth-ssh-identity", + "geth-ssh-proxy", "geth-types", "serde", "serde_json", @@ -1246,6 +1247,7 @@ dependencies = [ "geth-resource", "geth-secrets", "geth-ssh-identity", + "geth-ssh-proxy", "geth-store", "geth-types", "iroh", diff --git a/README.md b/README.md index 219a2f4..02b0a6d 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ 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 ` +- SSH proxy authorization probe: `geth ssh proxy ` - pipe registry/connect commands: `geth pipe listen ` and `geth pipe connect [--node ]` @@ -166,6 +167,11 @@ Remote pipe connect uses the same protected Iroh control path and requires `pipe.connect` on `resource:pipe:`. The current prototype records a remote connection attempt and whether a listener exists; byte streaming and forwarding are still future work. +`geth ssh proxy ` also uses the protected Iroh control path. The remote +peer validates the caller's endpoint/card binding and requires +`ssh_proxy.connect` on `resource:ssh-proxy:local` before returning proxy +connection metadata. The current prototype does not carry SSH bytes or connect +to remote sshd yet; it only proves the authorization gate. 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 @@ -182,8 +188,6 @@ When `[iroh].local_discovery = true`, the daemon also advertises and discovers signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is candidate metadata only; all geth node-to-node requests still run over Iroh. -Other command groups exist as explicit stubs: `ssh proxy`. - ## Resource Modules Everything meaningful is modeled as a resource. Planned resource kinds are: diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 769ba6e..eebb4e4 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -710,10 +710,7 @@ fn request_for_command(command: Command) -> Result { DocumentCommand::Sync { node, name } => ControlRequest::DocumentSync { node, name }, }, Command::Ssh { command } => match command { - SshCommand::Proxy { node } => ControlRequest::ModuleStub { - module: "ssh-proxy".to_owned(), - command: format!("proxy {node}"), - }, + SshCommand::Proxy { node } => ControlRequest::SshProxyConnect { node }, SshCommand::Cert { command } => match command { SshCertCommand::Request { public_key, @@ -1596,6 +1593,37 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("reason: {reason}"); println!("note: {note}"); } + ControlResponse::SshProxyConnected { + peer_node_id, + peer_agent_id, + endpoint_id, + connection, + allowed, + reason, + note, + } => { + if allowed { + println!("ssh proxy target: {peer_node_id}"); + if let Some(connection) = connection { + println!("connected_at_ms: {}", connection.connected_at.0); + if let Some(local_sshd_target) = connection.local_sshd_target { + println!("remote_sshd_target: {local_sshd_target}"); + } + println!( + "admin_shell_available: {}", + connection.admin_shell_available + ); + println!("connection_note: {}", connection.note); + } + } else { + println!("ssh proxy denied by {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/Cargo.toml b/crates/geth-control/Cargo.toml index a20411a..bb75e01 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -21,4 +21,5 @@ geth-pubsub = { path = "../geth-pubsub" } geth-resource = { path = "../geth-resource" } geth-secrets = { path = "../geth-secrets" } geth-ssh-identity = { path = "../geth-ssh-identity" } +geth-ssh-proxy = { path = "../geth-ssh-proxy" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index a332e84..faf8434 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -12,6 +12,7 @@ use geth_secrets::{BearerAccess, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, }; +use geth_ssh_proxy::SshProxyConnection; use geth_types::BlobHash; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -186,6 +187,9 @@ pub enum ControlRequest { SshRevocationSync { node: String, }, + SshProxyConnect { + node: String, + }, DbAdd { name: String, path: PathBuf, @@ -543,6 +547,15 @@ pub enum ControlResponse { reason: String, note: String, }, + SshProxyConnected { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + connection: Option, + allowed: bool, + reason: String, + note: String, + }, NotImplemented { module: String, command: String, @@ -649,6 +662,10 @@ pub enum PeerControlRequest { target: String, nonce: String, }, + SshProxyConnect { + peer_card: PeerCard, + nonce: String, + }, DocumentSync { peer_card: PeerCard, name: String, @@ -790,6 +807,18 @@ pub enum PeerControlResponse { nonce: String, note: String, }, + SshProxyConnected { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + connection: Option, + allowed: bool, + reason: String, + evaluated_ops: usize, + nonce: String, + note: String, + }, DocumentSynced { node_id: String, agent_id: String, @@ -1130,6 +1159,34 @@ mod tests { response ); + let request = ControlRequest::SshProxyConnect { + node: "node:peer".to_owned(), + }; + 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(), + endpoint_id: "endpoint:peer".to_owned(), + connection: Some(SshProxyConnection { + target_node: "node:peer".into(), + connected_at: geth_types::UnixMillis(1), + local_sshd_target: Some("127.0.0.1:22".to_owned()), + admin_shell_available: false, + note: "proxy".to_owned(), + }), + allowed: true, + reason: "direct grant".to_owned(), + note: "ssh proxy".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), @@ -1476,6 +1533,50 @@ mod tests { response ); + let request = PeerControlRequest::SshProxyConnect { + 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(), + }, + }, + nonce: "nonce".to_owned(), + }; + assert_eq!( + decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"), + request + ); + + let response = PeerControlResponse::SshProxyConnected { + 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(), + connection: Some(SshProxyConnection { + target_node: "node:peer".into(), + connected_at: geth_types::UnixMillis(1), + local_sshd_target: Some("127.0.0.1:22".to_owned()), + admin_shell_available: false, + note: "proxy".to_owned(), + }), + allowed: true, + reason: "direct grant".to_owned(), + evaluated_ops: 1, + nonce: "nonce".to_owned(), + note: "ssh proxy".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/Cargo.toml b/crates/geth-node/Cargo.toml index 9022749..4593cb9 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -28,6 +28,7 @@ geth-pubsub = { path = "../geth-pubsub" } geth-resource = { path = "../geth-resource" } geth-secrets = { path = "../geth-secrets" } geth-ssh-identity = { path = "../geth-ssh-identity" } +geth-ssh-proxy = { path = "../geth-ssh-proxy" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } iroh.workspace = true diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index aeed305..ca1da09 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -31,6 +31,7 @@ use geth_ssh_identity::{ cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id, ssh_public_key_fingerprint, write_openssh_krl, }; +use geth_ssh_proxy::SshProxyConnection; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState, @@ -281,6 +282,9 @@ pub async fn handle_request_async( target, node: Some(peer_node), } => pipe_connect_to_peer(node, &peer_node, target).await, + ControlRequest::SshProxyConnect { node: peer_node } => { + ssh_proxy_connect_to_peer(node, &peer_node).await + } ControlRequest::DocumentSync { node: peer_node, name, @@ -566,6 +570,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result Err(NodeError::IrohPeer( "peer returned wrong response type to ping request".to_owned(), @@ -680,6 +685,7 @@ async fn peer_auth_check( | PeerControlResponse::PubsubPublished { .. } | PeerControlResponse::PubsubSubscribed { .. } | PeerControlResponse::PipeConnected { .. } + | PeerControlResponse::SshProxyConnected { .. } | PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to auth-check request".to_owned(), @@ -824,6 +830,7 @@ async fn cas_fetch_from_peer( | PeerControlResponse::PubsubPublished { .. } | PeerControlResponse::PubsubSubscribed { .. } | PeerControlResponse::PipeConnected { .. } + | PeerControlResponse::SshProxyConnected { .. } | PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to CAS fetch".to_owned(), @@ -1194,6 +1201,41 @@ async fn pipe_connect_to_peer( } } +async fn ssh_proxy_connect_to_peer( + node: &LocalNode, + peer_node: &str, +) -> Result { + let response = + request_peer_control(node, peer_node, "ssh-proxy-connect", |peer_card, nonce| { + PeerControlRequest::SshProxyConnect { peer_card, nonce } + }) + .await?; + match response { + PeerControlResponse::SshProxyConnected { + node_id, + agent_id, + endpoint_id, + connection, + allowed, + reason, + note, + .. + } => Ok(ControlResponse::SshProxyConnected { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + connection, + allowed, + reason, + note, + }), + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + _ => Err(NodeError::IrohPeer( + "peer returned wrong response type to SSH proxy connect".to_owned(), + )), + } +} + async fn document_sync_from_peer( node: &LocalNode, peer_node: &str, @@ -1610,6 +1652,10 @@ async fn request_peer_control( nonce: response_nonce, .. } + | PeerControlResponse::SshProxyConnected { + nonce: response_nonce, + .. + } | PeerControlResponse::DocumentSynced { nonce: response_nonce, .. @@ -2234,6 +2280,52 @@ async fn handle_iroh_control_connection( note: "pipe connect authenticated endpoint/card binding and required pipe.connect on the remote pipe resource; byte streams are not implemented yet".to_owned(), } } + PeerControlRequest::SshProxyConnect { peer_card, nonce } => { + 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.connect".to_owned(); + let explanation = geth_auth::explain_auth_ops( + &load_auth_ops_for_resource(&store, &resource)?, + PrincipalId::new(peer_card.node_id.to_string()), + ResourceId::new(resource), + Capability::new(capability), + ); + let connection = if explanation.allowed { + Some(SshProxyConnection { + 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, + note: "authorized SSH proxy control-plane check passed; byte proxying and sshd/admin-shell connection are not implemented yet".to_owned(), + }) + } else { + None + }; + PeerControlResponse::SshProxyConnected { + 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, + connection, + allowed: explanation.allowed, + reason: explanation.reason, + evaluated_ops: explanation.evaluated_ops, + nonce, + 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 is not implemented yet".to_owned(), + } + } PeerControlRequest::DocumentSync { peer_card, name, @@ -3500,6 +3592,7 @@ pub fn handle_request( ControlRequest::PipeConnect { node: Some(_), .. } => { Err(NodeError::IrohEndpointUnavailable) } + ControlRequest::SshProxyConnect { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -4584,6 +4677,28 @@ mod tests { other => panic!("unexpected denied pipe connect response: {other:?}"), } + let denied_ssh_proxy = handle_request_async( + &left, + ControlRequest::SshProxyConnect { + node: right_card.node_id.to_string(), + }, + ) + .await + .expect("denied SSH proxy connect"); + match denied_ssh_proxy { + ControlResponse::SshProxyConnected { + allowed, + reason, + connection, + .. + } => { + assert!(!allowed); + assert!(connection.is_none()); + assert!(reason.contains("no active direct or group grant")); + } + other => panic!("unexpected denied SSH proxy response: {other:?}"), + } + let denied_document = handle_request_async( &left, ControlRequest::DocumentSync { @@ -4683,6 +4798,16 @@ mod tests { }, ) .expect("grant left pipe connect"); + handle_request( + &right, + ControlRequest::AuthGrant { + subject: left.node_id.clone(), + resource: "resource:ssh-proxy:local".to_owned(), + capability: "ssh_proxy.connect".to_owned(), + grant_id: Some("grant:left-ssh-proxy-connect".to_owned()), + }, + ) + .expect("grant left ssh proxy connect"); handle_request( &right, ControlRequest::AuthGrant { @@ -4899,6 +5024,35 @@ mod tests { other => panic!("unexpected allowed pipe connect response: {other:?}"), } + let ssh_proxy = handle_request_async( + &left, + ControlRequest::SshProxyConnect { + node: right_card.node_id.to_string(), + }, + ) + .await + .expect("allowed SSH proxy connect"); + match ssh_proxy { + ControlResponse::SshProxyConnected { + allowed, + connection, + reason, + note, + .. + } => { + assert!(allowed); + let connection = connection.expect("proxy connection metadata"); + assert_eq!(connection.target_node.to_string(), right.node_id); + assert_eq!( + connection.local_sshd_target.as_deref(), + Some("127.0.0.1:22") + ); + assert!(reason.contains("direct grant")); + assert!(note.contains("SSH is not a geth transport")); + } + other => panic!("unexpected allowed SSH proxy 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 a6a4c39..578c373 100644 --- a/crates/geth-ssh-proxy/src/lib.rs +++ b/crates/geth-ssh-proxy/src/lib.rs @@ -1,4 +1,4 @@ -use geth_types::{NodeId, ResourceId}; +use geth_types::{NodeId, ResourceId, UnixMillis}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -7,6 +7,15 @@ pub struct SshProxyTarget { pub node: NodeId, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshProxyConnection { + pub target_node: NodeId, + pub connected_at: UnixMillis, + pub local_sshd_target: Option, + pub admin_shell_available: bool, + pub note: String, +} + #[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" diff --git a/docs/architecture.md b/docs/architecture.md index fe7e3da..a771ca0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,9 +72,13 @@ binding is unavailable, the daemon keeps local control running and reports the Iroh startup error through status output. SSH keys are not transport keys. They are admin trust anchors and signing -identities for keychain and authorization operations. SSH proxying, when added, -will carry SSH bytes over an authorized Iroh stream and will not make SSH a geth -transport backend. +identities for keychain and authorization operations. The bootstrap `geth ssh +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. 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 @@ -192,7 +196,10 @@ before recording the connection attempt and reporting whether a listener exists. This is still a control-plane scaffold for names and connection attempts only; it does not carry bytes or forward sockets yet. -`geth-ssh-proxy` currently defines types, command shape, and roadmap stubs. +`geth-ssh-proxy` currently defines proxy target and connection metadata. The +daemon can authorize a remote proxy attempt over the protected Iroh control ALPN +with `ssh_proxy.connect` on `resource:ssh-proxy:local`, but it does not yet +forward bytes or connect to sshd/admin shell. `geth-ssh-identity` defines SSH trust namespaces plus certificate request, approval, certificate import, and revocation-list data models. The bootstrap diff --git a/docs/roadmap.md b/docs/roadmap.md index f1c3cc8..62f1227 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -344,12 +344,17 @@ Goal: add authorized stream-oriented management workflows over Iroh. - Unsupported platforms return clear errors. - Tests skip or use cfg guards where sockets are unavailable. -- `[ ]` SSH proxy over Iroh. +- `[~]` SSH proxy over Iroh. Acceptance criteria: - - `geth ssh proxy ` opens an authorized Iroh stream. - - Remote daemon checks `ssh_proxy.connect` before connecting to local sshd or - admin shell. - - Knowing an EndpointID alone cannot reach sshd. + - `[x]` `geth ssh proxy ` contacts an imported peer over the protected + Iroh control ALPN. + - `[x]` Remote daemon checks `ssh_proxy.connect` on + `resource:ssh-proxy:local` before returning proxy connection metadata. + - `[x]` Tests cover denied and granted SSH proxy control-plane attempts. + - `[x]` Knowing an EndpointID alone cannot reach sshd. + - `[ ]` Future completion opens a dedicated authorized Iroh byte stream. + - `[ ]` Remote daemon connects that stream to local sshd or a restricted + built-in geth admin shell only after authorization. - `[~]` SSH certificate and revocation distribution. Acceptance criteria: