Add SSH proxy authorization probe

This commit is contained in:
Eric Wendland 2026-05-19 15:51:11 +02:00
commit e145bb47cd
11 changed files with 333 additions and 17 deletions

View file

@ -164,6 +164,10 @@ Roadmap items should be actionable and checkable:
`geth pipe connect <name> --node <node-id>` uses the protected Iroh control
ALPN and requires `pipe.connect` on `resource:pipe:<name>`. Iroh byte streams,
TCP/Unix forwarding, and remote listener creation are still roadmap work.
- `geth ssh proxy <node-id>` 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,

2
Cargo.lock generated
View file

@ -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",

View file

@ -123,6 +123,7 @@ The bootstrap implementation provides:
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl] [--subject <principal>]`
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec] [--subject <principal>]`
- `geth ssh revocation sync <node-id>`
- SSH proxy authorization probe: `geth ssh proxy <node-id>`
- pipe registry/connect commands: `geth pipe listen <name>` and
`geth pipe connect <name> [--node <node-id>]`
@ -166,6 +167,11 @@ Remote pipe connect uses the same protected Iroh control path and requires
`pipe.connect` on `resource:pipe:<name>`. The current prototype records a remote
connection attempt and whether a listener exists; byte streaming and forwarding
are still future work.
`geth ssh proxy <node-id>` 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 <node-id> <name>` and background live-sync require
`document.read` on `resource:document:<name>` 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:

View file

@ -710,10 +710,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
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");
}

View file

@ -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" }

View file

@ -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<SshProxyConnection>,
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<SshProxyConnection>,
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(),

View file

@ -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

View file

@ -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<ControlResponse,
| PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::SshProxyConnected { .. }
| PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => 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<ControlResponse, NodeError> {
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 {

View file

@ -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<String>,
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"

View file

@ -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 <node-id>` 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

View file

@ -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 <node>` 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 <node>` 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: