Add restricted SSH admin shell
This commit is contained in:
parent
64e0168dbf
commit
59ccf6c748
9 changed files with 358 additions and 14 deletions
|
|
@ -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
|
CLI streams through the local daemon, the daemon uses `/geth/ssh-proxy/1` over
|
||||||
Iroh, the remote daemon requires `ssh_proxy.connect` on
|
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
|
`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 <node-id>
|
||||||
|
<help|status|node-id>` 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.
|
- Resource secret epoch metadata can be created, rotated, and listed locally.
|
||||||
Bearer access metadata can be created/listed/revoked as resource-scoped auth
|
Bearer access metadata can be created/listed/revoked as resource-scoped auth
|
||||||
ops and must not allow trust graph mutation capabilities. Bearer
|
ops and must not allow trust graph mutation capabilities. Bearer
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,9 @@ The bootstrap implementation provides:
|
||||||
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl] [--subject <principal>]`
|
- `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 import <path> [--format jsonl|openssh-krl-spec] [--subject <principal>]`
|
||||||
- `geth ssh revocation sync <node-id> [--bearer-secret <secret>]`
|
- `geth ssh revocation sync <node-id> [--bearer-secret <secret>]`
|
||||||
- SSH proxy over Iroh: `geth ssh proxy <node-id> [--bearer-secret <secret>]`
|
- SSH proxy/admin over Iroh:
|
||||||
|
`geth ssh proxy <node-id> [--bearer-secret <secret>]` and
|
||||||
|
`geth ssh admin-shell <node-id> <help|status|node-id> [--bearer-secret <secret>]`
|
||||||
- pipe registry/message commands:
|
- pipe registry/message commands:
|
||||||
`geth pipe listen <name> [--node <node-id>] [--bearer-secret <secret>]`,
|
`geth pipe listen <name> [--node <node-id>] [--bearer-secret <secret>]`,
|
||||||
`geth pipe connect <name> [--node <node-id>] [--bearer-secret <secret>]`,
|
`geth pipe connect <name> [--node <node-id>] [--bearer-secret <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
|
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
|
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.
|
byte stream; SSH is not a geth transport backend.
|
||||||
|
`geth ssh admin-shell <node-id> <command>` 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:
|
Document sync is a bootstrap JSON last-writer-wins path before Automerge:
|
||||||
manual `geth document sync <node-id> <name>` and background live-sync require
|
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
|
`document.read` on `resource:document:<name>` and import only state that is not
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,12 @@ pub enum SshCommand {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bearer_secret: Option<String>,
|
bearer_secret: Option<String>,
|
||||||
},
|
},
|
||||||
|
AdminShell {
|
||||||
|
node: String,
|
||||||
|
command: String,
|
||||||
|
#[arg(long)]
|
||||||
|
bearer_secret: Option<String>,
|
||||||
|
},
|
||||||
Cert {
|
Cert {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: SshCertCommand,
|
command: SshCertCommand,
|
||||||
|
|
@ -1050,6 +1056,15 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
node,
|
node,
|
||||||
bearer_secret,
|
bearer_secret,
|
||||||
},
|
},
|
||||||
|
SshCommand::AdminShell {
|
||||||
|
node,
|
||||||
|
command,
|
||||||
|
bearer_secret,
|
||||||
|
} => ControlRequest::SshAdminShell {
|
||||||
|
node,
|
||||||
|
command,
|
||||||
|
bearer_secret,
|
||||||
|
},
|
||||||
SshCommand::Cert { command } => match command {
|
SshCommand::Cert { command } => match command {
|
||||||
SshCertCommand::Request {
|
SshCertCommand::Request {
|
||||||
public_key,
|
public_key,
|
||||||
|
|
@ -2209,6 +2224,29 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
println!("reason: {reason}");
|
println!("reason: {reason}");
|
||||||
println!("note: {note}");
|
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 } => {
|
ControlResponse::NotImplemented { module, command } => {
|
||||||
println!("{module} {command}: not implemented yet");
|
println!("{module} {command}: not implemented yet");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,11 @@ pub enum ControlRequest {
|
||||||
node: String,
|
node: String,
|
||||||
bearer_secret: Option<String>,
|
bearer_secret: Option<String>,
|
||||||
},
|
},
|
||||||
|
SshAdminShell {
|
||||||
|
node: String,
|
||||||
|
command: String,
|
||||||
|
bearer_secret: Option<String>,
|
||||||
|
},
|
||||||
DbAdd {
|
DbAdd {
|
||||||
name: String,
|
name: String,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
@ -724,6 +729,16 @@ pub enum ControlResponse {
|
||||||
reason: String,
|
reason: String,
|
||||||
note: 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 {
|
NotImplemented {
|
||||||
module: String,
|
module: String,
|
||||||
command: String,
|
command: String,
|
||||||
|
|
@ -857,6 +872,12 @@ pub enum PeerControlRequest {
|
||||||
nonce: String,
|
nonce: String,
|
||||||
bearer_proof: Option<BearerProof>,
|
bearer_proof: Option<BearerProof>,
|
||||||
},
|
},
|
||||||
|
SshAdminShell {
|
||||||
|
peer_card: PeerCard,
|
||||||
|
command: String,
|
||||||
|
nonce: String,
|
||||||
|
bearer_proof: Option<BearerProof>,
|
||||||
|
},
|
||||||
DocumentSync {
|
DocumentSync {
|
||||||
peer_card: PeerCard,
|
peer_card: PeerCard,
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -1038,6 +1059,19 @@ pub enum PeerControlResponse {
|
||||||
nonce: String,
|
nonce: String,
|
||||||
note: 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 {
|
DocumentSynced {
|
||||||
node_id: String,
|
node_id: String,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
|
@ -1571,6 +1605,16 @@ mod tests {
|
||||||
request
|
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 {
|
let response = ControlResponse::SshProxyConnected {
|
||||||
peer_node_id: "node:peer".to_owned(),
|
peer_node_id: "node:peer".to_owned(),
|
||||||
peer_agent_id: "agent:peer".to_owned(),
|
peer_agent_id: "agent:peer".to_owned(),
|
||||||
|
|
@ -1591,6 +1635,21 @@ mod tests {
|
||||||
response
|
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 {
|
let request = ControlRequest::DbChanges {
|
||||||
name: "notes".to_owned(),
|
name: "notes".to_owned(),
|
||||||
after_db_version: Some(7),
|
after_db_version: Some(7),
|
||||||
|
|
@ -2039,6 +2098,34 @@ mod tests {
|
||||||
request
|
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 {
|
let request = PeerControlRequest::PipeListen {
|
||||||
peer_card: PeerCard {
|
peer_card: PeerCard {
|
||||||
node_id: "node:caller".into(),
|
node_id: "node:caller".into(),
|
||||||
|
|
@ -2209,6 +2296,25 @@ mod tests {
|
||||||
response
|
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 {
|
let response = PeerControlResponse::DbSynced {
|
||||||
node_id: "node:peer".to_owned(),
|
node_id: "node:peer".to_owned(),
|
||||||
agent_id: "agent:peer".to_owned(),
|
agent_id: "agent:peer".to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -552,6 +552,11 @@ pub async fn handle_request_async(
|
||||||
node: peer_node,
|
node: peer_node,
|
||||||
bearer_secret,
|
bearer_secret,
|
||||||
} => ssh_proxy_connect_to_peer(node, &peer_node, bearer_secret).await,
|
} => 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 {
|
ControlRequest::DocumentSync {
|
||||||
node: peer_node,
|
node: peer_node,
|
||||||
name,
|
name,
|
||||||
|
|
@ -869,6 +874,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
|
||||||
| PeerControlResponse::PipeConnected { .. }
|
| PeerControlResponse::PipeConnected { .. }
|
||||||
| PeerControlResponse::PipeListening { .. }
|
| PeerControlResponse::PipeListening { .. }
|
||||||
| PeerControlResponse::SshProxyConnected { .. }
|
| PeerControlResponse::SshProxyConnected { .. }
|
||||||
|
| PeerControlResponse::SshAdminShellOutput { .. }
|
||||||
| PeerControlResponse::DocumentSynced { .. }
|
| PeerControlResponse::DocumentSynced { .. }
|
||||||
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
|
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned wrong response type to ping request".to_owned(),
|
"peer returned wrong response type to ping request".to_owned(),
|
||||||
|
|
@ -986,6 +992,7 @@ async fn peer_auth_check(
|
||||||
| PeerControlResponse::PipeConnected { .. }
|
| PeerControlResponse::PipeConnected { .. }
|
||||||
| PeerControlResponse::PipeListening { .. }
|
| PeerControlResponse::PipeListening { .. }
|
||||||
| PeerControlResponse::SshProxyConnected { .. }
|
| PeerControlResponse::SshProxyConnected { .. }
|
||||||
|
| PeerControlResponse::SshAdminShellOutput { .. }
|
||||||
| PeerControlResponse::DocumentSynced { .. }
|
| PeerControlResponse::DocumentSynced { .. }
|
||||||
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
|
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned wrong response type to auth-check request".to_owned(),
|
"peer returned wrong response type to auth-check request".to_owned(),
|
||||||
|
|
@ -1135,6 +1142,7 @@ async fn cas_fetch_from_peer(
|
||||||
| PeerControlResponse::PipeConnected { .. }
|
| PeerControlResponse::PipeConnected { .. }
|
||||||
| PeerControlResponse::PipeListening { .. }
|
| PeerControlResponse::PipeListening { .. }
|
||||||
| PeerControlResponse::SshProxyConnected { .. }
|
| PeerControlResponse::SshProxyConnected { .. }
|
||||||
|
| PeerControlResponse::SshAdminShellOutput { .. }
|
||||||
| PeerControlResponse::DocumentSynced { .. }
|
| PeerControlResponse::DocumentSynced { .. }
|
||||||
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
|
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned wrong response type to CAS fetch".to_owned(),
|
"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(
|
async fn handle_local_ssh_proxy_stream(
|
||||||
node: LocalNode,
|
node: LocalNode,
|
||||||
peer_node: &str,
|
peer_node: &str,
|
||||||
|
|
@ -3706,7 +3762,7 @@ async fn handle_iroh_control_connection(
|
||||||
target_node: NodeId::new(node.node_id.clone()),
|
target_node: NodeId::new(node.node_id.clone()),
|
||||||
connected_at: UnixMillis(geth_store::now_ms()),
|
connected_at: UnixMillis(geth_store::now_ms()),
|
||||||
local_sshd_target: Some("127.0.0.1:22".to_owned()),
|
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(),
|
note: "authorized SSH proxy metadata check passed; run geth ssh proxy for the dedicated byte stream".to_owned(),
|
||||||
})
|
})
|
||||||
} else {
|
} 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(),
|
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 {
|
PeerControlRequest::DocumentSync {
|
||||||
peer_card,
|
peer_card,
|
||||||
name,
|
name,
|
||||||
|
|
@ -3925,7 +4029,7 @@ async fn handle_ssh_proxy_wire_connection(
|
||||||
target_node: node.node_id.clone().into(),
|
target_node: node.node_id.clone().into(),
|
||||||
connected_at: UnixMillis(geth_store::now_ms()),
|
connected_at: UnixMillis(geth_store::now_ms()),
|
||||||
local_sshd_target: Some("127.0.0.1:22".to_owned()),
|
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"
|
note: "authorized SSH proxy byte stream over Iroh; SSH is not a geth transport"
|
||||||
.to_owned(),
|
.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 {
|
fn display_alpn(alpn: Vec<u8>) -> String {
|
||||||
String::from_utf8_lossy(&alpn).into_owned()
|
String::from_utf8_lossy(&alpn).into_owned()
|
||||||
}
|
}
|
||||||
|
|
@ -5819,9 +5943,9 @@ pub fn handle_request(
|
||||||
note: geth_pipe::local_pipe_runtime_note().to_owned(),
|
note: geth_pipe::local_pipe_runtime_note().to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ControlRequest::SshProxyConnect { .. } | ControlRequest::SshProxyStream { .. } => {
|
ControlRequest::SshProxyConnect { .. }
|
||||||
Err(NodeError::IrohEndpointUnavailable)
|
| ControlRequest::SshProxyStream { .. }
|
||||||
}
|
| ControlRequest::SshAdminShell { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::ModuleStub { module, command } => {
|
ControlRequest::ModuleStub { module, command } => {
|
||||||
Ok(ControlResponse::NotImplemented { module, command })
|
Ok(ControlResponse::NotImplemented { module, command })
|
||||||
}
|
}
|
||||||
|
|
@ -7924,6 +8048,30 @@ mod tests {
|
||||||
other => panic!("unexpected denied SSH proxy response: {other:?}"),
|
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(
|
let denied_document = handle_request_async(
|
||||||
&left,
|
&left,
|
||||||
ControlRequest::DocumentSync {
|
ControlRequest::DocumentSync {
|
||||||
|
|
@ -8099,6 +8247,16 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("grant left ssh proxy connect");
|
.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(
|
handle_request(
|
||||||
&right,
|
&right,
|
||||||
ControlRequest::AuthGrant {
|
ControlRequest::AuthGrant {
|
||||||
|
|
@ -8634,6 +8792,33 @@ mod tests {
|
||||||
other => panic!("unexpected allowed SSH proxy response: {other:?}"),
|
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(
|
let document_sync = handle_request_async(
|
||||||
&left,
|
&left,
|
||||||
ControlRequest::DocumentSync {
|
ControlRequest::DocumentSync {
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,5 @@ pub struct SshProxyConnection {
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn ssh_proxy_roadmap() -> &'static str {
|
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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ Accepted.
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
SSH keys are admin signing identities and ecosystem integration points. SSH is
|
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
|
not a geth transport. SSH proxy support carries SSH protocol bytes over
|
||||||
authorized Iroh streams, and OpenSSH will still perform normal login auth.
|
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
|
Geth also manages OpenSSH certificate request, renewal, approval, import, and
|
||||||
revocation-list metadata. Signing is explicit: an approved request yields a
|
revocation-list metadata. Signing is explicit: an approved request yields a
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,12 @@ proxy <node-id>` command performs an authorized Iroh control-plane handshake:
|
||||||
the remote daemon validates the caller's signed peer card against the observed
|
the remote daemon validates the caller's signed peer card against the observed
|
||||||
Iroh EndpointID and requires `ssh_proxy.connect` on
|
Iroh EndpointID and requires `ssh_proxy.connect` on
|
||||||
`resource:ssh-proxy:local`. It returns connection metadata only. Carrying SSH
|
`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
|
bytes over an Iroh stream is implemented on the dedicated `/geth/ssh-proxy/1`
|
||||||
shell remain future work, and will not make SSH a geth transport backend.
|
ALPN and only connects to remote `127.0.0.1:22` after authorization.
|
||||||
|
`geth ssh admin-shell <node-id> <help|status|node-id>` 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
|
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
|
certificates or renewals through geth metadata. A machine with the CA key or
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
||||||
- `[ ]` Unsupported platforms return clear errors.
|
- `[ ]` Unsupported platforms return clear errors.
|
||||||
- `[x]` Tests cover a full two-node Unix socket forwarding exchange.
|
- `[x]` Tests cover a full two-node Unix socket forwarding exchange.
|
||||||
|
|
||||||
- `[~]` SSH proxy over Iroh.
|
- `[x]` SSH proxy over Iroh.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` `geth ssh proxy <node>` contacts an imported peer over the dedicated
|
- `[x]` `geth ssh proxy <node>` contacts an imported peer over the dedicated
|
||||||
`/geth/ssh-proxy/1` Iroh ALPN.
|
`/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]` The proxy opens a dedicated authorized Iroh byte stream.
|
||||||
- `[x]` Remote daemon connects that stream to local sshd at `127.0.0.1:22`
|
- `[x]` Remote daemon connects that stream to local sshd at `127.0.0.1:22`
|
||||||
only after authorization.
|
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.
|
- `[~]` SSH certificate and revocation distribution.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue