Add SSH proxy authorization probe
This commit is contained in:
parent
f7e85960f7
commit
e145bb47cd
11 changed files with 333 additions and 17 deletions
|
|
@ -164,6 +164,10 @@ Roadmap items should be actionable and checkable:
|
||||||
`geth pipe connect <name> --node <node-id>` uses the protected Iroh control
|
`geth pipe connect <name> --node <node-id>` uses the protected Iroh control
|
||||||
ALPN and requires `pipe.connect` on `resource:pipe:<name>`. Iroh byte streams,
|
ALPN and requires `pipe.connect` on `resource:pipe:<name>`. Iroh byte streams,
|
||||||
TCP/Unix forwarding, and remote listener creation are still roadmap work.
|
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.
|
- 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. Payload encryption,
|
ops and must not allow trust graph mutation capabilities. Payload encryption,
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1135,6 +1135,7 @@ dependencies = [
|
||||||
"geth-resource",
|
"geth-resource",
|
||||||
"geth-secrets",
|
"geth-secrets",
|
||||||
"geth-ssh-identity",
|
"geth-ssh-identity",
|
||||||
|
"geth-ssh-proxy",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -1246,6 +1247,7 @@ dependencies = [
|
||||||
"geth-resource",
|
"geth-resource",
|
||||||
"geth-secrets",
|
"geth-secrets",
|
||||||
"geth-ssh-identity",
|
"geth-ssh-identity",
|
||||||
|
"geth-ssh-proxy",
|
||||||
"geth-store",
|
"geth-store",
|
||||||
"geth-types",
|
"geth-types",
|
||||||
"iroh",
|
"iroh",
|
||||||
|
|
|
||||||
|
|
@ -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 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>`
|
- `geth ssh revocation sync <node-id>`
|
||||||
|
- SSH proxy authorization probe: `geth ssh proxy <node-id>`
|
||||||
- pipe registry/connect commands: `geth pipe listen <name>` and
|
- pipe registry/connect commands: `geth pipe listen <name>` and
|
||||||
`geth pipe connect <name> [--node <node-id>]`
|
`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
|
`pipe.connect` on `resource:pipe:<name>`. The current prototype records a remote
|
||||||
connection attempt and whether a listener exists; byte streaming and forwarding
|
connection attempt and whether a listener exists; byte streaming and forwarding
|
||||||
are still future work.
|
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:
|
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
|
||||||
|
|
@ -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
|
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.
|
candidate metadata only; all geth node-to-node requests still run over Iroh.
|
||||||
|
|
||||||
Other command groups exist as explicit stubs: `ssh proxy`.
|
|
||||||
|
|
||||||
## Resource Modules
|
## Resource Modules
|
||||||
|
|
||||||
Everything meaningful is modeled as a resource. Planned resource kinds are:
|
Everything meaningful is modeled as a resource. Planned resource kinds are:
|
||||||
|
|
|
||||||
|
|
@ -710,10 +710,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
DocumentCommand::Sync { node, name } => ControlRequest::DocumentSync { node, name },
|
DocumentCommand::Sync { node, name } => ControlRequest::DocumentSync { node, name },
|
||||||
},
|
},
|
||||||
Command::Ssh { command } => match command {
|
Command::Ssh { command } => match command {
|
||||||
SshCommand::Proxy { node } => ControlRequest::ModuleStub {
|
SshCommand::Proxy { node } => ControlRequest::SshProxyConnect { node },
|
||||||
module: "ssh-proxy".to_owned(),
|
|
||||||
command: format!("proxy {node}"),
|
|
||||||
},
|
|
||||||
SshCommand::Cert { command } => match command {
|
SshCommand::Cert { command } => match command {
|
||||||
SshCertCommand::Request {
|
SshCertCommand::Request {
|
||||||
public_key,
|
public_key,
|
||||||
|
|
@ -1596,6 +1593,37 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
println!("reason: {reason}");
|
println!("reason: {reason}");
|
||||||
println!("note: {note}");
|
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 } => {
|
ControlResponse::NotImplemented { module, command } => {
|
||||||
println!("{module} {command}: not implemented yet");
|
println!("{module} {command}: not implemented yet");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,5 @@ geth-pubsub = { path = "../geth-pubsub" }
|
||||||
geth-resource = { path = "../geth-resource" }
|
geth-resource = { path = "../geth-resource" }
|
||||||
geth-secrets = { path = "../geth-secrets" }
|
geth-secrets = { path = "../geth-secrets" }
|
||||||
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
||||||
|
geth-ssh-proxy = { path = "../geth-ssh-proxy" }
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use geth_secrets::{BearerAccess, ResourceMasterSecret};
|
||||||
use geth_ssh_identity::{
|
use geth_ssh_identity::{
|
||||||
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
|
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
|
||||||
};
|
};
|
||||||
|
use geth_ssh_proxy::SshProxyConnection;
|
||||||
use geth_types::BlobHash;
|
use geth_types::BlobHash;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
@ -186,6 +187,9 @@ pub enum ControlRequest {
|
||||||
SshRevocationSync {
|
SshRevocationSync {
|
||||||
node: String,
|
node: String,
|
||||||
},
|
},
|
||||||
|
SshProxyConnect {
|
||||||
|
node: String,
|
||||||
|
},
|
||||||
DbAdd {
|
DbAdd {
|
||||||
name: String,
|
name: String,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
@ -543,6 +547,15 @@ pub enum ControlResponse {
|
||||||
reason: String,
|
reason: String,
|
||||||
note: 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 {
|
NotImplemented {
|
||||||
module: String,
|
module: String,
|
||||||
command: String,
|
command: String,
|
||||||
|
|
@ -649,6 +662,10 @@ pub enum PeerControlRequest {
|
||||||
target: String,
|
target: String,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
},
|
},
|
||||||
|
SshProxyConnect {
|
||||||
|
peer_card: PeerCard,
|
||||||
|
nonce: String,
|
||||||
|
},
|
||||||
DocumentSync {
|
DocumentSync {
|
||||||
peer_card: PeerCard,
|
peer_card: PeerCard,
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -790,6 +807,18 @@ pub enum PeerControlResponse {
|
||||||
nonce: String,
|
nonce: String,
|
||||||
note: 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 {
|
DocumentSynced {
|
||||||
node_id: String,
|
node_id: String,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
|
|
@ -1130,6 +1159,34 @@ mod tests {
|
||||||
response
|
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 {
|
let request = ControlRequest::DbChanges {
|
||||||
name: "notes".to_owned(),
|
name: "notes".to_owned(),
|
||||||
after_db_version: Some(7),
|
after_db_version: Some(7),
|
||||||
|
|
@ -1476,6 +1533,50 @@ mod tests {
|
||||||
response
|
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 {
|
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(),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ geth-pubsub = { path = "../geth-pubsub" }
|
||||||
geth-resource = { path = "../geth-resource" }
|
geth-resource = { path = "../geth-resource" }
|
||||||
geth-secrets = { path = "../geth-secrets" }
|
geth-secrets = { path = "../geth-secrets" }
|
||||||
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
geth-ssh-identity = { path = "../geth-ssh-identity" }
|
||||||
|
geth-ssh-proxy = { path = "../geth-ssh-proxy" }
|
||||||
geth-store = { path = "../geth-store" }
|
geth-store = { path = "../geth-store" }
|
||||||
geth-types = { path = "../geth-types" }
|
geth-types = { path = "../geth-types" }
|
||||||
iroh.workspace = true
|
iroh.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ use geth_ssh_identity::{
|
||||||
cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id,
|
cert_request_id, certificate_id, openssh_krl_spec, parse_openssh_krl_spec, revocation_id,
|
||||||
ssh_public_key_fingerprint, write_openssh_krl,
|
ssh_public_key_fingerprint, write_openssh_krl,
|
||||||
};
|
};
|
||||||
|
use geth_ssh_proxy::SshProxyConnection;
|
||||||
use geth_store::{
|
use geth_store::{
|
||||||
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
|
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
|
||||||
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState,
|
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState,
|
||||||
|
|
@ -281,6 +282,9 @@ pub async fn handle_request_async(
|
||||||
target,
|
target,
|
||||||
node: Some(peer_node),
|
node: Some(peer_node),
|
||||||
} => pipe_connect_to_peer(node, &peer_node, target).await,
|
} => 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 {
|
ControlRequest::DocumentSync {
|
||||||
node: peer_node,
|
node: peer_node,
|
||||||
name,
|
name,
|
||||||
|
|
@ -566,6 +570,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
|
||||||
| PeerControlResponse::PubsubPublished { .. }
|
| PeerControlResponse::PubsubPublished { .. }
|
||||||
| PeerControlResponse::PubsubSubscribed { .. }
|
| PeerControlResponse::PubsubSubscribed { .. }
|
||||||
| PeerControlResponse::PipeConnected { .. }
|
| PeerControlResponse::PipeConnected { .. }
|
||||||
|
| PeerControlResponse::SshProxyConnected { .. }
|
||||||
| 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(),
|
||||||
|
|
@ -680,6 +685,7 @@ async fn peer_auth_check(
|
||||||
| PeerControlResponse::PubsubPublished { .. }
|
| PeerControlResponse::PubsubPublished { .. }
|
||||||
| PeerControlResponse::PubsubSubscribed { .. }
|
| PeerControlResponse::PubsubSubscribed { .. }
|
||||||
| PeerControlResponse::PipeConnected { .. }
|
| PeerControlResponse::PipeConnected { .. }
|
||||||
|
| PeerControlResponse::SshProxyConnected { .. }
|
||||||
| 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(),
|
||||||
|
|
@ -824,6 +830,7 @@ async fn cas_fetch_from_peer(
|
||||||
| PeerControlResponse::PubsubPublished { .. }
|
| PeerControlResponse::PubsubPublished { .. }
|
||||||
| PeerControlResponse::PubsubSubscribed { .. }
|
| PeerControlResponse::PubsubSubscribed { .. }
|
||||||
| PeerControlResponse::PipeConnected { .. }
|
| PeerControlResponse::PipeConnected { .. }
|
||||||
|
| PeerControlResponse::SshProxyConnected { .. }
|
||||||
| 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(),
|
||||||
|
|
@ -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(
|
async fn document_sync_from_peer(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
peer_node: &str,
|
peer_node: &str,
|
||||||
|
|
@ -1610,6 +1652,10 @@ async fn request_peer_control(
|
||||||
nonce: response_nonce,
|
nonce: response_nonce,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
|
| PeerControlResponse::SshProxyConnected {
|
||||||
|
nonce: response_nonce,
|
||||||
|
..
|
||||||
|
}
|
||||||
| PeerControlResponse::DocumentSynced {
|
| PeerControlResponse::DocumentSynced {
|
||||||
nonce: response_nonce,
|
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(),
|
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 {
|
PeerControlRequest::DocumentSync {
|
||||||
peer_card,
|
peer_card,
|
||||||
name,
|
name,
|
||||||
|
|
@ -3500,6 +3592,7 @@ pub fn handle_request(
|
||||||
ControlRequest::PipeConnect { node: Some(_), .. } => {
|
ControlRequest::PipeConnect { node: Some(_), .. } => {
|
||||||
Err(NodeError::IrohEndpointUnavailable)
|
Err(NodeError::IrohEndpointUnavailable)
|
||||||
}
|
}
|
||||||
|
ControlRequest::SshProxyConnect { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::ModuleStub { module, command } => {
|
ControlRequest::ModuleStub { module, command } => {
|
||||||
Ok(ControlResponse::NotImplemented { module, command })
|
Ok(ControlResponse::NotImplemented { module, command })
|
||||||
}
|
}
|
||||||
|
|
@ -4584,6 +4677,28 @@ mod tests {
|
||||||
other => panic!("unexpected denied pipe connect response: {other:?}"),
|
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(
|
let denied_document = handle_request_async(
|
||||||
&left,
|
&left,
|
||||||
ControlRequest::DocumentSync {
|
ControlRequest::DocumentSync {
|
||||||
|
|
@ -4683,6 +4798,16 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("grant left pipe connect");
|
.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(
|
handle_request(
|
||||||
&right,
|
&right,
|
||||||
ControlRequest::AuthGrant {
|
ControlRequest::AuthGrant {
|
||||||
|
|
@ -4899,6 +5024,35 @@ mod tests {
|
||||||
other => panic!("unexpected allowed pipe connect response: {other:?}"),
|
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(
|
let document_sync = handle_request_async(
|
||||||
&left,
|
&left,
|
||||||
ControlRequest::DocumentSync {
|
ControlRequest::DocumentSync {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use geth_types::{NodeId, ResourceId};
|
use geth_types::{NodeId, ResourceId, UnixMillis};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -7,6 +7,15 @@ pub struct SshProxyTarget {
|
||||||
pub node: NodeId,
|
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]
|
#[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"
|
"future SSH proxy carries SSH protocol bytes over authorized Iroh streams; SSH is not a geth transport"
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,13 @@ binding is unavailable, the daemon keeps local control running and reports the
|
||||||
Iroh startup error through status output.
|
Iroh startup error through status output.
|
||||||
|
|
||||||
SSH keys are not transport keys. They are admin trust anchors and signing
|
SSH keys are not transport keys. They are admin trust anchors and signing
|
||||||
identities for keychain and authorization operations. SSH proxying, when added,
|
identities for keychain and authorization operations. The bootstrap `geth ssh
|
||||||
will carry SSH bytes over an authorized Iroh stream and will not make SSH a geth
|
proxy <node-id>` command performs an authorized Iroh control-plane handshake:
|
||||||
transport backend.
|
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
|
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
|
||||||
|
|
@ -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;
|
This is still a control-plane scaffold for names and connection attempts only;
|
||||||
it does not carry bytes or forward sockets yet.
|
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,
|
`geth-ssh-identity` defines SSH trust namespaces plus certificate request,
|
||||||
approval, certificate import, and revocation-list data models. The bootstrap
|
approval, certificate import, and revocation-list data models. The bootstrap
|
||||||
|
|
|
||||||
|
|
@ -344,12 +344,17 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
||||||
- Unsupported platforms return clear errors.
|
- Unsupported platforms return clear errors.
|
||||||
- Tests skip or use cfg guards where sockets are unavailable.
|
- Tests skip or use cfg guards where sockets are unavailable.
|
||||||
|
|
||||||
- `[ ]` SSH proxy over Iroh.
|
- `[~]` SSH proxy over Iroh.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `geth ssh proxy <node>` opens an authorized Iroh stream.
|
- `[x]` `geth ssh proxy <node>` contacts an imported peer over the protected
|
||||||
- Remote daemon checks `ssh_proxy.connect` before connecting to local sshd or
|
Iroh control ALPN.
|
||||||
admin shell.
|
- `[x]` Remote daemon checks `ssh_proxy.connect` on
|
||||||
- Knowing an EndpointID alone cannot reach sshd.
|
`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.
|
- `[~]` SSH certificate and revocation distribution.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue