geth/crates/geth-node/src/lib.rs

13783 lines
499 KiB
Rust

pub mod backup;
mod daemon;
pub mod doctor;
mod local_control;
mod peer_client;
mod peer_control;
mod resource_contracts;
mod runtime;
pub mod service;
mod sync;
mod wire;
use base64::Engine;
pub use daemon::run_daemon;
#[cfg(test)]
use daemon::{spawn_iroh_control_accept_loop, start_daemon_iroh_endpoint};
use futures::StreamExt;
use geth_auth::{AUTH_SIGNATURE_NAMESPACE, AuthExplanation, AuthOp, AuthOpKind, AuthOpSignature};
use geth_cas::{
BlobInfoSummary, CasTreeEntryKind, CasTreeObject, FileConflict, FileConflictKind,
FileConflictResolution, FileConflictStatus, FileRoot, FileRootScan, LocalCas, hash_path,
};
use geth_config::{GethConfig, GethPaths};
use geth_control::{
CasBlob, CasProvider, ControlRequest, ControlResponse, KeychainStatusResponse,
NativeBackendStatus, NodeIdResponse, OverlayPeer, OverlayWireRequest, OverlayWireResponse,
PeerControlRequest, PeerControlResponse, PipeWireRequest, PipeWireResponse, StatusResponse,
SyncWatermark,
};
use geth_crypto::AgentKey;
use geth_db::DbResource;
use geth_discovery::{
DiscoveredPeer, DiscoverySource, EndpointCandidate, PEER_CARD_LAN_DISCOVERY_SERVICE, PeerCard,
discovery_is_untrusted_note, peer_card_from_txt_attributes, peer_card_txt_attributes,
};
use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohEndpoint};
use geth_keychain::{
KeychainOp, KeychainOpKind, KeychainOpSignature, NODE_ENROLLMENT_REQUEST_NAMESPACE,
NodeEnrollmentCapability, NodeEnrollmentProvenance, NodeEnrollmentRequest,
NodeEnrollmentStatus, node_enrollment_request_signing_payload,
};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener, PipeMessage};
use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret};
use geth_ssh_identity::{
SSH_CERT_ISSUANCE_NAMESPACE, SSH_CERT_REQUEST_NAMESPACE, SSH_REVOCATION_LIST_NAMESPACE,
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshDistributionLogEntry, SshRecordProvenance, SshRevocationEntry, SshRevocationExportFormat,
SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, certificate_id,
openssh_krl_spec, parse_openssh_krl_spec, revocation_id, ssh_cert_request_signing_payload,
ssh_certificate_signing_payload, ssh_public_key_fingerprint, ssh_revocation_signing_payload,
write_openssh_krl,
};
use geth_ssh_proxy::SshProxyConnection;
use geth_store::{
Store, StoredAuthOp, StoredAuthSignature, StoredDbResource, StoredDocumentResource,
StoredFileConflict, StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry,
StoredKvStore, StoredModuleState, StoredNodeEnrollmentRequest, StoredPeerCard, StoredResource,
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId,
};
use iroh::protocol::ProtocolHandler;
use iroh_docs::api::protocol::{AddrInfoOptions, ShareMode};
pub use local_control::handle_request_async;
use peer_client::{request_overlay_wire, request_peer_control, request_pipe_wire};
use peer_control::{
IrohAlpnRoute, PeerControlCaller, authenticate_peer_control_caller, classify_iroh_alpn,
display_alpn, ensure_peer_card_matches_endpoint, iroh_node_addr_from_candidate,
};
use runtime::{
NodeRuntime, OverlayTunCounters, OverlayTunRuntime, PipeRuntime, PubsubGossipTopicRuntime,
PubsubRuntime,
};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sync::{
KvDocsState, load_live_sync_cursor, store_live_sync_cursor, sync_now, sync_status_local,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot};
use tun_rs::{DeviceBuilder, Layer};
use wire::{
PEER_CONTROL_LINE_MAX, STREAM_HANDSHAKE_LINE_MAX, WIRE_REQUEST_LINE_MAX, finish_iroh_send,
read_iroh_line,
};
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("config error: {0}")]
Config(#[from] geth_config::ConfigError),
#[error("crypto error: {0}")]
Crypto(#[from] geth_crypto::CryptoError),
#[error("store error: {0}")]
Store(#[from] geth_store::StoreError),
#[error("cas error: {0}")]
Cas(#[from] geth_cas::CasError),
#[error("db error: {0}")]
Db(#[from] geth_db::DbError),
#[error("overlay error: {0}")]
Overlay(#[from] geth_overlay::OverlayError),
#[error("control error: {0}")]
Control(#[from] geth_control::ControlError),
#[error("codec error: {0}")]
Codec(#[from] geth_codec::CodecError),
#[error("keychain error: {0}")]
Keychain(#[from] geth_keychain::KeychainError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("backup error: {0}")]
Backup(String),
#[error("invalid resource kind: {0}")]
InvalidResourceKind(String),
#[error("owner init requires --admin-key so the owner trust anchor is recorded")]
OwnerInitRequiresAdminKey,
#[error("owner init requires --signing-key so the owner statement is signed")]
OwnerInitRequiresSigningKey,
#[error(
"{0} requires --signing-key so the keychain operation can replicate as a verified admin statement"
)]
SigningKeyRequired(String),
#[error("invalid init capability grant, expected <resource>=<capability>: {0}")]
InvalidInitGrant(String),
#[error("invalid enrollment capability, expected <resource>=<capability>: {0}")]
InvalidEnrollmentCapability(String),
#[error("invalid keychain snapshot, expected <name>=<path>: {0}")]
InvalidKeychainSnapshot(String),
#[error("node enrollment request not found: {0}")]
NodeEnrollmentRequestNotFound(String),
#[error("node enrollment request has invalid provenance: {0}")]
InvalidNodeEnrollment(String),
#[error("invalid db resource name: {0}")]
InvalidDbName(String),
#[error("db path does not exist or is not a file: {0}")]
InvalidDbPath(String),
#[error("db resource not found: {0}")]
DbNotFound(String),
#[error("invalid kv store name: {0}")]
InvalidKvName(String),
#[error("invalid kv key: {0}")]
InvalidKvKey(String),
#[error("kv store not found: {0}")]
KvNotFound(String),
#[error("invalid document name: {0}")]
InvalidDocumentName(String),
#[error("document not found: {0}")]
DocumentNotFound(String),
#[error("document error: {0}")]
Document(#[from] geth_document::DocumentError),
#[error("resource not found: {0}")]
ResourceNotFound(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("secrets error: {0}")]
Secrets(#[from] geth_secrets::SecretsError),
#[error("pubsub error: {0}")]
Pubsub(#[from] geth_pubsub::PubsubError),
#[error("pipe error: {0}")]
Pipe(#[from] geth_pipe::PipeError),
#[error("runtime state lock poisoned")]
RuntimeLockPoisoned,
#[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
InvalidSshCertStatus(String),
#[error("invalid ssh revocation kind: {0}")]
InvalidSshRevocationKind(String),
#[error("ssh certificate request not found: {0}")]
SshCertRequestNotFound(String),
#[error("ssh certificate request must include at least one principal")]
MissingSshCertPrincipal,
#[error("ssh certificate flow error: {0}")]
SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError),
#[error("ssh identity error: {0}")]
SshIdentity(#[from] geth_ssh_identity::SshIdentityError),
#[error("discovery error: {0}")]
Discovery(#[from] geth_discovery::DiscoveryError),
#[error("cannot export peer card before the daemon has an Iroh EndpointID")]
IrohEndpointUnavailable,
#[error("peer candidate not found: {0}")]
PeerNotFound(String),
#[error("iroh peer error: {0}")]
IrohPeer(String),
#[error("overlay runtime error: {0}")]
OverlayRuntime(String),
#[error("unsupported platform: {0}")]
UnsupportedPlatform(String),
}
#[derive(Clone, Debug)]
pub struct LocalNode {
pub paths: GethPaths,
pub agent_id: String,
pub node_id: String,
pub daemon_started_at_ms: i64,
pub iroh_status: EndpointStatus,
iroh_endpoint: Arc<Mutex<Option<GethIrohEndpoint>>>,
iroh_blob_store: Arc<Mutex<Option<iroh_blobs::store::fs::FsStore>>>,
iroh_docs: Arc<Mutex<Option<iroh_docs::protocol::Docs>>>,
iroh_gossip: Arc<Mutex<Option<iroh_gossip::net::Gossip>>>,
runtime: Arc<NodeRuntime>,
}
const PUBSUB_RING_LIMIT: usize = 256;
const PIPE_CONNECTION_RING_LIMIT: usize = 256;
const PIPE_MESSAGE_RING_LIMIT: usize = 1024;
struct PipeTcpConnectWire {
peer_card: PeerCard,
target_addr: String,
nonce: String,
bearer_proof: Option<BearerProof>,
}
struct PipeUnixConnectWire {
peer_card: PeerCard,
target_path: PathBuf,
nonce: String,
bearer_proof: Option<BearerProof>,
}
#[derive(Clone, Debug)]
pub struct InitOwnerOptions {
pub admin_key_path: Option<PathBuf>,
pub signing_key_path: Option<PathBuf>,
pub owner_name: String,
pub node_name: String,
pub capabilities: Vec<String>,
}
impl Default for InitOwnerOptions {
fn default() -> Self {
Self {
admin_key_path: None,
signing_key_path: None,
owner_name: "owner".to_owned(),
node_name: "local".to_owned(),
capabilities: Vec::new(),
}
}
}
impl InitOwnerOptions {
fn requests_owner_keychain(&self) -> bool {
self.admin_key_path.is_some()
|| self.signing_key_path.is_some()
|| self.node_name != "local"
|| self.owner_name != "owner"
|| !self.capabilities.is_empty()
}
}
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?;
if !paths.config_file().exists() {
std::fs::write(paths.config_file(), GethConfig::default_toml())?;
}
let key = AgentKey::load_or_create(&paths.agent_key())?;
let agent_id = key.agent_id().to_string();
let node_id = stable_node_id(&agent_id);
let store = Store::open(&paths.metadata_db())?;
store.upsert_agent(&agent_id, &key.public_key_hex())?;
store.upsert_node(&node_id, "local", &agent_id)?;
store.insert_resource(&StoredResource {
resource_id: "resource:cas:local".to_owned(),
kind: ResourceKind::Cas.to_string(),
name: "local-cas".to_owned(),
status: "active".to_owned(),
})?;
Ok(LocalNode {
paths: paths.clone(),
agent_id,
node_id,
daemon_started_at_ms: current_unix_ms(),
iroh_status: EndpointStatus::scaffolded(),
iroh_endpoint: Arc::new(Mutex::new(None)),
iroh_blob_store: Arc::new(Mutex::new(None)),
iroh_docs: Arc::new(Mutex::new(None)),
iroh_gossip: Arc::new(Mutex::new(None)),
runtime: Arc::new(NodeRuntime {
pubsub: Mutex::new(PubsubRuntime::default()),
pipes: Mutex::new(PipeRuntime::default()),
overlays: Mutex::new(BTreeMap::new()),
}),
})
}
fn current_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64
}
pub fn init_owned_node(
paths: &GethPaths,
options: InitOwnerOptions,
) -> Result<LocalNode, NodeError> {
let initialize_owner = options.requests_owner_keychain();
if initialize_owner && options.admin_key_path.is_none() {
return Err(NodeError::OwnerInitRequiresAdminKey);
}
if initialize_owner && options.signing_key_path.is_none() {
return Err(NodeError::OwnerInitRequiresSigningKey);
}
let node = init_node(paths)?;
if initialize_owner {
initialize_owner_keychain(&node, options)?;
}
Ok(node)
}
pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
init_node(paths)
}
fn initialize_owner_keychain(
node: &LocalNode,
options: InitOwnerOptions,
) -> Result<Vec<KeychainOp>, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let owner = UserId::new(format!("user:{}", stable_slug(&options.owner_name)));
let device = DeviceId::new(format!("device:{}", node.node_id));
let node_id = NodeId::new(node.node_id.clone());
let agent = node.agent_id.clone().into();
let now = UnixMillis(geth_store::now_ms());
let mut ops = vec![
KeychainOp {
id: generated_keychain_op_id("keychain-init", "owner", now),
created_at: now,
kind: KeychainOpKind::KeychainInit,
},
KeychainOp {
id: generated_keychain_op_id("user-add", owner.as_str(), now),
created_at: now,
kind: KeychainOpKind::UserAdd {
user: owner.clone(),
name: options.owner_name.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("device-add", device.as_str(), now),
created_at: now,
kind: KeychainOpKind::DeviceAdd {
device: device.clone(),
user: owner.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("node-add", node_id.as_str(), now),
created_at: now,
kind: KeychainOpKind::NodeAdd {
node: node_id.clone(),
device: device.clone(),
name: options.node_name.clone(),
},
},
KeychainOp {
id: generated_keychain_op_id("agent-bind", &node.agent_id, now),
created_at: now,
kind: KeychainOpKind::AgentBind {
agent,
node: node_id.clone(),
},
},
];
if let Some(admin_key_path) = options.admin_key_path.as_ref() {
let public_key = std::fs::read_to_string(admin_key_path)?;
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
ops.push(KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now),
created_at: now,
kind: KeychainOpKind::AdminKeyAdd {
key: admin_key,
public_key: Some(public_key.trim().to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
});
}
let signatures = store_and_sign_keychain_ops(
&store,
node,
&ops,
options.signing_key_path.as_deref(),
options.admin_key_path.as_deref(),
)?;
tracing::debug!(
ops = ops.len(),
signatures = signatures.len(),
"initialized owner keychain"
);
for grant in options.capabilities {
let (resource, capability) = grant
.split_once('=')
.ok_or_else(|| NodeError::InvalidInitGrant(grant.clone()))?;
record_node_grant(
&store,
node_id.as_str(),
resource,
capability,
Some(format!(
"grant:init:{}:{}",
stable_slug(node_id.as_str()),
stable_slug(capability)
)),
)?;
}
Ok(ops)
}
pub async fn send_control(
paths: &GethPaths,
request: ControlRequest,
) -> Result<ControlResponse, NodeError> {
let mut stream = UnixStream::connect(paths.socket_path()).await?;
stream
.write_all(geth_control::encode_request(&request)?.as_bytes())
.await?;
stream.shutdown().await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
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 run_tcp_forward(
paths: &GethPaths,
listen_addr: String,
peer_node: String,
target_addr: String,
bearer_secret: Option<String>,
) -> Result<(), NodeError> {
let listen_addr = geth_pipe::validate_tcp_forward_listen_addr(&listen_addr)?;
geth_pipe::validate_tcp_forward_target_addr(&target_addr)?;
let listener = TcpListener::bind(listen_addr).await?;
tracing::info!(
listen = %listen_addr,
peer = %peer_node,
target = %target_addr,
"tcp forward listening"
);
loop {
let (client, client_addr) = listener.accept().await?;
let paths = paths.clone();
let peer_node = peer_node.clone();
let target_addr = target_addr.clone();
let bearer_secret = bearer_secret.clone();
tokio::spawn(async move {
if let Err(error) =
stream_pipe_tcp(&paths, peer_node, target_addr, bearer_secret, client).await
{
tracing::warn!(%error, %client_addr, "tcp forward connection failed");
}
});
}
}
async fn stream_pipe_tcp(
paths: &GethPaths,
peer_node: String,
target_addr: String,
bearer_secret: Option<String>,
client: TcpStream,
) -> Result<(), NodeError> {
let mut stream = UnixStream::connect(paths.socket_path()).await?;
stream
.write_all(
geth_control::encode_request(&ControlRequest::PipeTcpStream {
node: peer_node,
target_addr,
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::PipeRemoteConnected { allowed: true, .. } => {
let daemon_stream = reader.into_inner();
let (mut daemon_read, mut daemon_write) = daemon_stream.into_split();
let (mut client_read, mut client_write) = client.into_split();
let upload = async {
tokio::io::copy(&mut client_read, &mut daemon_write).await?;
daemon_write.shutdown().await
};
let download = async {
tokio::io::copy(&mut daemon_read, &mut client_write).await?;
client_write.shutdown().await
};
tokio::try_join!(upload, download)?;
Ok(())
}
ControlResponse::PipeRemoteConnected {
allowed: false,
reason,
..
} => Err(NodeError::Unauthorized(reason)),
ControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
other => Err(NodeError::IrohPeer(format!(
"daemon returned unexpected pipe TCP stream response: {other:?}"
))),
}
}
pub async fn run_unix_forward(
paths: &GethPaths,
listen_path: PathBuf,
peer_node: String,
target_path: PathBuf,
bearer_secret: Option<String>,
) -> Result<(), NodeError> {
if !geth_pipe::unix_forward_supported() {
return Err(NodeError::UnsupportedPlatform(
geth_pipe::unix_forward_unsupported_message().to_owned(),
));
}
let listen_path = geth_pipe::validate_unix_forward_path(&listen_path)?;
geth_pipe::validate_unix_forward_path(&target_path)?;
let listener = UnixListener::bind(&listen_path)?;
tracing::info!(
listen = %listen_path.display(),
peer = %peer_node,
target = %target_path.display(),
"unix socket forward listening"
);
loop {
let (client, _) = listener.accept().await?;
let paths = paths.clone();
let peer_node = peer_node.clone();
let target_path = target_path.clone();
let bearer_secret = bearer_secret.clone();
tokio::spawn(async move {
if let Err(error) =
stream_pipe_unix(&paths, peer_node, target_path, bearer_secret, client).await
{
tracing::warn!(%error, "unix socket forward connection failed");
}
});
}
}
async fn stream_pipe_unix(
paths: &GethPaths,
peer_node: String,
target_path: PathBuf,
bearer_secret: Option<String>,
client: UnixStream,
) -> Result<(), NodeError> {
let mut stream = UnixStream::connect(paths.socket_path()).await?;
stream
.write_all(
geth_control::encode_request(&ControlRequest::PipeUnixStream {
node: peer_node,
target_path,
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::PipeRemoteConnected { allowed: true, .. } => {
let daemon_stream = reader.into_inner();
let (mut daemon_read, mut daemon_write) = daemon_stream.into_split();
let (mut client_read, mut client_write) = client.into_split();
let upload = async {
tokio::io::copy(&mut client_read, &mut daemon_write).await?;
daemon_write.shutdown().await
};
let download = async {
tokio::io::copy(&mut daemon_read, &mut client_write).await?;
client_write.shutdown().await
};
tokio::try_join!(upload, download)?;
Ok(())
}
ControlResponse::PipeRemoteConnected {
allowed: false,
reason,
..
} => Err(NodeError::Unauthorized(reason)),
ControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
other => Err(NodeError::IrohPeer(format!(
"daemon returned unexpected pipe Unix stream response: {other:?}"
))),
}
}
async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeError> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
let request = geth_control::decode_request(&line)?;
if let ControlRequest::SshProxyStream {
node: peer_node,
bearer_secret,
} = request
{
tracing::info!(
command = "ssh.proxy-stream",
peer_node = %peer_node,
resource = "resource:ssh-proxy:local",
capability = "ssh_proxy.connect",
stream = "ssh-proxy",
"streaming control request started"
);
let stream = reader.into_inner();
let result = handle_local_ssh_proxy_stream(node, &peer_node, bearer_secret, stream).await;
match &result {
Ok(_) => tracing::info!(
command = "ssh.proxy-stream",
peer_node = %peer_node,
resource = "resource:ssh-proxy:local",
capability = "ssh_proxy.connect",
stream = "ssh-proxy",
"streaming control request completed"
),
Err(error) => tracing::warn!(
command = "ssh.proxy-stream",
peer_node = %peer_node,
resource = "resource:ssh-proxy:local",
capability = "ssh_proxy.connect",
stream = "ssh-proxy",
error_code = local_control::node_error_code(error),
%error,
"streaming control request failed"
),
}
return result;
}
if let ControlRequest::PipeTcpStream {
node: peer_node,
target_addr,
bearer_secret,
} = request
{
let resource = format!("resource:pipe-tcp:{target_addr}");
tracing::info!(
command = "pipe.tcp-stream",
peer_node = %peer_node,
resource = %resource,
capability = "pipe.forward",
stream = "pipe-tcp",
"streaming control request started"
);
let stream = reader.into_inner();
let result =
handle_local_pipe_tcp_stream(node, &peer_node, target_addr, bearer_secret, stream)
.await;
match &result {
Ok(_) => tracing::info!(
command = "pipe.tcp-stream",
peer_node = %peer_node,
resource = %resource,
capability = "pipe.forward",
stream = "pipe-tcp",
"streaming control request completed"
),
Err(error) => tracing::warn!(
command = "pipe.tcp-stream",
peer_node = %peer_node,
resource = %resource,
capability = "pipe.forward",
stream = "pipe-tcp",
error_code = local_control::node_error_code(error),
%error,
"streaming control request failed"
),
}
return result;
}
if let ControlRequest::PipeUnixStream {
node: peer_node,
target_path,
bearer_secret,
} = request
{
let resource = format!("resource:pipe-unix:{}", target_path.display());
tracing::info!(
command = "pipe.unix-stream",
peer_node = %peer_node,
resource = %resource,
capability = "pipe.forward",
stream = "pipe-unix",
"streaming control request started"
);
let stream = reader.into_inner();
let result =
handle_local_pipe_unix_stream(node, &peer_node, target_path, bearer_secret, stream)
.await;
match &result {
Ok(_) => tracing::info!(
command = "pipe.unix-stream",
peer_node = %peer_node,
resource = %resource,
capability = "pipe.forward",
stream = "pipe-unix",
"streaming control request completed"
),
Err(error) => tracing::warn!(
command = "pipe.unix-stream",
peer_node = %peer_node,
resource = %resource,
capability = "pipe.forward",
stream = "pipe-unix",
error_code = local_control::node_error_code(error),
%error,
"streaming control request failed"
),
}
return result;
}
let response = match handle_request_async(&node, request).await {
Ok(response) => response,
Err(error) => ControlResponse::Error {
message: operator_error_message(&error),
},
};
let mut stream = reader.into_inner();
stream
.write_all(geth_control::encode_response(&response)?.as_bytes())
.await?;
Ok(())
}
fn operator_error_message(error: &NodeError) -> String {
let mut message = error.to_string();
if let Some(next) = operator_recovery_hint(error) {
message.push_str("\nnext: ");
message.push_str(&next);
}
message
}
fn operator_recovery_hint(error: &NodeError) -> Option<String> {
match error {
NodeError::PeerNotFound(peer) => Some(format!(
"import a signed peer card for {peer} with `geth peer import <path>`, then verify it with `geth peer list`"
)),
NodeError::IrohEndpointUnavailable => Some(
"run `geth status` to inspect Iroh endpoint state; restart with `geth daemon run` if the daemon was started without an endpoint".to_owned(),
),
NodeError::Unauthorized(_) => Some(
"run `geth auth explain <subject> <resource> <capability>` and then grant the missing capability with `geth auth grant ... --signing-key <admin-key>`".to_owned(),
),
NodeError::ResourceNotFound(resource) => Some(format!(
"run `geth resource list` to inspect resources or create {resource} with the matching module command"
)),
NodeError::DbNotFound(name) => Some(format!(
"register the database with `geth db add {name} <path>` or inspect existing DB resources with `geth resource list`"
)),
NodeError::KvNotFound(name) => Some(format!(
"create the KV store with `geth kv create {name}` or inspect existing resources with `geth resource list`"
)),
NodeError::DocumentNotFound(name) => Some(format!(
"create the document with `geth document create {name}` or inspect existing resources with `geth resource list`"
)),
NodeError::SshCertRequestNotFound(_) => Some(
"run `geth ssh cert requests` on the signing machine and sync metadata with `geth ssh cert sync <node>` if needed".to_owned(),
),
NodeError::InvalidDbPath(_) => Some(
"create the SQLite file first, then run `geth db add <name> <path>` again".to_owned(),
),
_ => None,
}
}
async fn start_peer_card_lan_discovery(node: &LocalNode) -> Option<swarm_discovery::DropGuard> {
if !node.iroh_status.local_discovery || !node.iroh_status.enabled {
return None;
}
let card = match local_peer_card(node, DiscoverySource::Mdns, true).await {
Ok(card) => card,
Err(error) => {
tracing::warn!(%error, "signed peer-card LAN discovery disabled");
return None;
}
};
let attributes = match peer_card_txt_attributes(&card) {
Ok(attributes) => attributes,
Err(error) => {
tracing::warn!(%error, "could not encode peer-card LAN TXT payload");
return None;
}
};
let Some((port, addrs)) = lan_discovery_addrs(&card) else {
tracing::warn!(
"signed peer-card LAN discovery disabled because no direct Iroh address is known"
);
return None;
};
let paths = node.paths.clone();
let self_node_id = node.node_id.clone();
let discoverer = match swarm_discovery::Discoverer::new_interactive(
PEER_CARD_LAN_DISCOVERY_SERVICE.to_owned(),
lan_discovery_peer_id(&node.agent_id),
)
.with_addrs(port, addrs)
.with_txt_attributes(attributes)
{
Ok(discoverer) => discoverer,
Err(error) => {
tracing::warn!(%error, "could not build peer-card LAN discovery");
return None;
}
}
.with_callback(move |_peer_id, peer| {
if peer.is_expiry() {
return;
}
let card = match peer_card_from_txt_attributes(peer.txt_attributes()) {
Ok(card) => card,
Err(error) => {
tracing::debug!(%error, "ignoring invalid peer-card LAN discovery payload");
return;
}
};
if card.node_id.as_str() == self_node_id {
return;
}
let discovered = match DiscoveredPeer::candidate(
card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::Mdns,
) {
Ok(discovered) => discovered,
Err(error) => {
tracing::debug!(%error, "ignoring invalid LAN peer-card candidate");
return;
}
};
match Store::open(&paths.metadata_db()).and_then(|store| {
store.upsert_peer_card(&StoredPeerCard {
peer_id: card.node_id.to_string(),
card_json: serde_json::to_string(&card).map_err(geth_store::StoreError::from)?,
updated_at_ms: discovered.discovered_at.0,
})
}) {
Ok(()) => tracing::debug!(node = %card.node_id, "stored LAN peer-card candidate"),
Err(error) => tracing::warn!(%error, "could not store LAN peer-card candidate"),
}
});
match discoverer.spawn(&tokio::runtime::Handle::current()) {
Ok(guard) => {
tracing::info!(
service = PEER_CARD_LAN_DISCOVERY_SERVICE,
"signed peer-card LAN discovery running"
);
Some(guard)
}
Err(error) => {
tracing::warn!(%error, "could not start signed peer-card LAN discovery");
None
}
}
}
fn lan_discovery_peer_id(agent_id: &str) -> String {
format!("geth-{}", geth_crypto::blake3_hex(agent_id.as_bytes()))
}
fn lan_discovery_addrs(card: &PeerCard) -> Option<(u16, Vec<std::net::IpAddr>)> {
let mut parsed = card
.endpoints
.iter()
.flat_map(|endpoint| endpoint.direct_addresses.iter())
.filter_map(|addr| addr.parse::<std::net::SocketAddr>().ok())
.collect::<Vec<_>>();
parsed.sort_unstable();
parsed.dedup();
let port = parsed.first()?.port();
let addrs = parsed
.into_iter()
.filter(|addr| addr.port() == port)
.map(|addr| addr.ip())
.collect::<Vec<_>>();
(!addrs.is_empty()).then_some((port, addrs))
}
async fn export_peer_card(
node: &LocalNode,
out: Option<std::path::PathBuf>,
include_node_addr: bool,
) -> Result<ControlResponse, NodeError> {
let card = local_peer_card(node, DiscoverySource::Manual, include_node_addr).await?;
if let Some(path) = &out {
std::fs::write(path, serde_json::to_string_pretty(&card)?)?;
}
Ok(ControlResponse::PeerCardExported {
card,
out,
note: discovery_is_untrusted_note().to_owned(),
})
}
async fn local_peer_card(
node: &LocalNode,
source: DiscoverySource,
include_node_addr: bool,
) -> Result<PeerCard, NodeError> {
let endpoint_id = node
.iroh_status
.endpoint_id
.clone()
.ok_or(NodeError::IrohEndpointUnavailable)?;
let endpoint = node
.iroh_endpoint
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.clone();
let node_addr = if include_node_addr {
match endpoint {
Some(endpoint) => endpoint.node_addr_snapshot().await.ok(),
None => None,
}
} else {
None
};
let candidate = EndpointCandidate {
endpoint_id,
relay_url: node_addr.as_ref().and_then(|addr| addr.relay_url.clone()),
direct_addresses: node_addr
.map(|addr| addr.direct_addresses)
.unwrap_or_default(),
source,
};
let key = AgentKey::load(&node.paths.agent_key())?;
PeerCard::signed(
NodeId::new(node.node_id.clone()),
&key,
vec![candidate],
UnixMillis(geth_store::now_ms()),
)
.map_err(NodeError::from)
}
async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse, 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{}\0{}", node.node_id, peer_node, geth_store::now_ms()).as_bytes(),
);
let request = PeerControlRequest::Ping {
peer_card: self_card,
nonce: nonce.clone(),
};
let conn = endpoint
.endpoint()
.connect(node_addr, geth_iroh::ALPN_CONTROL)
.await
.map_err(|error| NodeError::IrohPeer(format!("peer ping connect failed: {error}")))?;
let alpn = display_alpn(conn.alpn());
let (mut send, recv) = conn
.open_bi()
.await
.map_err(|error| NodeError::IrohPeer(format!("peer ping stream open failed: {error}")))?;
send.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(format!("peer ping request write failed: {error}")))?;
send.finish().map_err(|error| {
NodeError::IrohPeer(format!("peer ping request finish failed: {error}"))
})?;
send.stopped().await.map_err(|error| {
NodeError::IrohPeer(format!("peer ping request delivery failed: {error}"))
})?;
drop(send);
let mut recv = recv;
let response = read_iroh_line(&mut recv, PEER_CONTROL_LINE_MAX).await?;
match geth_control::decode_peer_response(&response)? {
PeerControlResponse::Pong {
node_id,
agent_id,
endpoint_id,
alpn: remote_alpn,
nonce: response_nonce,
note,
..
} if response_nonce == nonce => Ok(ControlResponse::PeerPinged {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
alpn: if remote_alpn == "unknown" {
alpn
} else {
remote_alpn
},
note,
}),
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
"peer ping response nonce did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
"peer returned auth-check response to ping request".to_owned(),
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::PipeListening { .. }
| PeerControlResponse::SshProxyConnected { .. }
| PeerControlResponse::SshAdminShellOutput { .. }
| PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to ping request".to_owned(),
)),
}
}
async fn peer_auth_check(
node: &LocalNode,
peer_node: &str,
resource: String,
capability: String,
) -> Result<ControlResponse, 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{}\0{}\0{}\0{}",
node.node_id,
peer_node,
resource,
capability,
geth_store::now_ms()
)
.as_bytes(),
);
let request = PeerControlRequest::AuthCheck {
peer_card: self_card,
resource: resource.clone(),
capability: capability.clone(),
nonce: nonce.clone(),
};
let conn = endpoint
.endpoint()
.connect(node_addr, geth_iroh::ALPN_CONTROL)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (mut send, recv) = conn
.open_bi()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.stopped()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
drop(send);
let mut recv = recv;
let response = read_iroh_line(&mut recv, PEER_CONTROL_LINE_MAX).await?;
match geth_control::decode_peer_response(&response)? {
PeerControlResponse::AuthChecked {
node_id,
agent_id,
endpoint_id,
resource: response_resource,
capability: response_capability,
allowed,
reason,
evaluated_ops,
nonce: response_nonce,
note,
..
} if response_nonce == nonce
&& response_resource == resource
&& response_capability == capability =>
{
Ok(ControlResponse::PeerAuthChecked {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
resource: response_resource,
capability: response_capability,
allowed,
reason,
evaluated_ops,
note,
})
}
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
"peer auth-check response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
"peer returned pong to auth-check request".to_owned(),
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::PipeListening { .. }
| PeerControlResponse::SshProxyConnected { .. }
| PeerControlResponse::SshAdminShellOutput { .. }
| PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to auth-check request".to_owned(),
)),
}
}
async fn cas_fetch_from_peer(
node: &LocalNode,
peer_node: &str,
hash: BlobHash,
bearer_secret: Option<String>,
) -> Result<ControlResponse, 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{}\0{}\0{}",
node.node_id,
peer_node,
hash.as_str(),
geth_store::now_ms()
)
.as_bytes(),
);
let request = PeerControlRequest::CasFetch {
peer_card: self_card,
hash: hash.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(bearer_secret, "resource:cas:local", "cas.fetch", &nonce),
};
let conn = endpoint
.endpoint()
.connect(node_addr.clone(), geth_iroh::ALPN_CONTROL)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
send.stopped()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
drop(send);
let bytes = recv
.read_to_end(64 * 1024 * 1024)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let text =
std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
match geth_control::decode_peer_response(text)? {
PeerControlResponse::CasFetched {
node_id,
agent_id,
endpoint_id,
hash: response_hash,
size_bytes,
content_base64: _,
allowed,
reason,
nonce: response_nonce,
note,
..
} if response_nonce == nonce && response_hash == hash => {
if !allowed {
return Ok(ControlResponse::CasFetched {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
hash: response_hash,
size_bytes: 0,
allowed,
reason,
note,
});
}
let info = fetch_blob_via_iroh_blobs(node, node_addr, response_hash.clone()).await?;
if info.hash != response_hash {
return Err(NodeError::IrohPeer(format!(
"peer returned content hash {} for requested {}",
info.hash, response_hash
)));
}
if info.size_bytes != size_bytes {
return Err(NodeError::IrohPeer(format!(
"peer authorized {size_bytes} CAS bytes but iroh-blobs fetched {} bytes",
info.size_bytes
)));
}
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
store.record_cas_provider(info.hash.as_str(), &node_id, &endpoint_id)?;
Ok(ControlResponse::CasFetched {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
hash: info.hash,
size_bytes: info.size_bytes,
allowed,
reason,
note,
})
}
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
"peer CAS fetch response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::KeychainSynced { .. }
| PeerControlResponse::AuthSynced { .. }
| PeerControlResponse::NodeEnrollmentSubmitted { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::PipeListening { .. }
| PeerControlResponse::SshProxyConnected { .. }
| PeerControlResponse::SshAdminShellOutput { .. }
| PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to CAS fetch".to_owned(),
)),
}
}
async fn fetch_blob_via_iroh_blobs(
node: &LocalNode,
node_addr: iroh::EndpointAddr,
hash: BlobHash,
) -> Result<geth_cas::BlobInfo, NodeError> {
let endpoint = node
.iroh_endpoint
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.clone()
.ok_or(NodeError::IrohEndpointUnavailable)?;
let blob_store = iroh_blob_store(node)?.ok_or_else(|| {
NodeError::IrohPeer(
"iroh-blobs store is unavailable; restart the daemon with native Iroh enabled"
.to_owned(),
)
})?;
let iroh_hash = iroh_blob_hash(&hash)?;
let conn = endpoint
.endpoint()
.connect(node_addr, iroh_blobs::ALPN)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs connect failed: {error}")))?;
blob_store
.remote()
.fetch(conn, iroh_blobs::HashAndFormat::raw(iroh_hash))
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs fetch failed: {error}")))?;
let bytes = blob_store
.get_bytes(iroh_hash)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs export failed: {error}")))?;
let cas = LocalCas::new(node.paths.cas_dir());
let info = cas.add_bytes(bytes.as_ref())?;
mirror_local_blob_to_iroh_blobs(node, &info.hash).await?;
Ok(info)
}
fn iroh_blob_store(node: &LocalNode) -> Result<Option<iroh_blobs::store::fs::FsStore>, NodeError> {
node.iroh_blob_store
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)
.map(|store| store.clone())
}
fn iroh_blob_hash(hash: &BlobHash) -> Result<iroh_blobs::Hash, NodeError> {
let bytes = hex::decode(hash.as_str()).map_err(|error| {
NodeError::IrohPeer(format!("invalid CAS hash for iroh-blobs: {error}"))
})?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| NodeError::IrohPeer("invalid CAS hash length for iroh-blobs".to_owned()))?;
Ok(iroh_blobs::Hash::from_bytes(bytes))
}
async fn mirror_local_blob_to_iroh_blobs(
node: &LocalNode,
hash: &BlobHash,
) -> Result<(), NodeError> {
let Some(blob_store) = iroh_blob_store(node)? else {
return Ok(());
};
let content = LocalCas::new(node.paths.cas_dir()).read_bytes(hash)?;
let tag = blob_store
.add_slice(&content)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs import failed: {error}")))?;
if tag.hash.to_string() != hash.as_str() {
return Err(NodeError::IrohPeer(format!(
"iroh-blobs imported hash {} but local CAS hash is {}",
tag.hash, hash
)));
}
Ok(())
}
async fn mirror_local_cas_to_iroh_blobs(
paths: &GethPaths,
blob_store: &iroh_blobs::store::fs::FsStore,
) -> Result<usize, NodeError> {
let cas = LocalCas::new(paths.cas_dir());
let mut count = 0;
for blob in cas.list()? {
let content = cas.read_bytes(&blob.hash)?;
let tag = blob_store
.add_slice(&content)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs import failed: {error}")))?;
if tag.hash.to_string() != blob.hash.as_str() {
return Err(NodeError::IrohPeer(format!(
"iroh-blobs imported hash {} but local CAS hash is {}",
tag.hash, blob.hash
)));
}
count += 1;
}
Ok(count)
}
async fn cas_root_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_cas::validate_file_root_name(name)?;
let response = request_peer_control(node, peer_node, "cas-root-sync", |peer_card, nonce| {
PeerControlRequest::CasRootSync {
peer_card,
name: name.to_owned(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:cas-tree:{name}"),
"cas.fetch",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::CasRootSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
root,
tree_content_base64,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::CasRootSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
root: None,
tree_bytes_imported: false,
sync_conflicts: Vec::new(),
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let mut tree_bytes_imported = false;
let mut imported_high_water = None;
let mut sync_conflicts = Vec::new();
let root = root
.map(|remote_root| {
let stored_name = remote_file_root_name(&node_id, &remote_root.name);
let stored_path = format!("remote:{node_id}:{}", remote_root.name);
let existing_remote_root = store.get_file_root_by_name(&stored_name)?;
if let Some(existing) = &existing_remote_root
&& existing.path != stored_path
{
return Err(NodeError::IrohPeer(format!(
"refusing to overwrite existing file root {} at {}",
existing.name, existing.path
)));
}
let base_tree = existing_remote_root
.as_ref()
.and_then(|existing| existing.latest_tree_json.as_deref())
.map(serde_json::from_str)
.transpose()?;
let mut remote_tree_json = None;
if let (Some(hash), Some(content)) = (
remote_root.latest_tree.clone(),
tree_content_base64.as_ref(),
) {
let decoded = base64::engine::general_purpose::STANDARD
.decode(content)
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let info = LocalCas::new(node.paths.cas_dir()).add_bytes(&decoded)?;
if info.hash != hash {
return Err(NodeError::IrohPeer(format!(
"peer returned file-root tree bytes hash {} for announced {}",
info.hash, hash
)));
}
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
tree_bytes_imported = true;
let remote_tree: CasTreeObject = geth_codec::decode_canonical(&decoded)?;
remote_tree_json = Some(serde_json::to_string(&remote_tree)?);
if let (Some(base_tree), Some(local_root)) =
(&base_tree, store.get_file_root_by_name(&remote_root.name)?)
&& local_root.latest_tree_json.is_some()
{
sync_conflicts.extend(record_sync_tree_conflicts(
&store,
&local_root,
existing_remote_root
.as_ref()
.and_then(|existing| existing.latest_tree_hash.as_deref()),
base_tree,
local_root.latest_tree_hash.as_deref(),
remote_root.latest_tree.as_ref(),
&remote_tree,
)?);
}
}
let stored = StoredFileRoot {
root_id: format!("file-root:remote:{node_id}:{}", remote_root.name),
resource_id: remote_root.resource.clone(),
name: stored_name,
path: stored_path,
latest_tree_hash: remote_root.latest_tree.as_ref().map(ToString::to_string),
latest_tree_json: remote_tree_json,
updated_at_ms: remote_root.updated_at_ms,
};
store.upsert_file_root(&stored)?;
imported_high_water = Some(remote_root.updated_at_ms);
Ok(file_root_from_stored(&stored))
})
.transpose()?;
if let Some(high_water_ms) = imported_high_water {
store_live_sync_cursor(
&store,
peer_node,
&format!("cas-tree:{response_name}"),
high_water_ms,
)?;
}
Ok(ControlResponse::CasRootSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
root,
tree_bytes_imported,
sync_conflicts,
allowed,
reason,
note,
})
}
PeerControlResponse::CasRootSynced { .. } => Err(NodeError::IrohPeer(
"peer file-root sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to file-root sync".to_owned(),
)),
}
}
fn remote_file_root_name(peer_node_id: &str, remote_name: &str) -> String {
let peer = peer_node_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch
} else {
'-'
}
})
.collect::<String>();
format!("remote-{peer}-{remote_name}")
}
async fn ssh_cert_sync_from_peer(
node: &LocalNode,
peer_node: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
let since_ms = load_live_sync_cursor(
&Store::open(&node.paths.metadata_db())?,
peer_node,
"ssh-certs",
)?;
let response = request_peer_control(node, peer_node, "ssh-cert-sync", |peer_card, nonce| {
PeerControlRequest::SshCertSync {
peer_card,
since_ms,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh:certs",
"ssh_cert.sync",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::SshCertSynced {
node_id,
agent_id,
endpoint_id,
requests,
certificates,
log_entries,
high_water_ms,
allowed,
reason,
note,
..
} => {
if !allowed {
return Ok(ControlResponse::SshCertSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
requests_imported: 0,
certificates_imported: 0,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let entries = if log_entries.is_empty() {
ssh_cert_distribution_log_entries(requests, certificates)
} else {
log_entries
};
let (requests_imported, certificates_imported, _) =
import_ssh_distribution_log_entries(&store, &entries)?;
store_live_sync_cursor(&store, peer_node, "ssh-certs", high_water_ms)?;
Ok(ControlResponse::SshCertSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
requests_imported,
certificates_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to SSH cert sync".to_owned(),
)),
}
}
async fn ssh_revocation_sync_from_peer(
node: &LocalNode,
peer_node: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
let since_ms = load_live_sync_cursor(
&Store::open(&node.paths.metadata_db())?,
peer_node,
"ssh-revocations",
)?;
let response = request_peer_control(
node,
peer_node,
"ssh-revocation-sync",
|peer_card, nonce| PeerControlRequest::SshRevocationSync {
peer_card,
since_ms,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh:revocations",
"ssh_revocation.sync",
&nonce,
),
},
)
.await?;
match response {
PeerControlResponse::SshRevocationSynced {
node_id,
agent_id,
endpoint_id,
revocations,
log_entries,
high_water_ms,
allowed,
reason,
note,
..
} => {
if !allowed {
return Ok(ControlResponse::SshRevocationSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
revocations_imported: 0,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let entries = if log_entries.is_empty() {
ssh_revocation_distribution_log_entries(revocations)
} else {
log_entries
};
let (_, _, revocations_imported) =
import_ssh_distribution_log_entries(&store, &entries)?;
store_live_sync_cursor(&store, peer_node, "ssh-revocations", high_water_ms)?;
Ok(ControlResponse::SshRevocationSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
revocations_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to SSH revocation sync".to_owned(),
)),
}
}
async fn kv_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_kv::validate_kv_name(name).map_err(|_| NodeError::InvalidKvName(name.to_owned()))?;
let stream = format!("kv:{name}");
let since_ms =
load_live_sync_cursor(&Store::open(&node.paths.metadata_db())?, peer_node, &stream)?;
let response = request_peer_control(node, peer_node, "kv-sync", |peer_card, nonce| {
PeerControlRequest::KvSync {
peer_card,
name: name.to_owned(),
since_ms,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:kv:{name}"),
"kv.read",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::KvSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
entries,
high_water_ms,
docs_ticket,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::KvSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
entries_imported: 0,
allowed,
reason,
note,
});
}
let mut entries_imported = 0;
if let Some(docs_ticket) = docs_ticket {
match import_kv_entries_from_iroh_docs(node, name, &docs_ticket).await {
Ok(imported) => entries_imported += imported,
Err(error) => {
tracing::debug!(%error, name, "best-effort iroh-docs KV import failed; falling back to peer-control entries")
}
}
}
let store = Store::open(&node.paths.metadata_db())?;
let kv = ensure_local_kv_store(&store, name)?;
for entry in entries {
geth_kv::validate_kv_key(&entry.key)
.map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?;
let should_import = should_import_kv_sync_entry(
store
.get_kv_entry(&kv.kv_id, &entry.key)?
.as_ref()
.map(|local| local.updated_at_ms),
entry.updated_at_ms,
);
if should_import {
store.set_kv_entry(&StoredKvEntry {
kv_id: kv.kv_id.clone(),
key: entry.key,
value: entry.value,
updated_at_ms: entry.updated_at_ms,
})?;
entries_imported += 1;
}
}
store_live_sync_cursor(&store, peer_node, &stream, high_water_ms)?;
drop(store);
mirror_kv_store_to_iroh_docs(node, name).await?;
Ok(ControlResponse::KvSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
entries_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
"peer KV sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to KV sync".to_owned(),
)),
}
}
fn iroh_docs(node: &LocalNode) -> Result<Option<iroh_docs::protocol::Docs>, NodeError> {
node.iroh_docs
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)
.map(|docs| docs.clone())
}
fn iroh_gossip(node: &LocalNode) -> Result<Option<iroh_gossip::net::Gossip>, NodeError> {
node.iroh_gossip
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)
.map(|gossip| gossip.clone())
}
fn kv_docs_state_key(name: &str) -> String {
format!("kv-doc:{name}")
}
fn kv_docs_meta_key() -> &'static str {
"__geth/kv/name"
}
async fn ensure_kv_docs_state(
node: &LocalNode,
name: &str,
) -> Result<Option<(iroh_docs::api::Doc, KvDocsState)>, NodeError> {
let Some(docs) = iroh_docs(node)? else {
return Ok(None);
};
let stored_state = Store::open(&node.paths.metadata_db())?
.get_module_state(&kv_docs_state_key(name))?
.map(|stored| serde_json::from_str::<KvDocsState>(&stored.state_json))
.transpose()?;
if let Some(state) = stored_state {
let namespace = state
.namespace_id
.parse::<iroh_docs::NamespaceId>()
.map_err(|error| NodeError::IrohPeer(format!("invalid KV docs namespace: {error}")))?;
if let Some(doc) = docs
.open(namespace)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs open failed: {error}")))?
{
return Ok(Some((doc, state)));
}
}
let doc = docs
.create()
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs create failed: {error}")))?;
let ticket = doc
.share(ShareMode::Read, AddrInfoOptions::RelayAndAddresses)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs share failed: {error}")))?;
let state = KvDocsState {
name: name.to_owned(),
namespace_id: doc.id().to_string(),
read_ticket: ticket.to_string(),
updated_at_ms: geth_store::now_ms(),
};
let store = Store::open(&node.paths.metadata_db())?;
store.put_module_state(&StoredModuleState {
module: kv_docs_state_key(name),
state_json: serde_json::to_string(&state)?,
updated_at_ms: state.updated_at_ms,
})?;
Ok(Some((doc, state)))
}
async fn kv_docs_read_ticket(node: &LocalNode, name: &str) -> Result<Option<String>, NodeError> {
Ok(ensure_kv_docs_state(node, name)
.await?
.map(|(_, state)| state.read_ticket))
}
async fn mirror_kv_store_to_iroh_docs(node: &LocalNode, name: &str) -> Result<(), NodeError> {
let Some(docs) = iroh_docs(node)? else {
return Ok(());
};
let entries = {
let store = Store::open(&node.paths.metadata_db())?;
let kv = ensure_local_kv_store(&store, name)?;
store.list_kv_entries_since(&kv.kv_id, 0)?
};
let Some((doc, _state)) = ensure_kv_docs_state(node, name).await? else {
return Ok(());
};
let author = docs
.author_default()
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs author failed: {error}")))?;
doc.set_bytes(
author,
kv_docs_meta_key().as_bytes().to_vec(),
name.as_bytes().to_vec(),
)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs set metadata failed: {error}")))?;
for entry in entries {
doc.set_bytes(author, entry.key.into_bytes(), entry.value.into_bytes())
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs set failed: {error}")))?;
}
Ok(())
}
async fn mirror_all_kv_stores_to_iroh_docs(node: &LocalNode) -> Result<usize, NodeError> {
if iroh_docs(node)?.is_none() {
return Ok(0);
}
let store = Store::open(&node.paths.metadata_db())?;
let mut mirrored = 0;
for kv in store.list_kv_stores()? {
mirror_kv_store_to_iroh_docs(node, &kv.name).await?;
mirrored += 1;
}
Ok(mirrored)
}
async fn import_kv_entries_from_iroh_docs(
node: &LocalNode,
name: &str,
ticket: &str,
) -> Result<usize, NodeError> {
let Some(docs) = iroh_docs(node)? else {
return Ok(0);
};
let Some(blob_store) = iroh_blob_store(node)? else {
return Ok(0);
};
let ticket = ticket
.parse::<iroh_docs::DocTicket>()
.map_err(|error| NodeError::IrohPeer(format!("invalid iroh-docs ticket: {error}")))?;
let doc = docs
.import(ticket.clone())
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs import failed: {error}")))?;
// Imported read tickets can be read-only replicas. Do not store them as the
// local writable KV document state; local mirroring creates its own document.
let mut imported = 0;
for _ in 0..5 {
imported += import_kv_doc_entries_once(node, name, &blob_store, &doc).await?;
if imported > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Ok(imported)
}
async fn import_kv_doc_entries_once(
node: &LocalNode,
name: &str,
blob_store: &iroh_blobs::store::fs::FsStore,
doc: &iroh_docs::api::Doc,
) -> Result<usize, NodeError> {
let mut imported_entries = Vec::new();
let entries = doc
.get_many(iroh_docs::store::Query::single_latest_per_key())
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs query failed: {error}")))?;
futures::pin_mut!(entries);
while let Some(entry) = entries.next().await {
let entry = entry
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs entry failed: {error}")))?;
let key = String::from_utf8(entry.key().to_vec())
.map_err(|error| NodeError::IrohPeer(format!("invalid KV docs key: {error}")))?;
if key == kv_docs_meta_key() {
continue;
}
geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?;
if entry.content_len() > 1024 * 1024 {
return Err(NodeError::IrohPeer(format!(
"KV docs value for {key} is too large: {} bytes",
entry.content_len()
)));
}
let value = blob_store
.get_bytes(entry.content_hash())
.await
.map_err(|error| {
NodeError::IrohPeer(format!("iroh-docs blob export failed: {error}"))
})?;
let value = String::from_utf8(value.to_vec())
.map_err(|error| NodeError::IrohPeer(format!("invalid KV docs value: {error}")))?;
let updated_at_ms = (entry.timestamp() / 1000) as i64;
imported_entries.push((key, value, updated_at_ms));
}
let store = Store::open(&node.paths.metadata_db())?;
let kv = ensure_local_kv_store(&store, name)?;
let mut imported = 0;
for (key, value, updated_at_ms) in imported_entries {
let should_import = store
.get_kv_entry(&kv.kv_id, &key)?
.is_none_or(|local| updated_at_ms >= local.updated_at_ms);
if should_import {
store.set_kv_entry(&StoredKvEntry {
kv_id: kv.kv_id.clone(),
key,
value,
updated_at_ms,
})?;
imported += 1;
}
}
Ok(imported)
}
async fn pubsub_publish_to_peer(
node: &LocalNode,
peer_node: &str,
topic: String,
message: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let bootstrap_peer = peer_gossip_endpoint_id(node, peer_node)?;
ensure_pubsub_gossip_topic(node, topic.as_str(), vec![bootstrap_peer]).await?;
let response = request_peer_control(node, peer_node, "pubsub-publish", |peer_card, nonce| {
PeerControlRequest::PubsubPublish {
peer_card,
topic: topic.clone(),
message: message.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pubsub:{topic}"),
"pubsub.publish",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::PubsubPublished {
node_id,
agent_id,
endpoint_id,
message,
allowed,
reason,
note,
..
} => {
let message = message.unwrap_or_else(|| PubsubMessage {
topic: topic.into(),
message: String::new(),
published_at: UnixMillis(0),
});
Ok(ControlResponse::PubsubRemotePublished {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
message,
allowed,
reason,
note,
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to pubsub publish".to_owned(),
)),
}
}
async fn pubsub_subscribe_from_peer(
node: &LocalNode,
peer_node: &str,
topic: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?;
let bootstrap_peer = peer_gossip_endpoint_id(node, peer_node)?;
ensure_pubsub_gossip_topic(node, topic.as_str(), vec![bootstrap_peer]).await?;
let response = request_peer_control(node, peer_node, "pubsub-subscribe", |peer_card, nonce| {
PeerControlRequest::PubsubSubscribe {
peer_card,
topic: topic.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pubsub:{topic}"),
"pubsub.subscribe",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::PubsubSubscribed {
node_id,
agent_id,
endpoint_id,
topic: response_topic,
messages,
allowed,
reason,
note,
..
} if response_topic == topic => Ok(ControlResponse::PubsubRemoteMessages {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
topic: response_topic,
messages,
allowed,
reason,
note,
}),
PeerControlResponse::PubsubSubscribed { .. } => Err(NodeError::IrohPeer(
"peer pubsub subscribe response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to pubsub subscribe".to_owned(),
)),
}
}
async fn pipe_connect_to_peer(
node: &LocalNode,
peer_node: &str,
target: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_pipe::validate_pipe_name(&target)?;
let response = request_peer_control(node, peer_node, "pipe-connect", |peer_card, nonce| {
PeerControlRequest::PipeConnect {
peer_card,
target: target.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pipe:{target}"),
"pipe.connect",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::PipeConnected {
node_id,
agent_id,
endpoint_id,
connection,
allowed,
reason,
note,
..
} => {
let connection = connection.unwrap_or_else(|| PipeConnection {
target,
connected_at: UnixMillis(0),
local_listener_found: false,
note: geth_pipe::pipe_roadmap().to_owned(),
});
Ok(ControlResponse::PipeRemoteConnected {
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 pipe connect".to_owned(),
)),
}
}
async fn pipe_listen_on_peer(
node: &LocalNode,
peer_node: &str,
name: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_pipe::validate_pipe_name(&name)?;
let response = request_peer_control(node, peer_node, "pipe-listen", |peer_card, nonce| {
PeerControlRequest::PipeListen {
peer_card,
name: name.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pipe:{name}"),
"pipe.listen",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::PipeListening {
node_id,
agent_id,
endpoint_id,
listener,
allowed,
reason,
note,
..
} => Ok(ControlResponse::PipeRemoteListening {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
listener,
allowed,
reason,
note,
}),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to pipe listen".to_owned(),
)),
}
}
async fn pipe_send_to_peer(
node: &LocalNode,
peer_node: &str,
target: String,
data_base64: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_pipe::validate_pipe_name(&target)?;
base64::engine::general_purpose::STANDARD
.decode(&data_base64)
.map_err(|error| NodeError::IrohPeer(format!("invalid pipe base64 payload: {error}")))?;
let response = request_pipe_wire(node, peer_node, "pipe-send", |peer_card, nonce| {
PipeWireRequest::Send {
peer_card,
target: target.clone(),
data_base64: data_base64.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pipe:{target}"),
"pipe.connect",
&nonce,
),
}
})
.await?;
match response {
PipeWireResponse::Sent {
node_id,
agent_id,
endpoint_id,
message,
listener_found,
allowed,
reason,
note,
..
} => Ok(ControlResponse::PipeRemoteSent {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
message: *message,
listener_found,
allowed,
reason,
note,
}),
PipeWireResponse::Error { message } => Err(NodeError::IrohPeer(message)),
PipeWireResponse::Connected { .. } => Err(NodeError::IrohPeer(
"peer returned TCP stream response to pipe send".to_owned(),
)),
}
}
async fn overlay_send_to_peer(
node: &LocalNode,
peer_node: &str,
name: String,
packet_base64: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_overlay::validate_overlay_name(&name)?;
let packet = base64::engine::general_purpose::STANDARD
.decode(&packet_base64)
.map_err(|error| NodeError::IrohPeer(format!("invalid overlay packet base64: {error}")))?;
geth_overlay::validate_ipv4_packet(&packet)?;
let resource = geth_overlay::overlay_resource_id(&name).to_string();
let response = request_overlay_wire(node, peer_node, "overlay-send", |peer_card, nonce| {
OverlayWireRequest::Packet {
peer_card,
network: name.clone(),
packet_base64: packet_base64.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(bearer_secret, &resource, "overlay.route", &nonce),
}
})
.await?;
match response {
OverlayWireResponse::PacketAccepted {
node_id,
agent_id,
endpoint_id,
packet,
allowed,
reason,
note,
..
} => Ok(ControlResponse::OverlayPacketSent {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
packet: packet.map(|packet| *packet),
allowed,
reason,
note,
}),
OverlayWireResponse::Error { message } => Err(NodeError::IrohPeer(message)),
}
}
async fn overlay_runtime_up(
node: &LocalNode,
name: String,
bearer_secret: Option<String>,
mtu: Option<u16>,
) -> Result<ControlResponse, NodeError> {
geth_overlay::validate_overlay_name(&name)?;
let store = Store::open(&node.paths.metadata_db())?;
let membership = overlay_membership(&store, &name)?.ok_or_else(|| {
NodeError::OverlayRuntime(format!(
"overlay `{name}` is not joined; run `geth overlay join {name} --secret <secret>` first"
))
})?;
if membership.state != geth_overlay::OverlayState::Joined {
return Err(NodeError::OverlayRuntime(format!(
"overlay `{name}` is not joined; current state is {:?}",
membership.state
)));
}
let virtual_ip = membership.virtual_ip.clone().ok_or_else(|| {
NodeError::OverlayRuntime(format!("overlay `{name}` does not have a virtual IP"))
})?;
let plan = geth_overlay::plan_overlay(
&name,
Some(&membership.cidr),
&String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY),
)?;
let interface = geth_overlay::interface_plan(&plan, None, membership.virtual_ip.as_deref())?;
let mtu = mtu.unwrap_or(1280);
{
let overlays = node
.runtime
.overlays
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
if let Some(runtime) = overlays.get(&name) {
return Ok(ControlResponse::OverlayRuntimeStarted {
status: overlay_runtime_status(runtime)?,
});
}
}
let device = DeviceBuilder::new()
.name(interface.interface_name.clone())
.ipv4(
virtual_ip.as_str(),
geth_overlay::cidr_prefix(&membership.cidr)?,
None,
)
.layer(Layer::L3)
.mtu(mtu)
.build_async()
.map_err(|error| {
NodeError::OverlayRuntime(format!(
"failed to create TUN/Wintun device `{}`: {error}. On Linux this usually means /dev/net/tun is unavailable or CAP_NET_ADMIN/sudo is required",
interface.interface_name
))
})?;
let (inject_tx, inject_rx) = mpsc::channel(1024);
let (stop_tx, stop_rx) = oneshot::channel();
let counters = Arc::new(Mutex::new(OverlayTunCounters::default()));
let started_at_ms = geth_store::now_ms();
let runtime = OverlayTunRuntime {
name: name.clone(),
interface_name: interface.interface_name.clone(),
virtual_ip: virtual_ip.clone(),
cidr: membership.cidr.clone(),
mtu,
started_at_ms,
inject_tx,
stop_tx: Some(stop_tx),
counters: counters.clone(),
};
{
let mut overlays = node
.runtime
.overlays
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
overlays.insert(name.clone(), runtime);
}
spawn_overlay_tun_task(
node.clone(),
OverlayTunTask {
name: name.clone(),
cidr: membership.cidr,
local_node_id: node.node_id.clone(),
bearer_secret,
device,
inject_rx,
stop_rx,
counters,
},
);
let overlays = node
.runtime
.overlays
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let runtime = overlays.get(&name).ok_or_else(|| {
NodeError::OverlayRuntime(format!("overlay `{name}` runtime did not start"))
})?;
Ok(ControlResponse::OverlayRuntimeStarted {
status: overlay_runtime_status(runtime)?,
})
}
fn overlay_runtime_down(node: &LocalNode, name: &str) -> Result<ControlResponse, NodeError> {
geth_overlay::validate_overlay_name(name)?;
let mut runtime = node
.runtime
.overlays
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.remove(name);
let stopped = if let Some(runtime) = runtime.as_mut() {
if let Some(stop_tx) = runtime.stop_tx.take() {
let _ = stop_tx.send(());
}
true
} else {
false
};
Ok(ControlResponse::OverlayRuntimeStopped {
name: name.to_owned(),
stopped,
note: if stopped {
"overlay runtime stop requested; the TUN/Wintun device is dropped by the daemon task"
} else {
"overlay runtime was not active"
}
.to_owned(),
})
}
struct OverlayTunTask {
name: String,
cidr: String,
local_node_id: String,
bearer_secret: Option<String>,
device: tun_rs::AsyncDevice,
inject_rx: mpsc::Receiver<Vec<u8>>,
stop_rx: oneshot::Receiver<()>,
counters: Arc<Mutex<OverlayTunCounters>>,
}
fn spawn_overlay_tun_task(node: LocalNode, mut task: OverlayTunTask) {
tokio::spawn(async move {
let mut buf = vec![0_u8; 65_535];
loop {
tokio::select! {
_ = &mut task.stop_rx => {
break;
}
maybe_packet = task.inject_rx.recv() => {
match maybe_packet {
Some(packet) => {
match task.device.send(&packet).await {
Ok(_) => overlay_counter(&task.counters, |counters| counters.packets_to_tun += 1),
Err(error) => overlay_counter(&task.counters, |counters| counters.last_error = Some(format!("write to TUN failed: {error}"))),
}
}
None => break,
}
}
result = task.device.recv(&mut buf) => {
match result {
Ok(len) => {
let packet = buf[..len].to_vec();
if let Err(error) = geth_overlay::validate_ipv4_packet(&packet) {
overlay_counter(&task.counters, |counters| counters.last_error = Some(error.to_string()));
continue;
}
overlay_counter(&task.counters, |counters| counters.packets_from_tun += 1);
match route_overlay_tun_packet(&node, &task, packet).await {
Ok(routed) if routed => overlay_counter(&task.counters, |counters| counters.packets_to_peers += 1),
Ok(_) => {}
Err(error) => overlay_counter(&task.counters, |counters| counters.last_error = Some(error.to_string())),
}
}
Err(error) => {
overlay_counter(&task.counters, |counters| counters.last_error = Some(format!("read from TUN failed: {error}")));
break;
}
}
}
}
}
if let Ok(mut overlays) = node.runtime.overlays.lock() {
overlays.remove(&task.name);
}
});
}
async fn route_overlay_tun_packet(
node: &LocalNode,
task: &OverlayTunTask,
packet: Vec<u8>,
) -> Result<bool, NodeError> {
let (_, destination) = geth_overlay::ipv4_source_destination(&packet)?;
let local_ip = geth_overlay::deterministic_virtual_ip(&task.cidr, &task.local_node_id)?;
if destination == local_ip {
return Ok(false);
}
let store = Store::open(&node.paths.metadata_db())?;
let Some(peer_node) = overlay_peer_for_virtual_ip(&store, &task.cidr, &destination)? else {
return Ok(false);
};
let packet_base64 = base64::engine::general_purpose::STANDARD.encode(packet);
let response = overlay_send_to_peer(
node,
&peer_node,
task.name.clone(),
packet_base64,
task.bearer_secret.clone(),
)
.await?;
match response {
ControlResponse::OverlayPacketSent { allowed: true, .. } => Ok(true),
ControlResponse::OverlayPacketSent { reason, .. } => Err(NodeError::Unauthorized(format!(
"overlay route denied: {reason}"
))),
other => Err(NodeError::OverlayRuntime(format!(
"unexpected overlay send response: {other:?}"
))),
}
}
async fn handle_local_pipe_tcp_stream(
node: LocalNode,
peer_node: &str,
target_addr: String,
bearer_secret: Option<String>,
local_stream: UnixStream,
) -> Result<(), NodeError> {
geth_pipe::validate_tcp_forward_target_addr(&target_addr)?;
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{}\0pipe-tcp-stream\0{}\0{}",
node.node_id,
peer_node,
target_addr,
geth_store::now_ms()
)
.as_bytes(),
);
let resource = format!("resource:pipe-tcp:{target_addr}");
let request = PipeWireRequest::TcpConnect {
peer_card: self_card,
target_addr: target_addr.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(bearer_secret, &resource, "pipe.forward", &nonce),
};
let conn = endpoint
.endpoint()
.connect(node_addr, geth_iroh::ALPN_PIPE)
.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_pipe_wire_request(&request)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let mut remote_recv = remote_recv;
let line = read_iroh_line(&mut remote_recv, STREAM_HANDSHAKE_LINE_MAX).await?;
let response = geth_control::decode_pipe_wire_response(&line)?;
let local_response = match response {
PipeWireResponse::Connected {
node_id,
agent_id,
endpoint_id,
connection,
allowed,
reason,
note,
nonce: response_nonce,
..
} if response_nonce == nonce => ControlResponse::PipeRemoteConnected {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
connection: connection.unwrap_or_else(|| PipeConnection {
target: target_addr.clone(),
connected_at: UnixMillis(0),
local_listener_found: false,
note: "pipe TCP forward was denied before connecting".to_owned(),
}),
allowed,
reason,
note,
},
PipeWireResponse::Error { message } => ControlResponse::Error { message },
_ => ControlResponse::Error {
message: "peer returned wrong response type to pipe TCP stream".to_owned(),
},
};
let mut local_stream = local_stream;
local_stream
.write_all(geth_control::encode_response(&local_response)?.as_bytes())
.await?;
let ControlResponse::PipeRemoteConnected { allowed: true, .. } = local_response else {
return Ok(());
};
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()))?;
finish_iroh_send(&mut remote_send).await?;
Ok::<(), NodeError>(())
};
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)?;
Ok::<(), NodeError>(())
};
tokio::try_join!(upload, download)?;
Ok(())
}
async fn handle_local_pipe_unix_stream(
node: LocalNode,
peer_node: &str,
target_path: PathBuf,
bearer_secret: Option<String>,
local_stream: UnixStream,
) -> Result<(), NodeError> {
let target_path = geth_pipe::validate_unix_forward_path(&target_path)?;
let target_display = target_path.display().to_string();
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{}\0pipe-unix-stream\0{}\0{}",
node.node_id,
peer_node,
target_display,
geth_store::now_ms()
)
.as_bytes(),
);
let resource = format!("resource:pipe-unix:{target_display}");
let request = PipeWireRequest::UnixConnect {
peer_card: self_card,
target_path: target_path.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(bearer_secret, &resource, "pipe.forward", &nonce),
};
let conn = endpoint
.endpoint()
.connect(node_addr, geth_iroh::ALPN_PIPE)
.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_pipe_wire_request(&request)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let mut remote_recv = remote_recv;
let line = read_iroh_line(&mut remote_recv, STREAM_HANDSHAKE_LINE_MAX).await?;
let response = geth_control::decode_pipe_wire_response(&line)?;
let local_response = match response {
PipeWireResponse::Connected {
node_id,
agent_id,
endpoint_id,
connection,
allowed,
reason,
note,
nonce: response_nonce,
..
} if response_nonce == nonce => ControlResponse::PipeRemoteConnected {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
connection: connection.unwrap_or_else(|| PipeConnection {
target: target_display.clone(),
connected_at: UnixMillis(0),
local_listener_found: false,
note: "pipe Unix forward was denied before connecting".to_owned(),
}),
allowed,
reason,
note,
},
PipeWireResponse::Error { message } => ControlResponse::Error { message },
_ => ControlResponse::Error {
message: "peer returned wrong response type to pipe Unix stream".to_owned(),
},
};
let mut local_stream = local_stream;
local_stream
.write_all(geth_control::encode_response(&local_response)?.as_bytes())
.await?;
let ControlResponse::PipeRemoteConnected { allowed: true, .. } = local_response else {
return Ok(());
};
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()))?;
finish_iroh_send(&mut remote_send).await?;
Ok::<(), NodeError>(())
};
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)?;
Ok::<(), NodeError>(())
};
tokio::try_join!(upload, download)?;
Ok(())
}
async fn ssh_proxy_connect_to_peer(
node: &LocalNode,
peer_node: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
let response =
request_peer_control(node, peer_node, "ssh-proxy-connect", |peer_card, nonce| {
PeerControlRequest::SshProxyConnect {
peer_card,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh-proxy:local",
"ssh_proxy.connect",
&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 ssh_admin_shell_to_peer(
node: &LocalNode,
peer_node: &str,
command: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(node, peer_node, "ssh-admin-shell", |peer_card, nonce| {
PeerControlRequest::SshAdminShell {
peer_card,
command: command.clone(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh-proxy:local",
"ssh_proxy.admin_shell",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::SshAdminShellOutput {
node_id,
agent_id,
endpoint_id,
command,
output,
allowed,
reason,
note,
..
} => Ok(ControlResponse::SshAdminShellOutput {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
command,
output,
allowed,
reason,
note,
}),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to SSH admin shell".to_owned(),
)),
}
}
async fn handle_local_ssh_proxy_stream(
node: LocalNode,
peer_node: &str,
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_recv = remote_recv;
let line = read_iroh_line(&mut remote_recv, STREAM_HANDSHAKE_LINE_MAX).await?;
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 (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()))?;
finish_iroh_send(&mut remote_send).await?;
Ok::<(), NodeError>(())
};
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)?;
Ok::<(), NodeError>(())
};
tokio::try_join!(upload, download)?;
Ok(())
}
async fn document_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_document::validate_document_name(name)
.map_err(|_| NodeError::InvalidDocumentName(name.to_owned()))?;
let stream = format!("document:{name}");
let since_ms =
load_live_sync_cursor(&Store::open(&node.paths.metadata_db())?, peer_node, &stream)?;
let response = request_peer_control(node, peer_node, "document-sync", |peer_card, nonce| {
PeerControlRequest::DocumentSync {
peer_card,
name: name.to_owned(),
since_ms,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:document:{name}"),
"document.read",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::DocumentSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
state,
high_water_ms,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::DocumentSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
updated: false,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let mut updated = false;
if let Some(state) = state {
let local = ensure_local_document(&store, name)?;
if let Some(state_json) = document_sync_state_json(
&local.state_json,
local.updated_at_ms,
&state.state_json,
state.updated_at.0,
)? {
store.insert_document_resource(&StoredDocumentResource {
document_id: local.document_id,
resource_id: local.resource_id,
name: local.name,
state_json,
updated_at_ms: state.updated_at.0,
})?;
updated = true;
}
}
store_live_sync_cursor(&store, peer_node, &stream, high_water_ms)?;
Ok(ControlResponse::DocumentSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
updated,
allowed,
reason,
note,
})
}
PeerControlResponse::DocumentSynced { .. } => Err(NodeError::IrohPeer(
"peer document sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to document sync".to_owned(),
)),
}
}
fn should_import_kv_sync_entry(
local_updated_at_ms: Option<i64>,
remote_updated_at_ms: i64,
) -> bool {
local_updated_at_ms
.is_none_or(|local_updated_at_ms| remote_updated_at_ms >= local_updated_at_ms)
}
fn document_sync_state_json(
local_state_json: &str,
local_updated_at_ms: i64,
remote_state_json: &str,
remote_updated_at_ms: i64,
) -> Result<Option<String>, NodeError> {
if remote_updated_at_ms < local_updated_at_ms {
return Ok(None);
}
if remote_updated_at_ms > local_updated_at_ms {
return Ok(Some(remote_state_json.to_owned()));
}
Ok(Some(geth_document::merge_automerge_states(
local_state_json,
remote_state_json,
)?))
}
async fn sync_status_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<Vec<SyncWatermark>, NodeError> {
let response = request_peer_control(node, peer_node, "sync-status", |peer_card, nonce| {
PeerControlRequest::SyncStatus { peer_card, nonce }
})
.await?;
match response {
PeerControlResponse::SyncStatus {
watermarks, note, ..
} => {
tracing::debug!(peer = %peer_node, %note, streams = watermarks.len(), "peer sync status received");
Ok(watermarks)
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to sync status".to_owned(),
)),
}
}
async fn keychain_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
keychain_sync_from_peer_since(node, peer_node, 0).await
}
async fn keychain_sync_from_peer_since(
node: &LocalNode,
peer_node: &str,
since_ms: i64,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(node, peer_node, "keychain-sync", |peer_card, nonce| {
PeerControlRequest::KeychainSync {
peer_card,
since_ms,
nonce,
}
})
.await?;
match response {
PeerControlResponse::KeychainSynced {
node_id,
agent_id,
endpoint_id,
ops,
signatures,
high_water_ms,
note,
..
} => {
let store = Store::open(&node.paths.metadata_db())?;
let mut local_ops = load_keychain_ops(&store)?;
let mut trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
let mut known_signatures = store
.list_keychain_signatures()?
.into_iter()
.map(|signature| (signature.op_id, signature.signer, signature.namespace))
.collect::<BTreeSet<_>>();
let mut accepted_imports = Vec::new();
let mut ops_imported = 0;
let mut signatures_imported = 0;
let mut invalid_ops_rejected = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect::<Vec<_>>();
if op_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let valid_signatures = op_signatures
.iter()
.filter(|signature| {
if !trusted_admins.contains(&signature.signer)
|| !geth_keychain::signature_uses_claimed_key(signature)
{
return false;
}
let stored = stored_keychain_signature_from_signature(signature);
verify_keychain_signature_with_ssh(node, &op, &stored).unwrap_or(false)
})
.cloned()
.collect::<Vec<_>>();
if valid_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let existing = local_ops.iter().find(|existing| existing.id == op.id);
if existing.as_ref().is_some_and(|existing| *existing != &op) {
invalid_ops_rejected += 1;
continue;
}
let stored_signatures = valid_signatures
.iter()
.map(stored_keychain_signature_from_signature)
.collect::<Vec<_>>();
if existing.is_none() {
local_ops.push(op.clone());
trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys;
ops_imported += 1;
}
for signature in valid_signatures {
let signature_key = (
signature.op_id.to_string(),
signature.signer.to_string(),
signature.namespace.clone(),
);
signatures_imported += usize::from(known_signatures.insert(signature_key));
}
accepted_imports.push((stored_keychain_op_from_op(&op)?, stored_signatures));
}
store.insert_keychain_ops_with_signatures(&accepted_imports)?;
store_live_sync_cursor(&store, peer_node, "keychain", high_water_ms)?;
Ok(ControlResponse::KeychainSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
high_water_ms,
note: format!(
"{note}; imported only keychain ops signed by currently trusted admin SSH keys"
),
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to keychain sync".to_owned(),
)),
}
}
async fn auth_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> Result<ControlResponse, NodeError> {
auth_sync_from_peer_since(node, peer_node, 0).await
}
async fn auth_sync_from_peer_since(
node: &LocalNode,
peer_node: &str,
since_ms: i64,
) -> Result<ControlResponse, NodeError> {
let response = request_peer_control(node, peer_node, "auth-sync", |peer_card, nonce| {
PeerControlRequest::AuthSync {
peer_card,
since_ms,
nonce,
}
})
.await?;
match response {
PeerControlResponse::AuthSynced {
node_id,
agent_id,
endpoint_id,
ops,
signatures,
high_water_ms,
note,
..
} => {
let store = Store::open(&node.paths.metadata_db())?;
let trusted_admins =
geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?).admin_keys;
let mut known_signatures = store
.list_auth_signatures()?
.into_iter()
.map(|signature| (signature.op_id, signature.signer, signature.namespace))
.collect::<BTreeSet<_>>();
let mut local_ops = load_auth_ops(&store)?;
let mut accepted_imports = Vec::new();
let mut ops_imported = 0;
let mut signatures_imported = 0;
let mut invalid_ops_rejected = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.cloned()
.collect::<Vec<_>>();
if op_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let valid_signatures = op_signatures
.iter()
.filter(|signature| {
if !trusted_admins.contains(&signature.signer)
|| !auth_signature_uses_claimed_key(signature)
{
return false;
}
let stored = stored_auth_signature_from_signature(signature);
verify_auth_signature_with_ssh(node, &op, &stored).unwrap_or(false)
})
.cloned()
.collect::<Vec<_>>();
if valid_signatures.is_empty() {
invalid_ops_rejected += 1;
continue;
}
let existing = local_ops.iter().find(|existing| existing.id == op.id);
if existing.as_ref().is_some_and(|existing| *existing != &op) {
invalid_ops_rejected += 1;
continue;
}
let stored_signatures = valid_signatures
.iter()
.map(stored_auth_signature_from_signature)
.collect::<Vec<_>>();
if existing.is_none() {
local_ops.push(op.clone());
ops_imported += 1;
}
for signature in valid_signatures {
let signature_key = (
signature.op_id.to_string(),
signature.signer.to_string(),
signature.namespace.clone(),
);
signatures_imported += usize::from(known_signatures.insert(signature_key));
}
accepted_imports.push((stored_auth_op_from_op(&op)?, stored_signatures));
}
store.insert_auth_ops_with_signatures(&accepted_imports)?;
store_live_sync_cursor(&store, peer_node, "auth", high_water_ms)?;
Ok(ControlResponse::AuthSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
high_water_ms,
note: format!("{note}; imported only auth ops signed by trusted admin SSH keys"),
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to auth sync".to_owned(),
)),
}
}
async fn node_enrollment_submit_to_peer(
node: &LocalNode,
owner_node: &str,
request_id: Option<String>,
path: Option<PathBuf>,
) -> Result<ControlResponse, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let request = if let Some(path) = path {
let request = read_node_enrollment_request_file(&path)?;
insert_node_enrollment_request_if_not_conflicting(&store, &request)?;
request
} else {
let request_id = request_id
.or_else(|| {
latest_pending_node_enrollment_request(&store).map(|request| request.id.to_string())
})
.ok_or_else(|| NodeError::NodeEnrollmentRequestNotFound("latest-pending".to_owned()))?;
load_node_enrollment_request(&store, &request_id)?
};
let response = request_peer_control(
node,
owner_node,
"node-enrollment-submit",
|peer_card, nonce| PeerControlRequest::NodeEnrollmentSubmit {
peer_card,
request: request.clone(),
nonce,
},
)
.await?;
match response {
PeerControlResponse::NodeEnrollmentSubmitted {
node_id,
accepted,
note,
..
} => Ok(ControlResponse::NodeEnrollmentSubmitted {
request_id: request.id.to_string(),
owner_node_id: node_id,
accepted,
note,
}),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to node enrollment submit".to_owned(),
)),
}
}
async fn node_enrollment_sync_from_owner(
node: &LocalNode,
owner_node: &str,
) -> Result<ControlResponse, NodeError> {
let keychain = keychain_sync_from_peer(node, owner_node).await?;
let auth = auth_sync_from_peer(node, owner_node).await?;
let (keychain_ops_imported, keychain_signatures_imported, mut invalid_ops_rejected) =
match keychain {
ControlResponse::KeychainSynced {
ops_imported,
signatures_imported,
invalid_ops_rejected,
..
} => (ops_imported, signatures_imported, invalid_ops_rejected),
other => {
return Err(NodeError::IrohPeer(format!(
"unexpected keychain sync response during enrollment sync: {other:?}"
)));
}
};
let (auth_ops_imported, auth_signatures_imported, auth_invalid) = match auth {
ControlResponse::AuthSynced {
ops_imported,
signatures_imported,
invalid_ops_rejected,
..
} => (ops_imported, signatures_imported, invalid_ops_rejected),
other => {
return Err(NodeError::IrohPeer(format!(
"unexpected auth sync response during enrollment sync: {other:?}"
)));
}
};
invalid_ops_rejected += auth_invalid;
Ok(ControlResponse::NodeEnrollmentSynced {
owner_node: owner_node.to_owned(),
keychain_ops_imported,
keychain_signatures_imported,
auth_ops_imported,
auth_signatures_imported,
invalid_ops_rejected,
note:
"enrollment sync pulled signed keychain and signed auth operations from the owner node"
.to_owned(),
})
}
async fn db_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
limit: u32,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_db::validate_db_name(name).map_err(|_| NodeError::InvalidDbName(name.to_owned()))?;
let store = Store::open(&node.paths.metadata_db())?;
let local = store
.get_db_resource_by_name(name)?
.ok_or_else(|| NodeError::DbNotFound(name.to_owned()))?;
let stream = format!("db:{name}");
let cursor = load_live_sync_cursor(&store, peer_node, &stream)?;
let after_db_version = (cursor > 0).then_some(cursor);
let response = request_peer_control(node, peer_node, "db-sync", |peer_card, nonce| {
PeerControlRequest::DbSync {
peer_card,
name: name.to_owned(),
after_db_version,
limit,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:db:{name}"),
"db.sync",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::DbSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
batch,
high_water_db_version,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::DbSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
changes_received: 0,
changes_applied: 0,
max_db_version: None,
schema_match: false,
allowed,
reason,
note,
});
}
let local_schema = geth_db::schema_metadata(Path::new(&local.path))?;
let (changes_received, changes_applied, max_db_version, schema_match) =
if let Some(batch) = batch {
let schema_match = batch.schema_metadata == local_schema;
let changes_received = batch.changes.len();
let max_db_version = batch.max_db_version;
let changes_applied = if schema_match {
geth_db::apply_crsqlite_changes(Path::new(&local.path), &batch)?
} else {
0
};
if schema_match
&& let Some(next_cursor) = high_water_db_version.or(max_db_version)
{
store_live_sync_cursor(&store, peer_node, &stream, next_cursor)?;
}
(
changes_received,
changes_applied,
max_db_version,
schema_match,
)
} else {
(0, 0, high_water_db_version, false)
};
Ok(ControlResponse::DbSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
changes_received,
changes_applied,
max_db_version,
schema_match,
allowed,
reason,
note,
})
}
PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
"peer DB sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to DB sync".to_owned(),
)),
}
}
fn sync_watermarks_for_peer(
store: &Store,
peer_node: &str,
) -> Result<Vec<SyncWatermark>, NodeError> {
let mut watermarks = Vec::new();
let keychain_high = load_keychain_ops(store)?
.into_iter()
.map(|op| op.created_at.0)
.chain(
load_keychain_signatures(store)?
.into_iter()
.map(|signature| signature.created_at.0),
)
.max()
.unwrap_or(0);
watermarks.push(SyncWatermark {
stream: "keychain".to_owned(),
high_water: keychain_high,
});
let auth_high = load_auth_ops(store)?
.into_iter()
.map(|op| op.created_at.0)
.chain(
load_auth_signatures(store)?
.into_iter()
.map(|signature| signature.created_at.0),
)
.max()
.unwrap_or(0);
watermarks.push(SyncWatermark {
stream: "auth".to_owned(),
high_water: auth_high,
});
let peer = PrincipalId::new(peer_node.to_owned());
if can_sync_resource(store, &peer, "resource:ssh:certs", "ssh_cert.sync")? {
let cert_high = store
.list_ssh_cert_requests()?
.into_iter()
.map(|request| request.created_at_ms)
.chain(
store
.list_ssh_certificates()?
.into_iter()
.map(|certificate| certificate.imported_at_ms),
)
.max()
.unwrap_or(0);
watermarks.push(SyncWatermark {
stream: "ssh-certs".to_owned(),
high_water: cert_high,
});
}
if can_sync_resource(
store,
&peer,
"resource:ssh:revocations",
"ssh_revocation.sync",
)? {
let revocation_high = store
.list_ssh_revocations()?
.into_iter()
.map(|revocation| revocation.created_at_ms)
.max()
.unwrap_or(0);
watermarks.push(SyncWatermark {
stream: "ssh-revocations".to_owned(),
high_water: revocation_high,
});
}
for kv in store.list_kv_stores()? {
if can_sync_resource(store, &peer, &kv.resource_id, "kv.read")? {
let high_water = store
.list_kv_entries_since(&kv.kv_id, 0)?
.into_iter()
.map(|entry| entry.updated_at_ms)
.max()
.unwrap_or(0);
watermarks.push(SyncWatermark {
stream: format!("kv:{}", kv.name),
high_water,
});
}
}
for document in store.list_document_resources()? {
if can_sync_resource(store, &peer, &document.resource_id, "document.read")? {
watermarks.push(SyncWatermark {
stream: format!("document:{}", document.name),
high_water: document.updated_at_ms,
});
}
}
for db in store.list_db_resources()? {
if can_sync_resource(store, &peer, &db.resource_id, "db.sync")?
&& let Ok(metadata) = geth_db::crsqlite_change_metadata(Path::new(&db.path))
{
watermarks.push(SyncWatermark {
stream: format!("db:{}", db.name),
high_water: metadata.max_db_version.unwrap_or(0),
});
}
}
for root in store.list_file_roots()? {
if root.path.starts_with("remote:") {
continue;
}
let resource = format!("resource:cas-tree:{}", root.name);
if can_sync_resource(store, &peer, &resource, "cas.fetch")? {
watermarks.push(SyncWatermark {
stream: format!("cas-tree:{}", root.name),
high_water: root.updated_at_ms,
});
}
}
watermarks.sort_by(|left, right| left.stream.cmp(&right.stream));
Ok(watermarks)
}
fn can_sync_resource(
store: &Store,
peer: &PrincipalId,
resource: &str,
capability: &str,
) -> Result<bool, NodeError> {
Ok(geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(store, resource)?,
peer.clone(),
ResourceId::new(resource.to_owned()),
Capability::new(capability.to_owned()),
)
.allowed)
}
fn explain_auth_for_operator(
store: &Store,
subject: &str,
resource: &str,
capability: &str,
) -> Result<AuthExplanation, NodeError> {
let ops = load_auth_ops_for_resource(store, resource)?;
let mut explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(subject.to_owned()),
ResourceId::new(resource.to_owned()),
Capability::new(capability.to_owned()),
);
if subject.starts_with("bearer:") {
return Ok(explanation);
}
let peer_card = store.get_peer_card(subject)?;
let keychain_ops = load_keychain_ops(store)?;
let keychain_view = geth_keychain::reduce_keychain_ops(&keychain_ops);
let subject_node = NodeId::new(subject.to_owned());
let trusted_node = keychain_view.nodes.get(&subject_node);
let revoked_node = keychain_node_is_currently_revoked(&keychain_ops, &subject_node);
match (trusted_node, peer_card) {
(None, Some(_)) => {
if revoked_node {
explanation.add_diagnostic("subject:revoked-node");
} else {
explanation.add_diagnostic("subject:discovered-only");
}
explanation.add_diagnostic("trust:missing");
if !explanation
.reason
.contains("discovery does not grant trust or authorization")
{
let subject_state = if revoked_node {
"subject is a revoked node with only a discovered peer card remaining"
} else {
"subject is a discovered peer candidate only"
};
explanation.reason = format!(
"{subject_state}; discovery does not grant trust or authorization; {}",
explanation.reason
);
}
}
(None, None) => {
if revoked_node {
explanation.add_diagnostic("subject:revoked-node");
} else {
explanation.add_diagnostic("subject:unknown");
}
explanation.add_diagnostic("trust:missing");
if !explanation.allowed {
let subject_state = if revoked_node {
"subject was revoked from the active keychain"
} else {
"subject is not present in the keychain and has no discovered peer card"
};
explanation.reason = format!("{subject_state}; {}", explanation.reason);
}
}
(Some(node), None) => {
explanation.add_diagnostic("subject:trusted-node");
if node.endpoints.is_empty() {
explanation.add_diagnostic("endpoint-binding:missing");
} else {
explanation.add_diagnostic("endpoint-binding:present");
explanation.add_diagnostic("discovery:missing-peer-card");
}
}
(Some(node), Some(stored_card)) => {
explanation.add_diagnostic("subject:trusted-node");
let card = serde_json::from_str::<PeerCard>(&stored_card.card_json).ok();
let matching_endpoint = card.as_ref().and_then(|card| {
card.endpoints
.iter()
.map(|endpoint| endpoint.endpoint_id.as_str())
.find(|endpoint| {
keychain_view
.endpoints
.get(*endpoint)
.is_some_and(|bound_node| bound_node == &node.id)
})
.map(ToOwned::to_owned)
});
if let Some(endpoint) = matching_endpoint {
explanation.add_diagnostic(format!("endpoint-binding:matched:{endpoint}"));
} else if node.endpoints.is_empty() {
explanation.add_diagnostic("endpoint-binding:missing");
if !explanation.allowed {
explanation.reason = format!(
"trusted node has no active endpoint binding; {}",
explanation.reason
);
}
} else {
explanation.add_diagnostic("endpoint-binding:missing-for-peer-card");
if !explanation.allowed {
explanation.reason = format!(
"discovered peer card has no endpoint candidate bound to the trusted node; {}",
explanation.reason
);
}
}
}
}
Ok(explanation)
}
fn keychain_node_is_currently_revoked(ops: &[KeychainOp], node: &NodeId) -> bool {
let mut revoked = false;
for op in ops {
match &op.kind {
KeychainOpKind::NodeAdd { node: op_node, .. } if op_node == node => revoked = false,
KeychainOpKind::NodeRevoke { node: op_node } if op_node == node => revoked = true,
_ => {}
}
}
revoked
}
fn bearer_proof(
bearer_secret: Option<String>,
resource: &str,
capability: &str,
nonce: &str,
) -> Option<BearerProof> {
bearer_secret.map(|token| {
let token = geth_types::SecretId::new(token);
let secret = geth_secrets::bearer_id_for_token(&token);
let resource = ResourceId::new(resource.to_owned());
let capabilities = vec![Capability::new(capability.to_owned())];
let response = geth_secrets::bearer_response(&token, &resource, &capabilities, nonce);
BearerProof {
secret,
resource,
capabilities,
nonce: nonce.to_owned(),
response,
}
})
}
fn explain_peer_or_bearer(
store: &Store,
peer_node: &str,
resource: &str,
capability: &str,
nonce: &str,
bearer_proof: Option<&BearerProof>,
) -> Result<AuthExplanation, NodeError> {
let ops = load_auth_ops_for_resource(store, resource)?;
let resource_id = ResourceId::new(resource.to_owned());
let capability_id = Capability::new(capability.to_owned());
let peer_explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(peer_node.to_owned()),
resource_id.clone(),
capability_id.clone(),
);
if peer_explanation.allowed {
return Ok(peer_explanation);
}
let Some(proof) = bearer_proof else {
return Ok(peer_explanation);
};
let bearer_subject = format!("bearer:{}", proof.secret);
let denied = |reason: String| AuthExplanation {
subject: bearer_subject.clone(),
resource: resource.to_owned(),
capability: capability.to_owned(),
allowed: false,
reason,
evaluated_ops: peer_explanation.evaluated_ops,
diagnostics: vec!["subject:bearer-secret".to_owned()],
};
if proof.resource != resource_id {
return Ok(denied(format!(
"bearer proof resource {} does not match requested resource {resource}",
proof.resource
)));
}
if proof.nonce != nonce {
return Ok(denied(
"bearer proof nonce does not match request".to_owned(),
));
}
if !proof
.capabilities
.iter()
.any(|granted| geth_auth::capability_allows(granted, &capability_id))
{
return Ok(denied(
"bearer proof does not include the requested capability".to_owned(),
));
}
let now = UnixMillis(geth_store::now_ms());
let Some(access) = load_bearer_access(store)?.into_iter().find(|access| {
access.secret == proof.secret
&& access.resource == proof.resource
&& access.expires_at.is_none_or(|expires| expires.0 > now.0)
}) else {
return Ok(denied(
"bearer access is not active for requested resource".to_owned(),
));
};
let Some(token_hash) = access.token_hash.as_deref() else {
return Ok(denied(
"bearer access does not have a token verifier".to_owned(),
));
};
if !geth_secrets::verify_bearer_response_with_token_hash(
token_hash,
&proof.resource,
&proof.capabilities,
nonce,
&proof.response,
) {
return Ok(denied("bearer proof response is invalid".to_owned()));
}
if !access
.capabilities
.iter()
.any(|granted| geth_auth::capability_allows(granted, &capability_id))
{
return Ok(denied(
"bearer access lacks requested capability".to_owned(),
));
}
Ok(AuthExplanation {
subject: bearer_subject,
resource: resource.to_owned(),
capability: capability.to_owned(),
allowed: true,
reason:
"bearer proof allows this resource-scoped capability without granting node identity"
.to_owned(),
evaluated_ops: peer_explanation.evaluated_ops,
diagnostics: vec![
"subject:bearer-secret".to_owned(),
format!("bearer:active:{}", access.secret),
"capability:matched".to_owned(),
],
})
}
async fn handle_iroh_control_connection(
node: LocalNode,
incoming: iroh::endpoint::Incoming,
) -> Result<(), NodeError> {
let conn = incoming
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let remote_endpoint_id = conn.remote_id().to_string();
let route = classify_iroh_alpn(conn.alpn());
let alpn = display_alpn(conn.alpn());
let log_remote_endpoint_id = remote_endpoint_id.clone();
let log_alpn = alpn.clone();
tracing::debug!(remote_endpoint_id = %log_remote_endpoint_id, alpn = %log_alpn, "iroh connection established");
match route {
IrohAlpnRoute::NativeBlobs => {
let blob_store = iroh_blob_store(&node)?.ok_or_else(|| {
NodeError::IrohPeer(
"iroh-blobs ALPN accepted but blob store is unavailable".to_owned(),
)
})?;
let protocol = iroh_blobs::BlobsProtocol::new(&blob_store, None);
return protocol.accept(conn).await.map_err(|error| {
NodeError::IrohPeer(format!("iroh-blobs accept failed: {error}"))
});
}
IrohAlpnRoute::NativeDocs => {
let docs = iroh_docs(&node)?.ok_or_else(|| {
NodeError::IrohPeer(
"iroh-docs ALPN accepted but docs runtime is unavailable".to_owned(),
)
})?;
return docs
.accept(conn)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-docs accept failed: {error}")));
}
IrohAlpnRoute::NativeGossip => {
let gossip = iroh_gossip(&node)?.ok_or_else(|| {
NodeError::IrohPeer(
"iroh-gossip ALPN accepted but gossip runtime is unavailable".to_owned(),
)
})?;
return gossip.accept(conn).await.map_err(|error| {
NodeError::IrohPeer(format!("iroh-gossip accept failed: {error}"))
});
}
IrohAlpnRoute::SshProxy
| IrohAlpnRoute::Pipe
| IrohAlpnRoute::Overlay
| IrohAlpnRoute::Control => {}
}
tracing::debug!(%remote_endpoint_id, %alpn, "waiting for iroh bidirectional stream");
let (mut send, recv) = conn
.accept_bi()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
tracing::debug!(%remote_endpoint_id, %alpn, "accepted iroh bidirectional stream");
match route {
IrohAlpnRoute::SshProxy => {
return handle_ssh_proxy_wire_connection(node, remote_endpoint_id, send, recv).await;
}
IrohAlpnRoute::Pipe => {
return handle_pipe_wire_connection(node, remote_endpoint_id, send, recv).await;
}
IrohAlpnRoute::Overlay => {
return handle_overlay_wire_connection(node, remote_endpoint_id, send, recv).await;
}
IrohAlpnRoute::Control => {}
IrohAlpnRoute::NativeBlobs | IrohAlpnRoute::NativeDocs | IrohAlpnRoute::NativeGossip => {
unreachable!("native ALPN routes return before bidirectional stream handling")
}
}
let mut recv = recv;
let request_line = read_iroh_line(&mut recv, PEER_CONTROL_LINE_MAX).await?;
let request_bytes = request_line.len();
tracing::debug!(remote_endpoint_id = %log_remote_endpoint_id, alpn = %log_alpn, request_bytes, "read iroh peer-control request line");
let response = match geth_control::decode_peer_request(&request_line)? {
PeerControlRequest::Ping { peer_card, nonce } => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
PeerControlResponse::Pong {
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: caller.remote_endpoint_id,
alpn,
nonce,
note: "peer endpoint authenticated by Iroh and peer-card signature; candidate status does not grant resource capabilities".to_owned(),
}
}
PeerControlRequest::SyncStatus { peer_card, nonce } => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let watermarks =
sync_watermarks_for_peer(&caller.store, caller.peer_card.node_id.as_str())?;
PeerControlResponse::SyncStatus {
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: caller.remote_endpoint_id,
watermarks,
nonce,
note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(),
}
}
PeerControlRequest::KeychainSync {
peer_card,
since_ms,
nonce,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let ops = load_keychain_ops(&caller.store)?;
let signatures = load_keychain_signatures(&caller.store)?;
let returned_op_ids = ops
.iter()
.filter(|op| op.created_at.0 >= since_ms)
.map(|op| op.id.to_string())
.collect::<BTreeSet<_>>();
let high_water_ms = ops
.iter()
.map(|op| op.created_at.0)
.chain(signatures.iter().map(|signature| signature.created_at.0))
.max()
.unwrap_or(0);
PeerControlResponse::KeychainSynced {
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: caller.remote_endpoint_id,
ops: ops
.into_iter()
.filter(|op| op.created_at.0 >= since_ms)
.collect(),
signatures: signatures
.into_iter()
.filter(|signature| {
signature.created_at.0 >= since_ms
|| returned_op_ids.contains(signature.op_id.as_str())
})
.collect(),
high_water_ms,
nonce,
note: "keychain sync returns signed operation-log data; receiver must verify OpenSSH signatures before import".to_owned(),
}
}
PeerControlRequest::AuthSync {
peer_card,
since_ms,
nonce,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let ops = load_auth_ops(&caller.store)?;
let signatures = load_auth_signatures(&caller.store)?;
let returned_op_ids = ops
.iter()
.filter(|op| op.created_at.0 >= since_ms)
.map(|op| op.id.to_string())
.collect::<BTreeSet<_>>();
let high_water_ms = ops
.iter()
.map(|op| op.created_at.0)
.chain(signatures.iter().map(|signature| signature.created_at.0))
.max()
.unwrap_or(0);
PeerControlResponse::AuthSynced {
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: caller.remote_endpoint_id,
ops: ops
.into_iter()
.filter(|op| op.created_at.0 >= since_ms)
.collect(),
signatures: signatures
.into_iter()
.filter(|signature| {
signature.created_at.0 >= since_ms
|| returned_op_ids.contains(signature.op_id.as_str())
})
.collect(),
high_water_ms,
nonce,
note: "auth sync returns signed resource operation-log data; receiver must verify admin signatures before import".to_owned(),
}
}
PeerControlRequest::NodeEnrollmentSubmit {
peer_card,
request,
nonce,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let accepted =
insert_node_enrollment_request_if_not_conflicting(&caller.store, &request)?;
PeerControlResponse::NodeEnrollmentSubmitted {
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: caller.remote_endpoint_id,
request_id: request.id.to_string(),
accepted,
nonce,
note: "stored signed node enrollment request for owner review".to_owned(),
}
}
PeerControlRequest::AuthCheck {
peer_card,
resource,
capability,
nonce,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&caller.store, &resource)?,
PrincipalId::new(caller.peer_card.node_id.to_string()),
ResourceId::new(resource.clone()),
Capability::new(capability.clone()),
);
PeerControlResponse::AuthChecked {
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: caller.remote_endpoint_id,
resource,
capability,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "protected peer request authenticated endpoint/card binding before resource capability evaluation".to_owned(),
}
}
PeerControlRequest::CasFetch {
peer_card,
hash,
nonce,
bearer_proof,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = "resource:cas:local".to_owned();
let capability = "cas.fetch".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
if explanation.allowed {
match LocalCas::new(node.paths.cas_dir()).read_bytes(&hash) {
Ok(content) => {
let size_bytes = content.len() as u64;
PeerControlResponse::CasFetched {
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: caller.remote_endpoint_id,
hash,
size_bytes,
content_base64: Some(
base64::engine::general_purpose::STANDARD.encode(content),
),
allowed: true,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "CAS fetch authenticated endpoint/card binding and required cas.fetch on resource:cas:local".to_owned(),
}
}
Err(error) => PeerControlResponse::Error {
message: format!("CAS blob {hash} is not available: {error}"),
},
}
} else {
PeerControlResponse::CasFetched {
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: caller.remote_endpoint_id,
hash,
size_bytes: 0,
content_base64: None,
allowed: false,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "CAS fetch authenticated endpoint/card binding and required cas.fetch on resource:cas:local".to_owned(),
}
}
}
PeerControlRequest::CasRootSync {
peer_card,
name,
nonce,
bearer_proof,
} => {
geth_cas::validate_file_root_name(&name)?;
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = format!("resource:cas-tree:{name}");
let capability = "cas.fetch".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let (root, tree_content_base64) = if explanation.allowed {
if let Some(root) = caller.store.get_file_root_by_name(&name)? {
let root_view = file_root_from_stored(&root);
let tree_content_base64 = root
.latest_tree_hash
.as_ref()
.map(|hash| -> Result<Option<String>, NodeError> {
let hash = BlobHash::new(hash.clone());
let content = LocalCas::new(node.paths.cas_dir()).read_bytes(&hash)?;
Ok(Some(
base64::engine::general_purpose::STANDARD.encode(content),
))
})
.transpose()?
.flatten();
(Some(root_view), tree_content_base64)
} else {
(None, None)
}
} else {
(None, None)
};
PeerControlResponse::CasRootSynced {
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: caller.remote_endpoint_id,
name,
root,
tree_content_base64,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "file-root sync authenticated endpoint/card binding and required cas.fetch on the remote cas-tree resource; it imports tree metadata and CAS tree bytes but never writes files".to_owned(),
}
}
PeerControlRequest::SshCertSync {
peer_card,
since_ms,
nonce,
bearer_proof,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = "resource:ssh:certs".to_owned();
let capability = "ssh_cert.sync".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let (requests, certificates, log_entries) = if explanation.allowed {
(
caller
.store
.list_ssh_cert_requests_since(since_ms)?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
caller
.store
.list_ssh_certificates_since(since_ms)?
.into_iter()
.map(ssh_certificate_from_stored)
.collect::<Result<Vec<_>, _>>()?,
Vec::new(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
let log_entries = if explanation.allowed {
ssh_cert_distribution_log_entries(requests.clone(), certificates.clone())
} else {
log_entries
};
PeerControlResponse::SshCertSynced {
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: caller.remote_endpoint_id,
requests,
certificates,
log_entries,
high_water_ms,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "SSH certificate metadata sync authenticated endpoint/card binding and required ssh_cert.sync on resource:ssh:certs".to_owned(),
}
}
PeerControlRequest::SshRevocationSync {
peer_card,
since_ms,
nonce,
bearer_proof,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = "resource:ssh:revocations".to_owned();
let capability = "ssh_revocation.sync".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let (revocations, log_entries) = if explanation.allowed {
caller
.store
.list_ssh_revocations_since(since_ms)?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()
.map(|revocations| {
let log_entries =
ssh_revocation_distribution_log_entries(revocations.clone());
(revocations, log_entries)
})?
} else {
(Vec::new(), Vec::new())
};
PeerControlResponse::SshRevocationSynced {
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: caller.remote_endpoint_id,
revocations,
log_entries,
high_water_ms,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "SSH revocation sync authenticated endpoint/card binding and required ssh_revocation.sync on resource:ssh:revocations".to_owned(),
}
}
PeerControlRequest::KvSync {
peer_card,
name,
since_ms,
nonce,
bearer_proof,
} => {
geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
let PeerControlCaller {
peer_card,
remote_endpoint_id,
store,
} = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
if let Some(kv) = store.get_kv_store_by_name(&name)? {
let capability = "kv.read".to_owned();
let explanation = explain_peer_or_bearer(
&store,
peer_card.node_id.as_str(),
&kv.resource_id,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let entries = if explanation.allowed {
store
.list_kv_entries_since(&kv.kv_id, since_ms)?
.into_iter()
.map(|entry| KvSyncEntry {
key: entry.key,
value: entry.value,
updated_at_ms: entry.updated_at_ms,
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let high_water_ms = entries
.iter()
.map(|entry| entry.updated_at_ms)
.max()
.unwrap_or(since_ms);
let docs_ticket = if explanation.allowed {
drop(store);
mirror_kv_store_to_iroh_docs(&node, &name).await?;
kv_docs_read_ticket(&node, &name).await?
} else {
None
};
PeerControlResponse::KvSynced {
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,
name,
entries,
high_water_ms,
docs_ticket,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "KV sync authenticated endpoint/card binding and required kv.read on the remote KV resource".to_owned(),
}
} else {
PeerControlResponse::Error {
message: format!("kv store not found: {name}"),
}
}
}
PeerControlRequest::PubsubPublish {
peer_card,
topic,
message,
nonce,
bearer_proof,
} => {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let PeerControlCaller {
peer_card,
remote_endpoint_id,
store,
} = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = format!("resource:pubsub:{topic}");
let capability = "pubsub.publish".to_owned();
let explanation = explain_peer_or_bearer(
&store,
peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
drop(store);
let published = if explanation.allowed {
let bootstrap_peer = remote_endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let published = record_pubsub_message(&node, topic.clone(), message.clone())?;
broadcast_pubsub_gossip(
&node,
topic.as_str(),
message.as_str(),
vec![bootstrap_peer],
)
.await?;
Some(published)
} else {
None
};
PeerControlResponse::PubsubPublished {
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,
message: published,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pubsub publish authenticated endpoint/card binding and required pubsub.publish on the remote topic resource; pubsub is lossy".to_owned(),
}
}
PeerControlRequest::PubsubSubscribe {
peer_card,
topic,
nonce,
bearer_proof,
} => {
geth_pubsub::validate_topic(&topic)?;
let PeerControlCaller {
peer_card,
remote_endpoint_id,
store,
} = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = format!("resource:pubsub:{topic}");
let capability = "pubsub.subscribe".to_owned();
let explanation = explain_peer_or_bearer(
&store,
peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
drop(store);
let messages = if explanation.allowed {
let bootstrap_peer = remote_endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
ensure_pubsub_gossip_topic(&node, topic.as_str(), vec![bootstrap_peer]).await?;
pubsub_messages_for_topic(&node, &topic)?
} else {
Vec::new()
};
PeerControlResponse::PubsubSubscribed {
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,
topic,
messages,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pubsub subscribe authenticated endpoint/card binding and required pubsub.subscribe on the remote topic resource; pubsub is lossy daemon-lifetime state".to_owned(),
}
}
PeerControlRequest::PipeConnect {
peer_card,
target,
nonce,
bearer_proof,
} => {
geth_pipe::validate_pipe_name(&target)?;
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = format!("resource:pipe:{target}");
let capability = "pipe.connect".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let connection = if explanation.allowed {
Some(record_pipe_connection(
&node,
target,
"remote pipe connect over protected Iroh control path; byte messages use the dedicated Iroh pipe ALPN".to_owned(),
)?)
} else {
None
};
PeerControlResponse::PipeConnected {
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: caller.remote_endpoint_id,
connection,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe connect authenticated endpoint/card binding and required pipe.connect on the remote pipe resource; byte messages use the dedicated Iroh pipe ALPN".to_owned(),
}
}
PeerControlRequest::PipeListen {
peer_card,
name,
nonce,
bearer_proof,
} => {
geth_pipe::validate_pipe_name(&name)?;
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = format!("resource:pipe:{name}");
let capability = "pipe.listen".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let listener = if explanation.allowed {
Some(record_pipe_listener(&node, name)?)
} else {
None
};
PeerControlResponse::PipeListening {
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: caller.remote_endpoint_id,
listener,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe listen authenticated endpoint/card binding and required pipe.listen on the remote pipe resource; listeners receive daemon-lifetime byte messages".to_owned(),
}
}
PeerControlRequest::SshProxyConnect {
peer_card,
nonce,
bearer_proof,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = "resource:ssh-proxy:local".to_owned();
let capability = "ssh_proxy.connect".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
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: true,
note: "authorized SSH proxy metadata check passed; run geth ssh proxy for the dedicated byte stream".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: caller.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 uses the dedicated /geth/ssh-proxy/1 stream".to_owned(),
}
}
PeerControlRequest::SshAdminShell {
peer_card,
command,
nonce,
bearer_proof,
} => {
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
let resource = "resource:ssh-proxy:local".to_owned();
let capability = "ssh_proxy.admin_shell".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.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: caller.remote_endpoint_id,
command,
output,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "restricted geth admin shell authenticated endpoint/card binding and required ssh_proxy.admin_shell; no host shell commands are executed".to_owned(),
}
}
PeerControlRequest::DocumentSync {
peer_card,
name,
since_ms,
nonce,
bearer_proof,
} => {
geth_document::validate_document_name(&name)
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
if let Some(document) = caller.store.get_document_resource_by_name(&name)? {
let capability = "document.read".to_owned();
let high_water_ms = document.updated_at_ms;
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&document.resource_id,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let state = if explanation.allowed && document.updated_at_ms >= since_ms {
Some(document_sync_state_from_stored(&document)?)
} else {
None
};
PeerControlResponse::DocumentSynced {
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: caller.remote_endpoint_id,
name,
state,
high_water_ms,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "document sync authenticated endpoint/card binding and required document.read on the remote document resource; durable Automerge state is exchanged with a JSON view for CLI output".to_owned(),
}
} else {
PeerControlResponse::Error {
message: format!("document not found: {name}"),
}
}
}
PeerControlRequest::DbSync {
peer_card,
name,
after_db_version,
limit,
nonce,
bearer_proof,
} => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
let caller = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
if let Some(db) = caller.store.get_db_resource_by_name(&name)? {
let capability = "db.sync".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&db.resource_id,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let sync_result: Result<_, String> = if explanation.allowed {
let path = Path::new(&db.path);
match geth_db::extract_crsqlite_changes(path, after_db_version, limit) {
Ok(batch) => match geth_db::crsqlite_change_metadata(path) {
Ok(metadata) => {
let high_water_db_version =
metadata.max_db_version.or(batch.max_db_version);
Ok((Some(batch), high_water_db_version))
}
Err(error) => Err(format!(
"db sync cannot read crsql_changes metadata for {name}: {error}"
)),
},
Err(error) => Err(format!(
"db sync cannot read crsql_changes for {name}: {error}"
)),
}
} else {
Ok((None, None))
};
match sync_result {
Ok((batch, high_water_db_version)) => PeerControlResponse::DbSynced {
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: caller.remote_endpoint_id,
name,
batch,
high_water_db_version,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "DB sync authenticated endpoint/card binding and required db.sync on the remote DB resource; bootstrap exchanges typed crsql_changes and applies compatible batches through local crsql_changes".to_owned(),
},
Err(message) => PeerControlResponse::Error { message },
}
} else {
PeerControlResponse::Error {
message: format!("db resource not found: {name}"),
}
}
}
};
tracing::debug!(remote_endpoint_id = %log_remote_endpoint_id, alpn = %log_alpn, "writing iroh peer-control response");
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()))?;
send.stopped()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
Ok(())
}
async fn handle_ssh_proxy_wire_connection(
node: LocalNode,
remote_endpoint_id: String,
mut send: iroh::endpoint::SendStream,
mut recv: iroh::endpoint::RecvStream,
) -> Result<(), NodeError> {
let line = read_iroh_line(&mut recv, STREAM_HANDSHAKE_LINE_MAX).await?;
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(());
};
let PeerControlCaller {
peer_card,
remote_endpoint_id,
store,
} = authenticate_peer_control_caller(&node, peer_card, &remote_endpoint_id)?;
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: true,
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 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(())
}
async fn handle_pipe_wire_connection(
node: LocalNode,
remote_endpoint_id: String,
mut send: iroh::endpoint::SendStream,
mut recv: iroh::endpoint::RecvStream,
) -> Result<(), NodeError> {
let line = read_iroh_line(&mut recv, WIRE_REQUEST_LINE_MAX).await?;
match geth_control::decode_pipe_wire_request(&line)? {
PipeWireRequest::Send {
peer_card,
target,
data_base64,
nonce,
bearer_proof,
} => {
let response = handle_pipe_send_wire_request(
&node,
&remote_endpoint_id,
peer_card,
target,
data_base64,
nonce,
bearer_proof,
)?;
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
finish_iroh_send(&mut send).await?;
Ok(())
}
PipeWireRequest::TcpConnect {
peer_card,
target_addr,
nonce,
bearer_proof,
} => {
handle_pipe_tcp_wire_connection(
node,
&remote_endpoint_id,
PipeTcpConnectWire {
peer_card,
target_addr,
nonce,
bearer_proof,
},
send,
recv,
)
.await
}
PipeWireRequest::UnixConnect {
peer_card,
target_path,
nonce,
bearer_proof,
} => {
handle_pipe_unix_wire_connection(
node,
&remote_endpoint_id,
PipeUnixConnectWire {
peer_card,
target_path,
nonce,
bearer_proof,
},
send,
recv,
)
.await
}
}
}
async fn handle_overlay_wire_connection(
node: LocalNode,
remote_endpoint_id: String,
mut send: iroh::endpoint::SendStream,
mut recv: iroh::endpoint::RecvStream,
) -> Result<(), NodeError> {
let line = read_iroh_line(&mut recv, WIRE_REQUEST_LINE_MAX).await?;
let response = match geth_control::decode_overlay_wire_request(&line)? {
OverlayWireRequest::Packet {
peer_card,
network,
packet_base64,
nonce,
bearer_proof,
} => {
handle_overlay_packet_wire_request(
&node,
&remote_endpoint_id,
peer_card,
network,
packet_base64,
nonce,
bearer_proof,
)
.await?
}
};
send.write_all(geth_control::encode_overlay_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
finish_iroh_send(&mut send).await?;
Ok(())
}
async fn handle_overlay_packet_wire_request(
node: &LocalNode,
remote_endpoint_id: &str,
peer_card: PeerCard,
network: String,
packet_base64: String,
nonce: String,
bearer_proof: Option<BearerProof>,
) -> Result<OverlayWireResponse, NodeError> {
geth_overlay::validate_overlay_name(&network)?;
let packet = base64::engine::general_purpose::STANDARD
.decode(&packet_base64)
.map_err(|error| NodeError::IrohPeer(format!("invalid overlay packet base64: {error}")))?;
geth_overlay::validate_ipv4_packet(&packet)?;
let caller = authenticate_peer_control_caller(node, peer_card, remote_endpoint_id)?;
let resource = geth_overlay::overlay_resource_id(&network).to_string();
let capability = "overlay.route".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let packet = if explanation.allowed {
let injected = overlay_runtime_inject(node, &network, packet.clone())?;
let mut packet = record_overlay_packet(
&caller.store,
&network,
caller.peer_card.node_id.as_str(),
&node.node_id,
packet_base64,
packet.len(),
)?;
if injected {
packet.note =
"received over /geth/overlay/1 and queued to active TUN/Wintun runtime".to_owned();
}
Some(Box::new(packet))
} else {
None
};
Ok(OverlayWireResponse::PacketAccepted {
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: caller.remote_endpoint_id,
packet,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "overlay packet authenticated endpoint/card binding and required overlay.route on the overlay resource; payload used /geth/overlay/1".to_owned(),
})
}
fn handle_pipe_send_wire_request(
node: &LocalNode,
remote_endpoint_id: &str,
peer_card: PeerCard,
target: String,
data_base64: String,
nonce: String,
bearer_proof: Option<BearerProof>,
) -> Result<PipeWireResponse, NodeError> {
geth_pipe::validate_pipe_name(&target)?;
base64::engine::general_purpose::STANDARD
.decode(&data_base64)
.map_err(|error| NodeError::IrohPeer(format!("invalid pipe payload: {error}")))?;
let caller = authenticate_peer_control_caller(node, peer_card, remote_endpoint_id)?;
let resource = format!("resource:pipe:{target}");
let capability = "pipe.connect".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let (message, listener_found) = if explanation.allowed {
record_pipe_message(
node,
target,
data_base64,
Some(caller.peer_card.node_id.to_string()),
"remote pipe byte message over dedicated Iroh pipe ALPN".to_owned(),
)?
} else {
(None, false)
};
Ok(PipeWireResponse::Sent {
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: caller.remote_endpoint_id,
message: Box::new(message),
listener_found,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe send authenticated endpoint/card binding and required pipe.connect on the remote pipe resource; payload used the dedicated Iroh pipe ALPN".to_owned(),
})
}
async fn handle_pipe_tcp_wire_connection(
node: LocalNode,
remote_endpoint_id: &str,
request: PipeTcpConnectWire,
mut send: iroh::endpoint::SendStream,
recv: iroh::endpoint::RecvStream,
) -> Result<(), NodeError> {
let PipeTcpConnectWire {
peer_card,
target_addr,
nonce,
bearer_proof,
} = request;
let target = geth_pipe::validate_tcp_forward_target_addr(&target_addr)?;
let caller = authenticate_peer_control_caller(&node, peer_card, remote_endpoint_id)?;
let resource = format!("resource:pipe-tcp:{target_addr}");
let capability = "pipe.forward".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
if !explanation.allowed {
let response = PipeWireResponse::Connected {
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: caller.remote_endpoint_id,
connection: None,
allowed: false,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe TCP forward denied before opening the remote TCP target".to_owned(),
};
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
return send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()));
}
let tcp = match TcpStream::connect(target).await {
Ok(tcp) => tcp,
Err(error) => {
let response = PipeWireResponse::Error {
message: format!(
"authorized pipe TCP forward could not connect to {target_addr}: {error}"
),
};
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
return send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()));
}
};
let response = PipeWireResponse::Connected {
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: caller.remote_endpoint_id,
connection: Some(PipeConnection {
target: target_addr.clone(),
connected_at: UnixMillis(geth_store::now_ms()),
local_listener_found: true,
note: "authorized TCP byte stream over the dedicated Iroh pipe ALPN".to_owned(),
}),
allowed: true,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe TCP forward authenticated endpoint/card binding and required pipe.forward before connecting to the loopback TCP target".to_owned(),
};
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (mut tcp_read, mut tcp_write) = tcp.into_split();
let mut recv = recv;
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()))?;
finish_iroh_send(&mut send).await
};
tokio::try_join!(inbound, outbound)?;
Ok(())
}
async fn handle_pipe_unix_wire_connection(
node: LocalNode,
remote_endpoint_id: &str,
request: PipeUnixConnectWire,
mut send: iroh::endpoint::SendStream,
recv: iroh::endpoint::RecvStream,
) -> Result<(), NodeError> {
let PipeUnixConnectWire {
peer_card,
target_path,
nonce,
bearer_proof,
} = request;
let target_path = geth_pipe::validate_unix_forward_path(&target_path)?;
let target_display = target_path.display().to_string();
let caller = authenticate_peer_control_caller(&node, peer_card, remote_endpoint_id)?;
let resource = format!("resource:pipe-unix:{target_display}");
let capability = "pipe.forward".to_owned();
let explanation = explain_peer_or_bearer(
&caller.store,
caller.peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
if !explanation.allowed {
let response = PipeWireResponse::Connected {
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: caller.remote_endpoint_id,
connection: None,
allowed: false,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe Unix forward denied before opening the remote Unix socket".to_owned(),
};
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
return send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()));
}
let unix = match UnixStream::connect(&target_path).await {
Ok(unix) => unix,
Err(error) => {
let response = PipeWireResponse::Error {
message: format!(
"authorized pipe Unix forward could not connect to {}: {error}",
target_path.display()
),
};
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
return send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()));
}
};
let response = PipeWireResponse::Connected {
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: caller.remote_endpoint_id,
connection: Some(PipeConnection {
target: target_display.clone(),
connected_at: UnixMillis(geth_store::now_ms()),
local_listener_found: true,
note: "authorized Unix socket byte stream over the dedicated Iroh pipe ALPN".to_owned(),
}),
allowed: true,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pipe Unix forward authenticated endpoint/card binding and required pipe.forward before connecting to the Unix socket target".to_owned(),
};
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (mut unix_read, mut unix_write) = unix.into_split();
let mut recv = recv;
let inbound = async {
tokio::io::copy(&mut recv, &mut unix_write)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
unix_write.shutdown().await.map_err(NodeError::from)
};
let outbound = async {
tokio::io::copy(&mut unix_read, &mut send)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
finish_iroh_send(&mut send).await
};
tokio::try_join!(inbound, outbound)?;
Ok(())
}
fn peer_gossip_endpoint_id(
node: &LocalNode,
peer_node: &str,
) -> Result<iroh::EndpointId, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let peer_node = resolve_peer_node_for_control(&store, peer_node);
let stored = store
.get_peer_card(&peer_node)?
.ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?;
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)?;
candidate
.endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))
}
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"
))),
}
}
pub fn handle_request(
node: &LocalNode,
request: ControlRequest,
) -> Result<ControlResponse, NodeError> {
debug_assert!(!resource_contracts::contracts().is_empty());
let store = Store::open(&node.paths.metadata_db())?;
match request {
ControlRequest::Status => {
let store_schema_version = store.schema_version()?;
let store_journal_mode = store.journal_mode()?;
let store_synchronous = store.synchronous_mode()?;
let store_ok = store_schema_version == geth_store::CURRENT_SCHEMA_VERSION
&& store_journal_mode == geth_store::FILE_JOURNAL_MODE
&& store_synchronous == geth_store::SYNCHRONOUS_MODE;
Ok(ControlResponse::Status(StatusResponse {
home: node.paths.home().to_path_buf(),
socket: node.paths.socket_path(),
agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(),
daemon_started_at_ms: node.daemon_started_at_ms,
daemon_uptime_seconds: current_unix_ms()
.saturating_sub(node.daemon_started_at_ms)
.max(0) as u64
/ 1000,
store_schema_version,
store_current_schema_version: geth_store::CURRENT_SCHEMA_VERSION,
store_journal_mode,
store_synchronous,
store_status: if store_ok { "ok" } else { "check" }.to_owned(),
store_note: format!(
"expected schema {}, journal {}, synchronous {}",
geth_store::CURRENT_SCHEMA_VERSION,
geth_store::FILE_JOURNAL_MODE,
geth_store::SYNCHRONOUS_MODE
),
iroh_enabled: node.iroh_status.enabled,
endpoint_id: node.iroh_status.endpoint_id.clone(),
iroh_relay_mode: node.iroh_status.relay_mode.clone(),
iroh_local_discovery: node.iroh_status.local_discovery,
iroh: node.iroh_status.note.clone(),
native_backends: native_backend_statuses(),
}))
}
ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse {
agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone(),
})),
ControlRequest::PeerCardExport { out } => {
let endpoint_id = node
.iroh_status
.endpoint_id
.clone()
.ok_or(NodeError::IrohEndpointUnavailable)?;
let key = AgentKey::load(&node.paths.agent_key())?;
let card = PeerCard::signed(
NodeId::new(node.node_id.clone()),
&key,
vec![EndpointCandidate {
endpoint_id,
relay_url: None,
direct_addresses: Vec::new(),
source: DiscoverySource::Manual,
}],
UnixMillis(geth_store::now_ms()),
)?;
if let Some(path) = &out {
std::fs::write(path, serde_json::to_string_pretty(&card)?)?;
}
Ok(ControlResponse::PeerCardExported {
card,
out,
note: discovery_is_untrusted_note().to_owned(),
})
}
ControlRequest::PeerCardImport { path } => {
let card_json = std::fs::read_to_string(&path)?;
let card: PeerCard = serde_json::from_str(&card_json)?;
card.validate_candidate()?;
let discovered = DiscoveredPeer::candidate(
card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::Imported,
)?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: card.node_id.to_string(),
card_json: serde_json::to_string(&card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
Ok(ControlResponse::PeerCardImported {
peer: discovered,
note: discovery_is_untrusted_note().to_owned(),
})
}
ControlRequest::PeerCardList => {
let peers = store
.list_peer_cards()?
.into_iter()
.map(discovered_peer_from_stored)
.collect::<Result<Vec<_>, _>>()?;
Ok(ControlResponse::PeerCardList {
peers,
note: discovery_is_untrusted_note().to_owned(),
})
}
ControlRequest::SyncStatus => sync_status_local(&store),
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SyncNow { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasRootSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::KvSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::DbSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::DocumentSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store
.list_resources()?
.into_iter()
.map(stored_resource_to_descriptor)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::ResourceCreate { kind, name } => {
let kind = kind
.parse::<ResourceKind>()
.map_err(|_| NodeError::InvalidResourceKind(kind.clone()))?;
let id = format!("resource:{}:{}", kind, name);
let stored = StoredResource {
resource_id: id,
kind: kind.to_string(),
name,
status: "active".to_owned(),
};
store.insert_resource(&stored)?;
Ok(ControlResponse::ResourceCreated {
resource: stored_resource_to_descriptor(stored)?,
})
}
ControlRequest::OverlayStatus => Ok(ControlResponse::OverlayStatus {
networks: overlay_networks(&store)?,
note: geth_overlay::overlay_status_note().to_owned(),
}),
ControlRequest::OverlayPlan { name, cidr } => {
let plan = geth_overlay::plan_overlay(
&name,
cidr.as_deref(),
&String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY),
)?;
Ok(ControlResponse::OverlayPlanned { plan })
}
ControlRequest::OverlayJoin { name, secret, cidr } => {
geth_overlay::validate_overlay_secret(&secret)?;
let resource_id = geth_overlay::overlay_resource_id(&name).to_string();
let bearer_required =
bearer_access_exists_for_resource_capability(&store, &resource_id, "overlay.join")?;
if bearer_required
&& !bearer_token_grants_resource_capability(
&store,
&secret,
&resource_id,
"overlay.join",
)?
{
return Err(NodeError::Unauthorized(
"overlay join requires a valid bearer secret with overlay.join on the overlay resource".to_owned(),
));
}
let plan = geth_overlay::plan_overlay(
&name,
cidr.as_deref(),
&String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY),
)?;
store.insert_resource(&StoredResource {
resource_id: plan.resource.to_string(),
kind: ResourceKind::Overlay.to_string(),
name: name.clone(),
status: "active".to_owned(),
})?;
let now = geth_store::now_ms();
let membership = geth_overlay::joined_overlay_membership(
&name,
Some(&plan.cidr),
&node.node_id,
&secret,
now,
)?;
let network = geth_overlay::membership_status(&membership);
store.put_module_state(&StoredModuleState {
module: geth_overlay::overlay_state_key(&name),
state_json: serde_json::to_string(&membership)?,
updated_at_ms: now,
})?;
let note = if bearer_required {
"overlay membership persisted after bearer overlay.join authorization; run `geth overlay up <name>` to start the explicit TUN/Wintun runtime"
} else {
"overlay membership persisted as a local bootstrap overlay; create a bearer invite with overlay.join before sharing this resource with other nodes"
};
Ok(ControlResponse::OverlayJoined {
join: geth_overlay::OverlayJoinPlan {
plan,
network,
enabled: true,
note: note.to_owned(),
},
})
}
ControlRequest::OverlayLeave { name } => {
geth_overlay::validate_overlay_name(&name)?;
let _ = overlay_runtime_down(node, &name)?;
let key = geth_overlay::overlay_state_key(&name);
let stopped = if let Some(state) = store.get_module_state(&key)? {
let membership: geth_overlay::OverlayMembership =
serde_json::from_str(&state.state_json)?;
let now = geth_store::now_ms();
let membership = geth_overlay::stopped_overlay_membership(membership, now);
store.put_module_state(&StoredModuleState {
module: key,
state_json: serde_json::to_string(&membership)?,
updated_at_ms: now,
})?;
true
} else {
false
};
Ok(ControlResponse::OverlayLeft {
name,
stopped,
note: "overlay membership stopped; run `geth overlay down <name>` first if the runtime is active"
.to_owned(),
})
}
ControlRequest::OverlayInterfacePlan { name, platform } => {
let membership = overlay_membership(&store, &name)?;
let plan = geth_overlay::plan_overlay(
&name,
membership
.as_ref()
.map(|membership| membership.cidr.as_str()),
&String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY),
)?;
let virtual_ip = membership.and_then(|membership| membership.virtual_ip);
Ok(ControlResponse::OverlayInterfacePlanned {
plan: geth_overlay::interface_plan(
&plan,
platform.as_deref(),
virtual_ip.as_deref(),
)?,
})
}
ControlRequest::OverlayPeers { name } => {
geth_overlay::validate_overlay_name(&name)?;
Ok(ControlResponse::OverlayPeers {
peers: overlay_peers(&store, &name)?,
name,
note: "overlay peers are candidate peer-card metadata; discovery remains untrusted"
.to_owned(),
})
}
ControlRequest::OverlayRecv { name, peek } => {
geth_overlay::validate_overlay_name(&name)?;
let packets = overlay_packets(&store, &name, !peek)?;
Ok(ControlResponse::OverlayPackets {
name,
packets,
drained: !peek,
note: "overlay packets were received over /geth/overlay/1 and stored locally for inspection".to_owned(),
})
}
ControlRequest::OverlaySend { .. }
| ControlRequest::OverlayUp { .. }
| ControlRequest::OverlayDown { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasAdd { path } => {
let cas = LocalCas::new(node.paths.cas_dir());
let info = cas.add_path(&path)?;
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
Ok(ControlResponse::CasAdded {
hash: info.hash,
size_bytes: info.size_bytes,
})
}
ControlRequest::CasAddPrivate { resource, path } => {
ensure_resource_exists(&store, &resource)?;
let secret = store.latest_resource_secret(&resource)?.ok_or_else(|| {
NodeError::IrohPeer(format!(
"resource {resource} has no active resource secret; run geth secret create {resource}"
))
})?;
let plaintext = std::fs::read(&path)?;
let plaintext_hash = geth_cas::hash_bytes(&plaintext);
let encrypted = geth_cas::encrypt_private_blob(
&resource,
&secret.secret_id,
secret.epoch,
&plaintext,
)?;
let cas = LocalCas::new(node.paths.cas_dir());
let info = cas.add_bytes(&encrypted)?;
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
Ok(ControlResponse::CasPrivateAdded {
resource,
epoch: secret.epoch,
plaintext_hash,
encrypted_hash: info.hash,
size_bytes: info.size_bytes,
note: "stored AES-256-GCM private blob envelope bound to the resource and secret epoch; no forward secrecy or post-compromise security is claimed".to_owned(),
})
}
ControlRequest::CasGet { hash, out } => {
let cas = LocalCas::new(node.paths.cas_dir());
let size_bytes = cas.get_to_path(&hash, &out)?;
Ok(ControlResponse::CasGot {
hash,
out,
size_bytes,
})
}
ControlRequest::CasGetPrivate {
resource,
hash,
out,
} => {
ensure_resource_exists(&store, &resource)?;
let cas = LocalCas::new(node.paths.cas_dir());
let encrypted = cas.read_bytes(&hash)?;
let secrets = store
.list_resource_secrets()?
.into_iter()
.filter(|secret| secret.resource_id == resource)
.collect::<Vec<_>>();
if secrets.is_empty() {
return Err(NodeError::IrohPeer(format!(
"resource {resource} has no resource secrets; cannot decrypt private blob"
)));
}
let mut plaintext = None;
for secret in secrets {
if let Ok(bytes) =
geth_cas::decrypt_private_blob(&resource, &secret.secret_id, &encrypted)
{
plaintext = Some(bytes);
break;
}
}
let plaintext = plaintext.ok_or_else(|| {
NodeError::IrohPeer(format!(
"no local resource secret epoch could decrypt private blob {hash}"
))
})?;
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out, &plaintext)?;
Ok(ControlResponse::CasPrivateGot {
resource,
hash,
plaintext_hash: geth_cas::hash_bytes(&plaintext),
out,
size_bytes: plaintext.len() as u64,
note:
"decrypted AES-256-GCM private blob envelope with a local resource secret epoch"
.to_owned(),
})
}
ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash {
hash: hash_path(&path)?,
}),
ControlRequest::CasHas { hash } => {
let cas = LocalCas::new(node.paths.cas_dir());
let present = cas.has(&hash)?;
Ok(ControlResponse::CasHas { hash, present })
}
ControlRequest::CasPin { hash } => {
let cas = LocalCas::new(node.paths.cas_dir());
if !cas.has(&hash)? {
return Err(NodeError::Cas(geth_cas::CasError::NotFound(
hash.to_string(),
)));
}
store.pin_cas_object(hash.as_str())?;
Ok(ControlResponse::CasPinned { hash, pinned: true })
}
ControlRequest::CasUnpin { hash } => {
geth_cas::validate_hash(&hash)?;
store.unpin_cas_object(hash.as_str())?;
Ok(ControlResponse::CasPinned {
hash,
pinned: false,
})
}
ControlRequest::CasCleanup { dry_run } => {
let cas = LocalCas::new(node.paths.cas_dir());
let mut removed = Vec::new();
let mut retained_pinned = Vec::new();
for blob in cas.list()? {
if store.is_cas_object_pinned(blob.hash.as_str())? {
retained_pinned.push(blob.hash);
continue;
}
if !dry_run && cas.remove(&blob.hash)? {
store.delete_cas_object(blob.hash.as_str())?;
}
removed.push(blob.hash);
}
Ok(ControlResponse::CasCleanup {
removed,
retained_pinned,
dry_run,
})
}
ControlRequest::CasProviders { hash } => {
geth_cas::validate_hash(&hash)?;
Ok(ControlResponse::CasProviders {
providers: store
.list_cas_providers(hash.as_str())?
.into_iter()
.map(|provider| CasProvider {
peer_node_id: provider.peer_node_id,
endpoint_id: provider.endpoint_id,
last_seen_ms: provider.last_seen_ms,
})
.collect(),
hash,
})
}
ControlRequest::CasList => {
let cas = LocalCas::new(node.paths.cas_dir());
let blobs = cas
.list()?
.into_iter()
.map(|blob| {
Ok(CasBlob {
pinned: store.is_cas_object_pinned(blob.hash.as_str())?,
hash: blob.hash,
size_bytes: blob.size_bytes,
})
})
.collect::<Result<Vec<_>, NodeError>>()?;
Ok(ControlResponse::CasList { blobs })
}
ControlRequest::CasRootAdd { name, path } => {
geth_cas::validate_file_root_name(&name)?;
if !path.is_dir() {
return Err(NodeError::Cas(geth_cas::CasError::TreeRootNotDirectory(
path.display().to_string(),
)));
}
let path = std::fs::canonicalize(path)?;
let resource_id = format!("resource:cas-tree:{name}");
store.insert_resource(&StoredResource {
resource_id: resource_id.clone(),
kind: ResourceKind::Cas.to_string(),
name: format!("file-root:{name}"),
status: "active".to_owned(),
})?;
let stored = StoredFileRoot {
root_id: format!("file-root:{name}"),
resource_id,
name,
path: path.display().to_string(),
latest_tree_hash: None,
latest_tree_json: None,
updated_at_ms: geth_store::now_ms(),
};
store.upsert_file_root(&stored)?;
Ok(ControlResponse::CasRootAdded {
root: file_root_from_stored(&stored),
})
}
ControlRequest::CasRootList => Ok(ControlResponse::CasRootList {
roots: store
.list_file_roots()?
.iter()
.map(file_root_from_stored)
.collect(),
}),
ControlRequest::CasRootScan { name } => {
geth_cas::validate_file_root_name(&name)?;
let mut stored = store
.get_file_root_by_name(&name)?
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root:{name}")))?;
let previous_tree = stored
.latest_tree_json
.as_deref()
.map(serde_json::from_str)
.transpose()?;
let cas = LocalCas::new(node.paths.cas_dir());
let scanned = cas.add_tree_path(Path::new(&stored.path))?;
store.record_cas_object(
scanned.object.hash.as_str(),
scanned.object.size_bytes,
&scanned.object.path.to_string_lossy(),
)?;
let changes = geth_cas::diff_tree_objects(previous_tree.as_ref(), &scanned.tree);
stored.latest_tree_hash = Some(scanned.object.hash.to_string());
stored.latest_tree_json = Some(serde_json::to_string(&scanned.tree)?);
stored.updated_at_ms = geth_store::now_ms();
store.upsert_file_root(&stored)?;
Ok(ControlResponse::CasRootScanned {
scan: FileRootScan {
root: file_root_from_stored(&stored),
tree: BlobInfoSummary {
hash: scanned.object.hash,
size_bytes: scanned.object.size_bytes,
},
changes,
note: geth_cas::file_root_scan_note().to_owned(),
},
})
}
ControlRequest::CasRootApply {
source,
target,
dry_run,
} => apply_file_root_tree(&store, node, &source, target, dry_run),
ControlRequest::CasConflictRecord {
root,
path,
kind,
detail,
base_tree,
local_tree,
remote_tree,
} => {
geth_cas::validate_file_root_name(&root)?;
if let Some(hash) = &base_tree {
geth_cas::validate_hash(hash)?;
}
if let Some(hash) = &local_tree {
geth_cas::validate_hash(hash)?;
}
if let Some(hash) = &remote_tree {
geth_cas::validate_hash(hash)?;
}
let root_record = store
.get_file_root_by_name(&root)?
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root:{root}")))?;
let kind = FileConflictKind::parse(&kind)?;
let created_at = geth_store::now_ms();
let conflict_id = generated_file_conflict_id(&root, &path, kind.as_str(), created_at);
let conflict = StoredFileConflict {
conflict_id,
root_name: root,
resource_id: root_record.resource_id,
path,
kind: kind.as_str().to_owned(),
status: FileConflictStatus::Open.as_str().to_owned(),
base_tree_hash: base_tree.map(|hash| hash.to_string()),
local_tree_hash: local_tree.map(|hash| hash.to_string()),
remote_tree_hash: remote_tree.map(|hash| hash.to_string()),
detail,
resolution: None,
resolution_note: None,
created_at_ms: created_at,
resolved_at_ms: None,
};
store.upsert_file_conflict(&conflict)?;
Ok(ControlResponse::CasConflictRecorded {
conflict: file_conflict_from_stored(conflict)?,
})
}
ControlRequest::CasConflictList { root } => {
if let Some(root) = root.as_deref() {
geth_cas::validate_file_root_name(root)?;
}
let conflicts = store
.list_file_conflicts(root.as_deref())?
.into_iter()
.map(file_conflict_from_stored)
.collect::<Result<Vec<_>, _>>()?;
Ok(ControlResponse::CasConflictList { conflicts })
}
ControlRequest::CasConflictResolve {
conflict_id,
resolution,
note,
} => {
let resolution = FileConflictResolution::parse(&resolution)?;
let mut conflict = store
.get_file_conflict(&conflict_id)?
.ok_or_else(|| NodeError::ResourceNotFound(conflict_id.clone()))?;
conflict.status = FileConflictStatus::Resolved.as_str().to_owned();
conflict.resolution = Some(resolution.as_str().to_owned());
conflict.resolution_note = note;
conflict.resolved_at_ms = Some(geth_store::now_ms());
store.upsert_file_conflict(&conflict)?;
Ok(ControlResponse::CasConflictResolved {
conflict: file_conflict_from_stored(conflict)?,
})
}
ControlRequest::KeychainInit {
admin_key_path,
signing_key_path,
} => {
let mut ops = Vec::new();
let created_at = UnixMillis(geth_store::now_ms());
let init = KeychainOp {
id: generated_keychain_op_id("keychain-init", "local", created_at),
created_at,
kind: KeychainOpKind::KeychainInit,
};
store_keychain_op(&store, &init)?;
ops.push(init);
if let Some(admin_key_path) = admin_key_path.as_ref() {
let public_key = std::fs::read_to_string(admin_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
let op = KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
created_at,
kind: KeychainOpKind::AdminKeyAdd {
key: admin_key,
public_key: Some(public_key.trim().to_owned()),
principal: Some("admin".to_owned()),
valid_after_ms: None,
valid_before_ms: None,
},
};
store_keychain_op(&store, &op)?;
ops.push(op);
}
let signatures = store_and_sign_keychain_ops(
&store,
node,
&ops,
signing_key_path.as_deref(),
admin_key_path.as_deref(),
)?;
Ok(ControlResponse::KeychainInitialized { ops, signatures })
}
ControlRequest::KeychainStatus => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
let signature_status = verify_keychain_signatures(&store, node)?;
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
initialized: view.initialized,
admin_keys: view.admin_keys.len(),
signatures: signature_status.total,
verified_signatures: signature_status.verified,
failed_signatures: signature_status.failed,
users: view.users.len(),
devices: view.devices.len(),
nodes: view.nodes.len(),
}))
}
ControlRequest::KeychainAdminAdd {
admin_key_path,
signing_key_path,
principal,
valid_after_ms,
valid_before_ms,
} => {
let public_key = std::fs::read_to_string(&admin_key_path)?;
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at),
created_at,
kind: KeychainOpKind::AdminKeyAdd {
key: admin_key,
public_key: Some(public_key.trim().to_owned()),
principal: principal.or_else(|| Some("admin".to_owned())),
valid_after_ms,
valid_before_ms,
},
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(&signing_key_path),
None,
)?;
Ok(ControlResponse::KeychainAdminUpdated {
op,
signatures,
note: "recorded signed admin key addition in the keychain sigchain".to_owned(),
})
}
ControlRequest::KeychainAdminRevoke {
key,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("admin-key-revoke", &key, created_at),
created_at,
kind: KeychainOpKind::AdminKeyRevoke {
key: KeyId::new(key),
},
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(&signing_key_path),
admin_key_path.as_deref(),
)?;
Ok(ControlResponse::KeychainAdminUpdated {
op,
signatures,
note: "recorded signed admin key revocation in the keychain sigchain".to_owned(),
})
}
ControlRequest::KeychainAllowedSigners { out } => {
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?,
);
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &allowed_signers)?;
}
Ok(ControlResponse::KeychainAllowedSigners {
entries,
allowed_signers,
out,
note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(),
})
}
ControlRequest::KeychainSignFile {
input,
out,
namespace,
signing_key_path,
admin_key_path,
} => {
let signing_key_path = signing_key_path
.as_ref()
.ok_or_else(|| NodeError::SigningKeyRequired("keychain sign-file".to_owned()))?;
let namespace =
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(&store)?,
&load_keychain_signatures(&store)?,
);
let (signer, _) =
keychain_signer_from_paths(signing_key_path, admin_key_path.as_deref())?;
if !entries.iter().any(|entry| entry.key == signer) {
return Err(NodeError::Unauthorized(format!(
"signing key {signer} is not an active keychain admin signer"
)));
}
let signature_out = sign_file_with_ssh(signing_key_path, &namespace, &input, out)?;
Ok(ControlResponse::KeychainFileSigned {
input,
out: Some(signature_out),
namespace,
signer: signer.to_string(),
note: "signed file with an active keychain admin signer; publish the file, signature, and allowed_signers projection together".to_owned(),
})
}
ControlRequest::KeychainVerifyFile {
input,
signature,
namespace,
allowed_signers_path,
principal,
} => {
let namespace =
namespace.unwrap_or_else(|| geth_keychain::AUTHORIZED_KEYS_NAMESPACE.to_owned());
let (verified, matched_principal) = verify_file_with_keychain_signers(
&store,
node,
&input,
&signature,
&namespace,
allowed_signers_path.as_deref(),
principal.as_deref(),
)?;
Ok(ControlResponse::KeychainFileVerified {
input,
signature,
namespace,
verified,
principal: matched_principal,
note: "verified detached OpenSSH signature against allowed_signers from the keychain or provided file".to_owned(),
})
}
ControlRequest::KeychainSigchainExport { out } => {
let ops = load_keychain_ops(&store)?;
let signatures = load_keychain_signatures(&store)?;
let entries = geth_keychain::sigchain_entries(&ops, &signatures);
let jsonl = geth_keychain::encode_sigchain_jsonl(&entries)?;
if let Some(path) = &out {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &jsonl)?;
}
Ok(ControlResponse::KeychainSigchainExported {
entries,
jsonl,
out,
note: "appendable JSONL keychain sigchain export; publish with cache validators or range requests for efficient static hosting".to_owned(),
})
}
ControlRequest::KeychainPublishBundle {
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
} => publish_keychain_bundle(
&store,
node,
out,
base_url,
signing_key_path,
admin_key_path,
snapshots,
),
ControlRequest::KeychainVerifySigchain { input } => {
let text = std::fs::read_to_string(&input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok(ControlResponse::KeychainSigchainFileVerified {
input,
report,
note: "verified JSONL sigchain by replaying operations and OpenSSH signatures"
.to_owned(),
})
}
ControlRequest::KeychainImportSigchain { input } => {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &input)?;
let imported =
import_verified_sigchain(&store, &ops, &signatures, report.rejected_ops)?;
Ok(ControlResponse::KeychainSigchainImported {
input,
ops_imported: imported.ops_imported,
signatures_imported: imported.signatures_imported,
invalid_ops_rejected: imported.invalid_ops_rejected,
note: if imported.invalid_ops_rejected == 0 {
"imported verified JSONL sigchain entries".to_owned()
} else {
"sigchain contained rejected operations; nothing imported".to_owned()
},
})
}
ControlRequest::KeychainVerifyCheckpoint {
checkpoint,
signature,
sigchain,
allowed_signers,
base_url,
principal,
} => {
let (checkpoint, verified, principal) = verify_keychain_checkpoint_files(
node,
&checkpoint,
&signature,
&sigchain,
&allowed_signers,
base_url.as_deref(),
principal.as_deref(),
)?;
Ok(ControlResponse::KeychainCheckpointVerified {
checkpoint,
verified,
principal,
note: "verified checkpoint signature, hashes, base URL, and sigchain head"
.to_owned(),
})
}
ControlRequest::KeychainFetch { url, out, import } => {
fetch_keychain_bundle(&store, node, url, out, import)
}
ControlRequest::KeychainExplain { op_id } => Ok(ControlResponse::KeychainExplained {
subject: op_id.clone(),
lines: explain_keychain_op(&store, node, &op_id)?,
}),
ControlRequest::KeychainExplainSigner { key } => Ok(ControlResponse::KeychainExplained {
subject: key.clone(),
lines: explain_keychain_signer(&store, &key)?,
}),
ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified {
report: verify_keychain_sigchain_with_ssh(&store, node)?,
}),
ControlRequest::NodeList => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
Ok(ControlResponse::NodeList {
nodes: view.nodes.into_values().collect(),
note: "nodes are reduced from the signed keychain operation log; revoked devices and nodes are omitted".to_owned(),
})
}
ControlRequest::NodeRename {
node: target,
name,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node rename".to_owned()))?;
let target = resolve_keychain_node(&store, &target)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("node-rename", target.as_str(), created_at),
created_at,
kind: KeychainOpKind::NodeRename { node: target, name },
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(signing_key_path.as_path()),
None,
)?;
Ok(ControlResponse::NodeKeychainUpdated {
ops: vec![op],
signatures,
note: "recorded signed node rename as a keychain operation".to_owned(),
})
}
ControlRequest::NodeRevoke {
node: target,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node revoke".to_owned()))?;
let target = resolve_keychain_node(&store, &target)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("node-revoke", target.as_str(), created_at),
created_at,
kind: KeychainOpKind::NodeRevoke { node: target },
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(signing_key_path.as_path()),
None,
)?;
Ok(ControlResponse::NodeKeychainUpdated {
ops: vec![op],
signatures,
note: "recorded node revocation as a keychain operation; replicated peers will omit the node after verified keychain sync".to_owned(),
})
}
ControlRequest::NodeGrant {
node: target,
resource,
capability,
grant_id,
signing_key_path,
admin_key_path,
} => {
let target = resolve_keychain_node(&store, &target)?;
let op = node_grant_op(target.as_str(), &resource, &capability, grant_id);
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::NodeGrantUpdated {
op,
signatures,
note: "recorded signed resource-scoped node capability grant; auth explain can show the grant path".to_owned(),
})
}
ControlRequest::NodeRevokeGrant {
resource,
grant_id,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::NodeGrantUpdated {
op,
signatures,
note: "recorded signed capability grant revocation".to_owned(),
})
}
ControlRequest::NodeEndpointAdd {
node: target,
endpoint,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node endpoint add".to_owned()))?;
let target = resolve_keychain_node(&store, &target)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("node-endpoint-add", &endpoint, created_at),
created_at,
kind: KeychainOpKind::NodeEndpointAdd {
node: target,
endpoint,
},
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(signing_key_path.as_path()),
None,
)?;
Ok(ControlResponse::NodeKeychainUpdated {
ops: vec![op],
signatures,
note: "recorded signed node endpoint binding".to_owned(),
})
}
ControlRequest::NodeEndpointRevoke {
node: target,
endpoint,
signing_key_path,
} => {
let signing_key_path = signing_key_path
.ok_or_else(|| NodeError::SigningKeyRequired("node endpoint revoke".to_owned()))?;
let target = resolve_keychain_node(&store, &target)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = KeychainOp {
id: generated_keychain_op_id("node-endpoint-revoke", &endpoint, created_at),
created_at,
kind: KeychainOpKind::NodeEndpointRevoke {
node: target,
endpoint,
},
};
let signatures = store_and_sign_keychain_ops(
&store,
node,
std::slice::from_ref(&op),
Some(signing_key_path.as_path()),
None,
)?;
Ok(ControlResponse::NodeKeychainUpdated {
ops: vec![op],
signatures,
note: "recorded signed node endpoint revocation".to_owned(),
})
}
ControlRequest::NodeEnrollRequest {
node_name,
capabilities,
reason,
out,
} => {
let mut request =
create_node_enrollment_request(node, node_name, capabilities, reason)?;
sign_node_enrollment_request(node, &mut request)?;
store_node_enrollment_request(&store, &request)?;
if let Some(path) = out.as_ref() {
write_node_enrollment_request_file(path, &request)?;
}
Ok(ControlResponse::NodeEnrollmentRequested {
request,
out,
note: "created signed node enrollment request; submit it to an owner node or move the JSON file to the owner machine".to_owned(),
})
}
ControlRequest::NodeEnrollImport { path } => {
let request = read_node_enrollment_request_file(&path)?;
insert_node_enrollment_request_if_not_conflicting(&store, &request)?;
Ok(ControlResponse::NodeEnrollmentImported {
request,
note: "imported signed node enrollment request for owner review".to_owned(),
})
}
ControlRequest::NodeEnrollList { status } => {
let requests = load_node_enrollment_requests(&store)?
.into_iter()
.filter(|request| {
status
.as_ref()
.is_none_or(|status| request.status.as_str() == status)
})
.collect();
Ok(ControlResponse::NodeEnrollmentList {
requests,
note: "node enrollment requests are signed by the requesting agent; approve on an owner machine with an admin signing key".to_owned(),
})
}
ControlRequest::NodeEnrollApprove {
request_id,
signing_key_path,
admin_key_path,
node_name,
capabilities,
} => approve_node_enrollment(
&store,
node,
&request_id,
&signing_key_path,
admin_key_path.as_deref(),
node_name,
capabilities,
),
ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus {
secrets: store
.list_resource_secrets()?
.into_iter()
.map(resource_secret_from_stored)
.collect(),
}),
ControlRequest::SecretCreate { resource } => {
ensure_resource_exists(&store, &resource)?;
let secret = create_resource_secret(&store, &resource, 1)?;
Ok(ControlResponse::SecretCreated { secret })
}
ControlRequest::SecretRotate { resource } => {
ensure_resource_exists(&store, &resource)?;
let next_epoch = store
.latest_resource_secret(&resource)?
.map(|secret| secret.epoch + 1)
.unwrap_or(1);
let secret = create_resource_secret(&store, &resource, next_epoch)?;
Ok(ControlResponse::SecretCreated { secret })
}
ControlRequest::SecretBearerCreate {
resource,
capabilities,
expires_at_ms,
} => {
ensure_resource_exists(&store, &resource)?;
let capabilities = capabilities
.into_iter()
.map(Capability::new)
.collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&capabilities)?;
let created_at = UnixMillis(geth_store::now_ms());
let token = geth_types::SecretId::new(format!(
"gbt_{}",
geth_crypto::blake3_hex(
format!(
"{}\0{resource}\0{}\0{}",
node.agent_id,
capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
created_at.0
)
.as_bytes()
)
));
let secret = geth_secrets::bearer_id_for_token(&token);
let token_hash = geth_secrets::bearer_token_hash(&token);
let access = BearerAccess::resource_scoped(
secret.clone(),
ResourceId::new(resource.clone()),
capabilities.clone(),
);
let op = AuthOp {
id: generated_auth_op_id("bearer-create", &resource, secret.as_str(), created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::BearerAccessCreate {
secret,
token_hash: Some(token_hash),
capabilities,
expires_at: expires_at_ms.map(UnixMillis),
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::SecretBearerCreated {
access: BearerAccess {
token: Some(token),
expires_at: expires_at_ms.map(UnixMillis),
..access
},
})
}
ControlRequest::SecretBearerList => Ok(ControlResponse::SecretBearerList {
access: load_bearer_access(&store)?,
}),
ControlRequest::SecretBearerChallenge {
resource,
capabilities,
} => {
ensure_resource_exists(&store, &resource)?;
let capabilities = capabilities
.into_iter()
.map(Capability::new)
.collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&capabilities)?;
let issued_at = UnixMillis(geth_store::now_ms());
let nonce = geth_crypto::blake3_hex(
format!("{}\0{}\0{}", node.agent_id, resource, issued_at.0).as_bytes(),
);
Ok(ControlResponse::SecretBearerChallenge {
challenge: BearerChallenge {
resource: ResourceId::new(resource),
capabilities,
nonce,
issued_at,
},
})
}
ControlRequest::SecretBearerProve {
secret,
resource,
capabilities,
nonce,
} => {
ensure_resource_exists(&store, &resource)?;
let capabilities = capabilities
.into_iter()
.map(Capability::new)
.collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&capabilities)?;
let token = geth_types::SecretId::new(secret);
let secret = geth_secrets::bearer_id_for_token(&token);
let resource = ResourceId::new(resource);
let response = geth_secrets::bearer_response(&token, &resource, &capabilities, &nonce);
Ok(ControlResponse::SecretBearerProof {
proof: BearerProof {
secret,
resource,
capabilities,
nonce,
response,
},
})
}
ControlRequest::SecretBearerVerify {
secret,
resource,
capabilities,
nonce,
response,
} => {
ensure_resource_exists(&store, &resource)?;
let requested_capabilities = capabilities
.iter()
.cloned()
.map(Capability::new)
.collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&requested_capabilities)?;
let token = geth_types::SecretId::new(secret.clone());
let secret_id = geth_secrets::bearer_id_for_token(&token);
let resource_id = ResourceId::new(resource.clone());
let access = load_bearer_access(&store)?.into_iter().find(|access| {
access.secret == secret_id
&& access.resource == resource_id
&& access
.expires_at
.is_none_or(|expires_at| expires_at.0 >= geth_store::now_ms())
});
let (verified, reason) = if let Some(access) = access {
let has_capabilities = requested_capabilities
.iter()
.all(|capability| access.capabilities.contains(capability));
if !has_capabilities {
(
false,
"bearer secret lacks requested capabilities".to_owned(),
)
} else if access.token_hash.as_deref().is_some_and(|token_hash| {
geth_secrets::verify_bearer_response_with_token_hash(
token_hash,
&resource_id,
&requested_capabilities,
&nonce,
&response,
)
}) {
(
true,
"bearer proof verified for resource-scoped capabilities".to_owned(),
)
} else {
(
false,
"bearer proof response did not match challenge".to_owned(),
)
}
} else {
(false, "bearer access not found or expired".to_owned())
};
Ok(ControlResponse::SecretBearerVerified {
secret,
resource,
capabilities,
verified,
reason,
})
}
ControlRequest::SecretBearerRevoke { resource, secret } => {
ensure_resource_exists(&store, &resource)?;
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("bearer-revoke", &resource, &secret, created_at),
resource: ResourceId::new(resource.clone()),
created_at,
kind: AuthOpKind::BearerAccessRevoke {
secret: secret.clone().into(),
},
};
store_auth_op(&store, &op)?;
Ok(ControlResponse::SecretBearerRevoked { resource, secret })
}
ControlRequest::AuthExplain {
subject,
resource,
capability,
} => Ok(ControlResponse::AuthExplain(explain_auth_for_operator(
&store,
&subject,
&resource,
&capability,
)?)),
ControlRequest::AuthGrant {
subject,
resource,
capability,
grant_id,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let grant_id =
grant_id.unwrap_or_else(|| generated_grant_id(&subject, &resource, &capability));
let op = AuthOp {
id: generated_auth_op_id("grant-create", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id,
principal: PrincipalId::new(subject),
capabilities: vec![Capability::new(capability)],
},
};
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::AuthOpRecorded { op, signatures })
}
ControlRequest::AuthRevoke {
resource,
grant_id,
signing_key_path,
admin_key_path,
} => {
let created_at = UnixMillis(geth_store::now_ms());
let op = AuthOp {
id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at),
resource: ResourceId::new(resource),
created_at,
kind: AuthOpKind::GrantRevoke { grant_id },
};
let signatures = if let Some(signing_key_path) = signing_key_path {
store_and_sign_auth_ops(
&store,
node,
std::slice::from_ref(&op),
&signing_key_path,
admin_key_path.as_deref(),
)?
} else {
store_auth_op(&store, &op)?;
Vec::new()
};
Ok(ControlResponse::AuthOpRecorded { op, signatures })
}
ControlRequest::SshCertRequest {
public_key_path,
cert_kind,
principals,
requested_validity,
renewal_of,
reason,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.request",
)?;
if principals.is_empty() {
return Err(NodeError::MissingSshCertPrincipal);
}
let cert_kind = cert_kind
.parse::<SshCertKind>()
.map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?;
let public_key = std::fs::read_to_string(&public_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let mut request = SshCertRequest {
id: cert_request_id(
&NodeId::new(node.node_id.clone()),
&public_key,
&principals,
created_at,
),
requester_node: NodeId::new(node.node_id.clone()),
public_key_fingerprint: ssh_public_key_fingerprint(&public_key),
public_key,
cert_kind,
principals,
requested_validity,
renewal_of: renewal_of.map(SshCertId::new),
reason,
status: SshCertRequestStatus::Pending,
created_at,
provenance: None,
};
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
Ok(ControlResponse::SshCertRequested { request })
}
ControlRequest::SshCertRequests { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.read",
)?;
Ok(ControlResponse::SshCertRequests {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
})
}
ControlRequest::SshCertApprove {
request_id,
ca_key_path,
valid_for,
serial,
out,
sign,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.approve",
)?;
let stored = store
.get_ssh_cert_request(&request_id)?
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Approved;
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
let public_key_path = out.clone().unwrap_or_else(|| {
node.paths
.home()
.join("ssh-cert-requests")
.join(format!("{}.pub", request.id))
});
if let Some(parent) = public_key_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&public_key_path, &request.public_key)?;
let valid_for = valid_for
.or_else(|| request.requested_validity.clone())
.unwrap_or_else(|| "+52w".to_owned());
let signing_command = build_ssh_cert_sign_command(
&request,
&ca_key_path,
&public_key_path,
&valid_for,
serial,
)?;
let expected_certificate_path = expected_openssh_cert_path(&public_key_path);
let mut signed = false;
let mut certificate_id_value = None;
let mut note = "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned();
if sign {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let output = std::process::Command::new(&signing_command[0])
.args(&signing_command[1..])
.output()?;
if !output.status.success() {
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
)
.into());
}
let certificate = std::fs::read_to_string(&expected_certificate_path)?;
let mut record = SshCertificateRecord {
id: certificate_id(&certificate),
request_id: request.id.clone(),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate,
imported_at: UnixMillis(geth_store::now_ms()),
provenance: None,
};
sign_ssh_certificate(node, &mut record)?;
store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
request.status = SshCertRequestStatus::Signed;
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
signed = true;
certificate_id_value = Some(record.id);
note = "request approved, signed with ssh-keygen, and imported into local certificate metadata".to_owned();
}
let approval = SshCertApproval {
request_id: request.id,
approved_by_node: NodeId::new(node.node_id.clone()),
ca_key_path: ca_key_path.display().to_string(),
key_id: request_id,
valid_for,
serial,
output_path: Some(expected_certificate_path),
signing_command,
signed,
certificate_id: certificate_id_value,
note,
};
Ok(ControlResponse::SshCertApproved { approval })
}
ControlRequest::SshCertImport {
request_id,
cert_path,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.import",
)?;
let certificate = std::fs::read_to_string(&cert_path)?;
let mut record = SshCertificateRecord {
id: certificate_id(&certificate),
request_id: SshCertRequestId::new(request_id.clone()),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate,
imported_at: UnixMillis(geth_store::now_ms()),
provenance: None,
};
sign_ssh_certificate(node, &mut record)?;
store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
if let Some(stored) = store.get_ssh_cert_request(&request_id)? {
let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Signed;
sign_ssh_cert_request(node, &mut request)?;
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
}
Ok(ControlResponse::SshCertImported {
certificate: record,
})
}
ControlRequest::SshCertList { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:certs",
"ssh_cert.read",
)?;
Ok(ControlResponse::SshCertList {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
certificates: store
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect::<Result<Vec<_>, _>>()?,
})
}
ControlRequest::SshRevocationAdd {
kind,
target,
reason,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.publish",
)?;
let kind = kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
let created_at = UnixMillis(geth_store::now_ms());
let mut revocation = SshRevocationEntry {
id: revocation_id(&kind, &target, created_at),
kind,
target,
reason,
created_at,
published: true,
provenance: None,
};
sign_ssh_revocation(node, &mut revocation)?;
store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?;
Ok(ControlResponse::SshRevocationAdded { revocation })
}
ControlRequest::SshRevocationList { subject } => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.read",
)?;
Ok(ControlResponse::SshRevocationList {
revocations: store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?,
})
}
ControlRequest::SshRevocationExport {
out,
format,
ca_public,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.read",
)?;
let revocations = store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?;
let format = format
.parse::<SshRevocationExportFormat>()
.map_err(NodeError::SshIdentity)?;
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let (body, note) = match format {
SshRevocationExportFormat::Jsonl => {
let mut body = String::new();
for revocation in &revocations {
body.push_str(&serde_json::to_string(revocation)?);
body.push('\n');
}
(
body,
"JSONL geth revocation metadata; not an OpenSSH KRL binary".to_owned(),
)
}
SshRevocationExportFormat::OpenSshKrlSpec => (
openssh_krl_spec(&revocations)?,
"OpenSSH KRL specification; generate a binary KRL with ssh-keygen -k -f <krl> [-s <ca.pub>] <spec>".to_owned(),
),
SshRevocationExportFormat::OpenSshKrl => {
write_openssh_krl(&revocations, &out, ca_public.as_deref())?;
(
String::new(),
"OpenSSH binary KRL generated with ssh-keygen; use ssh-keygen -Q -f <krl> <key-or-cert> to query it".to_owned(),
)
}
};
if format != SshRevocationExportFormat::OpenSshKrl {
std::fs::write(&out, body)?;
}
Ok(ControlResponse::SshRevocationExported {
out,
format: format.to_string(),
count: revocations.len(),
note,
})
}
ControlRequest::SshRevocationImport {
path,
format,
subject,
} => {
ensure_subject_authorized(
&store,
node,
subject.as_deref(),
"resource:ssh:revocations",
"ssh_revocation.import",
)?;
let body = std::fs::read_to_string(&path)?;
let created_at = UnixMillis(geth_store::now_ms());
let mut revocations = match format.as_str() {
"jsonl" => body
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).map_err(NodeError::from))
.collect::<Result<Vec<SshRevocationEntry>, NodeError>>()?,
"openssh-krl-spec" | "krl-spec" => parse_openssh_krl_spec(&body)?
.into_iter()
.enumerate()
.map(|(index, (kind, target))| {
let created_at = UnixMillis(created_at.0 + index as i64);
SshRevocationEntry {
id: revocation_id(&kind, &target, created_at),
kind,
target,
reason: Some(format!("imported from {}", path.display())),
created_at,
published: true,
provenance: None,
}
})
.collect(),
"openssh-krl" | "krl" => {
return Err(NodeError::SshIdentity(
geth_ssh_identity::SshIdentityError::BinaryKrlImportUnsupported,
));
}
_ => {
return Err(NodeError::SshIdentity(
geth_ssh_identity::SshIdentityError::InvalidRevocationExportFormat(
format.clone(),
),
));
}
};
for revocation in &mut revocations {
sign_ssh_revocation(node, revocation)?;
}
for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
}
Ok(ControlResponse::SshRevocationImported {
count: revocations.len(),
revocations,
format,
note: "imported revocation metadata; binary OpenSSH KRL files cannot be enumerated, import JSONL or the KRL spec source instead".to_owned(),
})
}
ControlRequest::DbAdd { name, path } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
if !path.is_file() {
return Err(NodeError::InvalidDbPath(path.display().to_string()));
}
let path = std::fs::canonicalize(path)?;
let schema_metadata = geth_db::schema_metadata(&path)?;
let crsqlite_changes = geth_db::crsqlite_change_metadata(&path)?;
let resource_id = format!("resource:db:{name}");
let db_id = format!("db:{name}");
let resource = StoredResource {
resource_id: resource_id.clone(),
kind: ResourceKind::Db.to_string(),
name: name.clone(),
status: "active".to_owned(),
};
store.insert_resource(&resource)?;
let stored = StoredDbResource {
db_id,
resource_id,
name,
path: path.display().to_string(),
};
store.insert_db_resource(&stored)?;
Ok(ControlResponse::DbAdded {
db: db_resource_from_stored_with_schema(&stored, schema_metadata, crsqlite_changes),
})
}
ControlRequest::DbStatus { name } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
let stored = store
.get_db_resource_by_name(&name)?
.ok_or_else(|| NodeError::DbNotFound(name.clone()))?;
Ok(ControlResponse::DbStatus {
db: db_resource_from_stored(&stored)?,
})
}
ControlRequest::DbChanges {
name,
after_db_version,
limit,
} => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
let stored = store
.get_db_resource_by_name(&name)?
.ok_or_else(|| NodeError::DbNotFound(name.clone()))?;
let batch = geth_db::extract_crsqlite_changes(
Path::new(&stored.path),
after_db_version,
limit,
)?;
Ok(ControlResponse::DbChanges {
db: db_resource_from_stored(&stored)?,
batch,
})
}
ControlRequest::KvCreate { name } => {
geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
let resource_id = format!("resource:kv:{name}");
let kv_id = format!("kv:{name}");
let resource = StoredResource {
resource_id: resource_id.clone(),
kind: ResourceKind::Kv.to_string(),
name: name.clone(),
status: "active".to_owned(),
};
store.insert_resource(&resource)?;
let stored = StoredKvStore {
kv_id,
resource_id,
name,
};
store.insert_kv_store(&stored)?;
Ok(ControlResponse::KvCreated {
kv: kv_resource_from_stored(&stored),
})
}
ControlRequest::KvSet {
name,
key,
value,
subject,
} => {
geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?;
let kv = store
.get_kv_store_by_name(&name)?
.ok_or_else(|| NodeError::KvNotFound(name.clone()))?;
ensure_kv_write_authorized(&store, node, subject, &kv.resource_id, &key)?;
let stored = StoredKvEntry {
kv_id: kv.kv_id,
key,
value,
updated_at_ms: geth_store::now_ms(),
};
store.set_kv_entry(&stored)?;
Ok(ControlResponse::KvSet {
entry: kv_entry_from_stored(stored),
})
}
ControlRequest::KvGet { name, key } => {
geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?;
let kv = store
.get_kv_store_by_name(&name)?
.ok_or_else(|| NodeError::KvNotFound(name.clone()))?;
let entry = store
.get_kv_entry(&kv.kv_id, &key)?
.map(kv_entry_from_stored);
Ok(ControlResponse::KvGet { entry })
}
ControlRequest::DocumentCreate { name } => {
geth_document::validate_document_name(&name)
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
if let Some(existing) = store.get_document_resource_by_name(&name)? {
return Ok(ControlResponse::DocumentCreated {
document: document_resource_from_stored(&existing),
});
}
let resource_id = format!("resource:document:{name}");
let document_id = format!("document:{name}");
let resource = StoredResource {
resource_id: resource_id.clone(),
kind: ResourceKind::Document.to_string(),
name: name.clone(),
status: "active".to_owned(),
};
store.insert_resource(&resource)?;
let stored = StoredDocumentResource {
document_id,
resource_id,
name,
state_json: geth_document::create_automerge_state("{}")?,
updated_at_ms: geth_store::now_ms(),
};
store.insert_document_resource(&stored)?;
Ok(ControlResponse::DocumentCreated {
document: document_resource_from_stored(&stored),
})
}
ControlRequest::DocumentStatus { name } => {
geth_document::validate_document_name(&name)
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
let stored = store
.get_document_resource_by_name(&name)?
.ok_or_else(|| NodeError::DocumentNotFound(name.clone()))?;
Ok(ControlResponse::DocumentStatus {
document: document_resource_from_stored(&stored),
})
}
ControlRequest::DocumentSet { name, state_json } => {
geth_document::validate_document_name(&name)
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
let mut stored = store
.get_document_resource_by_name(&name)?
.ok_or_else(|| NodeError::DocumentNotFound(name.clone()))?;
stored.state_json = geth_document::create_automerge_state(&state_json)?;
stored.updated_at_ms = geth_store::now_ms();
store.insert_document_resource(&stored)?;
Ok(ControlResponse::DocumentSet {
state: document_state_from_stored(&stored)?,
})
}
ControlRequest::DocumentGet { name } => {
geth_document::validate_document_name(&name)
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
let stored = store
.get_document_resource_by_name(&name)?
.ok_or_else(|| NodeError::DocumentNotFound(name.clone()))?;
Ok(ControlResponse::DocumentGet {
state: document_state_from_stored(&stored)?,
})
}
ControlRequest::PubsubPub {
topic,
message,
node: None,
..
} => {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let message = record_pubsub_message(node, topic, message)?;
Ok(ControlResponse::PubsubPublished { message })
}
ControlRequest::PubsubPub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PubsubSub {
topic, node: None, ..
} => {
geth_pubsub::validate_topic(&topic)?;
let messages = pubsub_messages_for_topic(node, &topic)?;
Ok(ControlResponse::PubsubMessages {
topic,
messages,
note: geth_pubsub::pubsub_storage_warning().to_owned(),
})
}
ControlRequest::PubsubSub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PipeListen {
name, node: None, ..
} => {
geth_pipe::validate_pipe_name(&name)?;
let listener = record_pipe_listener(node, name)?;
Ok(ControlResponse::PipeListening { listener })
}
ControlRequest::PipeListen { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PipeConnect {
target, node: None, ..
} => {
geth_pipe::validate_pipe_name(&target)?;
let connection = record_pipe_connection(
node,
target,
geth_pipe::local_pipe_runtime_note().to_owned(),
)?;
Ok(ControlResponse::PipeConnected { connection })
}
ControlRequest::PipeConnect { node: Some(_), .. } => {
Err(NodeError::IrohEndpointUnavailable)
}
ControlRequest::PipeSend {
target,
data_base64,
node: None,
..
} => {
geth_pipe::validate_pipe_name(&target)?;
base64::engine::general_purpose::STANDARD
.decode(&data_base64)
.map_err(|error| NodeError::IrohPeer(format!("invalid pipe payload: {error}")))?;
let (message, listener_found) = record_pipe_message(
node,
target,
data_base64,
Some(node.node_id.clone()),
"local pipe byte message".to_owned(),
)?;
Ok(ControlResponse::PipeSent {
message,
listener_found,
note: geth_pipe::local_pipe_runtime_note().to_owned(),
})
}
ControlRequest::PipeSend { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PipeTcpForward { .. } | ControlRequest::PipeTcpStream { .. } => {
Err(NodeError::IrohEndpointUnavailable)
}
ControlRequest::PipeUnixForward { .. } | ControlRequest::PipeUnixStream { .. } => {
Err(NodeError::UnsupportedPlatform(
geth_pipe::unix_forward_unsupported_message().to_owned(),
))
}
ControlRequest::PipeRecv { name, peek } => {
geth_pipe::validate_pipe_name(&name)?;
let messages = pipe_messages(node, &name, !peek)?;
Ok(ControlResponse::PipeMessages {
name,
messages,
drained: !peek,
note: geth_pipe::local_pipe_runtime_note().to_owned(),
})
}
ControlRequest::SshProxyConnect { .. }
| ControlRequest::SshProxyStream { .. }
| ControlRequest::SshAdminShell { .. }
| ControlRequest::KeychainSync { .. }
| ControlRequest::AuthSync { .. }
| ControlRequest::NodeEnrollSubmit { .. }
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command })
}
}
}
fn native_backend_statuses() -> Vec<NativeBackendStatus> {
geth_iroh::native_iroh_libraries()
.into_iter()
.map(|library| {
let (status, blocker) = match library.module.as_str() {
"cas" => (
"wired",
format!(
"none; CAS payload fetches use native ALPN {} after geth control authorization preflight",
library.alpn
),
),
"kv" => (
"wired",
format!(
"none; KV stores mirror into Iroh Documents over native ALPN {} after geth control authorization preflight",
library.alpn
),
),
"pubsub" => (
"wired",
format!(
"none; pubsub topics broadcast over native ALPN {} after geth control authorization preflight",
library.alpn
),
),
_ => (
"ready-to-wire",
format!(
"none; native ALPN {} is compiled against the daemon-owned iroh {} endpoint, but the module still needs its bootstrap control-path implementation replaced",
library.alpn,
geth_iroh::IROH_VERSION
),
),
};
NativeBackendStatus {
module: library.module,
current_backend: "shared-iroh-endpoint-ready".to_owned(),
target_crate: library.crate_name,
target_version: library.crate_version,
status: status.to_owned(),
blocker,
}
})
.collect()
}
fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescriptor, NodeError> {
let kind = stored
.kind
.parse::<ResourceKind>()
.map_err(|_| NodeError::InvalidResourceKind(stored.kind.clone()))?;
Ok(ResourceDescriptor::local(
ResourceId::new(stored.resource_id),
kind,
ResourceName::new(stored.name),
))
}
fn db_resource_from_stored(stored: &StoredDbResource) -> Result<DbResource, NodeError> {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
let (schema_metadata, crsqlite_changes) = if metadata
.as_ref()
.is_some_and(std::fs::Metadata::is_file)
{
(
geth_db::schema_metadata(path).unwrap_or_else(|error| format!("unavailable:{error}")),
geth_db::crsqlite_change_metadata(path)
.unwrap_or_else(|error| geth_db::CrSqliteChangeMetadata::error(error.to_string())),
)
} else {
(
"unavailable:path-missing".to_owned(),
geth_db::CrSqliteChangeMetadata::error("path-missing"),
)
};
Ok(db_resource_from_stored_with_schema(
stored,
schema_metadata,
crsqlite_changes,
))
}
fn db_resource_from_stored_with_schema(
stored: &StoredDbResource,
schema_metadata: String,
crsqlite_changes: geth_db::CrSqliteChangeMetadata,
) -> DbResource {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
DbResource {
id: stored.db_id.clone().into(),
resource: stored.resource_id.clone().into(),
name: stored.name.clone(),
path: stored.path.clone(),
path_exists: metadata.as_ref().is_some_and(std::fs::Metadata::is_file),
size_bytes: metadata.map(|metadata| metadata.len()),
schema_metadata,
crsqlite_changes,
sync_status: "local-only".to_owned(),
}
}
fn kv_resource_from_stored(stored: &StoredKvStore) -> KvResource {
KvResource {
id: stored.kv_id.clone().into(),
resource: stored.resource_id.clone().into(),
name: stored.name.clone(),
sync_status: "local-only".to_owned(),
}
}
fn kv_entry_from_stored(stored: StoredKvEntry) -> KvEntry {
KvEntry {
store: stored.kv_id.into(),
key: stored.key,
value: stored.value,
}
}
fn ensure_local_kv_store(store: &Store, name: &str) -> Result<StoredKvStore, NodeError> {
if let Some(kv) = store.get_kv_store_by_name(name)? {
return Ok(kv);
}
let stored = StoredKvStore {
kv_id: format!("kv:{name}"),
resource_id: format!("resource:kv:{name}"),
name: name.to_owned(),
};
store.insert_kv_store(&stored)?;
Ok(stored)
}
fn record_pubsub_message(
node: &LocalNode,
topic: String,
message: String,
) -> Result<PubsubMessage, NodeError> {
record_pubsub_message_at(node, topic, message, UnixMillis(geth_store::now_ms()))
}
fn record_pubsub_message_at(
node: &LocalNode,
topic: String,
message: String,
published_at: UnixMillis,
) -> Result<PubsubMessage, NodeError> {
let message = PubsubMessage {
topic: topic.into(),
message,
published_at,
};
let mut runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
runtime.messages.push_back(message.clone());
while runtime.messages.len() > PUBSUB_RING_LIMIT {
runtime.messages.pop_front();
}
Ok(message)
}
fn pubsub_messages_for_topic(
node: &LocalNode,
topic: &str,
) -> Result<Vec<PubsubMessage>, NodeError> {
let runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
Ok(runtime
.messages
.iter()
.filter(|message| message.topic.as_str() == topic)
.cloned()
.collect())
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
struct PubsubGossipPayload {
version: u8,
source_node_id: String,
source_agent_id: String,
topic: String,
message: String,
published_at_ms: i64,
}
fn pubsub_gossip_topic_id(topic: &str) -> iroh_gossip::TopicId {
blake3::hash(format!("geth.pubsub.v1\0{topic}").as_bytes()).into()
}
async fn ensure_pubsub_gossip_topic(
node: &LocalNode,
topic: &str,
bootstrap: Vec<iroh::EndpointId>,
) -> Result<Option<iroh_gossip::api::GossipSender>, NodeError> {
let existing = {
node.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.gossip_topics
.get(topic)
.cloned()
};
if let Some(existing) = existing {
if !bootstrap.is_empty() {
existing
.sender
.join_peers(bootstrap)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
}
return Ok(Some(existing.sender));
}
let Some(gossip) = iroh_gossip(node)? else {
return Ok(None);
};
let topic_id = pubsub_gossip_topic_id(topic);
let subscribed = gossip
.subscribe(topic_id, bootstrap)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (sender, mut receiver) = subscribed.split();
let topic_name = topic.to_owned();
let receiver_node = node.clone();
tokio::spawn(async move {
while let Some(event) = receiver.next().await {
match event {
Ok(iroh_gossip::api::Event::Received(message)) => {
if message.content.len() > 64 * 1024 {
tracing::warn!(
topic = %topic_name,
bytes = message.content.len(),
"dropping oversized pubsub gossip message"
);
continue;
}
match serde_json::from_slice::<PubsubGossipPayload>(&message.content) {
Ok(payload) => {
if let Err(error) =
record_pubsub_gossip_payload(&receiver_node, &topic_name, payload)
{
tracing::warn!(%error, topic = %topic_name, "dropping invalid pubsub gossip message");
}
}
Err(error) => {
tracing::warn!(%error, topic = %topic_name, "dropping undecodable pubsub gossip message");
}
}
}
Ok(iroh_gossip::api::Event::Lagged) => {
tracing::warn!(topic = %topic_name, "pubsub gossip receiver lagged");
}
Ok(iroh_gossip::api::Event::NeighborUp(endpoint_id)) => {
tracing::debug!(%endpoint_id, topic = %topic_name, "pubsub gossip neighbor up");
}
Ok(iroh_gossip::api::Event::NeighborDown(endpoint_id)) => {
tracing::debug!(%endpoint_id, topic = %topic_name, "pubsub gossip neighbor down");
}
Err(error) => {
tracing::warn!(%error, topic = %topic_name, "pubsub gossip receiver closed");
break;
}
}
}
});
node.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.gossip_topics
.insert(
topic.to_owned(),
PubsubGossipTopicRuntime {
sender: sender.clone(),
},
);
Ok(Some(sender))
}
fn record_pubsub_gossip_payload(
node: &LocalNode,
expected_topic: &str,
payload: PubsubGossipPayload,
) -> Result<(), NodeError> {
if payload.version != 1
|| payload.topic != expected_topic
|| payload.source_node_id == node.node_id
{
return Ok(());
}
geth_pubsub::validate_topic(&payload.topic)?;
geth_pubsub::validate_message(&payload.message)?;
let published_at = UnixMillis(payload.published_at_ms);
{
let runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
if runtime.messages.iter().any(|message| {
message.topic.as_str() == payload.topic
&& message.message == payload.message
&& message.published_at == published_at
}) {
return Ok(());
}
}
record_pubsub_message_at(node, payload.topic, payload.message, published_at)?;
Ok(())
}
async fn broadcast_pubsub_gossip(
node: &LocalNode,
topic: &str,
message: &str,
bootstrap: Vec<iroh::EndpointId>,
) -> Result<(), NodeError> {
let Some(sender) = ensure_pubsub_gossip_topic(node, topic, bootstrap).await? else {
return Ok(());
};
let payload = PubsubGossipPayload {
version: 1,
source_node_id: node.node_id.clone(),
source_agent_id: node.agent_id.clone(),
topic: topic.to_owned(),
message: message.to_owned(),
published_at_ms: geth_store::now_ms(),
};
let bytes = bytes::Bytes::from(serde_json::to_vec(&payload)?);
for attempt in 0..3 {
sender
.broadcast(bytes.clone())
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
if attempt < 2 {
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
Ok(())
}
fn record_pipe_listener(node: &LocalNode, name: String) -> Result<PipeListener, NodeError> {
let listener = PipeListener {
id: format!("pipe:{name}").into(),
name: name.clone(),
listened_at: UnixMillis(geth_store::now_ms()),
note: geth_pipe::local_pipe_runtime_note().to_owned(),
};
let mut runtime = node
.runtime
.pipes
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
runtime.listeners.insert(name, listener.clone());
Ok(listener)
}
fn record_pipe_connection(
node: &LocalNode,
target: String,
note: String,
) -> Result<PipeConnection, NodeError> {
let mut runtime = node
.runtime
.pipes
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let local_listener_found = runtime.listeners.contains_key(&target);
let connection = PipeConnection {
target,
connected_at: UnixMillis(geth_store::now_ms()),
local_listener_found,
note,
};
runtime.connections.push_back(connection.clone());
while runtime.connections.len() > PIPE_CONNECTION_RING_LIMIT {
runtime.connections.pop_front();
}
Ok(connection)
}
fn record_pipe_message(
node: &LocalNode,
target: String,
data_base64: String,
source_node: Option<String>,
note: String,
) -> Result<(Option<PipeMessage>, bool), NodeError> {
let mut runtime = node
.runtime
.pipes
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let listener_found = runtime.listeners.contains_key(&target);
if !listener_found {
return Ok((None, false));
}
let message = PipeMessage {
pipe: target,
data_base64,
received_at: UnixMillis(geth_store::now_ms()),
source_node,
note,
};
runtime.messages.push_back(message.clone());
while runtime.messages.len() > PIPE_MESSAGE_RING_LIMIT {
runtime.messages.pop_front();
}
Ok((Some(message), true))
}
fn pipe_messages(node: &LocalNode, name: &str, drain: bool) -> Result<Vec<PipeMessage>, NodeError> {
let mut runtime = node
.runtime
.pipes
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let messages = runtime
.messages
.iter()
.filter(|message| message.pipe == name)
.cloned()
.collect::<Vec<_>>();
if drain {
runtime.messages.retain(|message| message.pipe != name);
}
Ok(messages)
}
fn ensure_local_document(store: &Store, name: &str) -> Result<StoredDocumentResource, NodeError> {
if let Some(document) = store.get_document_resource_by_name(name)? {
return Ok(document);
}
let stored = StoredDocumentResource {
document_id: format!("document:{name}"),
resource_id: format!("resource:document:{name}"),
name: name.to_owned(),
state_json: geth_document::create_automerge_state("{}")?,
updated_at_ms: 0,
};
store.insert_document_resource(&stored)?;
Ok(stored)
}
fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentResource {
DocumentResource {
id: stored.document_id.clone().into(),
resource: stored.resource_id.clone().into(),
name: stored.name.clone(),
sync_status: "automerge-local".to_owned(),
state_bytes: geth_document::document_state_bytes(&stored.state_json),
}
}
fn document_state_from_stored(stored: &StoredDocumentResource) -> Result<DocumentState, NodeError> {
Ok(DocumentState {
document: document_resource_from_stored(stored),
state_json: geth_document::document_view_json(&stored.state_json)?,
updated_at: UnixMillis(stored.updated_at_ms),
})
}
fn document_sync_state_from_stored(
stored: &StoredDocumentResource,
) -> Result<DocumentState, NodeError> {
Ok(DocumentState {
document: document_resource_from_stored(stored),
state_json: stored.state_json.clone(),
updated_at: UnixMillis(stored.updated_at_ms),
})
}
fn file_root_from_stored(stored: &StoredFileRoot) -> FileRoot {
FileRoot {
id: stored.root_id.clone(),
resource: stored.resource_id.clone(),
name: stored.name.clone(),
path: stored.path.clone(),
latest_tree: stored.latest_tree_hash.clone().map(Into::into),
updated_at_ms: stored.updated_at_ms,
}
}
fn discovered_peer_from_stored(stored: StoredPeerCard) -> Result<DiscoveredPeer, NodeError> {
let card: PeerCard = serde_json::from_str(&stored.card_json)?;
Ok(DiscoveredPeer::candidate(
card,
UnixMillis(stored.updated_at_ms),
DiscoverySource::Imported,
)?)
}
fn file_conflict_from_stored(stored: StoredFileConflict) -> Result<FileConflict, NodeError> {
Ok(FileConflict {
id: stored.conflict_id,
root: stored.root_name,
resource: stored.resource_id,
path: stored.path,
kind: FileConflictKind::parse(&stored.kind)?,
status: FileConflictStatus::parse(&stored.status)?,
base_tree: stored.base_tree_hash.map(Into::into),
local_tree: stored.local_tree_hash.map(Into::into),
remote_tree: stored.remote_tree_hash.map(Into::into),
detail: stored.detail,
resolution: stored
.resolution
.as_deref()
.map(FileConflictResolution::parse)
.transpose()?,
resolution_note: stored.resolution_note,
created_at_ms: stored.created_at_ms,
resolved_at_ms: stored.resolved_at_ms,
})
}
fn apply_file_root_tree(
store: &Store,
node: &LocalNode,
source: &str,
target: PathBuf,
dry_run: bool,
) -> Result<ControlResponse, NodeError> {
geth_cas::validate_file_root_name(source)?;
let source_root = store
.get_file_root_by_name(source)?
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root:{source}")))?;
let tree_hash = source_root
.latest_tree_hash
.as_ref()
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root-tree:{source}")))?;
let tree_hash = BlobHash::new(tree_hash.clone());
let cas = LocalCas::new(node.paths.cas_dir());
let tree_bytes = cas.read_bytes(&tree_hash)?;
let tree: CasTreeObject = geth_codec::decode_canonical(&tree_bytes)?;
if target.exists() && !target.is_dir() {
return Err(NodeError::Cas(geth_cas::CasError::TreeRootNotDirectory(
target.display().to_string(),
)));
}
if !dry_run {
std::fs::create_dir_all(&target)?;
}
if let Some((local_root, base_tree, local_tree, local_tree_hash)) =
load_apply_three_way_state(store, &cas, &target)?
{
return apply_file_root_tree_three_way(
store,
&source_root,
&local_root,
&cas,
target,
dry_run,
&tree_hash,
&tree,
&base_tree,
&local_tree,
local_tree_hash.as_ref(),
);
}
apply_file_root_tree_conservative(
store,
&source_root,
&cas,
target,
dry_run,
&tree_hash,
&tree,
)
}
fn apply_file_root_tree_conservative(
store: &Store,
source_root: &StoredFileRoot,
cas: &LocalCas,
target: PathBuf,
dry_run: bool,
tree_hash: &BlobHash,
tree: &CasTreeObject,
) -> Result<ControlResponse, NodeError> {
let mut files_written = 0;
let mut dirs_created = 0;
let mut conflicts = Vec::new();
for entry in &tree.entries {
let out = safe_tree_output_path(&target, &entry.path)?;
match entry.kind {
CasTreeEntryKind::Directory => {
if out.exists() {
if !out.is_dir() {
conflicts.push(record_apply_conflict(
store,
source_root,
&entry.path,
tree_hash,
"remote directory conflicts with a local non-directory path",
dry_run,
)?);
}
continue;
}
dirs_created += 1;
if !dry_run {
std::fs::create_dir_all(&out)?;
}
}
CasTreeEntryKind::File => {
let Some(blob) = &entry.blob else {
conflicts.push(record_apply_conflict(
store,
source_root,
&entry.path,
tree_hash,
"remote file entry is missing a CAS blob hash",
dry_run,
)?);
continue;
};
if out.exists() {
if !out.is_file() {
conflicts.push(record_apply_conflict(
store,
source_root,
&entry.path,
tree_hash,
"remote file conflicts with a local non-file path",
dry_run,
)?);
continue;
}
if hash_path(&out)? != *blob {
conflicts.push(record_apply_conflict(
store,
source_root,
&entry.path,
tree_hash,
"local file differs from remote CAS tree; refusing to overwrite",
dry_run,
)?);
}
continue;
}
files_written += 1;
if !dry_run {
let bytes = cas.read_bytes(blob)?;
write_file_atomic(&out, &bytes)?;
set_executable_bit(&out, entry.executable)?;
}
}
}
}
Ok(ControlResponse::CasRootApplied {
source: source_root.name.clone(),
target,
files_written,
dirs_created,
conflicts,
dry_run,
note: "safe file-root apply creates missing paths but never deletes extra files or overwrites differing local files; conflicts are recorded for manual resolution".to_owned(),
})
}
type ApplyThreeWayState = (
StoredFileRoot,
CasTreeObject,
CasTreeObject,
Option<BlobHash>,
);
fn load_apply_three_way_state(
store: &Store,
cas: &LocalCas,
target: &Path,
) -> Result<Option<ApplyThreeWayState>, NodeError> {
if !target.is_dir() {
return Ok(None);
}
let target = std::fs::canonicalize(target)?;
let Some(local_root) = store
.list_file_roots()?
.into_iter()
.filter(|root| !root.path.starts_with("remote:"))
.find(|root| {
std::fs::canonicalize(&root.path)
.map(|path| path == target)
.unwrap_or(false)
})
else {
return Ok(None);
};
let Some(base_tree_json) = local_root.latest_tree_json.as_deref() else {
return Ok(None);
};
let base_tree = serde_json::from_str(base_tree_json)?;
let local_scan = cas.add_tree_path(&target)?;
Ok(Some((
local_root,
base_tree,
local_scan.tree,
Some(local_scan.object.hash),
)))
}
#[allow(clippy::too_many_arguments)]
fn apply_file_root_tree_three_way(
store: &Store,
source_root: &StoredFileRoot,
local_root: &StoredFileRoot,
cas: &LocalCas,
target: PathBuf,
dry_run: bool,
remote_tree_hash: &BlobHash,
remote_tree: &CasTreeObject,
base_tree: &CasTreeObject,
local_tree: &CasTreeObject,
local_tree_hash: Option<&BlobHash>,
) -> Result<ControlResponse, NodeError> {
let mut files_written = 0;
let mut dirs_created = 0;
let mut conflicts = record_three_way_apply_conflicts(
store,
source_root,
local_root,
base_tree,
local_tree_hash,
remote_tree_hash,
remote_tree,
local_tree,
dry_run,
)?;
let conflict_paths = conflicts
.iter()
.map(|conflict| conflict.path.clone())
.collect::<BTreeSet<_>>();
let base_entries = owned_tree_entry_map(base_tree);
let local_entries = owned_tree_entry_map(local_tree);
let remote_entries = owned_tree_entry_map(remote_tree);
let mut consumed_base_paths = BTreeSet::new();
let mut consumed_remote_paths = BTreeSet::new();
for (from, to) in remote_rename_pairs(&base_entries, &remote_entries) {
if conflict_paths.contains(&from) || conflict_paths.contains(&to) {
continue;
}
let Some(base_entry) = base_entries.get(&from) else {
continue;
};
if local_entries.get(&from) != Some(base_entry) {
continue;
}
let Some(remote_entry) = remote_entries.get(&to) else {
continue;
};
let from_path = safe_tree_output_path(&target, &from)?;
let to_path = safe_tree_output_path(&target, &to)?;
if to_path.exists() {
if path_matches_entry(cas, &to_path, remote_entry)? {
consumed_base_paths.insert(from);
consumed_remote_paths.insert(to);
} else {
conflicts.push(record_apply_conflict_with_trees(
store,
source_root,
&to,
local_root.latest_tree_hash.as_deref(),
local_tree_hash,
remote_tree_hash,
"remote rename target conflicts with an existing local path",
dry_run,
)?);
}
continue;
}
if !from_path.exists() {
continue;
}
if !dry_run {
if let Some(parent) = to_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(&from_path, &to_path)?;
set_executable_bit(&to_path, remote_entry.executable)?;
}
consumed_base_paths.insert(from);
consumed_remote_paths.insert(to);
}
let mut remote_paths = remote_entries.keys().cloned().collect::<Vec<_>>();
remote_paths.sort_by(|left, right| {
kind_order_for_apply(remote_entries.get(left))
.cmp(&kind_order_for_apply(remote_entries.get(right)))
.then_with(|| left.cmp(right))
});
for path in remote_paths {
if consumed_remote_paths.contains(&path) || conflict_paths.contains(&path) {
continue;
}
let Some(remote_entry) = remote_entries.get(&path) else {
continue;
};
let base_entry = base_entries.get(&path);
let local_entry = local_entries.get(&path);
if local_entry == Some(remote_entry) || base_entry == Some(remote_entry) {
continue;
}
if base_entry.is_some() && local_entry != base_entry {
continue;
}
if base_entry.is_none() && local_entry.is_some() {
conflicts.push(record_apply_conflict_with_trees(
store,
source_root,
&path,
local_root.latest_tree_hash.as_deref(),
local_tree_hash,
remote_tree_hash,
"remote create conflicts with an existing local path",
dry_run,
)?);
continue;
}
match remote_entry.kind {
CasTreeEntryKind::Directory => {
let out = safe_tree_output_path(&target, &path)?;
if !out.exists() {
dirs_created += 1;
if !dry_run {
std::fs::create_dir_all(out)?;
}
}
}
CasTreeEntryKind::File => {
let Some(blob) = &remote_entry.blob else {
conflicts.push(record_apply_conflict_with_trees(
store,
source_root,
&path,
local_root.latest_tree_hash.as_deref(),
local_tree_hash,
remote_tree_hash,
"remote file entry is missing a CAS blob hash",
dry_run,
)?);
continue;
};
let out = safe_tree_output_path(&target, &path)?;
files_written += 1;
if !dry_run {
let bytes = cas.read_bytes(blob)?;
write_file_atomic(&out, &bytes)?;
set_executable_bit(&out, remote_entry.executable)?;
}
}
}
}
let mut deleted_paths = base_entries
.keys()
.filter(|path| {
!remote_entries.contains_key(*path)
&& !consumed_base_paths.contains(*path)
&& !conflict_paths.contains(*path)
})
.cloned()
.collect::<Vec<_>>();
deleted_paths.sort_by(|left, right| right.cmp(left));
for path in deleted_paths {
let Some(base_entry) = base_entries.get(&path) else {
continue;
};
if local_entries.get(&path) != Some(base_entry) {
continue;
}
let out = safe_tree_output_path(&target, &path)?;
if dry_run || !out.exists() {
continue;
}
match base_entry.kind {
CasTreeEntryKind::Directory => {
let _ = std::fs::remove_dir(&out);
}
CasTreeEntryKind::File => {
std::fs::remove_file(&out)?;
}
}
}
Ok(ControlResponse::CasRootApplied {
source: source_root.name.clone(),
target,
files_written,
dirs_created,
conflicts,
dry_run,
note: "three-way file-root apply updated only paths where local state still matched the recorded base; ambiguous edits remain durable conflicts".to_owned(),
})
}
#[allow(clippy::too_many_arguments)]
fn record_three_way_apply_conflicts(
store: &Store,
source_root: &StoredFileRoot,
local_root: &StoredFileRoot,
base_tree: &CasTreeObject,
local_tree_hash: Option<&BlobHash>,
remote_tree_hash: &BlobHash,
remote_tree: &CasTreeObject,
local_tree: &CasTreeObject,
dry_run: bool,
) -> Result<Vec<FileConflict>, NodeError> {
geth_cas::detect_tree_conflicts(base_tree, local_tree, remote_tree)
.into_iter()
.map(|conflict| {
record_apply_conflict_with_trees(
store,
source_root,
&conflict.path,
local_root.latest_tree_hash.as_deref(),
local_tree_hash,
remote_tree_hash,
&conflict.detail,
dry_run,
)
})
.collect()
}
fn owned_tree_entry_map(tree: &CasTreeObject) -> BTreeMap<String, geth_cas::CasTreeEntry> {
tree.entries
.iter()
.map(|entry| (entry.path.clone(), entry.clone()))
.collect()
}
fn remote_rename_pairs(
base_entries: &BTreeMap<String, geth_cas::CasTreeEntry>,
remote_entries: &BTreeMap<String, geth_cas::CasTreeEntry>,
) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for (base_path, base_entry) in base_entries {
let Some(base_blob) = &base_entry.blob else {
continue;
};
if remote_entries.contains_key(base_path) {
continue;
}
if let Some((remote_path, _)) = remote_entries.iter().find(|(remote_path, remote_entry)| {
!base_entries.contains_key(*remote_path)
&& remote_entry.blob.as_ref() == Some(base_blob)
}) {
pairs.push((base_path.clone(), remote_path.clone()));
}
}
pairs.sort();
pairs
}
fn path_matches_entry(
cas: &LocalCas,
path: &Path,
entry: &geth_cas::CasTreeEntry,
) -> Result<bool, NodeError> {
match entry.kind {
CasTreeEntryKind::Directory => Ok(path.is_dir()),
CasTreeEntryKind::File => {
let Some(blob) = &entry.blob else {
return Ok(false);
};
Ok(path.is_file() && hash_path(path)? == *blob && cas.has(blob)?)
}
}
}
fn kind_order_for_apply(entry: Option<&geth_cas::CasTreeEntry>) -> u8 {
match entry.map(|entry| &entry.kind) {
Some(CasTreeEntryKind::Directory) => 0,
Some(CasTreeEntryKind::File) => 1,
None => 2,
}
}
fn safe_tree_output_path(root: &Path, relative: &str) -> Result<PathBuf, NodeError> {
let relative_path = Path::new(relative);
if relative_path.is_absolute() {
return Err(NodeError::Cas(geth_cas::CasError::NonRelativeTreePath(
relative.to_owned(),
)));
}
for component in relative_path.components() {
if !matches!(component, Component::Normal(_)) {
return Err(NodeError::Cas(geth_cas::CasError::NonRelativeTreePath(
relative.to_owned(),
)));
}
}
Ok(root.join(relative_path))
}
fn record_apply_conflict(
store: &Store,
root: &StoredFileRoot,
path: &str,
remote_tree: &BlobHash,
detail: &str,
dry_run: bool,
) -> Result<FileConflict, NodeError> {
let created_at = geth_store::now_ms();
let stored = StoredFileConflict {
conflict_id: generated_file_conflict_id(
&root.name,
path,
FileConflictKind::ConcurrentEdit.as_str(),
created_at,
),
root_name: root.name.clone(),
resource_id: root.resource_id.clone(),
path: path.to_owned(),
kind: FileConflictKind::ConcurrentEdit.as_str().to_owned(),
status: FileConflictStatus::Open.as_str().to_owned(),
base_tree_hash: None,
local_tree_hash: None,
remote_tree_hash: Some(remote_tree.to_string()),
detail: detail.to_owned(),
resolution: None,
resolution_note: None,
created_at_ms: created_at,
resolved_at_ms: None,
};
if !dry_run {
store.upsert_file_conflict(&stored)?;
}
file_conflict_from_stored(stored)
}
#[allow(clippy::too_many_arguments)]
fn record_apply_conflict_with_trees(
store: &Store,
root: &StoredFileRoot,
path: &str,
base_tree: Option<&str>,
local_tree: Option<&BlobHash>,
remote_tree: &BlobHash,
detail: &str,
dry_run: bool,
) -> Result<FileConflict, NodeError> {
let created_at = geth_store::now_ms();
let stored = StoredFileConflict {
conflict_id: generated_file_conflict_id(
&root.name,
path,
FileConflictKind::ConcurrentEdit.as_str(),
created_at,
),
root_name: root.name.clone(),
resource_id: root.resource_id.clone(),
path: path.to_owned(),
kind: FileConflictKind::ConcurrentEdit.as_str().to_owned(),
status: FileConflictStatus::Open.as_str().to_owned(),
base_tree_hash: base_tree.map(ToOwned::to_owned),
local_tree_hash: local_tree.map(ToString::to_string),
remote_tree_hash: Some(remote_tree.to_string()),
detail: detail.to_owned(),
resolution: None,
resolution_note: None,
created_at_ms: created_at,
resolved_at_ms: None,
};
if !dry_run {
store.upsert_file_conflict(&stored)?;
}
file_conflict_from_stored(stored)
}
fn record_sync_tree_conflicts(
store: &Store,
local_root: &StoredFileRoot,
base_tree_hash: Option<&str>,
base_tree: &CasTreeObject,
local_tree_hash: Option<&str>,
remote_tree_hash: Option<&BlobHash>,
remote_tree: &CasTreeObject,
) -> Result<Vec<FileConflict>, NodeError> {
let Some(local_tree_json) = local_root.latest_tree_json.as_deref() else {
return Ok(Vec::new());
};
let local_tree: CasTreeObject = serde_json::from_str(local_tree_json)?;
let tree_conflicts = geth_cas::detect_tree_conflicts(base_tree, &local_tree, remote_tree);
let created_at = geth_store::now_ms();
tree_conflicts
.into_iter()
.enumerate()
.map(|(index, conflict)| {
let created_at = created_at + index as i64;
let stored = StoredFileConflict {
conflict_id: generated_file_conflict_id(
&local_root.name,
&conflict.path,
conflict.kind.as_str(),
created_at,
),
root_name: local_root.name.clone(),
resource_id: local_root.resource_id.clone(),
path: conflict.path,
kind: conflict.kind.as_str().to_owned(),
status: FileConflictStatus::Open.as_str().to_owned(),
base_tree_hash: base_tree_hash.map(ToOwned::to_owned),
local_tree_hash: local_tree_hash.map(ToOwned::to_owned),
remote_tree_hash: remote_tree_hash.map(ToString::to_string),
detail: format!(
"{}; detected while syncing remote file-root metadata",
conflict.detail
),
resolution: None,
resolution_note: None,
created_at_ms: created_at,
resolved_at_ms: None,
};
store.upsert_file_conflict(&stored)?;
file_conflict_from_stored(stored)
})
.collect()
}
fn write_file_atomic(path: &Path, bytes: &[u8]) -> Result<(), NodeError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!(
"geth-tmp-{}",
geth_crypto::blake3_hex(path.display().to_string().as_bytes())
));
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)?;
Ok(())
}
#[cfg(unix)]
fn set_executable_bit(path: &Path, executable: bool) -> Result<(), NodeError> {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path)?.permissions();
let mut mode = permissions.mode();
if executable {
mode |= 0o111;
} else {
mode &= !0o111;
}
permissions.set_mode(mode);
std::fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable_bit(_path: &Path, _executable: bool) -> Result<(), NodeError> {
Ok(())
}
fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> {
if store
.list_resources()?
.into_iter()
.any(|resource| resource.resource_id == resource_id)
{
Ok(())
} else {
Err(NodeError::ResourceNotFound(resource_id.to_owned()))
}
}
fn ensure_kv_write_authorized(
store: &Store,
node: &LocalNode,
subject: Option<String>,
resource_id: &str,
key: &str,
) -> Result<(), NodeError> {
let Some(subject) = subject else {
return Ok(());
};
let capability = format!("kv.write_key:{key}");
ensure_subject_authorized(store, node, Some(&subject), resource_id, &capability)
}
fn ensure_subject_authorized(
store: &Store,
node: &LocalNode,
subject: Option<&str>,
resource_id: &str,
capability: &str,
) -> Result<(), NodeError> {
let Some(subject) = subject else {
return Ok(());
};
if subject == node.node_id || subject == node.agent_id {
return Ok(());
}
let ops = load_auth_ops_for_resource(store, resource_id)?;
let explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(subject.to_owned()),
ResourceId::new(resource_id.to_owned()),
Capability::new(capability.to_owned()),
);
if explanation.allowed {
Ok(())
} else {
Err(NodeError::Unauthorized(format!(
"{subject} lacks {capability} on {resource_id}: {}",
explanation.reason
)))
}
}
fn create_resource_secret(
store: &Store,
resource_id: &str,
epoch: u64,
) -> Result<ResourceMasterSecret, NodeError> {
let created_at = UnixMillis(geth_store::now_ms());
let secret_id = format!(
"secret:{}",
geth_crypto::blake3_hex(format!("{resource_id}\0{epoch}\0{}", created_at.0).as_bytes())
);
let stored = StoredResourceSecret {
secret_id,
resource_id: resource_id.to_owned(),
epoch,
status: "active".to_owned(),
created_at_ms: created_at.0,
};
store.insert_resource_secret(&stored)?;
Ok(resource_secret_from_stored(stored))
}
fn resource_secret_from_stored(stored: StoredResourceSecret) -> ResourceMasterSecret {
ResourceMasterSecret {
id: stored.secret_id.into(),
resource: stored.resource_id.into(),
epoch: stored.epoch,
created_at: UnixMillis(stored.created_at_ms),
}
}
fn load_bearer_access(store: &Store) -> Result<Vec<BearerAccess>, NodeError> {
let ops = store
.list_auth_ops()?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect::<Result<Vec<AuthOp>, NodeError>>()?;
let view = geth_auth::reduce_auth_ops(&ops);
Ok(view
.bearer_access
.into_values()
.map(|record| BearerAccess {
secret: record.secret,
token: None,
token_hash: record.token_hash,
resource: record.resource,
capabilities: record.capabilities,
expires_at: record.expires_at,
may_delegate: false,
})
.collect())
}
fn bearer_access_exists_for_resource_capability(
store: &Store,
resource: &str,
capability: &str,
) -> Result<bool, NodeError> {
let resource = ResourceId::new(resource.to_owned());
let capability = Capability::new(capability.to_owned());
Ok(load_bearer_access(store)?
.into_iter()
.any(|access| access.resource == resource && access.capabilities.contains(&capability)))
}
fn bearer_token_grants_resource_capability(
store: &Store,
token: &str,
resource: &str,
capability: &str,
) -> Result<bool, NodeError> {
let token = geth_types::SecretId::new(token.to_owned());
let secret = geth_secrets::bearer_id_for_token(&token);
let token_hash = geth_secrets::bearer_token_hash(&token);
let resource = ResourceId::new(resource.to_owned());
let capability = Capability::new(capability.to_owned());
Ok(load_bearer_access(store)?.into_iter().any(|access| {
access.secret == secret
&& access.resource == resource
&& access.capabilities.contains(&capability)
&& access.token_hash.as_deref() == Some(token_hash.as_str())
&& access
.expires_at
.is_none_or(|expires_at| expires_at.0 >= geth_store::now_ms())
}))
}
fn overlay_networks(store: &Store) -> Result<Vec<geth_overlay::OverlayNetworkStatus>, NodeError> {
store
.list_module_states_with_prefix("overlay:")?
.into_iter()
.map(|state| {
let membership: geth_overlay::OverlayMembership =
serde_json::from_str(&state.state_json)?;
Ok(geth_overlay::membership_status(&membership))
})
.collect()
}
fn overlay_membership(
store: &Store,
name: &str,
) -> Result<Option<geth_overlay::OverlayMembership>, NodeError> {
store
.get_module_state(&geth_overlay::overlay_state_key(name))?
.map(|state| serde_json::from_str(&state.state_json).map_err(NodeError::from))
.transpose()
}
fn overlay_peers(store: &Store, network: &str) -> Result<Vec<OverlayPeer>, NodeError> {
let cidr = overlay_membership(store, network)?
.map(|membership| membership.cidr)
.unwrap_or_else(|| geth_overlay::DEFAULT_OVERLAY_CIDR.to_owned());
let resource = geth_overlay::overlay_resource_id(network).to_string();
store
.list_peer_cards()?
.into_iter()
.map(|stored| {
let card: PeerCard = serde_json::from_str(&stored.card_json)?;
let peer = PrincipalId::new(card.node_id.to_string());
let route_authorized = can_sync_resource(
store,
&peer,
resource.as_str(),
geth_overlay::CAPABILITY_ROUTE,
)?;
Ok(OverlayPeer {
node_id: card.node_id.to_string(),
endpoint_id: card
.endpoints
.first()
.map(|candidate| candidate.endpoint_id.clone()),
virtual_ip: Some(geth_overlay::deterministic_virtual_ip(
&cidr,
card.node_id.as_str(),
)?),
state: if route_authorized {
"route-authorized-candidate".to_owned()
} else {
"candidate-only".to_owned()
},
})
})
.collect()
}
fn overlay_peer_for_virtual_ip(
store: &Store,
cidr: &str,
virtual_ip: &str,
) -> Result<Option<String>, NodeError> {
for stored in store.list_peer_cards()? {
let card: PeerCard = serde_json::from_str(&stored.card_json)?;
let peer_ip = geth_overlay::deterministic_virtual_ip(cidr, card.node_id.as_str())?;
if peer_ip == virtual_ip {
return Ok(Some(card.node_id.to_string()));
}
}
Ok(None)
}
fn overlay_counter(
counters: &Arc<Mutex<OverlayTunCounters>>,
update: impl FnOnce(&mut OverlayTunCounters),
) {
if let Ok(mut counters) = counters.lock() {
update(&mut counters);
}
}
fn overlay_runtime_status(
runtime: &OverlayTunRuntime,
) -> Result<geth_overlay::OverlayRuntimeStatus, NodeError> {
let counters = runtime
.counters
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
Ok(geth_overlay::OverlayRuntimeStatus {
name: runtime.name.clone(),
interface_name: runtime.interface_name.clone(),
virtual_ip: runtime.virtual_ip.clone(),
cidr: runtime.cidr.clone(),
mtu: runtime.mtu,
started_at_ms: runtime.started_at_ms,
packets_from_tun: counters.packets_from_tun,
packets_to_tun: counters.packets_to_tun,
packets_to_peers: counters.packets_to_peers,
last_error: counters.last_error.clone(),
note: "real TUN/Wintun runtime is active; packets are routed over /geth/overlay/1"
.to_owned(),
})
}
fn overlay_runtime_inject(
node: &LocalNode,
network: &str,
packet: Vec<u8>,
) -> Result<bool, NodeError> {
let overlays = node
.runtime
.overlays
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let Some(runtime) = overlays.get(network) else {
return Ok(false);
};
match runtime.inject_tx.try_send(packet) {
Ok(()) => Ok(true),
Err(error) => {
overlay_counter(&runtime.counters, |counters| {
counters.last_error = Some(format!("queue remote packet for TUN failed: {error}"));
});
Err(NodeError::OverlayRuntime(format!(
"queue remote packet for TUN failed: {error}"
)))
}
}
}
fn record_overlay_packet(
store: &Store,
network: &str,
source_node: &str,
destination_node: &str,
packet_base64: String,
size_bytes: usize,
) -> Result<geth_overlay::OverlayPacket, NodeError> {
let received_at_ms = geth_store::now_ms();
let packet_bytes = base64::engine::general_purpose::STANDARD
.decode(&packet_base64)
.map_err(|error| NodeError::IrohPeer(format!("invalid overlay packet base64: {error}")))?;
let id = geth_overlay::overlay_packet_id(
network,
source_node,
destination_node,
&packet_bytes,
received_at_ms,
);
let packet = geth_overlay::OverlayPacket {
id: id.clone(),
network: network.to_owned(),
source_node: source_node.to_owned(),
destination_node: destination_node.to_owned(),
packet_base64,
size_bytes,
received_at_ms,
note: "received over /geth/overlay/1".to_owned(),
};
store.put_module_state(&StoredModuleState {
module: geth_overlay::overlay_packet_key(network, received_at_ms, &id),
state_json: serde_json::to_string(&packet)?,
updated_at_ms: received_at_ms,
})?;
Ok(packet)
}
fn overlay_packets(
store: &Store,
network: &str,
drain: bool,
) -> Result<Vec<geth_overlay::OverlayPacket>, NodeError> {
let states = store.list_module_states_with_prefix(&format!("overlay-packet:{network}:"))?;
let mut packets = Vec::new();
for state in states {
let packet = serde_json::from_str(&state.state_json)?;
if drain {
store.delete_module_state(&state.module)?;
}
packets.push(packet);
}
Ok(packets)
}
fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&stored_auth_op_from_op(op)?)?;
Ok(())
}
fn stored_auth_op_from_op(op: &AuthOp) -> Result<StoredAuthOp, NodeError> {
Ok(StoredAuthOp {
op_id: op.id.to_string(),
resource_id: op.resource.to_string(),
op_json: serde_json::to_string(op)?,
created_at_ms: op.created_at.0,
})
}
fn store_and_sign_auth_ops(
store: &Store,
node: &LocalNode,
ops: &[AuthOp],
signing_key_path: &Path,
admin_key_path: Option<&Path>,
) -> Result<Vec<AuthOpSignature>, NodeError> {
for op in ops {
store_auth_op(store, op)?;
}
let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?;
ops.iter()
.map(|op| {
sign_auth_op_with_ssh(
store,
node,
op,
signing_key_path,
&signer,
&signer_public_key,
)
})
.collect()
}
fn sign_auth_op_with_ssh(
store: &Store,
node: &LocalNode,
op: &AuthOp,
signing_key_path: &Path,
signer: &KeyId,
signer_public_key: &str,
) -> Result<AuthOpSignature, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let signature_dir = node.paths.home().join("auth-signatures");
std::fs::create_dir_all(&signature_dir)?;
let payload_path = signature_dir.join(format!(
"{}.payload",
geth_crypto::blake3_hex(op.id.as_str().as_bytes())
));
std::fs::write(&payload_path, geth_auth::auth_signing_payload(op)?)?;
let output =
geth_ssh_identity::sign_command(signing_key_path, AUTH_SIGNATURE_NAMESPACE, &payload_path)
.output()?;
if !output.status.success() {
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
)
.into());
}
let signature_path = Path::new(&format!("{}.sig", payload_path.display())).to_path_buf();
let signature_bytes = std::fs::read(signature_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let signature = AuthOpSignature {
op_id: op.id.clone(),
signer: signer.clone(),
signer_public_key: signer_public_key.to_owned(),
namespace: AUTH_SIGNATURE_NAMESPACE.to_owned(),
signature: signature_bytes,
created_at,
};
store.insert_auth_signature(&StoredAuthSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
})?;
Ok(signature)
}
fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthOp>, NodeError> {
store
.list_auth_ops_for_resource(resource)?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect()
}
fn load_auth_ops(store: &Store) -> Result<Vec<AuthOp>, NodeError> {
store
.list_auth_ops()?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect()
}
fn load_auth_signatures(store: &Store) -> Result<Vec<AuthOpSignature>, NodeError> {
Ok(store
.list_auth_signatures()?
.into_iter()
.map(|stored| AuthOpSignature {
op_id: AuthOpId::new(stored.op_id),
signer: KeyId::new(stored.signer),
signer_public_key: stored.signer_public_key,
namespace: stored.namespace,
signature: stored.signature,
created_at: UnixMillis(stored.created_at_ms),
})
.collect())
}
fn stored_auth_signature_from_signature(signature: &AuthOpSignature) -> StoredAuthSignature {
StoredAuthSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
}
}
fn auth_signature_uses_claimed_key(signature: &AuthOpSignature) -> bool {
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
}
fn verify_auth_signature_with_ssh(
node: &LocalNode,
op: &AuthOp,
signature: &StoredAuthSignature,
) -> Result<bool, NodeError> {
if signature.signer_public_key.trim().is_empty() {
return Ok(false);
}
if geth_ssh_identity::ensure_ssh_keygen_available().is_err() {
return Ok(false);
}
let verify_dir = node.paths.home().join("auth-signatures").join("verify");
std::fs::create_dir_all(&verify_dir)?;
let stable_id = geth_crypto::blake3_hex(
format!(
"{}\0{}\0{}\0{}",
op.id, signature.signer, signature.namespace, signature.created_at_ms
)
.as_bytes(),
);
let payload_path = verify_dir.join(format!("{stable_id}.payload"));
let signature_path = verify_dir.join(format!("{stable_id}.sig"));
let allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers"));
std::fs::write(&payload_path, geth_auth::auth_signing_payload(op)?)?;
std::fs::write(&signature_path, &signature.signature)?;
std::fs::write(
&allowed_signers_path,
format!(
"{} {}\n",
signature.signer,
signature.signer_public_key.trim()
),
)?;
let payload = std::fs::File::open(&payload_path)?;
let output = std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(&allowed_signers_path)
.arg("-I")
.arg(&signature.signer)
.arg("-n")
.arg(&signature.namespace)
.arg("-s")
.arg(&signature_path)
.stdin(std::process::Stdio::from(payload))
.output()?;
Ok(output.status.success())
}
fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
store.insert_keychain_op(&stored_keychain_op_from_op(op)?)?;
Ok(())
}
fn stored_keychain_op_from_op(op: &KeychainOp) -> Result<StoredKeychainOp, NodeError> {
Ok(StoredKeychainOp {
op_id: op.id.to_string(),
op_json: serde_json::to_string(op)?,
created_at_ms: op.created_at.0,
})
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct StaticKeychainSourceState {
head: Option<AuthOpId>,
ops: usize,
sigchain_hash: String,
generated_at_ms: i64,
}
fn read_and_verify_sigchain_file(
node: &LocalNode,
input: &Path,
) -> Result<
(
Vec<KeychainOp>,
Vec<KeychainOpSignature>,
geth_keychain::KeychainSigchainReport,
),
NodeError,
> {
let text = std::fs::read_to_string(input)?;
let entries = geth_keychain::decode_sigchain_jsonl(&text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
Ok((ops, signatures, report))
}
fn import_verified_sigchain(
store: &Store,
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
rejected_ops: usize,
) -> Result<geth_control::KeychainFetchImportReport, NodeError> {
if rejected_ops != 0 {
return Ok(geth_control::KeychainFetchImportReport {
ops_imported: 0,
signatures_imported: 0,
invalid_ops_rejected: rejected_ops,
});
}
let existing_ops = load_keychain_ops(store)?
.into_iter()
.map(|op| op.id)
.collect::<BTreeSet<_>>();
let existing_signatures = load_keychain_signatures(store)?
.into_iter()
.map(|signature| {
(
signature.op_id,
signature.signer,
signature.namespace,
signature.signature,
)
})
.collect::<BTreeSet<_>>();
let mut signatures_imported = 0;
let mut ops_imported = 0;
for op in ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.map(stored_keychain_signature_from_signature)
.collect::<Vec<_>>();
store
.insert_keychain_op_with_signatures(&stored_keychain_op_from_op(op)?, &op_signatures)?;
if !existing_ops.contains(&op.id) {
ops_imported += 1;
}
for signature in signatures
.iter()
.filter(|signature| signature.op_id == op.id)
{
let key = (
signature.op_id.clone(),
signature.signer.clone(),
signature.namespace.clone(),
signature.signature.clone(),
);
signatures_imported += usize::from(!existing_signatures.contains(&key));
}
}
Ok(geth_control::KeychainFetchImportReport {
ops_imported,
signatures_imported,
invalid_ops_rejected: 0,
})
}
fn verify_keychain_checkpoint_files(
node: &LocalNode,
checkpoint_path: &Path,
signature_path: &Path,
sigchain_path: &Path,
allowed_signers_path: &Path,
expected_base_url: Option<&str>,
principal: Option<&str>,
) -> Result<(geth_keychain::KeychainCheckpoint, bool, Option<String>), NodeError> {
let checkpoint_text = std::fs::read_to_string(checkpoint_path)?;
let checkpoint: geth_keychain::KeychainCheckpoint = serde_json::from_str(&checkpoint_text)?;
if let Some(expected) = expected_base_url {
let expected = geth_keychain::normalize_base_url(expected.to_owned());
if checkpoint.base_url != expected {
return Ok((checkpoint, false, None));
}
}
let sigchain_text = std::fs::read_to_string(sigchain_path)?;
let allowed_signers = std::fs::read_to_string(allowed_signers_path)?;
if checkpoint.sigchain_bytes != sigchain_text.len() as u64
|| checkpoint.sigchain_hash != geth_keychain::blake3_tagged_hash(sigchain_text.as_bytes())
|| checkpoint.allowed_signers_hash
!= geth_keychain::blake3_tagged_hash(allowed_signers.as_bytes())
{
return Ok((checkpoint, false, None));
}
let entries = geth_keychain::decode_sigchain_jsonl(&sigchain_text)?;
let (ops, signatures) = geth_keychain::flatten_sigchain_entries(&entries);
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
if report.rejected_ops != 0 || report.accepted_head != checkpoint.head {
return Ok((checkpoint, false, None));
}
let (verified, matched_principal) = verify_file_with_keychain_signers(
&Store::open(&node.paths.metadata_db())?,
node,
checkpoint_path,
signature_path,
geth_keychain::KEYCHAIN_CHECKPOINT_NAMESPACE,
Some(allowed_signers_path),
principal,
)?;
Ok((checkpoint, verified, matched_principal))
}
fn fetch_keychain_bundle(
store: &Store,
node: &LocalNode,
url: String,
out: Option<PathBuf>,
import: bool,
) -> Result<ControlResponse, NodeError> {
let base_url = geth_keychain::normalize_base_url(url.clone());
let out = out.unwrap_or_else(|| {
node.paths
.home()
.join("keychain-fetch")
.join(geth_crypto::blake3_hex(base_url.as_bytes()))
});
std::fs::create_dir_all(&out)?;
for name in [
"allowed_signers",
"geth.sigchain.jsonl",
"geth.sigchain.checkpoint.json",
"geth.sigchain.checkpoint.json.sig",
] {
fetch_bundle_file(&base_url, name, &out.join(name))?;
}
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
let signature_path = out.join("geth.sigchain.checkpoint.json.sig");
let sigchain_path = out.join("geth.sigchain.jsonl");
let allowed_signers_path = out.join("allowed_signers");
let (checkpoint, verified, _) = verify_keychain_checkpoint_files(
node,
&checkpoint_path,
&signature_path,
&sigchain_path,
&allowed_signers_path,
None,
None,
)?;
if !verified {
return Err(NodeError::Unauthorized(
"fetched keychain checkpoint did not verify".to_owned(),
));
}
check_static_source_rollback(store, &base_url, &checkpoint)?;
let imported = if import {
let (ops, signatures, report) = read_and_verify_sigchain_file(node, &sigchain_path)?;
let imported = import_verified_sigchain(store, &ops, &signatures, report.rejected_ops)?;
if imported.invalid_ops_rejected == 0 {
remember_static_source_checkpoint(store, &base_url, &checkpoint)?;
}
Some(imported)
} else {
None
};
let note = format!(
"fetched and verified static SSH sigchain bundle; checkpoint base URL is {}",
checkpoint.base_url
);
Ok(ControlResponse::KeychainFetched {
url: base_url,
out,
checkpoint,
imported,
note,
})
}
fn fetch_bundle_file(base_url: &str, name: &str, out: &Path) -> Result<(), NodeError> {
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(root) = base_url.strip_prefix("file://") {
std::fs::copy(Path::new(root).join(name), out)?;
return Ok(());
}
if base_url.starts_with("http://") || base_url.starts_with("https://") {
let url = format!("{base_url}{name}");
let output = std::process::Command::new("curl")
.arg("-fsSL")
.arg(&url)
.arg("-o")
.arg(out)
.output()?;
if output.status.success() {
return Ok(());
}
return Err(NodeError::IrohPeer(format!(
"curl failed fetching {url}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
std::fs::copy(Path::new(base_url).join(name), out)?;
Ok(())
}
fn static_source_state_key(base_url: &str) -> String {
format!(
"keychain-static-source:{}",
geth_crypto::blake3_hex(base_url.as_bytes())
)
}
fn check_static_source_rollback(
store: &Store,
base_url: &str,
checkpoint: &geth_keychain::KeychainCheckpoint,
) -> Result<(), NodeError> {
let Some(state) = store.get_module_state(&static_source_state_key(base_url))? else {
return Ok(());
};
let previous: StaticKeychainSourceState = serde_json::from_str(&state.state_json)?;
if previous.generated_at_ms > checkpoint.generated_at.0 || previous.ops > checkpoint.ops {
return Err(NodeError::Unauthorized(
"fetched keychain checkpoint is older than the last accepted checkpoint".to_owned(),
));
}
Ok(())
}
fn remember_static_source_checkpoint(
store: &Store,
base_url: &str,
checkpoint: &geth_keychain::KeychainCheckpoint,
) -> Result<(), NodeError> {
let state = StaticKeychainSourceState {
head: checkpoint.head.clone(),
ops: checkpoint.ops,
sigchain_hash: checkpoint.sigchain_hash.clone(),
generated_at_ms: checkpoint.generated_at.0,
};
store.put_module_state(&StoredModuleState {
module: static_source_state_key(base_url),
state_json: serde_json::to_string(&state)?,
updated_at_ms: geth_store::now_ms(),
})?;
Ok(())
}
fn explain_keychain_op(
store: &Store,
node: &LocalNode,
op_id: &str,
) -> Result<Vec<String>, NodeError> {
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let Some(op) = ops.iter().find(|op| op.id.as_str() == op_id) else {
return Ok(vec![format!("operation not found: {op_id}")]);
};
let sorted = geth_keychain::sorted_keychain_ops(ops.clone());
let position = sorted.iter().position(|candidate| candidate.id == op.id);
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.collect::<Vec<_>>();
let report = verify_keychain_sigchain_entries_with_ssh(node, &ops, &signatures);
let mut lines = Vec::new();
lines.push(format!("op_id: {}", op.id));
lines.push(format!("created_at_ms: {}", op.created_at.0));
lines.push(format!("kind: {:?}", op.kind));
if let Some(position) = position {
lines.push(format!("position: {position}"));
}
lines.push(format!("signatures: {}", op_signatures.len()));
for signature in op_signatures {
let claimed = geth_keychain::signature_uses_claimed_key(signature);
let verified = verify_keychain_signature_with_ssh(
node,
op,
&stored_keychain_signature_from_signature(signature),
)
.unwrap_or(false);
lines.push(format!(
"signature signer={} namespace={} claimed_key={} cryptographic_verify={}",
signature.signer, signature.namespace, claimed, verified
));
}
lines.push(format!(
"replay_accepted_head: {}",
report
.accepted_head
.as_ref()
.map(|head| head.as_str())
.unwrap_or("none")
));
lines.push(format!("replay_rejected_ops: {}", report.rejected_ops));
let accepted = report.accepted_head.as_ref().is_some_and(|_| {
let accepted_ops = report.accepted_ops;
sorted
.iter()
.take(accepted_ops)
.any(|accepted_op| accepted_op.id == op.id)
});
lines.push(format!("accepted_by_replay: {accepted}"));
Ok(lines)
}
fn explain_keychain_signer(store: &Store, key: &str) -> Result<Vec<String>, NodeError> {
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let key_id = KeyId::new(key.to_owned());
let allowed = geth_keychain::allowed_signers(&ops, &signatures);
let mut lines = Vec::new();
lines.push(format!("key: {key}"));
if let Some(entry) = allowed.iter().find(|entry| entry.key == key_id) {
lines.push("active_admin_signer: true".to_owned());
lines.push(format!("principal: {}", entry.principal));
lines.push(format!("public_key: {}", entry.public_key.trim()));
} else {
lines.push("active_admin_signer: false".to_owned());
}
for op in geth_keychain::sorted_keychain_ops(ops) {
match &op.kind {
KeychainOpKind::AdminKeyAdd { key: op_key, .. } if op_key == &key_id => {
lines.push(format!("added_by_op: {} at {}", op.id, op.created_at.0));
}
KeychainOpKind::AdminKeyRevoke { key: op_key } if op_key == &key_id => {
lines.push(format!("revoked_by_op: {} at {}", op.id, op.created_at.0));
}
_ => {}
}
}
let signed = signatures
.iter()
.filter(|signature| signature.signer == key_id)
.count();
lines.push(format!("signed_operations: {signed}"));
Ok(lines)
}
fn publish_keychain_bundle(
store: &Store,
_node: &LocalNode,
out: PathBuf,
base_url: Option<String>,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
snapshots: Vec<String>,
) -> Result<ControlResponse, NodeError> {
let base_url = base_url
.map(geth_keychain::normalize_base_url)
.unwrap_or_else(|| geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL.to_owned());
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(store)?,
&load_keychain_signatures(store)?,
);
let (signer, _) = keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
if !entries.iter().any(|entry| entry.key == signer) {
return Err(NodeError::Unauthorized(format!(
"signing key {signer} is not an active keychain admin signer"
)));
}
std::fs::create_dir_all(&out)?;
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let sigchain_entries = geth_keychain::sigchain_entries(&ops, &signatures);
let sigchain_jsonl = geth_keychain::encode_sigchain_jsonl(&sigchain_entries)?;
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
let allowed_signers_path = out.join("allowed_signers");
let sigchain_path = out.join("geth.sigchain.jsonl");
let checkpoint_path = out.join("geth.sigchain.checkpoint.json");
std::fs::write(&allowed_signers_path, &allowed_signers)?;
std::fs::write(&sigchain_path, &sigchain_jsonl)?;
let checkpoint = geth_keychain::keychain_checkpoint(
&ops,
&signatures,
&sigchain_jsonl,
&allowed_signers,
base_url.clone(),
UnixMillis(geth_store::now_ms()),
)?;
std::fs::write(&checkpoint_path, serde_json::to_vec_pretty(&checkpoint)?)?;
let checkpoint_signature_path = sign_file_with_ssh(
&signing_key_path,
"geth.sigchain-checkpoint.v1@eric.wendland.dev",
&checkpoint_path,
Some(out.join("geth.sigchain.checkpoint.json.sig")),
)?;
let mut published_snapshots = Vec::new();
for snapshot in snapshots {
let (name, source) = parse_snapshot_arg(&snapshot)?;
let path = out.join(&name);
std::fs::copy(&source, &path)?;
let namespace = snapshot_namespace(&name);
let signature_path = sign_file_with_ssh(
&signing_key_path,
&namespace,
&path,
Some(out.join(format!("{name}.sig"))),
)?;
published_snapshots.push(geth_control::KeychainPublishedSnapshot {
name,
source,
path,
signature_path,
namespace,
});
}
Ok(ControlResponse::KeychainBundlePublished {
out,
base_url,
allowed_signers_path,
sigchain_path,
checkpoint_path,
checkpoint_signature_path,
checkpoint,
snapshots: published_snapshots,
note:
"published static SSH sigchain bundle; serve this directory at the discovery base URL"
.to_owned(),
})
}
fn verify_keychain_sigchain_entries_with_ssh(
node: &LocalNode,
ops: &[KeychainOp],
signatures: &[KeychainOpSignature],
) -> geth_keychain::KeychainSigchainReport {
struct SshKeychainVerifier<'a> {
node: &'a LocalNode,
}
impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> {
fn verify_keychain_signature(
&self,
op: &KeychainOp,
signature: &KeychainOpSignature,
) -> bool {
verify_keychain_signature_with_ssh(
self.node,
op,
&stored_keychain_signature_from_signature(signature),
)
.unwrap_or(false)
}
}
let verifier = SshKeychainVerifier { node };
geth_keychain::verify_sigchain(ops, signatures, &verifier)
}
fn parse_snapshot_arg(value: &str) -> Result<(String, PathBuf), NodeError> {
let Some((name, path)) = value.split_once('=') else {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
};
validate_snapshot_name(name)?;
let path = PathBuf::from(path);
if !path.is_file() {
return Err(NodeError::InvalidKeychainSnapshot(value.to_owned()));
}
Ok((name.to_owned(), path))
}
fn validate_snapshot_name(name: &str) -> Result<(), NodeError> {
let valid = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'));
if valid {
Ok(())
} else {
Err(NodeError::InvalidKeychainSnapshot(name.to_owned()))
}
}
fn snapshot_namespace(name: &str) -> String {
format!("geth.snapshot.{name}.v1@eric.wendland.dev")
}
fn verify_keychain_sigchain_with_ssh(
store: &Store,
node: &LocalNode,
) -> Result<geth_keychain::KeychainSigchainReport, NodeError> {
struct SshKeychainVerifier<'a> {
node: &'a LocalNode,
}
impl geth_keychain::KeychainSignatureVerifier for SshKeychainVerifier<'_> {
fn verify_keychain_signature(
&self,
op: &KeychainOp,
signature: &KeychainOpSignature,
) -> bool {
verify_keychain_signature_with_ssh(
self.node,
op,
&stored_keychain_signature_from_signature(signature),
)
.unwrap_or(false)
}
}
let ops = load_keychain_ops(store)?;
let signatures = load_keychain_signatures(store)?;
let verifier = SshKeychainVerifier { node };
Ok(geth_keychain::verify_sigchain(&ops, &signatures, &verifier))
}
fn store_and_sign_keychain_ops(
store: &Store,
node: &LocalNode,
ops: &[KeychainOp],
signing_key_path: Option<&Path>,
admin_key_path: Option<&Path>,
) -> Result<Vec<KeychainOpSignature>, NodeError> {
for op in ops {
store_keychain_op(store, op)?;
}
let Some(signing_key_path) = signing_key_path else {
return Ok(Vec::new());
};
let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?;
ops.iter()
.map(|op| {
sign_keychain_op_with_ssh(
store,
node,
op,
signing_key_path,
&signer,
&signer_public_key,
)
})
.collect()
}
fn keychain_signer_from_paths(
signing_key_path: &Path,
admin_key_path: Option<&Path>,
) -> Result<(KeyId, String), NodeError> {
let public_key_path = admin_key_path.map(Path::to_path_buf).unwrap_or_else(|| {
if signing_key_path
.extension()
.is_some_and(|extension| extension == "pub")
{
signing_key_path.to_path_buf()
} else {
Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf()
}
});
let public_key = std::fs::read_to_string(public_key_path)?;
Ok((
KeyId::new(ssh_public_key_fingerprint(&public_key)),
public_key,
))
}
fn sign_file_with_ssh(
signing_key_path: &Path,
namespace: &str,
input_path: &Path,
out: Option<PathBuf>,
) -> Result<PathBuf, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let signature_path = Path::new(&format!("{}.sig", input_path.display())).to_path_buf();
if signature_path.exists() {
std::fs::remove_file(&signature_path)?;
}
let output =
geth_ssh_identity::sign_command(signing_key_path, namespace, input_path).output()?;
if !output.status.success() {
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
)
.into());
}
let Some(out) = out else {
return Ok(signature_path);
};
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if out != signature_path {
if out.exists() {
std::fs::remove_file(&out)?;
}
match std::fs::rename(&signature_path, &out) {
Ok(()) => {}
Err(_) => {
std::fs::copy(&signature_path, &out)?;
std::fs::remove_file(&signature_path)?;
}
}
}
Ok(out)
}
fn verify_file_with_keychain_signers(
store: &Store,
node: &LocalNode,
input_path: &Path,
signature_path: &Path,
namespace: &str,
allowed_signers_path: Option<&Path>,
principal: Option<&str>,
) -> Result<(bool, Option<String>), NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let verify_dir = node
.paths
.home()
.join("keychain-signatures")
.join("file-verify");
std::fs::create_dir_all(&verify_dir)?;
let stable_id = geth_crypto::blake3_hex(
format!(
"{}\0{}\0{}\0{}",
input_path.display(),
signature_path.display(),
namespace,
principal.unwrap_or("")
)
.as_bytes(),
);
let owned_allowed_signers_path;
let allowed_signers_path = if let Some(path) = allowed_signers_path {
path
} else {
let entries = geth_keychain::allowed_signers(
&load_keychain_ops(store)?,
&load_keychain_signatures(store)?,
);
let allowed_signers = geth_keychain::render_allowed_signers(&entries);
owned_allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers"));
std::fs::write(&owned_allowed_signers_path, allowed_signers)?;
&owned_allowed_signers_path
};
let principals = if let Some(principal) = principal {
vec![principal.to_owned()]
} else {
read_allowed_signer_principals(allowed_signers_path)?
};
for principal in principals {
let payload = std::fs::File::open(input_path)?;
let output = std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(allowed_signers_path)
.arg("-I")
.arg(&principal)
.arg("-n")
.arg(namespace)
.arg("-s")
.arg(signature_path)
.stdin(std::process::Stdio::from(payload))
.output()?;
if output.status.success() {
return Ok((true, Some(principal)));
}
}
Ok((false, None))
}
fn read_allowed_signer_principals(path: &Path) -> Result<Vec<String>, NodeError> {
let text = std::fs::read_to_string(path)?;
let mut principals = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(field) = line.split_whitespace().next() {
for principal in field.split(',') {
if !principal.is_empty() && !principals.iter().any(|item| item == principal) {
principals.push(principal.to_owned());
}
}
}
}
Ok(principals)
}
fn sign_keychain_op_with_ssh(
store: &Store,
node: &LocalNode,
op: &KeychainOp,
signing_key_path: &Path,
signer: &KeyId,
signer_public_key: &str,
) -> Result<KeychainOpSignature, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?;
let signature_dir = node.paths.home().join("keychain-signatures");
std::fs::create_dir_all(&signature_dir)?;
let payload_path = signature_dir.join(format!(
"{}.payload",
geth_crypto::blake3_hex(op.id.as_str().as_bytes())
));
std::fs::write(&payload_path, geth_keychain::keychain_signing_payload(op)?)?;
let output = geth_ssh_identity::sign_command(
signing_key_path,
geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE,
&payload_path,
)
.output()?;
if !output.status.success() {
return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed(
String::from_utf8_lossy(&output.stderr).trim().to_owned(),
)
.into());
}
let signature_path = Path::new(&format!("{}.sig", payload_path.display())).to_path_buf();
let signature_bytes = std::fs::read(signature_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let signature = KeychainOpSignature {
op_id: op.id.clone(),
signer: signer.clone(),
signer_public_key: signer_public_key.to_owned(),
namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
signature: signature_bytes,
created_at,
};
store.insert_keychain_signature(&StoredKeychainSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
})?;
Ok(signature)
}
fn stored_keychain_signature_from_signature(
signature: &KeychainOpSignature,
) -> StoredKeychainSignature {
StoredKeychainSignature {
op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(),
signature: signature.signature.clone(),
created_at_ms: signature.created_at.0,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct KeychainSignatureStatus {
total: usize,
verified: usize,
failed: usize,
}
fn verify_keychain_signatures(
store: &Store,
node: &LocalNode,
) -> Result<KeychainSignatureStatus, NodeError> {
let ops = load_keychain_ops(store)?;
let ops_by_id = ops
.into_iter()
.map(|op| (op.id.to_string(), op))
.collect::<BTreeMap<_, _>>();
let signatures = store.list_keychain_signatures()?;
let total = signatures.len();
let mut verified = 0;
let mut failed = 0;
for signature in signatures {
let Some(op) = ops_by_id.get(&signature.op_id) else {
failed += 1;
continue;
};
if verify_keychain_signature_with_ssh(node, op, &signature)? {
verified += 1;
} else {
failed += 1;
}
}
Ok(KeychainSignatureStatus {
total,
verified,
failed,
})
}
fn verify_keychain_signature_with_ssh(
node: &LocalNode,
op: &KeychainOp,
signature: &StoredKeychainSignature,
) -> Result<bool, NodeError> {
if signature.signer_public_key.trim().is_empty() {
return Ok(false);
}
if geth_ssh_identity::ensure_ssh_keygen_available().is_err() {
return Ok(false);
}
let verify_dir = node.paths.home().join("keychain-signatures").join("verify");
std::fs::create_dir_all(&verify_dir)?;
let stable_id = geth_crypto::blake3_hex(
format!(
"{}\0{}\0{}\0{}",
op.id, signature.signer, signature.namespace, signature.created_at_ms
)
.as_bytes(),
);
let payload_path = verify_dir.join(format!("{stable_id}.payload"));
let signature_path = verify_dir.join(format!("{stable_id}.sig"));
let allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers"));
std::fs::write(&payload_path, geth_keychain::keychain_signing_payload(op)?)?;
std::fs::write(&signature_path, &signature.signature)?;
std::fs::write(
&allowed_signers_path,
format!(
"{} {}\n",
signature.signer,
signature.signer_public_key.trim()
),
)?;
let payload = std::fs::File::open(&payload_path)?;
let output = std::process::Command::new("ssh-keygen")
.arg("-Y")
.arg("verify")
.arg("-f")
.arg(&allowed_signers_path)
.arg("-I")
.arg(&signature.signer)
.arg("-n")
.arg(&signature.namespace)
.arg("-s")
.arg(&signature_path)
.stdin(std::process::Stdio::from(payload))
.output()?;
Ok(output.status.success())
}
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
let ops = store
.list_keychain_ops()?
.into_iter()
.map(|stored| serde_json::from_str::<KeychainOp>(&stored.op_json).map_err(NodeError::from))
.collect::<Result<Vec<_>, NodeError>>()?;
Ok(geth_keychain::sorted_keychain_ops(ops))
}
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {
Ok(store
.list_keychain_signatures()?
.into_iter()
.map(|stored| KeychainOpSignature {
op_id: AuthOpId::new(stored.op_id),
signer: KeyId::new(stored.signer),
signer_public_key: stored.signer_public_key,
namespace: stored.namespace,
signature: stored.signature,
created_at: UnixMillis(stored.created_at_ms),
})
.collect())
}
fn resolve_keychain_node(store: &Store, node_or_name: &str) -> Result<NodeId, NodeError> {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?);
if let Some((node_id, _)) = view
.nodes
.iter()
.find(|(node_id, record)| node_id.as_str() == node_or_name || record.name == node_or_name)
{
Ok(node_id.clone())
} else {
Err(NodeError::ResourceNotFound(format!("node:{node_or_name}")))
}
}
fn resolve_peer_node_for_control(store: &Store, node_or_name: &str) -> String {
resolve_keychain_node(store, node_or_name)
.map(|node| node.to_string())
.unwrap_or_else(|_| node_or_name.to_owned())
}
fn record_node_grant(
store: &Store,
node_id: &str,
resource: &str,
capability: &str,
grant_id: Option<String>,
) -> Result<AuthOp, NodeError> {
let op = node_grant_op(node_id, resource, capability, grant_id);
store_auth_op(store, &op)?;
Ok(op)
}
fn node_grant_op(
node_id: &str,
resource: &str,
capability: &str,
grant_id: Option<String>,
) -> AuthOp {
let created_at = UnixMillis(geth_store::now_ms());
let grant_id = grant_id.unwrap_or_else(|| generated_grant_id(node_id, resource, capability));
AuthOp {
id: generated_auth_op_id("grant-create", resource, &grant_id, created_at),
resource: ResourceId::new(resource.to_owned()),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id,
principal: PrincipalId::new(node_id.to_owned()),
capabilities: vec![Capability::new(capability.to_owned())],
},
}
}
fn stable_slug(value: &str) -> String {
let slug = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_owned();
if slug.is_empty() {
"unnamed".to_owned()
} else {
slug
}
}
fn parse_enrollment_capabilities(
capabilities: Vec<String>,
) -> Result<Vec<NodeEnrollmentCapability>, NodeError> {
capabilities
.into_iter()
.map(|value| {
let (resource, capability) = value
.split_once('=')
.ok_or_else(|| NodeError::InvalidEnrollmentCapability(value.clone()))?;
Ok(NodeEnrollmentCapability {
resource: ResourceId::new(resource.to_owned()),
capability: Capability::new(capability.to_owned()),
})
})
.collect()
}
fn create_node_enrollment_request(
node: &LocalNode,
node_name: String,
capabilities: Vec<String>,
reason: Option<String>,
) -> Result<NodeEnrollmentRequest, NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let created_at = UnixMillis(geth_store::now_ms());
let requested_capabilities = parse_enrollment_capabilities(capabilities)?;
let endpoint_id = node.iroh_status.endpoint_id.clone();
let stable = format!(
"{}\0{}\0{}\0{}",
node.node_id, node.agent_id, node_name, created_at.0
);
Ok(NodeEnrollmentRequest {
id: AuthOpId::new(format!(
"node-enrollment:{}",
geth_crypto::blake3_hex(stable.as_bytes())
)),
requester_node: NodeId::new(node.node_id.clone()),
requester_agent: node.agent_id.clone().into(),
requester_agent_public_key: key.public_key_hex(),
requested_node_name: node_name,
requested_capabilities,
endpoint_id,
reason,
status: NodeEnrollmentStatus::Pending,
created_at,
provenance: None,
})
}
fn sign_node_enrollment_request(
node: &LocalNode,
request: &mut NodeEnrollmentRequest,
) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
NODE_ENROLLMENT_REQUEST_NAMESPACE,
&node_enrollment_request_signing_payload(request),
)?;
request.provenance = Some(NodeEnrollmentProvenance {
namespace: NODE_ENROLLMENT_REQUEST_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone().into(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn verify_node_enrollment_request(request: &NodeEnrollmentRequest) -> Result<(), NodeError> {
let provenance = request
.provenance
.as_ref()
.ok_or_else(|| NodeError::InvalidNodeEnrollment("missing signed provenance".to_owned()))?;
if provenance.namespace != NODE_ENROLLMENT_REQUEST_NAMESPACE {
return Err(NodeError::InvalidNodeEnrollment(format!(
"invalid namespace {}",
provenance.namespace
)));
}
if provenance.signer_node != request.requester_node
|| provenance.signer_agent != request.requester_agent
|| provenance.signer_public_key != request.requester_agent_public_key
{
return Err(NodeError::InvalidNodeEnrollment(
"provenance does not match requesting node/agent".to_owned(),
));
}
verify_record_provenance(
&provenance.signer_public_key,
NODE_ENROLLMENT_REQUEST_NAMESPACE,
&node_enrollment_request_signing_payload(request),
&provenance.signature_hex,
)
}
fn store_node_enrollment_request(
store: &Store,
request: &NodeEnrollmentRequest,
) -> Result<(), NodeError> {
store.insert_node_enrollment_request(&StoredNodeEnrollmentRequest {
request_id: request.id.to_string(),
requester_node: request.requester_node.to_string(),
request_json: serde_json::to_string(request)?,
status: request.status.to_string(),
created_at_ms: request.created_at.0,
})?;
Ok(())
}
fn insert_node_enrollment_request_if_not_conflicting(
store: &Store,
request: &NodeEnrollmentRequest,
) -> Result<bool, NodeError> {
verify_node_enrollment_request(request)?;
let stored = StoredNodeEnrollmentRequest {
request_id: request.id.to_string(),
requester_node: request.requester_node.to_string(),
request_json: serde_json::to_string(request)?,
status: request.status.to_string(),
created_at_ms: request.created_at.0,
};
if let Some(existing) = store.get_node_enrollment_request(&stored.request_id)? {
return Ok(existing == stored);
}
store.insert_node_enrollment_request(&stored)?;
Ok(true)
}
fn load_node_enrollment_request(
store: &Store,
request_id: &str,
) -> Result<NodeEnrollmentRequest, NodeError> {
store
.get_node_enrollment_request(request_id)?
.ok_or_else(|| NodeError::NodeEnrollmentRequestNotFound(request_id.to_owned()))
.and_then(node_enrollment_request_from_stored)
}
fn load_node_enrollment_requests(store: &Store) -> Result<Vec<NodeEnrollmentRequest>, NodeError> {
store
.list_node_enrollment_requests()?
.into_iter()
.map(node_enrollment_request_from_stored)
.collect()
}
fn node_enrollment_request_from_stored(
stored: StoredNodeEnrollmentRequest,
) -> Result<NodeEnrollmentRequest, NodeError> {
let mut request: NodeEnrollmentRequest = serde_json::from_str(&stored.request_json)?;
request.status = stored
.status
.parse()
.map_err(|error: geth_keychain::KeychainError| {
NodeError::InvalidNodeEnrollment(error.to_string())
})?;
Ok(request)
}
fn latest_pending_node_enrollment_request(store: &Store) -> Option<NodeEnrollmentRequest> {
load_node_enrollment_requests(store)
.ok()
.and_then(|requests| {
requests
.into_iter()
.filter(|request| request.status == NodeEnrollmentStatus::Pending)
.max_by_key(|request| request.created_at.0)
})
}
fn write_node_enrollment_request_file(
path: &Path,
request: &NodeEnrollmentRequest,
) -> Result<(), NodeError> {
std::fs::write(path, serde_json::to_vec_pretty(request)?)?;
Ok(())
}
fn read_node_enrollment_request_file(path: &Path) -> Result<NodeEnrollmentRequest, NodeError> {
let request = serde_json::from_slice(&std::fs::read(path)?)?;
Ok(request)
}
fn approve_node_enrollment(
store: &Store,
node: &LocalNode,
request_id: &str,
signing_key_path: &Path,
admin_key_path: Option<&Path>,
node_name: Option<String>,
extra_capabilities: Vec<String>,
) -> Result<ControlResponse, NodeError> {
let mut request = load_node_enrollment_request(store, request_id)?;
verify_node_enrollment_request(&request)?;
let now = UnixMillis(geth_store::now_ms());
let owner = first_keychain_user(store).unwrap_or_else(|| UserId::new("user:owner"));
let device = DeviceId::new(format!(
"device:{}",
stable_slug(request.requester_node.as_str())
));
let node_id = request.requester_node.clone();
let name = node_name.unwrap_or_else(|| request.requested_node_name.clone());
let mut keychain_ops = vec![
KeychainOp {
id: generated_keychain_op_id("device-add", device.as_str(), now),
created_at: now,
kind: KeychainOpKind::DeviceAdd {
device: device.clone(),
user: owner,
},
},
KeychainOp {
id: generated_keychain_op_id("node-add", node_id.as_str(), now),
created_at: now,
kind: KeychainOpKind::NodeAdd {
node: node_id.clone(),
device,
name,
},
},
KeychainOp {
id: generated_keychain_op_id("agent-bind", request.requester_agent.as_str(), now),
created_at: now,
kind: KeychainOpKind::AgentBind {
agent: request.requester_agent.clone(),
node: node_id.clone(),
},
},
];
if let Some(endpoint) = request.endpoint_id.clone() {
keychain_ops.push(KeychainOp {
id: generated_keychain_op_id("node-endpoint-add", &endpoint, now),
created_at: now,
kind: KeychainOpKind::NodeEndpointAdd {
node: node_id.clone(),
endpoint,
},
});
}
let keychain_signatures = store_and_sign_keychain_ops(
store,
node,
&keychain_ops,
Some(signing_key_path),
admin_key_path,
)?;
let mut grant_caps = request.requested_capabilities.clone();
grant_caps.extend(parse_enrollment_capabilities(extra_capabilities)?);
let auth_ops = grant_caps
.into_iter()
.map(|grant| {
let resource = grant.resource.to_string();
let capability = grant.capability.to_string();
let grant_id = generated_grant_id(node_id.as_str(), &resource, &capability);
AuthOp {
id: generated_auth_op_id("grant-create", &resource, &grant_id, now),
resource: grant.resource,
created_at: now,
kind: AuthOpKind::GrantCreate {
grant_id,
principal: PrincipalId::new(node_id.to_string()),
capabilities: vec![grant.capability],
},
}
})
.collect::<Vec<_>>();
let auth_signatures =
store_and_sign_auth_ops(store, node, &auth_ops, signing_key_path, admin_key_path)?;
request.status = NodeEnrollmentStatus::Approved;
store_node_enrollment_request(store, &request)?;
Ok(ControlResponse::NodeEnrollmentApproved {
request,
keychain_ops,
keychain_signatures,
auth_ops,
auth_signatures,
note: "approved enrollment; requester can run keychain sync and auth sync from this owner node".to_owned(),
})
}
fn first_keychain_user(store: &Store) -> Option<UserId> {
geth_keychain::reduce_keychain_ops(&load_keychain_ops(store).ok()?)
.users
.keys()
.next()
.cloned()
}
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!(
"grant:{}",
geth_crypto::blake3_hex(format!("{subject}\0{resource}\0{capability}").as_bytes())
)
}
fn generated_file_conflict_id(root: &str, path: &str, kind: &str, created_at_ms: i64) -> String {
format!(
"file-conflict:{}",
geth_crypto::blake3_hex(format!("{created_at_ms}\0{root}\0{path}\0{kind}").as_bytes())
)
}
fn generated_auth_op_id(
kind: &str,
resource: &str,
stable_id: &str,
created_at: UnixMillis,
) -> AuthOpId {
AuthOpId::new(format!(
"auth-op:{}",
geth_crypto::blake3_hex(
format!("{}\0{kind}\0{resource}\0{stable_id}", created_at.0).as_bytes()
)
))
}
fn generated_keychain_op_id(kind: &str, stable_id: &str, created_at: UnixMillis) -> AuthOpId {
AuthOpId::new(format!(
"keychain-op:{}",
geth_crypto::blake3_hex(format!("{}\0{kind}\0{stable_id}", created_at.0).as_bytes())
))
}
fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}")
}
fn expected_openssh_cert_path(public_key_path: &Path) -> String {
let text = public_key_path.display().to_string();
if let Some(prefix) = text.strip_suffix(".pub") {
format!("{prefix}-cert.pub")
} else {
format!("{text}-cert.pub")
}
}
fn sign_ssh_cert_request(node: &LocalNode, request: &mut SshCertRequest) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
SSH_CERT_REQUEST_NAMESPACE,
&ssh_cert_request_signing_payload(request),
)?;
request.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_REQUEST_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn sign_ssh_certificate(
node: &LocalNode,
certificate: &mut SshCertificateRecord,
) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
SSH_CERT_ISSUANCE_NAMESPACE,
&ssh_certificate_signing_payload(certificate),
)?;
certificate.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_ISSUANCE_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn sign_ssh_revocation(
node: &LocalNode,
revocation: &mut SshRevocationEntry,
) -> Result<(), NodeError> {
let key = AgentKey::load(&node.paths.agent_key())?;
let signature = key.sign_canonical(
SSH_REVOCATION_LIST_NAMESPACE,
&ssh_revocation_signing_payload(revocation),
)?;
revocation.provenance = Some(SshRecordProvenance {
namespace: SSH_REVOCATION_LIST_NAMESPACE.to_owned(),
signer_node: NodeId::new(node.node_id.clone()),
signer_agent: node.agent_id.clone(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(geth_store::now_ms()),
});
Ok(())
}
fn verify_ssh_cert_request_provenance(request: &SshCertRequest) -> Result<(), NodeError> {
let provenance = request.provenance.as_ref().ok_or_else(|| {
NodeError::IrohPeer(format!(
"SSH cert request {} is missing signed provenance",
request.id
))
})?;
if provenance.namespace != SSH_CERT_REQUEST_NAMESPACE {
return Err(NodeError::IrohPeer(format!(
"SSH cert request {} uses invalid provenance namespace {}",
request.id, provenance.namespace
)));
}
verify_record_provenance(
&provenance.signer_public_key,
SSH_CERT_REQUEST_NAMESPACE,
&ssh_cert_request_signing_payload(request),
&provenance.signature_hex,
)
}
fn verify_ssh_certificate_provenance(certificate: &SshCertificateRecord) -> Result<(), NodeError> {
let provenance = certificate.provenance.as_ref().ok_or_else(|| {
NodeError::IrohPeer(format!(
"SSH certificate {} is missing signed provenance",
certificate.id
))
})?;
if provenance.namespace != SSH_CERT_ISSUANCE_NAMESPACE {
return Err(NodeError::IrohPeer(format!(
"SSH certificate {} uses invalid provenance namespace {}",
certificate.id, provenance.namespace
)));
}
verify_record_provenance(
&provenance.signer_public_key,
SSH_CERT_ISSUANCE_NAMESPACE,
&ssh_certificate_signing_payload(certificate),
&provenance.signature_hex,
)
}
fn verify_ssh_revocation_provenance(revocation: &SshRevocationEntry) -> Result<(), NodeError> {
let provenance = revocation.provenance.as_ref().ok_or_else(|| {
NodeError::IrohPeer(format!(
"SSH revocation {} is missing signed provenance",
revocation.id
))
})?;
if provenance.namespace != SSH_REVOCATION_LIST_NAMESPACE {
return Err(NodeError::IrohPeer(format!(
"SSH revocation {} uses invalid provenance namespace {}",
revocation.id, provenance.namespace
)));
}
verify_record_provenance(
&provenance.signer_public_key,
SSH_REVOCATION_LIST_NAMESPACE,
&ssh_revocation_signing_payload(revocation),
&provenance.signature_hex,
)
}
fn verify_record_provenance<T: serde::Serialize + ?Sized>(
public_key_hex: &str,
namespace: &str,
payload: &T,
signature_hex: &str,
) -> Result<(), NodeError> {
let public_key =
hex::decode(public_key_hex).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let signature =
hex::decode(signature_hex).map_err(|error| NodeError::IrohPeer(error.to_string()))?;
geth_crypto::verify_canonical(&public_key, namespace, payload, &signature)?;
Ok(())
}
fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertRequest {
StoredSshCertRequest {
request_id: request.id.to_string(),
requester_node: request.requester_node.to_string(),
public_key: request.public_key.clone(),
public_key_fingerprint: request.public_key_fingerprint.clone(),
cert_kind: request.cert_kind.to_string(),
principals: request.principals.clone(),
requested_validity: request.requested_validity.clone(),
renewal_of: request.renewal_of.as_ref().map(ToString::to_string),
reason: request.reason.clone(),
status: request.status.to_string(),
created_at_ms: request.created_at.0,
provenance_json: request
.provenance
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("SSH provenance serializes"),
}
}
#[cfg(test)]
fn insert_ssh_cert_request_if_not_conflicting(
store: &Store,
request: &SshCertRequest,
) -> Result<bool, NodeError> {
match stage_ssh_cert_request_if_not_conflicting(store, request)? {
Some(stored) => {
store.insert_ssh_cert_request(&stored)?;
Ok(true)
}
None => Ok(false),
}
}
fn stage_ssh_cert_request_if_not_conflicting(
store: &Store,
request: &SshCertRequest,
) -> Result<Option<StoredSshCertRequest>, NodeError> {
let stored = stored_from_ssh_cert_request(request);
if let Some(existing) = store.get_ssh_cert_request(&stored.request_id)? {
if existing != stored {
tracing::warn!(
request_id = %stored.request_id,
"rejected conflicting SSH certificate request during sync"
);
}
return Ok(None);
}
verify_ssh_cert_request_provenance(request)?;
Ok(Some(stored))
}
fn ssh_cert_request_from_stored(stored: StoredSshCertRequest) -> Result<SshCertRequest, NodeError> {
let cert_kind = stored
.cert_kind
.parse::<SshCertKind>()
.map_err(|_| NodeError::InvalidSshCertKind(stored.cert_kind.clone()))?;
let status = stored
.status
.parse::<SshCertRequestStatus>()
.map_err(|_| NodeError::InvalidSshCertStatus(stored.status.clone()))?;
Ok(SshCertRequest {
id: SshCertRequestId::new(stored.request_id),
requester_node: NodeId::new(stored.requester_node),
public_key: stored.public_key,
public_key_fingerprint: stored.public_key_fingerprint,
cert_kind,
principals: stored.principals,
requested_validity: stored.requested_validity,
renewal_of: stored.renewal_of.map(SshCertId::new),
reason: stored.reason,
status,
created_at: UnixMillis(stored.created_at_ms),
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
})
}
fn stored_from_ssh_certificate(certificate: &SshCertificateRecord) -> StoredSshCertificate {
StoredSshCertificate {
cert_id: certificate.id.to_string(),
request_id: certificate.request_id.to_string(),
certificate: certificate.certificate.clone(),
certificate_fingerprint: certificate.certificate_fingerprint.clone(),
imported_at_ms: certificate.imported_at.0,
provenance_json: certificate
.provenance
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("SSH provenance serializes"),
}
}
#[cfg(test)]
fn insert_ssh_certificate_if_not_conflicting(
store: &Store,
certificate: &SshCertificateRecord,
) -> Result<bool, NodeError> {
match stage_ssh_certificate_if_not_conflicting(store, certificate)? {
Some(stored) => {
store.insert_ssh_certificate(&stored)?;
Ok(true)
}
None => Ok(false),
}
}
fn stage_ssh_certificate_if_not_conflicting(
store: &Store,
certificate: &SshCertificateRecord,
) -> Result<Option<StoredSshCertificate>, NodeError> {
let stored = stored_from_ssh_certificate(certificate);
if let Some(existing) = store
.list_ssh_certificates()?
.into_iter()
.find(|existing| existing.cert_id == stored.cert_id)
{
if existing != stored {
tracing::warn!(
cert_id = %stored.cert_id,
"rejected conflicting SSH certificate during sync"
);
}
return Ok(None);
}
verify_ssh_certificate_provenance(certificate)?;
Ok(Some(stored))
}
fn ssh_certificate_from_stored(
stored: StoredSshCertificate,
) -> Result<SshCertificateRecord, NodeError> {
Ok(SshCertificateRecord {
id: SshCertId::new(stored.cert_id),
request_id: SshCertRequestId::new(stored.request_id),
certificate: stored.certificate,
certificate_fingerprint: stored.certificate_fingerprint,
imported_at: UnixMillis(stored.imported_at_ms),
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
})
}
fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevocation {
StoredSshRevocation {
revocation_id: revocation.id.to_string(),
kind: revocation.kind.to_string(),
target: revocation.target.clone(),
reason: revocation.reason.clone(),
created_at_ms: revocation.created_at.0,
published: revocation.published,
provenance_json: revocation
.provenance
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("SSH provenance serializes"),
}
}
#[cfg(test)]
fn insert_ssh_revocation_if_not_conflicting(
store: &Store,
revocation: &SshRevocationEntry,
) -> Result<bool, NodeError> {
match stage_ssh_revocation_if_not_conflicting(store, revocation)? {
Some(stored) => {
store.insert_ssh_revocation(&stored)?;
Ok(true)
}
None => Ok(false),
}
}
fn stage_ssh_revocation_if_not_conflicting(
store: &Store,
revocation: &SshRevocationEntry,
) -> Result<Option<StoredSshRevocation>, NodeError> {
let stored = stored_from_ssh_revocation(revocation);
if let Some(existing) = store
.list_ssh_revocations()?
.into_iter()
.find(|existing| existing.revocation_id == stored.revocation_id)
{
if existing != stored {
tracing::warn!(
revocation_id = %stored.revocation_id,
"rejected conflicting SSH revocation during sync"
);
}
return Ok(None);
}
verify_ssh_revocation_provenance(revocation)?;
Ok(Some(stored))
}
fn stage_unique_ssh_cert_request(
staged: &mut Vec<StoredSshCertRequest>,
stored: StoredSshCertRequest,
) -> Result<bool, NodeError> {
if let Some(existing) = staged
.iter()
.find(|existing| existing.request_id == stored.request_id)
{
if existing == &stored {
return Ok(false);
}
return Err(NodeError::IrohPeer(format!(
"conflicting SSH certificate request in sync batch: {}",
stored.request_id
)));
}
staged.push(stored);
Ok(true)
}
fn stage_unique_ssh_certificate(
staged: &mut Vec<StoredSshCertificate>,
stored: StoredSshCertificate,
) -> Result<bool, NodeError> {
if let Some(existing) = staged
.iter()
.find(|existing| existing.cert_id == stored.cert_id)
{
if existing == &stored {
return Ok(false);
}
return Err(NodeError::IrohPeer(format!(
"conflicting SSH certificate in sync batch: {}",
stored.cert_id
)));
}
staged.push(stored);
Ok(true)
}
fn stage_unique_ssh_revocation(
staged: &mut Vec<StoredSshRevocation>,
stored: StoredSshRevocation,
) -> Result<bool, NodeError> {
if let Some(existing) = staged
.iter()
.find(|existing| existing.revocation_id == stored.revocation_id)
{
if existing == &stored {
return Ok(false);
}
return Err(NodeError::IrohPeer(format!(
"conflicting SSH revocation in sync batch: {}",
stored.revocation_id
)));
}
staged.push(stored);
Ok(true)
}
fn ssh_cert_distribution_log_entries(
requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>,
) -> Vec<SshDistributionLogEntry> {
let mut entries = requests
.into_iter()
.map(SshDistributionLogEntry::cert_request)
.chain(
certificates
.into_iter()
.map(SshDistributionLogEntry::certificate),
)
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left.timestamp()
.0
.cmp(&right.timestamp().0)
.then_with(|| left.entry_id().cmp(right.entry_id()))
});
entries
}
fn ssh_revocation_distribution_log_entries(
revocations: Vec<SshRevocationEntry>,
) -> Vec<SshDistributionLogEntry> {
let mut entries = revocations
.into_iter()
.map(SshDistributionLogEntry::revocation)
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left.timestamp()
.0
.cmp(&right.timestamp().0)
.then_with(|| left.entry_id().cmp(right.entry_id()))
});
entries
}
fn import_ssh_distribution_log_entries(
store: &Store,
entries: &[SshDistributionLogEntry],
) -> Result<(usize, usize, usize), NodeError> {
let mut requests_imported = 0;
let mut certificates_imported = 0;
let mut revocations_imported = 0;
let mut requests = Vec::new();
let mut certificates = Vec::new();
let mut revocations = Vec::new();
for entry in entries {
match entry {
SshDistributionLogEntry::CertRequest { request, .. } => {
if let Some(stored) = stage_ssh_cert_request_if_not_conflicting(store, request)? {
if !stage_unique_ssh_cert_request(&mut requests, stored)? {
continue;
}
requests_imported += 1;
}
}
SshDistributionLogEntry::Certificate { certificate, .. } => {
if let Some(stored) = stage_ssh_certificate_if_not_conflicting(store, certificate)?
{
if !stage_unique_ssh_certificate(&mut certificates, stored)? {
continue;
}
certificates_imported += 1;
}
}
SshDistributionLogEntry::Revocation { revocation, .. } => {
if let Some(stored) = stage_ssh_revocation_if_not_conflicting(store, revocation)? {
if !stage_unique_ssh_revocation(&mut revocations, stored)? {
continue;
}
revocations_imported += 1;
}
}
}
}
store.insert_ssh_distribution_records(&requests, &certificates, &revocations)?;
Ok((
requests_imported,
certificates_imported,
revocations_imported,
))
}
fn ssh_revocation_from_stored(
stored: StoredSshRevocation,
) -> Result<SshRevocationEntry, NodeError> {
let kind = stored
.kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(stored.kind.clone()))?;
Ok(SshRevocationEntry {
id: geth_types::SshRevocationId::new(stored.revocation_id),
kind,
target: stored.target,
reason: stored.reason,
created_at: UnixMillis(stored.created_at_ms),
published: stored.published,
provenance: stored
.provenance_json
.map(|json| serde_json::from_str(&json))
.transpose()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sync::{
SyncRunMode, record_live_sync_failure, record_live_sync_success, run_live_sync_once,
should_live_sync_stream,
};
use std::collections::BTreeMap;
use tokio::io::AsyncReadExt;
fn skip_iroh_integration_tests() -> bool {
std::env::var_os("GETH_TEST_SKIP_IROH").is_some()
}
#[test]
fn kv_sync_import_rule_is_timestamp_last_writer_wins() {
assert!(should_import_kv_sync_entry(None, 10));
assert!(!should_import_kv_sync_entry(Some(11), 10));
assert!(should_import_kv_sync_entry(Some(10), 10));
assert!(should_import_kv_sync_entry(Some(9), 10));
}
#[test]
fn document_sync_rule_merges_equal_timestamps_only() {
let older =
document_sync_state_json(r#"{"title":"local"}"#, 20, r#"{"title":"remote"}"#, 19)
.expect("older remote");
assert_eq!(older, None);
let newer =
document_sync_state_json(r#"{"title":"local"}"#, 20, r#"{"title":"remote"}"#, 21)
.expect("newer remote");
assert_eq!(newer.as_deref(), Some(r#"{"title":"remote"}"#));
let local_state =
geth_document::create_automerge_state(r#"{"local":true}"#).expect("local envelope");
let remote_state =
geth_document::create_automerge_state(r#"{"remote":true}"#).expect("remote envelope");
let equal = document_sync_state_json(&local_state, 20, &remote_state, 20)
.expect("equal timestamp merge")
.expect("merged state");
let merged: serde_json::Value =
serde_json::from_str(&geth_document::document_view_json(&equal).expect("view json"))
.expect("merged json");
assert_eq!(merged["local"], true);
assert_eq!(merged["remote"], true);
}
#[test]
fn control_trace_fields_exclude_bearer_secrets() {
let trace = local_control::control_request_trace_fields(&ControlRequest::KvSync {
node: "node:peer".to_owned(),
name: "prefs".to_owned(),
bearer_secret: Some("private-token-value".to_owned()),
});
assert_eq!(trace.command, "kv.sync");
assert_eq!(trace.peer_node.as_deref(), Some("node:peer"));
assert_eq!(trace.resource.as_deref(), Some("resource:kv:prefs"));
assert_eq!(trace.capability.as_deref(), Some("kv.read"));
assert_eq!(trace.stream.as_deref(), Some("kv:prefs"));
assert!(!format!("{trace:?}").contains("private-token-value"));
}
#[test]
fn node_error_codes_are_stable_for_common_tracing_failures() {
assert_eq!(
local_control::node_error_code(&NodeError::Unauthorized("missing grant".to_owned())),
"unauthorized"
);
assert_eq!(
local_control::node_error_code(&NodeError::PeerNotFound("node:missing".to_owned())),
"peer_not_found"
);
assert_eq!(
local_control::node_error_code(&NodeError::IrohEndpointUnavailable),
"iroh_endpoint_unavailable"
);
}
#[derive(Debug, PartialEq, Eq)]
enum RemoteGuardKind {
Capability,
SignedLog,
OwnerReview,
CandidateOnly,
}
#[derive(Debug)]
struct RemoteGuardAudit {
operation: &'static str,
mutates_or_opens_service: bool,
guard: RemoteGuardKind,
resource: Option<&'static str>,
capability: Option<&'static str>,
}
fn remote_guard_audit_matrix() -> Vec<RemoteGuardAudit> {
vec![
RemoteGuardAudit {
operation: "peer.ping",
mutates_or_opens_service: false,
guard: RemoteGuardKind::CandidateOnly,
resource: None,
capability: None,
},
RemoteGuardAudit {
operation: "sync.status",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("per-stream"),
capability: Some("per-stream"),
},
RemoteGuardAudit {
operation: "keychain.sync",
mutates_or_opens_service: true,
guard: RemoteGuardKind::SignedLog,
resource: Some("keychain"),
capability: None,
},
RemoteGuardAudit {
operation: "auth.sync",
mutates_or_opens_service: true,
guard: RemoteGuardKind::SignedLog,
resource: Some("resource-auth"),
capability: None,
},
RemoteGuardAudit {
operation: "node.enrollment.submit",
mutates_or_opens_service: true,
guard: RemoteGuardKind::OwnerReview,
resource: Some("node-enrollment"),
capability: None,
},
RemoteGuardAudit {
operation: "cas.fetch",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:cas:local"),
capability: Some("cas.fetch"),
},
RemoteGuardAudit {
operation: "cas.root.sync",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:cas-tree:<name>"),
capability: Some("cas.fetch"),
},
RemoteGuardAudit {
operation: "ssh.cert.sync",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:ssh:certs"),
capability: Some("ssh_cert.sync"),
},
RemoteGuardAudit {
operation: "ssh.revocation.sync",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:ssh:revocations"),
capability: Some("ssh_revocation.sync"),
},
RemoteGuardAudit {
operation: "kv.sync",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:kv:<name>"),
capability: Some("kv.read"),
},
RemoteGuardAudit {
operation: "document.sync",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:document:<name>"),
capability: Some("document.read"),
},
RemoteGuardAudit {
operation: "db.sync",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:db:<name>"),
capability: Some("db.sync"),
},
RemoteGuardAudit {
operation: "pubsub.publish",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pubsub:<topic>"),
capability: Some("pubsub.publish"),
},
RemoteGuardAudit {
operation: "pubsub.subscribe",
mutates_or_opens_service: false,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pubsub:<topic>"),
capability: Some("pubsub.subscribe"),
},
RemoteGuardAudit {
operation: "pipe.connect",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pipe:<name>"),
capability: Some("pipe.connect"),
},
RemoteGuardAudit {
operation: "pipe.listen",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pipe:<name>"),
capability: Some("pipe.listen"),
},
RemoteGuardAudit {
operation: "pipe.send",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pipe:<name>"),
capability: Some("pipe.connect"),
},
RemoteGuardAudit {
operation: "pipe.tcp.forward",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pipe-tcp:<target>"),
capability: Some("pipe.forward"),
},
RemoteGuardAudit {
operation: "pipe.unix.forward",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:pipe-unix:<target>"),
capability: Some("pipe.forward"),
},
RemoteGuardAudit {
operation: "overlay.packet.route",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:overlay:<name>"),
capability: Some("overlay.route"),
},
RemoteGuardAudit {
operation: "ssh.proxy.connect",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:ssh-proxy:local"),
capability: Some("ssh_proxy.connect"),
},
RemoteGuardAudit {
operation: "ssh.admin-shell",
mutates_or_opens_service: true,
guard: RemoteGuardKind::Capability,
resource: Some("resource:ssh-proxy:local"),
capability: Some("ssh_proxy.admin_shell"),
},
]
}
#[test]
fn remote_authorization_audit_matrix_covers_mutating_and_service_operations() {
let matrix = remote_guard_audit_matrix();
assert_eq!(matrix.len(), 22);
for item in matrix.iter().filter(|item| item.mutates_or_opens_service) {
assert_ne!(
item.guard,
RemoteGuardKind::CandidateOnly,
"{} must not rely on peer-card discovery alone",
item.operation
);
if item.guard == RemoteGuardKind::Capability {
assert!(
item.resource.is_some() && item.capability.is_some(),
"{} capability guard must document resource and capability",
item.operation
);
}
}
}
fn write_offline_iroh_config(paths: &GethPaths) {
std::fs::write(
paths.config_file(),
"[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n",
)
.expect("write config");
}
fn create_mock_crsqlite_db(path: &Path, change_db_version: Option<i64>) {
let conn = rusqlite::Connection::open(path).expect("open sqlite");
conn.execute("CREATE TABLE notes(id INTEGER PRIMARY KEY, body TEXT)", [])
.expect("create notes");
conn.execute(
r#"CREATE TABLE crsql_changes(
table_name TEXT NOT NULL,
pk BLOB NOT NULL,
cid TEXT NOT NULL,
val BLOB,
col_version INTEGER NOT NULL,
db_version INTEGER NOT NULL,
site_id BLOB,
cl INTEGER,
seq INTEGER
)"#,
[],
)
.expect("create crsql_changes");
if let Some(db_version) = change_db_version {
conn.execute(
"INSERT INTO crsql_changes(table_name, pk, cid, val, col_version, db_version, site_id, cl, seq) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
(
"notes",
vec![db_version as u8],
"body",
Vec::from("hello".as_bytes()),
1_i64,
db_version,
vec![1_u8],
1_i64,
db_version,
),
)
.expect("insert initial mock crsqlite change");
}
}
fn insert_mock_crsqlite_change(path: &Path, db_version: i64, body: &str) {
let conn = rusqlite::Connection::open(path).expect("open sqlite");
conn.execute(
"INSERT INTO crsql_changes(table_name, pk, cid, val, col_version, db_version, site_id, cl, seq) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
(
"notes",
vec![db_version as u8],
"body",
body.as_bytes().to_vec(),
1_i64,
db_version,
vec![1_u8],
1_i64,
db_version,
),
)
.expect("insert mock crsqlite change");
}
fn grant_test_capability(store: &Store, peer: &str, resource: &str, capability: &str) {
let created_at = UnixMillis(geth_store::now_ms());
store_auth_op(
store,
&AuthOp {
id: generated_auth_op_id("grant-create", resource, capability, created_at),
resource: ResourceId::new(resource.to_owned()),
created_at,
kind: AuthOpKind::GrantCreate {
grant_id: format!("grant:{peer}:{resource}:{capability}"),
principal: PrincipalId::new(peer.to_owned()),
capabilities: vec![Capability::new(capability.to_owned())],
},
},
)
.expect("store auth grant");
}
fn test_ipv4_packet_base64() -> String {
base64::engine::general_purpose::STANDARD.encode([
0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x40, 0x00, 64, 1, 0, 0, 172, 22, 0, 1, 172, 22, 0,
2,
])
}
#[test]
fn ssh_sync_import_rejects_conflicting_records() {
let store = Store::open_memory().expect("open");
let mut request = SshCertRequest {
id: SshCertRequestId::new("ssh-cert-request:1"),
requester_node: NodeId::new("node:right"),
public_key: "ssh-ed25519 AAAA".to_owned(),
public_key_fingerprint: "SHA256:one".to_owned(),
cert_kind: SshCertKind::User,
principals: vec!["eric".to_owned()],
requested_validity: None,
renewal_of: None,
reason: Some("original".to_owned()),
status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1),
provenance: None,
};
add_test_ssh_cert_request_provenance(&mut request);
assert!(
insert_ssh_cert_request_if_not_conflicting(&store, &request).expect("insert request")
);
let mut conflicting_request = request.clone();
conflicting_request.reason = Some("conflict".to_owned());
assert!(
!insert_ssh_cert_request_if_not_conflicting(&store, &conflicting_request)
.expect("reject conflicting request")
);
let mut unsigned_request = request.clone();
unsigned_request.id = SshCertRequestId::new("ssh-cert-request:unsigned");
unsigned_request.provenance = None;
assert!(insert_ssh_cert_request_if_not_conflicting(&store, &unsigned_request).is_err());
assert_eq!(
store
.get_ssh_cert_request(request.id.as_str())
.expect("get request")
.expect("request")
.reason
.as_deref(),
Some("original")
);
let mut certificate = SshCertificateRecord {
id: SshCertId::new("ssh-cert:1"),
request_id: request.id.clone(),
certificate: "ssh-ed25519-cert-v01@openssh.com AAAA".to_owned(),
certificate_fingerprint: "SHA256:cert-one".to_owned(),
imported_at: UnixMillis(2),
provenance: None,
};
add_test_ssh_certificate_provenance(&mut certificate);
assert!(
insert_ssh_certificate_if_not_conflicting(&store, &certificate)
.expect("insert certificate")
);
let mut conflicting_certificate = certificate.clone();
conflicting_certificate.certificate = "ssh-ed25519-cert-v01@openssh.com BBBB".to_owned();
conflicting_certificate.provenance = None;
add_test_ssh_certificate_provenance(&mut conflicting_certificate);
assert!(
!insert_ssh_certificate_if_not_conflicting(&store, &conflicting_certificate)
.expect("reject conflicting certificate")
);
assert_eq!(
store
.list_ssh_certificates()
.expect("list certificates")
.first()
.expect("certificate")
.certificate,
"ssh-ed25519-cert-v01@openssh.com AAAA"
);
let mut revocation = SshRevocationEntry {
id: geth_types::SshRevocationId::new("ssh-revocation:1"),
kind: SshRevocationKind::KeyId,
target: "old-key".to_owned(),
reason: Some("original".to_owned()),
created_at: UnixMillis(2),
published: true,
provenance: None,
};
add_test_ssh_revocation_provenance(&mut revocation);
assert!(
insert_ssh_revocation_if_not_conflicting(&store, &revocation)
.expect("insert revocation")
);
let mut conflicting_revocation = revocation.clone();
conflicting_revocation.target = "other-key".to_owned();
assert!(
!insert_ssh_revocation_if_not_conflicting(&store, &conflicting_revocation)
.expect("reject conflicting revocation")
);
let mut unsigned_revocation = revocation.clone();
unsigned_revocation.id = geth_types::SshRevocationId::new("ssh-revocation:unsigned");
unsigned_revocation.provenance = None;
assert!(insert_ssh_revocation_if_not_conflicting(&store, &unsigned_revocation).is_err());
assert_eq!(
store
.list_ssh_revocations()
.expect("list revocations")
.first()
.expect("revocation")
.target,
"old-key"
);
}
#[test]
fn ssh_sync_import_rejects_batch_conflicts_without_partial_write() {
let store = Store::open_memory().expect("open");
let mut request = SshCertRequest {
id: SshCertRequestId::new("ssh-cert-request:batch-conflict"),
requester_node: NodeId::new("node:right"),
public_key: "ssh-ed25519 AAAA".to_owned(),
public_key_fingerprint: "SHA256:one".to_owned(),
cert_kind: SshCertKind::User,
principals: vec!["eric".to_owned()],
requested_validity: None,
renewal_of: None,
reason: Some("original".to_owned()),
status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1),
provenance: None,
};
add_test_ssh_cert_request_provenance(&mut request);
let mut conflicting_request = request.clone();
conflicting_request.reason = Some("conflict".to_owned());
conflicting_request.provenance = None;
add_test_ssh_cert_request_provenance(&mut conflicting_request);
let result = import_ssh_distribution_log_entries(
&store,
&[
SshDistributionLogEntry::cert_request(request.clone()),
SshDistributionLogEntry::cert_request(conflicting_request),
],
);
assert!(result.is_err());
assert_eq!(
store
.get_ssh_cert_request(request.id.as_str())
.expect("get request"),
None
);
}
fn add_test_ssh_cert_request_provenance(request: &mut SshCertRequest) {
let key = AgentKey::generate();
let signature = key
.sign_canonical(
SSH_CERT_REQUEST_NAMESPACE,
&ssh_cert_request_signing_payload(request),
)
.expect("sign request");
request.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_REQUEST_NAMESPACE.to_owned(),
signer_node: NodeId::new("node:right"),
signer_agent: key.agent_id().to_string(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(1),
});
}
fn add_test_ssh_certificate_provenance(certificate: &mut SshCertificateRecord) {
let key = AgentKey::generate();
let signature = key
.sign_canonical(
SSH_CERT_ISSUANCE_NAMESPACE,
&ssh_certificate_signing_payload(certificate),
)
.expect("sign certificate");
certificate.provenance = Some(SshRecordProvenance {
namespace: SSH_CERT_ISSUANCE_NAMESPACE.to_owned(),
signer_node: NodeId::new("node:right"),
signer_agent: key.agent_id().to_string(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(2),
});
}
fn add_test_ssh_revocation_provenance(revocation: &mut SshRevocationEntry) {
let key = AgentKey::generate();
let signature = key
.sign_canonical(
SSH_REVOCATION_LIST_NAMESPACE,
&ssh_revocation_signing_payload(revocation),
)
.expect("sign revocation");
revocation.provenance = Some(SshRecordProvenance {
namespace: SSH_REVOCATION_LIST_NAMESPACE.to_owned(),
signer_node: NodeId::new("node:right"),
signer_agent: key.agent_id().to_string(),
signer_public_key: key.public_key_hex(),
signature_hex: hex::encode(signature),
signed_at: UnixMillis(2),
});
}
#[test]
fn sync_watermarks_include_only_authorized_streams() {
let store = Store::open_memory().expect("open");
store
.insert_resource(&StoredResource {
resource_id: "resource:kv:prefs".to_owned(),
kind: "kv".to_owned(),
name: "prefs".to_owned(),
status: "active".to_owned(),
})
.expect("insert kv resource");
store
.insert_kv_store(&StoredKvStore {
kv_id: "kv:prefs".to_owned(),
resource_id: "resource:kv:prefs".to_owned(),
name: "prefs".to_owned(),
})
.expect("insert kv");
store
.set_kv_entry(&StoredKvEntry {
kv_id: "kv:prefs".to_owned(),
key: "theme".to_owned(),
value: "dark".to_owned(),
updated_at_ms: 42,
})
.expect("set kv");
store
.insert_resource(&StoredResource {
resource_id: "resource:document:notes".to_owned(),
kind: "document".to_owned(),
name: "notes".to_owned(),
status: "active".to_owned(),
})
.expect("insert document resource");
store
.insert_document_resource(&StoredDocumentResource {
document_id: "document:notes".to_owned(),
resource_id: "resource:document:notes".to_owned(),
name: "notes".to_owned(),
state_json: "{}".to_owned(),
updated_at_ms: 99,
})
.expect("insert document");
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("notes.sqlite");
create_mock_crsqlite_db(&db_path, Some(7));
store
.insert_resource(&StoredResource {
resource_id: "resource:db:notes".to_owned(),
kind: "db".to_owned(),
name: "notes".to_owned(),
status: "active".to_owned(),
})
.expect("insert db resource");
store
.insert_db_resource(&StoredDbResource {
db_id: "db:notes".to_owned(),
resource_id: "resource:db:notes".to_owned(),
name: "notes".to_owned(),
path: db_path.display().to_string(),
})
.expect("insert db");
store
.upsert_file_root(&StoredFileRoot {
root_id: "file-root:shared".to_owned(),
resource_id: "resource:cas-tree:shared".to_owned(),
name: "shared".to_owned(),
path: dir.path().join("shared").display().to_string(),
latest_tree_hash: Some("abc".to_owned()),
latest_tree_json: None,
updated_at_ms: 123,
})
.expect("insert file root");
store
.upsert_file_root(&StoredFileRoot {
root_id: "file-root:remote:node:right:shared".to_owned(),
resource_id: "resource:cas-tree:shared".to_owned(),
name: "remote-node-right-shared".to_owned(),
path: "remote:node:right:shared".to_owned(),
latest_tree_hash: Some("def".to_owned()),
latest_tree_json: None,
updated_at_ms: 456,
})
.expect("insert imported remote file root");
grant_test_capability(&store, "node:left", "resource:kv:prefs", "kv.read");
grant_test_capability(&store, "node:left", "resource:db:notes", "db.sync");
grant_test_capability(&store, "node:left", "resource:cas-tree:shared", "cas.fetch");
let watermarks = sync_watermarks_for_peer(&store, "node:left").expect("watermarks");
assert!(
watermarks
.iter()
.any(|watermark| watermark.stream == "auth")
);
assert!(
watermarks
.iter()
.any(|watermark| watermark.stream == "keychain")
);
assert!(watermarks.contains(&SyncWatermark {
stream: "kv:prefs".to_owned(),
high_water: 42,
}));
assert!(watermarks.contains(&SyncWatermark {
stream: "db:notes".to_owned(),
high_water: 7,
}));
assert!(watermarks.contains(&SyncWatermark {
stream: "cas-tree:shared".to_owned(),
high_water: 123,
}));
assert!(
!watermarks
.iter()
.any(|watermark| watermark.stream == "cas-tree:remote-node-right-shared")
);
assert!(
!watermarks
.iter()
.any(|watermark| watermark.stream == "document:notes")
);
assert!(
!watermarks
.iter()
.any(|watermark| watermark.stream == "ssh-certs")
);
}
#[test]
fn sync_status_local_reports_recorded_stream_health() {
let store = Store::open_memory().expect("open");
store
.upsert_peer_card(&StoredPeerCard {
peer_id: "node:left".to_owned(),
card_json: "{}".to_owned(),
updated_at_ms: 1,
})
.expect("insert peer");
store_live_sync_cursor(&store, "node:left", "keychain", 42).expect("store cursor");
record_live_sync_success(&store, "node:left", "keychain", 3, 1, Some(42))
.expect("record success");
let response = sync_status_local(&store).expect("sync status");
let ControlResponse::SyncStatus { peers, .. } = response else {
panic!("unexpected sync status response");
};
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].peer_node_id, "node:left");
assert_eq!(peers[0].streams.len(), 1);
assert_eq!(peers[0].streams[0].stream, "keychain");
assert_eq!(peers[0].streams[0].cursor_ms, 42);
assert_eq!(peers[0].streams[0].state, "ok");
assert!(!peers[0].streams[0].stale);
assert_eq!(
peers[0].streams[0].stale_after_ms,
sync::LIVE_SYNC_STALE_AFTER_MS
);
assert_eq!(peers[0].streams[0].last_imported, 3);
assert_eq!(peers[0].streams[0].last_rejected, 1);
assert!(peers[0].streams[0].last_error.is_none());
assert_eq!(peers[0].streams[0].next_action, "none");
}
#[test]
fn live_sync_failures_back_off_background_streams() {
let store = Store::open_memory().expect("open");
store
.upsert_peer_card(&StoredPeerCard {
peer_id: "node:left".to_owned(),
card_json: "{}".to_owned(),
updated_at_ms: 1,
})
.expect("insert peer");
record_live_sync_failure(
&store,
"node:left",
"kv:prefs",
&NodeError::IrohPeer("peer unavailable".to_owned()),
)
.expect("record failure");
let response = sync_status_local(&store).expect("sync status");
let ControlResponse::SyncStatus { peers, .. } = response else {
panic!("unexpected sync status response");
};
assert_eq!(peers.len(), 1);
let stream = &peers[0].streams[0];
assert_eq!(stream.stream, "kv:prefs");
assert_eq!(stream.state, "failed");
assert_eq!(stream.consecutive_failures, 1);
assert!(stream.retry_after_ms.is_some());
assert!(
stream
.retry_in_ms
.is_some_and(|retry_in_ms| retry_in_ms > 0)
);
assert!(stream.next_action.contains("geth sync now node:left"));
let watermarks = BTreeMap::from([("kv:prefs".to_owned(), 42)]);
assert!(
!should_live_sync_stream(
&store,
"node:left",
"kv:prefs",
Some(&watermarks),
SyncRunMode::Background,
)
.expect("background decision")
);
assert!(
should_live_sync_stream(
&store,
"node:left",
"kv:prefs",
Some(&watermarks),
SyncRunMode::Manual,
)
.expect("manual decision")
);
}
#[test]
fn operator_errors_include_recovery_commands() {
let peer_error =
operator_error_message(&NodeError::PeerNotFound("node:missing".to_owned()));
assert!(peer_error.contains("geth peer import"));
assert!(peer_error.contains("geth peer list"));
let denied_error =
operator_error_message(&NodeError::Unauthorized("missing grant".to_owned()));
assert!(denied_error.contains("geth auth explain"));
assert!(denied_error.contains("geth auth grant"));
}
#[test]
fn file_root_sync_records_automatic_three_tree_conflicts() {
let store = Store::open_memory().expect("open");
let base_tree = test_tree(vec![
test_tree_file("edit.txt", "01"),
test_tree_file("delete-edit.txt", "02"),
test_tree_file("rename.txt", "03"),
]);
let local_tree = test_tree(vec![
test_tree_file("edit.txt", "04"),
test_tree_file("delete-edit.txt", "05"),
test_tree_file("local-rename.txt", "03"),
]);
let remote_tree = test_tree(vec![
test_tree_file("edit.txt", "06"),
test_tree_file("remote-rename.txt", "03"),
]);
let local_root = StoredFileRoot {
root_id: "file-root:shared".to_owned(),
resource_id: "resource:cas-tree:shared".to_owned(),
name: "shared".to_owned(),
path: "/tmp/shared".to_owned(),
latest_tree_hash: Some("local-tree".to_owned()),
latest_tree_json: Some(serde_json::to_string(&local_tree).expect("local tree json")),
updated_at_ms: 2,
};
store.upsert_file_root(&local_root).expect("upsert root");
let conflicts = record_sync_tree_conflicts(
&store,
&local_root,
Some("base-tree"),
&base_tree,
local_root.latest_tree_hash.as_deref(),
Some(&BlobHash::new("remote-tree".to_owned())),
&remote_tree,
)
.expect("record conflicts");
assert_eq!(conflicts.len(), 3);
assert!(conflicts.iter().any(|conflict| {
conflict.path == "edit.txt" && conflict.kind == FileConflictKind::ConcurrentEdit
}));
assert!(conflicts.iter().any(|conflict| {
conflict.path == "delete-edit.txt" && conflict.kind == FileConflictKind::DeleteEdit
}));
assert!(conflicts.iter().any(|conflict| {
conflict.path == "rename.txt" && conflict.kind == FileConflictKind::Rename
}));
let stored = store
.list_file_conflicts(Some("shared"))
.expect("list conflicts");
assert_eq!(stored.len(), 3);
assert!(stored.iter().all(|conflict| {
conflict.base_tree_hash.as_deref() == Some("base-tree")
&& conflict.local_tree_hash.as_deref() == Some("local-tree")
&& conflict.remote_tree_hash.as_deref() == Some("remote-tree")
&& conflict
.detail
.contains("syncing remote file-root metadata")
}));
}
#[test]
fn overlay_peers_combine_trusted_route_metadata_with_untrusted_candidates() {
let store = Store::open_memory().expect("open store");
let key = AgentKey::generate();
let card = PeerCard::signed(
"node:right".into(),
&key,
vec![EndpointCandidate {
endpoint_id: "endpoint:right".to_owned(),
relay_url: None,
direct_addresses: Vec::new(),
source: DiscoverySource::Imported,
}],
UnixMillis(1),
)
.expect("peer card");
store
.upsert_peer_card(&StoredPeerCard {
peer_id: card.node_id.to_string(),
card_json: serde_json::to_string(&card).expect("card json"),
updated_at_ms: 1,
})
.expect("store peer card");
store
.put_module_state(&StoredModuleState {
module: geth_overlay::overlay_state_key("home"),
state_json: serde_json::to_string(
&geth_overlay::joined_overlay_membership(
"home",
Some("172.22.0.0/24"),
"node:left",
"secret",
1,
)
.expect("membership"),
)
.expect("membership json"),
updated_at_ms: 1,
})
.expect("store membership");
let peers = overlay_peers(&store, "home").expect("candidate peers");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].node_id, "node:right");
assert_eq!(peers[0].state, "candidate-only");
assert_eq!(
peers[0].virtual_ip,
Some(
geth_overlay::deterministic_virtual_ip("172.22.0.0/24", "node:right")
.expect("virtual ip")
)
);
grant_test_capability(
&store,
"node:right",
"resource:overlay:home",
geth_overlay::CAPABILITY_ROUTE,
);
let peers = overlay_peers(&store, "home").expect("authorized peers");
assert_eq!(peers[0].state, "route-authorized-candidate");
}
#[test]
fn lan_discovery_address_selection_uses_iroh_direct_addresses() {
let key = AgentKey::generate();
let card = PeerCard::signed(
"node:test".into(),
&key,
vec![EndpointCandidate {
endpoint_id: "endpoint:test".to_owned(),
relay_url: None,
direct_addresses: vec![
"127.0.0.1:1111".to_owned(),
"127.0.0.2:1111".to_owned(),
"127.0.0.3:2222".to_owned(),
],
source: DiscoverySource::Mdns,
}],
UnixMillis(1),
)
.expect("peer card");
let (port, addrs) = lan_discovery_addrs(&card).expect("lan addresses");
assert_eq!(port, 1111);
assert_eq!(addrs.len(), 2);
}
#[tokio::test]
async fn peer_ping_uses_signed_peer_card_over_iroh() {
if skip_iroh_integration_tests() {
eprintln!("skipping Iroh integration test because GETH_TEST_SKIP_IROH is set");
return;
}
let left_home = tempfile::tempdir().expect("left home");
let right_home = tempfile::tempdir().expect("right home");
let left_paths = GethPaths::from_home(left_home.path());
let right_paths = GethPaths::from_home(right_home.path());
let mut left = init_node(&left_paths).expect("init left");
let mut right = init_node(&right_paths).expect("init right");
write_offline_iroh_config(&left_paths);
write_offline_iroh_config(&right_paths);
let Some(left_endpoint) = start_daemon_iroh_endpoint(&mut left)
.await
.expect("left iroh")
else {
eprintln!("skipping peer ping assertion; left Iroh endpoint unavailable");
return;
};
let Some(right_endpoint) = start_daemon_iroh_endpoint(&mut right)
.await
.expect("right iroh")
else {
eprintln!("skipping peer ping assertion; right Iroh endpoint unavailable");
left_endpoint.shutdown().await;
return;
};
spawn_iroh_control_accept_loop(right.clone(), right_endpoint.clone());
let exported = handle_request_async(&right, ControlRequest::PeerCardExport { out: None })
.await
.expect("export right peer card");
let right_card = match exported {
ControlResponse::PeerCardExported { card, .. } => card,
other => panic!("unexpected export response: {other:?}"),
};
assert!(!right_card.endpoints[0].direct_addresses.is_empty());
Store::open(&left_paths.metadata_db())
.expect("open left store")
.upsert_peer_card(&StoredPeerCard {
peer_id: right_card.node_id.to_string(),
card_json: serde_json::to_string(&right_card).expect("card json"),
updated_at_ms: geth_store::now_ms(),
})
.expect("insert right peer");
let right_blob = LocalCas::new(right.paths.cas_dir())
.add_bytes(b"remote cas bytes")
.expect("right cas add");
mirror_local_blob_to_iroh_blobs(&right, &right_blob.hash)
.await
.expect("mirror right CAS blob into iroh-blobs");
let left_file_root = left_home.path().join("files");
std::fs::create_dir_all(&left_file_root).expect("create left file root");
std::fs::write(left_file_root.join("note.txt"), b"local tree").expect("write left file");
handle_request(
&left,
ControlRequest::CasRootAdd {
name: "shared".to_owned(),
path: left_file_root.clone(),
},
)
.expect("left file root add");
let right_file_root = right_home.path().join("files");
std::fs::create_dir_all(&right_file_root).expect("create right file root");
std::fs::write(right_file_root.join("note.txt"), b"synced tree").expect("write right file");
handle_request(
&right,
ControlRequest::CasRootAdd {
name: "shared".to_owned(),
path: right_file_root.clone(),
},
)
.expect("right file root add");
let right_tree_hash = match handle_request(
&right,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("right file root scan")
{
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
other => panic!("unexpected right file root scan response: {other:?}"),
};
let right_pubkey = right_home.path().join("request.pub");
std::fs::write(
&right_pubkey,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== node\n",
)
.expect("write right public key");
let requested = handle_request(
&right,
ControlRequest::SshCertRequest {
public_key_path: right_pubkey,
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("test sync".to_owned()),
subject: None,
},
)
.expect("right ssh cert request");
let right_request_id = match requested {
ControlResponse::SshCertRequested { request } => request.id,
other => panic!("unexpected SSH cert request response: {other:?}"),
};
let added_revocation = handle_request(
&right,
ControlRequest::SshRevocationAdd {
kind: "key-id".to_owned(),
target: "old-node-key".to_owned(),
reason: Some("test sync".to_owned()),
subject: None,
},
)
.expect("right ssh revocation");
let right_revocation_id = match added_revocation {
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
other => panic!("unexpected SSH revocation response: {other:?}"),
};
handle_request(
&right,
ControlRequest::KvCreate {
name: "prefs".to_owned(),
},
)
.expect("right kv create");
handle_request(
&right,
ControlRequest::KvSet {
name: "prefs".to_owned(),
key: "apps/foo/theme".to_owned(),
value: "dark".to_owned(),
subject: None,
},
)
.expect("right kv set");
handle_request(
&right,
ControlRequest::PipeListen {
name: "inbox".to_owned(),
node: None,
bearer_secret: None,
},
)
.expect("right pipe listen");
handle_request(
&right,
ControlRequest::DocumentCreate {
name: "notes".to_owned(),
},
)
.expect("right document create");
handle_request(
&right,
ControlRequest::DocumentSet {
name: "notes".to_owned(),
state_json: r#"{"title":"remote"}"#.to_owned(),
},
)
.expect("right document set");
let left_db_path = left_home.path().join("notes.sqlite");
let right_db_path = right_home.path().join("notes.sqlite");
create_mock_crsqlite_db(&left_db_path, None);
create_mock_crsqlite_db(&right_db_path, Some(7));
handle_request(
&left,
ControlRequest::DbAdd {
name: "notes".to_owned(),
path: left_db_path.clone(),
},
)
.expect("left db add");
handle_request(
&right,
ControlRequest::DbAdd {
name: "notes".to_owned(),
path: right_db_path.clone(),
},
)
.expect("right db add");
let ping = handle_request_async(
&left,
ControlRequest::PeerPing {
node: right_card.node_id.to_string(),
},
)
.await
.expect("peer ping");
match ping {
ControlResponse::PeerPinged {
peer_node_id,
peer_agent_id,
alpn,
note,
..
} => {
assert_eq!(peer_node_id, right.node_id);
assert_eq!(peer_agent_id, right.agent_id);
assert_eq!(alpn, "/geth/control/1");
assert!(note.contains("does not grant resource capabilities"));
}
other => panic!("unexpected ping response: {other:?}"),
}
let denied = handle_request_async(
&left,
ControlRequest::PeerAuthCheck {
node: right_card.node_id.to_string(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.await
.expect("denied peer auth check");
match denied {
ControlResponse::PeerAuthChecked {
allowed,
reason,
note,
..
} => {
assert!(!allowed);
assert!(reason.contains("no active direct or group grant"));
assert!(note.contains("authenticated endpoint/card binding"));
}
other => panic!("unexpected denied auth response: {other:?}"),
}
let denied_fetch = handle_request_async(
&left,
ControlRequest::CasFetch {
node: right_card.node_id.to_string(),
hash: right_blob.hash.clone(),
bearer_secret: None,
},
)
.await
.expect("denied cas fetch");
match denied_fetch {
ControlResponse::CasFetched {
allowed,
reason,
size_bytes,
..
} => {
assert!(!allowed);
assert_eq!(size_bytes, 0);
assert!(reason.contains("no active direct or group grant"));
assert!(
!LocalCas::new(left.paths.cas_dir())
.has(&right_blob.hash)
.expect("left cas has after denied fetch")
);
}
other => panic!("unexpected denied CAS fetch response: {other:?}"),
}
let denied_root_sync = handle_request_async(
&left,
ControlRequest::CasRootSync {
node: right_card.node_id.to_string(),
name: "shared".to_owned(),
bearer_secret: None,
},
)
.await
.expect("denied file root sync");
match denied_root_sync {
ControlResponse::CasRootSynced {
allowed,
root,
reason,
..
} => {
assert!(!allowed);
assert!(root.is_none());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied file root sync response: {other:?}"),
}
let denied_kv_sync = handle_request_async(
&left,
ControlRequest::KvSync {
node: right_card.node_id.to_string(),
name: "prefs".to_owned(),
bearer_secret: None,
},
)
.await
.expect("denied kv sync");
match denied_kv_sync {
ControlResponse::KvSynced {
allowed,
entries_imported,
reason,
..
} => {
assert!(!allowed);
assert_eq!(entries_imported, 0);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied KV sync response: {other:?}"),
}
let denied_pubsub = handle_request_async(
&left,
ControlRequest::PubsubPub {
topic: "presence/test".to_owned(),
message: "hello".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("denied remote pubsub publish");
match denied_pubsub {
ControlResponse::PubsubRemotePublished {
allowed,
reason,
message,
..
} => {
assert!(!allowed);
assert!(message.message.is_empty());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied pubsub publish response: {other:?}"),
}
let denied_pubsub_subscribe = handle_request_async(
&left,
ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("denied remote pubsub subscribe");
match denied_pubsub_subscribe {
ControlResponse::PubsubRemoteMessages {
allowed,
messages,
reason,
..
} => {
assert!(!allowed);
assert!(messages.is_empty());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied pubsub subscribe response: {other:?}"),
}
let denied_pipe = handle_request_async(
&left,
ControlRequest::PipeConnect {
target: "inbox".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("denied remote pipe connect");
match denied_pipe {
ControlResponse::PipeRemoteConnected {
allowed,
reason,
connection,
..
} => {
assert!(!allowed);
assert!(!connection.local_listener_found);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied pipe connect response: {other:?}"),
}
let denied_pipe_listen = handle_request_async(
&left,
ControlRequest::PipeListen {
name: "remote-inbox".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("denied remote pipe listen");
match denied_pipe_listen {
ControlResponse::PipeRemoteListening {
allowed,
reason,
listener,
..
} => {
assert!(!allowed);
assert!(listener.is_none());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied pipe listen response: {other:?}"),
}
let denied_ssh_proxy = handle_request_async(
&left,
ControlRequest::SshProxyConnect {
node: right_card.node_id.to_string(),
bearer_secret: None,
},
)
.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_admin_shell = handle_request_async(
&left,
ControlRequest::SshAdminShell {
node: right_card.node_id.to_string(),
command: "status".to_owned(),
bearer_secret: None,
},
)
.await
.expect("denied SSH admin shell");
match denied_admin_shell {
ControlResponse::SshAdminShellOutput {
allowed,
output,
reason,
..
} => {
assert!(!allowed);
assert!(output.is_empty());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied SSH admin shell response: {other:?}"),
}
let denied_document = handle_request_async(
&left,
ControlRequest::DocumentSync {
node: right_card.node_id.to_string(),
name: "notes".to_owned(),
bearer_secret: None,
},
)
.await
.expect("denied document sync");
match denied_document {
ControlResponse::DocumentSynced {
allowed,
updated,
reason,
..
} => {
assert!(!allowed);
assert!(!updated);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied document sync response: {other:?}"),
}
let denied_db_sync = handle_request_async(
&left,
ControlRequest::DbSync {
node: right_card.node_id.to_string(),
name: "notes".to_owned(),
limit: 10,
bearer_secret: None,
},
)
.await
.expect("denied db sync");
match denied_db_sync {
ControlResponse::DbSynced {
allowed,
changes_received,
changes_applied,
schema_match,
reason,
..
} => {
assert!(!allowed);
assert_eq!(changes_received, 0);
assert_eq!(changes_applied, 0);
assert!(!schema_match);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied DB sync response: {other:?}"),
}
let bearer_secret = match handle_request(
&right,
ControlRequest::SecretBearerCreate {
resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.fetch".to_owned()],
expires_at_ms: None,
},
)
.expect("create remote CAS bearer access")
{
ControlResponse::SecretBearerCreated { access } => access
.token
.expect("private bearer token on create")
.to_string(),
other => panic!("unexpected bearer create response: {other:?}"),
};
let bearer_fetch = handle_request_async(
&left,
ControlRequest::CasFetch {
node: right_card.node_id.to_string(),
hash: right_blob.hash.clone(),
bearer_secret: Some(bearer_secret),
},
)
.await
.expect("bearer cas fetch");
match bearer_fetch {
ControlResponse::CasFetched {
allowed,
hash,
size_bytes,
reason,
..
} => {
assert!(allowed);
assert_eq!(hash, right_blob.hash);
assert_eq!(size_bytes, right_blob.size_bytes);
assert!(reason.contains("bearer proof"));
}
other => panic!("unexpected bearer CAS fetch response: {other:?}"),
}
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-fetch".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left peer");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:cas-tree:shared".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-tree-fetch".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left file root fetch");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:kv:prefs".to_owned(),
capability: "kv.read".to_owned(),
grant_id: Some("grant:left-kv-read".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left kv read");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.publish".to_owned(),
grant_id: Some("grant:left-pubsub-publish".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pubsub publish");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.subscribe".to_owned(),
grant_id: Some("grant:left-pubsub-subscribe".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pubsub subscribe");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:pipe:inbox".to_owned(),
capability: "pipe.connect".to_owned(),
grant_id: Some("grant:left-pipe-connect".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe connect");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:pipe:remote-inbox".to_owned(),
capability: "pipe.listen".to_owned(),
grant_id: Some("grant:left-pipe-listen".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe listen");
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()),
signing_key_path: None,
admin_key_path: None,
},
)
.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()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh admin shell");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:document:notes".to_owned(),
capability: "document.read".to_owned(),
grant_id: Some("grant:left-document-read".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left document read");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:db:notes".to_owned(),
capability: "db.sync".to_owned(),
grant_id: Some("grant:left-db-sync".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left db sync");
let tcp_echo = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind tcp echo");
let tcp_target = tcp_echo.local_addr().expect("tcp echo addr").to_string();
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: format!("resource:pipe-tcp:{tcp_target}"),
capability: "pipe.forward".to_owned(),
grant_id: Some("grant:left-pipe-tcp-forward".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe tcp forward");
let echo_task = tokio::spawn(async move {
let (mut socket, _) = tcp_echo.accept().await.expect("accept tcp echo");
let mut buf = [0_u8; 4];
socket.read_exact(&mut buf).await.expect("read tcp echo");
assert_eq!(&buf, b"ping");
socket.write_all(b"pong").await.expect("write tcp echo");
socket.shutdown().await.expect("shutdown tcp echo");
});
let (forward_client, forward_daemon) = UnixStream::pair().expect("unix stream pair");
let right_node_id = right_card.node_id.to_string();
let left_for_forward = left.clone();
let tcp_target_for_forward = tcp_target.clone();
let forward_task = tokio::spawn(async move {
handle_local_pipe_tcp_stream(
left_for_forward,
&right_node_id,
tcp_target_for_forward,
None,
forward_daemon,
)
.await
});
let mut forward_reader = BufReader::new(forward_client);
let mut line = String::new();
forward_reader
.read_line(&mut line)
.await
.expect("read pipe tcp response");
match geth_control::decode_response(&line).expect("decode pipe tcp response") {
ControlResponse::PipeRemoteConnected {
allowed,
connection,
..
} => {
assert!(allowed);
assert_eq!(connection.target, tcp_target);
}
other => panic!("unexpected pipe TCP stream response: {other:?}"),
}
let stream = forward_reader.into_inner();
let (mut forward_read, mut forward_write) = stream.into_split();
forward_write
.write_all(b"ping")
.await
.expect("write through pipe tcp forward");
forward_write
.shutdown()
.await
.expect("shutdown pipe tcp forward write");
let mut forwarded = Vec::new();
forward_read
.read_to_end(&mut forwarded)
.await
.expect("read through pipe tcp forward");
assert_eq!(forwarded, b"pong");
forward_task
.await
.expect("join pipe tcp forward")
.expect("pipe tcp forward");
echo_task.await.expect("join tcp echo");
#[cfg(unix)]
{
let unix_target = right_home.path().join("echo.sock");
let unix_echo = UnixListener::bind(&unix_target).expect("bind unix echo");
let unix_target_display = unix_target.display().to_string();
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: format!("resource:pipe-unix:{unix_target_display}"),
capability: "pipe.forward".to_owned(),
grant_id: Some("grant:left-pipe-unix-forward".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left pipe unix forward");
let unix_echo_task = tokio::spawn(async move {
let (mut socket, _) = unix_echo.accept().await.expect("accept unix echo");
let mut buf = [0_u8; 5];
socket.read_exact(&mut buf).await.expect("read unix echo");
assert_eq!(&buf, b"hello");
socket.write_all(b"world").await.expect("write unix echo");
socket.shutdown().await.expect("shutdown unix echo");
});
let (unix_forward_client, unix_forward_daemon) =
UnixStream::pair().expect("unix stream pair for unix forward");
let right_node_id = right_card.node_id.to_string();
let left_for_forward = left.clone();
let unix_target_for_forward = unix_target.clone();
let unix_forward_task = tokio::spawn(async move {
handle_local_pipe_unix_stream(
left_for_forward,
&right_node_id,
unix_target_for_forward,
None,
unix_forward_daemon,
)
.await
});
let mut unix_forward_reader = BufReader::new(unix_forward_client);
let mut unix_line = String::new();
unix_forward_reader
.read_line(&mut unix_line)
.await
.expect("read pipe unix response");
match geth_control::decode_response(&unix_line).expect("decode pipe unix response") {
ControlResponse::PipeRemoteConnected {
allowed,
connection,
..
} => {
assert!(allowed);
assert_eq!(connection.target, unix_target_display);
}
other => panic!("unexpected pipe Unix stream response: {other:?}"),
}
let stream = unix_forward_reader.into_inner();
let (mut unix_forward_read, mut unix_forward_write) = stream.into_split();
unix_forward_write
.write_all(b"hello")
.await
.expect("write through pipe unix forward");
unix_forward_write
.shutdown()
.await
.expect("shutdown pipe unix forward write");
let mut unix_forwarded = Vec::new();
unix_forward_read
.read_to_end(&mut unix_forwarded)
.await
.expect("read through pipe unix forward");
assert_eq!(unix_forwarded, b"world");
unix_forward_task
.await
.expect("join pipe unix forward")
.expect("pipe unix forward");
unix_echo_task.await.expect("join unix echo");
}
let allowed = handle_request_async(
&left,
ControlRequest::PeerAuthCheck {
node: right_card.node_id.to_string(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
},
)
.await
.expect("allowed peer auth check");
match allowed {
ControlResponse::PeerAuthChecked {
allowed,
reason,
evaluated_ops,
..
} => {
assert!(allowed);
assert!(reason.contains("direct grant"));
assert!(evaluated_ops >= 1);
}
other => panic!("unexpected allowed auth response: {other:?}"),
}
let fetched = handle_request_async(
&left,
ControlRequest::CasFetch {
node: right_card.node_id.to_string(),
hash: right_blob.hash.clone(),
bearer_secret: None,
},
)
.await
.expect("allowed cas fetch");
match fetched {
ControlResponse::CasFetched {
allowed,
hash,
size_bytes,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(hash, right_blob.hash);
assert_eq!(size_bytes, right_blob.size_bytes);
assert!(reason.contains("direct grant"));
assert!(note.contains("required cas.fetch"));
assert_eq!(
LocalCas::new(left.paths.cas_dir())
.read_bytes(&right_blob.hash)
.expect("left cas read fetched blob"),
b"remote cas bytes"
);
}
other => panic!("unexpected allowed CAS fetch response: {other:?}"),
}
let providers = Store::open(&left_paths.metadata_db())
.expect("open left after cas fetch")
.list_cas_providers(right_blob.hash.as_str())
.expect("list cas providers");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].peer_node_id, right.node_id);
let root_sync = handle_request_async(
&left,
ControlRequest::CasRootSync {
node: right_card.node_id.to_string(),
name: "shared".to_owned(),
bearer_secret: None,
},
)
.await
.expect("allowed file root sync");
match root_sync {
ControlResponse::CasRootSynced {
allowed,
root,
tree_bytes_imported,
reason,
note,
..
} => {
assert!(allowed);
assert!(tree_bytes_imported);
let root = root.expect("synced root");
assert_ne!(root.name, "shared");
assert!(root.name.starts_with("remote-"));
assert!(root.name.ends_with("-shared"));
assert_eq!(root.latest_tree, Some(right_tree_hash.clone()));
assert!(root.path.starts_with("remote:"));
assert!(reason.contains("direct grant"));
assert!(note.contains("never writes files"));
}
other => panic!("unexpected allowed file root sync response: {other:?}"),
}
assert!(
LocalCas::new(left.paths.cas_dir())
.has(&right_tree_hash)
.expect("left has synced tree object")
);
let local_shared = match handle_request(
&left,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("left local shared root remains scannable")
{
ControlResponse::CasRootScanned { scan } => scan.root,
other => panic!("unexpected left local file root scan response: {other:?}"),
};
assert_eq!(local_shared.path, left_file_root.to_string_lossy());
let kv_synced = handle_request_async(
&left,
ControlRequest::KvSync {
node: right_card.node_id.to_string(),
name: "prefs".to_owned(),
bearer_secret: None,
},
)
.await
.expect("allowed kv sync");
match kv_synced {
ControlResponse::KvSynced {
allowed,
entries_imported,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(entries_imported, 1);
assert!(reason.contains("direct grant"));
assert!(note.contains("kv.read"));
}
other => panic!("unexpected allowed KV sync response: {other:?}"),
}
let synced_theme = handle_request(
&left,
ControlRequest::KvGet {
name: "prefs".to_owned(),
key: "apps/foo/theme".to_owned(),
},
)
.expect("left kv get synced");
match synced_theme {
ControlResponse::KvGet { entry: Some(entry) } => {
assert_eq!(entry.value, "dark");
}
other => panic!("unexpected synced KV get response: {other:?}"),
}
let left_kv_doc_state = Store::open(&left_paths.metadata_db())
.expect("open left store for kv doc state")
.get_module_state("kv-doc:prefs")
.expect("get left kv doc state")
.expect("left imported kv docs state");
let left_kv_doc_state: KvDocsState =
serde_json::from_str(&left_kv_doc_state.state_json).expect("parse kv docs state");
assert_eq!(left_kv_doc_state.name, "prefs");
assert!(!left_kv_doc_state.namespace_id.is_empty());
assert!(left_kv_doc_state.read_ticket.starts_with("doc"));
let published = handle_request_async(
&left,
ControlRequest::PubsubPub {
topic: "presence/test".to_owned(),
message: "hello".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("allowed remote pubsub publish");
match published {
ControlResponse::PubsubRemotePublished {
allowed,
message,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(message.topic.as_str(), "presence/test");
assert_eq!(message.message, "hello");
assert!(reason.contains("direct grant"));
assert!(note.contains("lossy"));
}
other => panic!("unexpected allowed pubsub publish response: {other:?}"),
}
let mut gossiped_to_left = false;
for _ in 0..20 {
let local_messages = pubsub_messages_for_topic(&left, "presence/test")
.expect("left pubsub local messages");
if local_messages
.iter()
.any(|message| message.message == "hello")
{
gossiped_to_left = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
gossiped_to_left,
"authorized remote pubsub publish should also arrive through iroh-gossip"
);
let remote_messages = handle_request(
&right,
ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
node: None,
bearer_secret: None,
},
)
.expect("right pubsub sub after remote publish");
match remote_messages {
ControlResponse::PubsubMessages { messages, .. } => {
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message, "hello");
}
other => panic!("unexpected remote pubsub messages response: {other:?}"),
}
let subscribed = handle_request_async(
&left,
ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("allowed remote pubsub subscribe");
match subscribed {
ControlResponse::PubsubRemoteMessages {
allowed,
messages,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message, "hello");
assert!(reason.contains("direct grant"));
assert!(note.contains("pubsub.subscribe"));
}
other => panic!("unexpected allowed pubsub subscribe response: {other:?}"),
}
let remote_pipe = handle_request_async(
&left,
ControlRequest::PipeConnect {
target: "inbox".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("allowed remote pipe connect");
match remote_pipe {
ControlResponse::PipeRemoteConnected {
allowed,
connection,
reason,
note,
..
} => {
assert!(allowed);
assert!(connection.local_listener_found);
assert!(reason.contains("direct grant"));
assert!(note.contains("pipe.connect"));
}
other => panic!("unexpected allowed pipe connect response: {other:?}"),
}
let remote_pipe_send = handle_request_async(
&left,
ControlRequest::PipeSend {
target: "inbox".to_owned(),
data_base64: "aGVsbG8gZnJvbSBsZWZ0".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("allowed remote pipe send");
match remote_pipe_send {
ControlResponse::PipeRemoteSent {
allowed,
listener_found,
message,
reason,
note,
..
} => {
assert!(allowed);
assert!(listener_found);
let message = message.expect("remote pipe message");
assert_eq!(message.pipe, "inbox");
assert_eq!(message.data_base64, "aGVsbG8gZnJvbSBsZWZ0");
assert!(reason.contains("direct grant"));
assert!(note.contains("Iroh pipe ALPN"));
}
other => panic!("unexpected allowed pipe send response: {other:?}"),
}
let right_messages = handle_request(
&right,
ControlRequest::PipeRecv {
name: "inbox".to_owned(),
peek: false,
},
)
.expect("right pipe recv after remote send");
match right_messages {
ControlResponse::PipeMessages {
messages, drained, ..
} => {
assert!(drained);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].data_base64, "aGVsbG8gZnJvbSBsZWZ0");
assert_eq!(
messages[0].source_node.as_deref(),
Some(left.node_id.as_str())
);
}
other => panic!("unexpected right pipe messages response: {other:?}"),
}
let remote_pipe_listen = handle_request_async(
&left,
ControlRequest::PipeListen {
name: "remote-inbox".to_owned(),
node: Some(right_card.node_id.to_string()),
bearer_secret: None,
},
)
.await
.expect("allowed remote pipe listen");
match remote_pipe_listen {
ControlResponse::PipeRemoteListening {
allowed,
listener,
reason,
note,
..
} => {
assert!(allowed);
let listener = listener.expect("remote listener metadata");
assert_eq!(listener.name, "remote-inbox");
assert!(reason.contains("direct grant"));
assert!(note.contains("pipe.listen"));
}
other => panic!("unexpected allowed pipe listen response: {other:?}"),
}
let ssh_proxy = handle_request_async(
&left,
ControlRequest::SshProxyConnect {
node: right_card.node_id.to_string(),
bearer_secret: None,
},
)
.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 admin_shell = handle_request_async(
&left,
ControlRequest::SshAdminShell {
node: right_card.node_id.to_string(),
command: "status".to_owned(),
bearer_secret: None,
},
)
.await
.expect("allowed SSH admin shell");
match admin_shell {
ControlResponse::SshAdminShellOutput {
allowed,
output,
reason,
note,
..
} => {
assert!(allowed);
assert!(output.contains(&format!("node_id={}", right.node_id)));
assert!(output.contains("agent_id="));
assert!(reason.contains("direct grant"));
assert!(note.contains("no host shell commands"));
}
other => panic!("unexpected allowed SSH admin shell response: {other:?}"),
}
let document_sync = handle_request_async(
&left,
ControlRequest::DocumentSync {
node: right_card.node_id.to_string(),
name: "notes".to_owned(),
bearer_secret: None,
},
)
.await
.expect("allowed document sync");
match document_sync {
ControlResponse::DocumentSynced {
allowed,
updated,
reason,
note,
..
} => {
assert!(allowed);
assert!(updated);
assert!(reason.contains("direct grant"));
assert!(note.contains("durable Automerge"));
}
other => panic!("unexpected allowed document sync response: {other:?}"),
}
let synced_document = handle_request(
&left,
ControlRequest::DocumentGet {
name: "notes".to_owned(),
},
)
.expect("left document get synced");
match synced_document {
ControlResponse::DocumentGet { state } => {
assert_eq!(state.state_json, r#"{"title":"remote"}"#);
}
other => panic!("unexpected synced document get response: {other:?}"),
}
let db_sync = handle_request_async(
&left,
ControlRequest::DbSync {
node: right_card.node_id.to_string(),
name: "notes".to_owned(),
limit: 10,
bearer_secret: None,
},
)
.await
.expect("allowed db sync");
match db_sync {
ControlResponse::DbSynced {
allowed,
changes_received,
changes_applied,
max_db_version,
schema_match,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(changes_received, 1);
assert_eq!(changes_applied, 1);
assert_eq!(max_db_version, Some(7));
assert!(schema_match);
assert!(reason.contains("direct grant"));
assert!(note.contains("db.sync"));
assert!(note.contains("applies compatible batches"));
}
other => panic!("unexpected allowed DB sync response: {other:?}"),
}
let left_changes = geth_db::extract_crsqlite_changes(&left_db_path, None, 10)
.expect("left db changes after apply");
assert_eq!(left_changes.changes.len(), 1);
assert_eq!(left_changes.max_db_version, Some(7));
let denied_cert_sync = handle_request_async(
&left,
ControlRequest::SshCertSync {
node: right_card.node_id.to_string(),
bearer_secret: None,
},
)
.await
.expect("denied ssh cert sync");
match denied_cert_sync {
ControlResponse::SshCertSynced {
allowed,
requests_imported,
certificates_imported,
reason,
..
} => {
assert!(!allowed);
assert_eq!(requests_imported, 0);
assert_eq!(certificates_imported, 0);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied SSH cert sync response: {other:?}"),
}
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:ssh:certs".to_owned(),
capability: "ssh_cert.sync".to_owned(),
grant_id: Some("grant:left-ssh-cert-sync".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh cert sync");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:ssh:revocations".to_owned(),
capability: "ssh_revocation.sync".to_owned(),
grant_id: Some("grant:left-ssh-revocation-sync".to_owned()),
signing_key_path: None,
admin_key_path: None,
},
)
.expect("grant left ssh revocation sync");
let cert_sync = handle_request_async(
&left,
ControlRequest::SshCertSync {
node: right_card.node_id.to_string(),
bearer_secret: None,
},
)
.await
.expect("allowed ssh cert sync");
match cert_sync {
ControlResponse::SshCertSynced {
allowed,
requests_imported,
certificates_imported,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(requests_imported, 1);
assert_eq!(certificates_imported, 0);
assert!(reason.contains("direct grant"));
assert!(note.contains("ssh_cert.sync"));
}
other => panic!("unexpected allowed SSH cert sync response: {other:?}"),
}
let left_requests = Store::open(&left_paths.metadata_db())
.expect("open left after cert sync")
.list_ssh_cert_requests()
.expect("list synced requests");
assert!(
left_requests
.iter()
.any(|request| request.request_id == right_request_id.as_str())
);
let revocation_sync = handle_request_async(
&left,
ControlRequest::SshRevocationSync {
node: right_card.node_id.to_string(),
bearer_secret: None,
},
)
.await
.expect("allowed ssh revocation sync");
match revocation_sync {
ControlResponse::SshRevocationSynced {
allowed,
revocations_imported,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(revocations_imported, 1);
assert!(reason.contains("direct grant"));
assert!(note.contains("ssh_revocation.sync"));
}
other => panic!("unexpected allowed SSH revocation sync response: {other:?}"),
}
let left_revocations = Store::open(&left_paths.metadata_db())
.expect("open left after revocation sync")
.list_ssh_revocations()
.expect("list synced revocations");
assert!(
left_revocations
.iter()
.any(|revocation| revocation.revocation_id == right_revocation_id.as_str())
);
tokio::time::sleep(Duration::from_millis(2)).await;
let second_pubkey = right_home.path().join("request-2.pub");
std::fs::write(
&second_pubkey,
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aDI= node2\n",
)
.expect("write second public key");
let second_requested = handle_request(
&right,
ControlRequest::SshCertRequest {
public_key_path: second_pubkey,
cert_kind: "user".to_owned(),
principals: vec!["admin".to_owned()],
requested_validity: Some("+4w".to_owned()),
renewal_of: None,
reason: Some("background sync test".to_owned()),
subject: None,
},
)
.expect("right second ssh cert request");
let second_request_id = match second_requested {
ControlResponse::SshCertRequested { request } => request.id,
other => panic!("unexpected second SSH cert request response: {other:?}"),
};
let second_revocation = handle_request(
&right,
ControlRequest::SshRevocationAdd {
kind: "key-id".to_owned(),
target: "newly-revoked-key".to_owned(),
reason: Some("background sync test".to_owned()),
subject: None,
},
)
.expect("right second ssh revocation");
let second_revocation_id = match second_revocation {
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
other => panic!("unexpected second SSH revocation response: {other:?}"),
};
handle_request(
&right,
ControlRequest::KvSet {
name: "prefs".to_owned(),
key: "apps/foo/live".to_owned(),
value: "synced".to_owned(),
subject: None,
},
)
.expect("right second kv set");
handle_request(
&right,
ControlRequest::DocumentSet {
name: "notes".to_owned(),
state_json: r#"{"title":"live"}"#.to_owned(),
},
)
.expect("right live document set");
std::fs::write(right_file_root.join("live.txt"), b"live file root sync")
.expect("write live file");
let second_tree_hash = match handle_request(
&right,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("right live file root scan")
{
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
other => panic!("unexpected right live file root scan response: {other:?}"),
};
insert_mock_crsqlite_change(&right_db_path, 8, "live");
run_live_sync_once(&left)
.await
.expect("background live sync tick");
let left_store = Store::open(&left_paths.metadata_db()).expect("open left after live sync");
assert!(
left_store
.list_ssh_cert_requests()
.expect("list live-synced requests")
.iter()
.any(|request| request.request_id == second_request_id.as_str())
);
assert!(
left_store
.list_ssh_revocations()
.expect("list live-synced revocations")
.iter()
.any(|revocation| revocation.revocation_id == second_revocation_id.as_str())
);
assert_eq!(
left_store
.get_kv_entry("kv:prefs", "apps/foo/live")
.expect("get live-synced kv")
.map(|entry| entry.value),
Some("synced".to_owned())
);
assert_eq!(
left_store
.get_document_resource_by_name("notes")
.expect("get live-synced document")
.map(|document| {
geth_document::document_view_json(&document.state_json)
.expect("document view json")
}),
Some(r#"{"title":"live"}"#.to_owned())
);
let live_remote_root = left_store
.list_file_roots()
.expect("list live-synced file roots")
.into_iter()
.find(|root| root.path == format!("remote:{}:shared", right.node_id))
.expect("live-synced remote root");
assert_eq!(
live_remote_root.latest_tree_hash.as_deref(),
Some(second_tree_hash.as_str())
);
assert!(
LocalCas::new(left.paths.cas_dir())
.has(&second_tree_hash)
.expect("left has live-synced tree object")
);
let file_root_cursor = left_store
.get_module_state(&sync::live_sync_cursor_key(
right_card.node_id.as_str(),
"cas-tree:shared",
))
.expect("get live-synced file root cursor")
.expect("file root cursor exists");
assert_eq!(
serde_json::from_str::<sync::LiveSyncCursor>(&file_root_cursor.state_json)
.expect("parse file root cursor")
.cursor_ms,
live_remote_root.updated_at_ms
);
let db_cursor = left_store
.get_module_state(&sync::live_sync_cursor_key(
right_card.node_id.as_str(),
"db:notes",
))
.expect("get live-synced db cursor")
.expect("db cursor exists");
assert_eq!(
serde_json::from_str::<sync::LiveSyncCursor>(&db_cursor.state_json)
.expect("parse db cursor")
.cursor_ms,
8
);
left_endpoint.shutdown().await;
right_endpoint.shutdown().await;
}
#[tokio::test]
async fn overlay_packets_route_over_dedicated_iroh_alpn_with_authorization() {
if skip_iroh_integration_tests() {
eprintln!("skipping Iroh integration test because GETH_TEST_SKIP_IROH is set");
return;
}
let left_home = tempfile::tempdir().expect("left home");
let right_home = tempfile::tempdir().expect("right home");
let left_paths = GethPaths::from_home(left_home.path());
let right_paths = GethPaths::from_home(right_home.path());
let mut left = init_node(&left_paths).expect("init left");
let mut right = init_node(&right_paths).expect("init right");
write_offline_iroh_config(&left_paths);
write_offline_iroh_config(&right_paths);
let Some(left_endpoint) = start_daemon_iroh_endpoint(&mut left)
.await
.expect("left iroh")
else {
eprintln!("skipping overlay packet assertion; left Iroh endpoint unavailable");
return;
};
let Some(right_endpoint) = start_daemon_iroh_endpoint(&mut right)
.await
.expect("right iroh")
else {
eprintln!("skipping overlay packet assertion; right Iroh endpoint unavailable");
left_endpoint.shutdown().await;
return;
};
spawn_iroh_control_accept_loop(right.clone(), right_endpoint.clone());
let exported = handle_request_async(&right, ControlRequest::PeerCardExport { out: None })
.await
.expect("export right peer card");
let right_card = match exported {
ControlResponse::PeerCardExported { card, .. } => card,
other => panic!("unexpected export response: {other:?}"),
};
Store::open(&left_paths.metadata_db())
.expect("open left store")
.upsert_peer_card(&StoredPeerCard {
peer_id: right_card.node_id.to_string(),
card_json: serde_json::to_string(&right_card).expect("card json"),
updated_at_ms: geth_store::now_ms(),
})
.expect("insert right peer");
handle_request(
&right,
ControlRequest::OverlayJoin {
name: "home".to_owned(),
secret: "local-overlay".to_owned(),
cidr: None,
},
)
.expect("right overlay join");
let denied = handle_request_async(
&left,
ControlRequest::OverlaySend {
name: "home".to_owned(),
node: right_card.node_id.to_string(),
packet_base64: test_ipv4_packet_base64(),
bearer_secret: None,
},
)
.await
.expect("denied overlay packet");
match denied {
ControlResponse::OverlayPacketSent {
allowed, packet, ..
} => {
assert!(!allowed);
assert!(packet.is_none());
}
other => panic!("unexpected denied overlay response: {other:?}"),
}
grant_test_capability(
&Store::open(&right_paths.metadata_db()).expect("open right store"),
&left.node_id,
"resource:overlay:home",
"overlay.route",
);
let sent = handle_request_async(
&left,
ControlRequest::OverlaySend {
name: "home".to_owned(),
node: right_card.node_id.to_string(),
packet_base64: test_ipv4_packet_base64(),
bearer_secret: None,
},
)
.await
.expect("allowed overlay packet");
match sent {
ControlResponse::OverlayPacketSent {
allowed,
packet,
reason,
note,
..
} => {
assert!(allowed);
assert!(packet.is_some());
assert!(reason.contains("direct grant"));
assert!(note.contains("/geth/overlay/1"));
}
other => panic!("unexpected allowed overlay response: {other:?}"),
}
let received = handle_request(
&right,
ControlRequest::OverlayRecv {
name: "home".to_owned(),
peek: false,
},
)
.expect("overlay recv");
match received {
ControlResponse::OverlayPackets {
packets, drained, ..
} => {
assert!(drained);
assert_eq!(packets.len(), 1);
assert_eq!(packets[0].source_node, left.node_id);
assert_eq!(packets[0].destination_node, right.node_id);
assert_eq!(packets[0].size_bytes, 20);
}
other => panic!("unexpected overlay recv response: {other:?}"),
}
left_endpoint.shutdown().await;
right_endpoint.shutdown().await;
}
fn test_tree(entries: Vec<geth_cas::CasTreeEntry>) -> CasTreeObject {
CasTreeObject {
version: geth_cas::CAS_TREE_OBJECT_VERSION,
entries,
}
}
fn test_tree_file(path: &str, byte: &str) -> geth_cas::CasTreeEntry {
geth_cas::CasTreeEntry {
path: path.to_owned(),
kind: CasTreeEntryKind::File,
blob: Some(BlobHash::new(byte.repeat(32))),
size_bytes: 1,
executable: false,
}
}
}