Stream SSH proxy over Iroh
This commit is contained in:
parent
55cb455de3
commit
87d8801d71
7 changed files with 366 additions and 23 deletions
|
|
@ -181,10 +181,11 @@ Roadmap items should be actionable and checkable:
|
||||||
message over the dedicated `/geth/pipe/1` ALPN when `pipe.connect` is authorized, and
|
message over the dedicated `/geth/pipe/1` ALPN when `pipe.connect` is authorized, and
|
||||||
`geth pipe recv <name>` drains local daemon-lifetime messages. Long-lived
|
`geth pipe recv <name>` drains local daemon-lifetime messages. Long-lived
|
||||||
bidirectional streams and TCP/Unix forwarding are still roadmap work.
|
bidirectional streams and TCP/Unix forwarding are still roadmap work.
|
||||||
- `geth ssh proxy <node-id>` performs an authorized control-plane handshake over
|
- `geth ssh proxy <node-id>` is a streaming OpenSSH ProxyCommand-style path. The
|
||||||
the protected Iroh control ALPN and requires `ssh_proxy.connect` on
|
CLI streams through the local daemon, the daemon uses `/geth/ssh-proxy/1` over
|
||||||
`resource:ssh-proxy:local` before returning proxy metadata. It does not carry
|
Iroh, the remote daemon requires `ssh_proxy.connect` on
|
||||||
SSH bytes or connect to sshd/admin shell yet.
|
`resource:ssh-proxy:local`, and only then connects to `127.0.0.1:22`. SSH is
|
||||||
|
still not a geth transport backend.
|
||||||
- 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
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -60,8 +60,8 @@ discovery is enabled by default with `[iroh].local_discovery = true`.
|
||||||
SSH keys are used as admin trust anchors and ecosystem integration points.
|
SSH keys are used as admin trust anchors and ecosystem integration points.
|
||||||
OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through
|
OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through
|
||||||
canonical geth envelopes with explicit namespaces such as
|
canonical geth envelopes with explicit namespaces such as
|
||||||
`geth.keychain.v1@geth.local`. Future SSH proxying may carry SSH protocol bytes
|
`geth.keychain.v1@geth.local`. SSH proxying carries SSH protocol bytes over an
|
||||||
over authorized Iroh streams, but the geth transport remains Iroh.
|
authorized Iroh stream, but SSH is still not a geth transport backend.
|
||||||
|
|
||||||
SSH certificate request and renewal flows are managed as geth metadata. A node
|
SSH certificate request and renewal flows are managed as geth metadata. A node
|
||||||
can create a certificate request, another machine can approve it and receive an
|
can create a certificate request, another machine can approve it and receive an
|
||||||
|
|
@ -141,7 +141,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> [--bearer-secret <secret>]`
|
- `geth ssh revocation sync <node-id> [--bearer-secret <secret>]`
|
||||||
- SSH proxy authorization probe: `geth ssh proxy <node-id> [--bearer-secret <secret>]`
|
- SSH proxy over Iroh: `geth ssh proxy <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>]`,
|
||||||
|
|
@ -209,11 +209,12 @@ Remote pipe listen uses the same protected path:
|
||||||
`resource:pipe:<name>` before registering a daemon-lifetime listener on the
|
`resource:pipe:<name>` before registering a daemon-lifetime listener on the
|
||||||
peer. Long-lived stdin/stdout streaming and socket forwarding are still future
|
peer. Long-lived stdin/stdout streaming and socket forwarding are still future
|
||||||
work.
|
work.
|
||||||
`geth ssh proxy <node-id>` also uses the protected Iroh control path. The remote
|
`geth ssh proxy <node-id>` is usable as an OpenSSH `ProxyCommand`: the CLI opens
|
||||||
peer validates the caller's endpoint/card binding and requires
|
a local daemon stream, the daemon opens the dedicated `/geth/ssh-proxy/1` Iroh
|
||||||
`ssh_proxy.connect` on `resource:ssh-proxy:local` before returning proxy
|
ALPN, the remote daemon validates the caller's endpoint/card binding and
|
||||||
connection metadata. The current prototype does not carry SSH bytes or connect
|
requires `ssh_proxy.connect` on `resource:ssh-proxy:local`, and only then
|
||||||
to remote sshd yet; it only proves the authorization gate.
|
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.
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,17 @@ pub async fn run() -> Result<()> {
|
||||||
run_service_command(&paths, command).context("manage geth user service")?;
|
run_service_command(&paths, command).context("manage geth user service")?;
|
||||||
print_service_report(report, cli.json || cli.jsonl)?;
|
print_service_report(report, cli.json || cli.jsonl)?;
|
||||||
}
|
}
|
||||||
|
Command::Ssh {
|
||||||
|
command:
|
||||||
|
SshCommand::Proxy {
|
||||||
|
node,
|
||||||
|
bearer_secret,
|
||||||
|
},
|
||||||
|
} if !cli.json && !cli.jsonl => {
|
||||||
|
geth_node::stream_ssh_proxy(&paths, node, bearer_secret)
|
||||||
|
.await
|
||||||
|
.context("stream SSH proxy through geth daemon")?;
|
||||||
|
}
|
||||||
command => {
|
command => {
|
||||||
let request = request_for_command(command)?;
|
let request = request_for_command(command)?;
|
||||||
let response = geth_node::send_control(&paths, request)
|
let response = geth_node::send_control(&paths, request)
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,10 @@ pub enum ControlRequest {
|
||||||
node: String,
|
node: String,
|
||||||
bearer_secret: Option<String>,
|
bearer_secret: Option<String>,
|
||||||
},
|
},
|
||||||
|
SshProxyStream {
|
||||||
|
node: String,
|
||||||
|
bearer_secret: Option<String>,
|
||||||
|
},
|
||||||
DbAdd {
|
DbAdd {
|
||||||
name: String,
|
name: String,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
|
@ -1486,6 +1490,15 @@ mod tests {
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::SshProxyStream {
|
||||||
|
node: "node:peer".to_owned(),
|
||||||
|
bearer_secret: Some("bearer:test".to_owned()),
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{TcpStream, UnixListener, UnixStream};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum NodeError {
|
pub enum NodeError {
|
||||||
|
|
@ -248,6 +248,53 @@ pub async fn send_control(
|
||||||
Ok(geth_control::decode_response(&line)?)
|
Ok(geth_control::decode_response(&line)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn stream_ssh_proxy(
|
||||||
|
paths: &GethPaths,
|
||||||
|
peer_node: String,
|
||||||
|
bearer_secret: Option<String>,
|
||||||
|
) -> Result<(), NodeError> {
|
||||||
|
let mut stream = UnixStream::connect(paths.socket_path()).await?;
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
geth_control::encode_request(&ControlRequest::SshProxyStream {
|
||||||
|
node: peer_node,
|
||||||
|
bearer_secret,
|
||||||
|
})?
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).await?;
|
||||||
|
match geth_control::decode_response(&line)? {
|
||||||
|
ControlResponse::SshProxyConnected { allowed: true, .. } => {
|
||||||
|
let stream = reader.into_inner();
|
||||||
|
let (mut local_read, mut local_write) = stream.into_split();
|
||||||
|
let mut stdin = tokio::io::stdin();
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
|
let upload = async {
|
||||||
|
tokio::io::copy(&mut stdin, &mut local_write).await?;
|
||||||
|
local_write.shutdown().await
|
||||||
|
};
|
||||||
|
let download = async {
|
||||||
|
tokio::io::copy(&mut local_read, &mut stdout).await?;
|
||||||
|
stdout.flush().await
|
||||||
|
};
|
||||||
|
tokio::try_join!(upload, download)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
ControlResponse::SshProxyConnected {
|
||||||
|
allowed: false,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} => Err(NodeError::Unauthorized(reason)),
|
||||||
|
ControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||||
|
other => Err(NodeError::IrohPeer(format!(
|
||||||
|
"daemon returned unexpected SSH proxy stream response: {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_request_async(
|
pub async fn handle_request_async(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
request: ControlRequest,
|
request: ControlRequest,
|
||||||
|
|
@ -334,6 +381,14 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
reader.read_line(&mut line).await?;
|
reader.read_line(&mut line).await?;
|
||||||
let request = geth_control::decode_request(&line)?;
|
let request = geth_control::decode_request(&line)?;
|
||||||
|
if let ControlRequest::SshProxyStream {
|
||||||
|
node: peer_node,
|
||||||
|
bearer_secret,
|
||||||
|
} = request
|
||||||
|
{
|
||||||
|
let stream = reader.into_inner();
|
||||||
|
return handle_local_ssh_proxy_stream(node, &peer_node, bearer_secret, stream).await;
|
||||||
|
}
|
||||||
let response = match handle_request_async(&node, request).await {
|
let response = match handle_request_async(&node, request).await {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(error) => ControlResponse::Error {
|
Err(error) => ControlResponse::Error {
|
||||||
|
|
@ -1577,6 +1632,123 @@ async fn ssh_proxy_connect_to_peer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_local_ssh_proxy_stream(
|
||||||
|
node: LocalNode,
|
||||||
|
peer_node: &str,
|
||||||
|
bearer_secret: Option<String>,
|
||||||
|
local_stream: UnixStream,
|
||||||
|
) -> Result<(), NodeError> {
|
||||||
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
|
let stored = store
|
||||||
|
.get_peer_card(peer_node)?
|
||||||
|
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
|
||||||
|
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
||||||
|
peer_card.validate_candidate()?;
|
||||||
|
let candidate = peer_card
|
||||||
|
.endpoints
|
||||||
|
.first()
|
||||||
|
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
|
||||||
|
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
|
||||||
|
let node_addr = iroh_node_addr_from_candidate(candidate)?;
|
||||||
|
let endpoint = node
|
||||||
|
.iroh_endpoint
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
||||||
|
.clone()
|
||||||
|
.ok_or(NodeError::IrohEndpointUnavailable)?;
|
||||||
|
let self_card = local_peer_card(&node, DiscoverySource::PeerExchange, true).await?;
|
||||||
|
let nonce = geth_crypto::blake3_hex(
|
||||||
|
format!(
|
||||||
|
"{}\0{}\0ssh-proxy-stream\0{}",
|
||||||
|
node.node_id,
|
||||||
|
peer_node,
|
||||||
|
geth_store::now_ms()
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
let request = PeerControlRequest::SshProxyConnect {
|
||||||
|
peer_card: self_card,
|
||||||
|
nonce: nonce.clone(),
|
||||||
|
bearer_proof: bearer_proof(
|
||||||
|
bearer_secret,
|
||||||
|
"resource:ssh-proxy:local",
|
||||||
|
"ssh_proxy.connect",
|
||||||
|
&nonce,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let conn = endpoint
|
||||||
|
.endpoint()
|
||||||
|
.connect(node_addr, geth_iroh::ALPN_SSH_PROXY)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let (mut remote_send, remote_recv) = conn
|
||||||
|
.open_bi()
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
remote_send
|
||||||
|
.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let mut remote_reader = BufReader::new(remote_recv);
|
||||||
|
let mut line = String::new();
|
||||||
|
remote_reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let response = geth_control::decode_peer_response(&line)?;
|
||||||
|
let local_response = match response {
|
||||||
|
PeerControlResponse::SshProxyConnected {
|
||||||
|
node_id,
|
||||||
|
agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
connection,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
nonce: response_nonce,
|
||||||
|
..
|
||||||
|
} if response_nonce == nonce => ControlResponse::SshProxyConnected {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
connection,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
},
|
||||||
|
PeerControlResponse::Error { message } => ControlResponse::Error { message },
|
||||||
|
_ => ControlResponse::Error {
|
||||||
|
message: "peer returned wrong response type to SSH proxy stream".to_owned(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut local_stream = local_stream;
|
||||||
|
local_stream
|
||||||
|
.write_all(geth_control::encode_response(&local_response)?.as_bytes())
|
||||||
|
.await?;
|
||||||
|
let ControlResponse::SshProxyConnected { allowed: true, .. } = local_response else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let remote_recv = remote_reader.into_inner();
|
||||||
|
let (mut local_read, mut local_write) = local_stream.into_split();
|
||||||
|
let upload = async {
|
||||||
|
tokio::io::copy(&mut local_read, &mut remote_send)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
remote_send
|
||||||
|
.finish()
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
||||||
|
};
|
||||||
|
let mut remote_recv = remote_recv;
|
||||||
|
let download = async {
|
||||||
|
tokio::io::copy(&mut remote_recv, &mut local_write)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
local_write.shutdown().await.map_err(NodeError::from)
|
||||||
|
};
|
||||||
|
tokio::try_join!(upload, download)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn document_sync_from_peer(
|
async fn document_sync_from_peer(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
peer_node: &str,
|
peer_node: &str,
|
||||||
|
|
@ -2402,6 +2574,9 @@ async fn handle_iroh_control_connection(
|
||||||
.accept_bi()
|
.accept_bi()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
if alpn == display_alpn(geth_iroh::ALPN_SSH_PROXY.to_vec()) {
|
||||||
|
return handle_ssh_proxy_wire_connection(node, remote_endpoint_id, send, recv).await;
|
||||||
|
}
|
||||||
let bytes = recv
|
let bytes = recv
|
||||||
.read_to_end(64 * 1024)
|
.read_to_end(64 * 1024)
|
||||||
.await
|
.await
|
||||||
|
|
@ -3216,6 +3391,141 @@ async fn handle_iroh_control_connection(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_ssh_proxy_wire_connection(
|
||||||
|
node: LocalNode,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
mut send: iroh::endpoint::SendStream,
|
||||||
|
recv: iroh::endpoint::RecvStream,
|
||||||
|
) -> Result<(), NodeError> {
|
||||||
|
let mut reader = BufReader::new(recv);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
let request = geth_control::decode_peer_request(&line)?;
|
||||||
|
let PeerControlRequest::SshProxyConnect {
|
||||||
|
peer_card,
|
||||||
|
nonce,
|
||||||
|
bearer_proof,
|
||||||
|
} = request
|
||||||
|
else {
|
||||||
|
let response = PeerControlResponse::Error {
|
||||||
|
message: "SSH proxy stream expected ssh-proxy-connect handshake".to_owned(),
|
||||||
|
};
|
||||||
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
send.finish()
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = explain_peer_or_bearer(
|
||||||
|
&store,
|
||||||
|
peer_card.node_id.as_str(),
|
||||||
|
&resource,
|
||||||
|
&capability,
|
||||||
|
&nonce,
|
||||||
|
bearer_proof.as_ref(),
|
||||||
|
)?;
|
||||||
|
let connection = if explanation.allowed {
|
||||||
|
Some(SshProxyConnection {
|
||||||
|
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,
|
||||||
|
note: "authorized SSH proxy byte stream over Iroh; SSH is not a geth transport"
|
||||||
|
.to_owned(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if !explanation.allowed {
|
||||||
|
let response = 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: false,
|
||||||
|
reason: explanation.reason,
|
||||||
|
evaluated_ops: explanation.evaluated_ops,
|
||||||
|
nonce,
|
||||||
|
note: "SSH proxy denied before opening sshd; SSH is not a geth transport".to_owned(),
|
||||||
|
};
|
||||||
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
send.finish()
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tcp = match TcpStream::connect("127.0.0.1:22").await {
|
||||||
|
Ok(tcp) => tcp,
|
||||||
|
Err(error) => {
|
||||||
|
let response = PeerControlResponse::Error {
|
||||||
|
message: format!("authorized SSH proxy could not connect to 127.0.0.1:22: {error}"),
|
||||||
|
};
|
||||||
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
send.finish()
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let response = 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: true,
|
||||||
|
reason: explanation.reason,
|
||||||
|
evaluated_ops: explanation.evaluated_ops,
|
||||||
|
nonce,
|
||||||
|
note: "SSH proxy authenticated endpoint/card binding and required ssh_proxy.connect before connecting to local sshd; SSH is not a geth transport".to_owned(),
|
||||||
|
};
|
||||||
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
|
||||||
|
let mut recv = reader.into_inner();
|
||||||
|
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
||||||
|
let inbound = async {
|
||||||
|
tokio::io::copy(&mut recv, &mut tcp_write)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
tcp_write.shutdown().await.map_err(NodeError::from)
|
||||||
|
};
|
||||||
|
let outbound = async {
|
||||||
|
tokio::io::copy(&mut tcp_read, &mut send)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
send.finish()
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
||||||
|
};
|
||||||
|
tokio::try_join!(inbound, outbound)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_pipe_wire_request(
|
fn handle_pipe_wire_request(
|
||||||
node: &LocalNode,
|
node: &LocalNode,
|
||||||
remote_endpoint_id: &str,
|
remote_endpoint_id: &str,
|
||||||
|
|
@ -4629,7 +4939,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 { .. } => Err(NodeError::IrohEndpointUnavailable),
|
ControlRequest::SshProxyConnect { .. } | ControlRequest::SshProxyStream { .. } => {
|
||||||
|
Err(NodeError::IrohEndpointUnavailable)
|
||||||
|
}
|
||||||
ControlRequest::ModuleStub { module, command } => {
|
ControlRequest::ModuleStub { module, command } => {
|
||||||
Ok(ControlResponse::NotImplemented { module, command })
|
Ok(ControlResponse::NotImplemented { module, command })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,10 +216,14 @@ listener after the same endpoint/card and capability checks. `geth pipe recv
|
||||||
checking `pipe.listen` on the same resource. Long-lived stdin/stdout streams and
|
checking `pipe.listen` on the same resource. Long-lived stdin/stdout streams and
|
||||||
TCP/Unix forwarding are still future work.
|
TCP/Unix forwarding are still future work.
|
||||||
|
|
||||||
`geth-ssh-proxy` currently defines proxy target and connection metadata. The
|
`geth-ssh-proxy` defines proxy target and connection metadata. `geth ssh proxy
|
||||||
daemon can authorize a remote proxy attempt over the protected Iroh control ALPN
|
<node>` is a streaming command intended for OpenSSH `ProxyCommand`: the CLI
|
||||||
with `ssh_proxy.connect` on `resource:ssh-proxy:local`, but it does not yet
|
streams stdin/stdout through the local daemon, the local daemon dials the remote
|
||||||
forward bytes or connect to sshd/admin shell.
|
daemon with `/geth/ssh-proxy/1`, the remote daemon validates the signed peer card
|
||||||
|
against the observed Iroh EndpointID, reduces `ssh_proxy.connect` on
|
||||||
|
`resource:ssh-proxy:local`, and only then connects the Iroh stream to
|
||||||
|
`127.0.0.1:22`. OpenSSH still performs its normal login authentication over the
|
||||||
|
resulting byte stream. SSH is not used as a geth transport backend.
|
||||||
|
|
||||||
`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
|
||||||
|
|
|
||||||
|
|
@ -386,15 +386,16 @@ Goal: add authorized stream-oriented management workflows over Iroh.
|
||||||
|
|
||||||
- `[~]` SSH proxy over Iroh.
|
- `[~]` SSH proxy over Iroh.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` `geth ssh proxy <node>` contacts an imported peer over the protected
|
- `[x]` `geth ssh proxy <node>` contacts an imported peer over the dedicated
|
||||||
Iroh control ALPN.
|
`/geth/ssh-proxy/1` Iroh ALPN.
|
||||||
- `[x]` Remote daemon checks `ssh_proxy.connect` on
|
- `[x]` Remote daemon checks `ssh_proxy.connect` on
|
||||||
`resource:ssh-proxy:local` before returning proxy connection metadata.
|
`resource:ssh-proxy:local` before returning proxy connection metadata.
|
||||||
- `[x]` Tests cover denied and granted SSH proxy control-plane attempts.
|
- `[x]` Tests cover denied and granted SSH proxy control-plane attempts.
|
||||||
- `[x]` Knowing an EndpointID alone cannot reach sshd.
|
- `[x]` Knowing an EndpointID alone cannot reach sshd.
|
||||||
- `[ ]` Future completion opens a dedicated authorized Iroh byte stream.
|
- `[x]` The proxy opens a dedicated authorized Iroh byte stream.
|
||||||
- `[ ]` Remote daemon connects that stream to local sshd or a restricted
|
- `[x]` Remote daemon connects that stream to local sshd at `127.0.0.1:22`
|
||||||
built-in geth admin shell only after authorization.
|
only after authorization.
|
||||||
|
- `[ ]` 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