Add restricted SSH admin shell

This commit is contained in:
Eric Wendland 2026-05-21 01:49:48 +02:00
commit 59ccf6c748
9 changed files with 358 additions and 14 deletions

View file

@ -500,6 +500,12 @@ pub enum SshCommand {
#[arg(long)]
bearer_secret: Option<String>,
},
AdminShell {
node: String,
command: String,
#[arg(long)]
bearer_secret: Option<String>,
},
Cert {
#[command(subcommand)]
command: SshCertCommand,
@ -1050,6 +1056,15 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
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");
}

View file

@ -236,6 +236,11 @@ pub enum ControlRequest {
node: String,
bearer_secret: Option<String>,
},
SshAdminShell {
node: String,
command: String,
bearer_secret: Option<String>,
},
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<BearerProof>,
},
SshAdminShell {
peer_card: PeerCard,
command: String,
nonce: String,
bearer_proof: Option<BearerProof>,
},
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(),

View file

@ -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<ControlResponse,
| PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::PipeListening { .. }
| PeerControlResponse::SshProxyConnected { .. }
| PeerControlResponse::SshAdminShellOutput { .. }
| PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => 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<String>,
) -> Result<ControlResponse, NodeError> {
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<String, NodeError> {
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<u8>) -> 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 {

View file

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