13910 lines
505 KiB
Rust
13910 lines
505 KiB
Rust
pub mod backup;
|
|
mod daemon;
|
|
pub mod doctor;
|
|
mod local_control;
|
|
mod local_transport;
|
|
mod peer_client;
|
|
mod peer_control;
|
|
pub 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 local_transport::LocalStream;
|
|
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::io::Read;
|
|
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};
|
|
#[cfg(unix)]
|
|
use tokio::net::{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("SSH sigchain error: {0}")]
|
|
SshSigchain(#[from] geth_keychain::SshSigchainError),
|
|
#[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("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),
|
|
#[error("geth daemon is already running for home: {0}")]
|
|
DaemonAlreadyRunning(PathBuf),
|
|
}
|
|
|
|
#[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 = local_transport::connect(paths).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 = local_transport::connect(paths).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) = tokio::io::split(stream);
|
|
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 = local_transport::connect(paths).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) = tokio::io::split(daemon_stream);
|
|
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:?}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
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");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub async fn run_unix_forward(
|
|
_paths: &GethPaths,
|
|
_listen_path: PathBuf,
|
|
_peer_node: String,
|
|
_target_path: PathBuf,
|
|
_bearer_secret: Option<String>,
|
|
) -> Result<(), NodeError> {
|
|
Err(NodeError::UnsupportedPlatform(
|
|
geth_pipe::unix_forward_unsupported_message().to_owned(),
|
|
))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn stream_pipe_unix(
|
|
paths: &GethPaths,
|
|
peer_node: String,
|
|
target_path: PathBuf,
|
|
bearer_secret: Option<String>,
|
|
client: UnixStream,
|
|
) -> Result<(), NodeError> {
|
|
let mut stream = local_transport::connect(paths).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) = tokio::io::split(daemon_stream);
|
|
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: LocalStream) -> 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;
|
|
}
|
|
#[cfg(unix)]
|
|
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: LocalStream,
|
|
) -> 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) = tokio::io::split(local_stream);
|
|
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(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn handle_local_pipe_unix_stream(
|
|
node: LocalNode,
|
|
peer_node: &str,
|
|
target_path: PathBuf,
|
|
bearer_secret: Option<String>,
|
|
local_stream: LocalStream,
|
|
) -> 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) = tokio::io::split(local_stream);
|
|
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: LocalStream,
|
|
) -> 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) = tokio::io::split(local_stream);
|
|
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_join_owner(
|
|
node: &LocalNode,
|
|
peer_card_path: PathBuf,
|
|
admin_key_path: PathBuf,
|
|
node_name: String,
|
|
capabilities: Vec<String>,
|
|
reason: Option<String>,
|
|
out: Option<PathBuf>,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let (discovered, request, admin_key_id) = prepare_node_enrollment_join(
|
|
node,
|
|
&peer_card_path,
|
|
&admin_key_path,
|
|
node_name,
|
|
capabilities,
|
|
reason,
|
|
out.as_deref(),
|
|
)?;
|
|
let owner_node = discovered.card.node_id.to_string();
|
|
let submission = node_enrollment_submit_to_peer(
|
|
node,
|
|
&owner_node,
|
|
Some(request.id.to_string()),
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
NodeError::IrohPeer(format!(
|
|
"imported owner candidate and created enrollment request {}, but submission failed: {error}\nnext: retry with `geth node enroll submit {owner_node} --request-id {}`",
|
|
request.id, request.id
|
|
))
|
|
})?;
|
|
let ControlResponse::NodeEnrollmentSubmitted {
|
|
owner_node_id,
|
|
accepted,
|
|
note,
|
|
..
|
|
} = submission
|
|
else {
|
|
return Err(NodeError::IrohPeer(
|
|
"owner returned an unexpected enrollment response".to_owned(),
|
|
));
|
|
};
|
|
Ok(ControlResponse::NodeEnrollmentJoined {
|
|
peer: discovered,
|
|
request,
|
|
out,
|
|
owner_node_id,
|
|
admin_key_id,
|
|
accepted,
|
|
note: format!(
|
|
"{}; {note}; owner approval with an admin signing key is still required",
|
|
discovery_is_untrusted_note()
|
|
),
|
|
})
|
|
}
|
|
|
|
fn prepare_node_enrollment_join(
|
|
node: &LocalNode,
|
|
peer_card_path: &Path,
|
|
admin_key_path: &Path,
|
|
node_name: String,
|
|
capabilities: Vec<String>,
|
|
reason: Option<String>,
|
|
out: Option<&Path>,
|
|
) -> Result<(DiscoveredPeer, NodeEnrollmentRequest, String), NodeError> {
|
|
let admin_public_key = std::fs::read_to_string(admin_key_path)?;
|
|
if admin_public_key.trim().is_empty() {
|
|
return Err(NodeError::IrohPeer(
|
|
"owner admin public key file is empty".to_owned(),
|
|
));
|
|
}
|
|
validate_openssh_public_key_text(&admin_public_key)?;
|
|
let admin_key_id = ssh_public_key_fingerprint(&admin_public_key);
|
|
let card_json = std::fs::read_to_string(peer_card_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,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let admin_key = KeyId::new(admin_key_id.clone());
|
|
let keychain = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
|
|
if !keychain.admin_keys.contains(&admin_key) {
|
|
handle_request(
|
|
node,
|
|
ControlRequest::KeychainInit {
|
|
admin_key_path: Some(admin_key_path.to_path_buf()),
|
|
signing_key_path: None,
|
|
},
|
|
)?;
|
|
}
|
|
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,
|
|
})?;
|
|
|
|
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 {
|
|
write_node_enrollment_request_file(path, &request)?;
|
|
}
|
|
Ok((discovered, request, admin_key_id))
|
|
}
|
|
|
|
fn validate_openssh_public_key_text(public_key: &str) -> Result<(), NodeError> {
|
|
let mut fields = public_key.split_whitespace();
|
|
let key_type = fields.next().unwrap_or_default();
|
|
let encoded = fields.next().unwrap_or_default();
|
|
let recognized_type = key_type.starts_with("ssh-")
|
|
|| key_type.starts_with("ecdsa-sha2-")
|
|
|| key_type.starts_with("sk-");
|
|
if !recognized_type || encoded.is_empty() {
|
|
return Err(NodeError::IrohPeer(
|
|
"owner admin key must be an OpenSSH public key, for example `ssh-ed25519 AAAA...`"
|
|
.to_owned(),
|
|
));
|
|
}
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded))
|
|
.map_err(|_| {
|
|
NodeError::IrohPeer("owner admin key contains invalid base64 key data".to_owned())
|
|
})?;
|
|
if decoded.is_empty() {
|
|
return Err(NodeError::IrohPeer(
|
|
"owner admin key contains empty key data".to_owned(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
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(())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
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 response = PipeWireResponse::Error {
|
|
message: geth_pipe::unix_forward_unsupported_message().to_owned(),
|
|
};
|
|
send.write_all(geth_control::encode_pipe_wire_response(&response)?.as_bytes())
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
}
|
|
|
|
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(node)?,
|
|
}))
|
|
}
|
|
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::KeychainVerifySigchain {
|
|
input,
|
|
chain_id,
|
|
root_key_path,
|
|
namespace,
|
|
} => {
|
|
let records = read_sshsigchain_jsonl_file(&input)?;
|
|
let trust = geth_keychain::SshSigchainTrust::new(
|
|
geth_keychain::SshSigchainChainId::from_hex(&chain_id)?,
|
|
namespace.unwrap_or_else(|| geth_keychain::SSH_SIGCHAIN_NAMESPACE.to_owned()),
|
|
std::fs::read_to_string(root_key_path)?,
|
|
)?;
|
|
let verified = verify_keychain_sshsigchain_with_ssh(&records, &trust)?;
|
|
Ok(ControlResponse::KeychainSigchainVerified {
|
|
input,
|
|
chain_id: trust.chain_id.to_hex(),
|
|
records: verified.records,
|
|
head: verified.head.to_hex(),
|
|
active_keys: verified.state.active_key_count(),
|
|
devices: verified.state.devices.values().filter(|device| device.active).count(),
|
|
disclosed_profiles: verified.state.disclosed_profiles.len(),
|
|
incomplete_profiles: verified.state.incomplete_profiles.len(),
|
|
anchor_threshold: verified.state.anchor_policy.threshold,
|
|
note:
|
|
"verified sequence-free SSHSIGCHAIN v1 authority records and disclosed profile commitments against the explicitly pinned root key"
|
|
.to_owned(),
|
|
})
|
|
}
|
|
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::NodeEnrollJoin { .. }
|
|
| ControlRequest::NodeEnrollSubmit { .. }
|
|
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
|
ControlRequest::ModuleStub { module, command } => {
|
|
Ok(ControlResponse::NotImplemented { module, command })
|
|
}
|
|
}
|
|
}
|
|
|
|
fn native_backend_statuses(node: &LocalNode) -> Result<Vec<NativeBackendStatus>, NodeError> {
|
|
let endpoint_ready = node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.is_some();
|
|
let blobs_ready = node
|
|
.iroh_blob_store
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.is_some();
|
|
let docs_ready = node
|
|
.iroh_docs
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.is_some();
|
|
let gossip_ready = node
|
|
.iroh_gossip
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.is_some();
|
|
Ok(geth_iroh::native_iroh_libraries()
|
|
.into_iter()
|
|
.map(|library| {
|
|
let runtime_ready = match library.module.as_str() {
|
|
"cas" => endpoint_ready && blobs_ready,
|
|
"kv" => endpoint_ready && docs_ready,
|
|
"pubsub" => endpoint_ready && gossip_ready,
|
|
_ => endpoint_ready,
|
|
};
|
|
if !runtime_ready {
|
|
return NativeBackendStatus {
|
|
module: library.module,
|
|
current_backend: "unavailable".to_owned(),
|
|
target_crate: library.crate_name,
|
|
target_version: library.crate_version,
|
|
status: "unavailable".to_owned(),
|
|
blocker: node.iroh_status.note.clone(),
|
|
};
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
|
|
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 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 verify_keychain_sshsigchain_with_ssh(
|
|
records: &[geth_keychain::SshSigchainRecord],
|
|
trust: &geth_keychain::SshSigchainTrust,
|
|
) -> Result<geth_keychain::SshSigchainVerification, NodeError> {
|
|
geth_ssh_identity::ensure_ssh_keygen_available()?;
|
|
let verify_dir = tempfile::tempdir()?;
|
|
|
|
struct SshSigchainOpenSshVerifier {
|
|
verify_dir: PathBuf,
|
|
}
|
|
|
|
impl geth_keychain::SshSigchainVerifier for SshSigchainOpenSshVerifier {
|
|
fn verify(
|
|
&self,
|
|
namespace: &str,
|
|
message: &[u8],
|
|
public_key: &str,
|
|
signature: &[u8],
|
|
) -> bool {
|
|
let payload_path = self.verify_dir.join("payload");
|
|
let signature_path = self.verify_dir.join("signature");
|
|
let allowed_signers_path = self.verify_dir.join("allowed-signers");
|
|
if std::fs::write(&payload_path, message).is_err()
|
|
|| std::fs::write(&signature_path, signature).is_err()
|
|
|| std::fs::write(
|
|
&allowed_signers_path,
|
|
format!(
|
|
"{} {}\n",
|
|
geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL,
|
|
public_key
|
|
),
|
|
)
|
|
.is_err()
|
|
{
|
|
return false;
|
|
}
|
|
let Ok(payload) = std::fs::File::open(payload_path) else {
|
|
return false;
|
|
};
|
|
std::process::Command::new("ssh-keygen")
|
|
.arg("-Y")
|
|
.arg("verify")
|
|
.arg("-f")
|
|
.arg(allowed_signers_path)
|
|
.arg("-I")
|
|
.arg(geth_keychain::SSH_SIGCHAIN_VERIFIER_PRINCIPAL)
|
|
.arg("-n")
|
|
.arg(namespace)
|
|
.arg("-s")
|
|
.arg(signature_path)
|
|
.stdin(std::process::Stdio::from(payload))
|
|
.output()
|
|
.is_ok_and(|output| output.status.success())
|
|
}
|
|
}
|
|
|
|
let verifier = SshSigchainOpenSshVerifier {
|
|
verify_dir: verify_dir.path().to_path_buf(),
|
|
};
|
|
Ok(geth_keychain::verify_sshsigchain(
|
|
records, trust, &verifier,
|
|
)?)
|
|
}
|
|
|
|
fn read_sshsigchain_jsonl_file(
|
|
input: &Path,
|
|
) -> Result<Vec<geth_keychain::SshSigchainRecord>, NodeError> {
|
|
let mut reader = std::fs::File::open(input)?.take((geth_keychain::MAX_JSONL_BYTES + 1) as u64);
|
|
let mut text = String::new();
|
|
reader.read_to_string(&mut text)?;
|
|
if text.len() > geth_keychain::MAX_JSONL_BYTES {
|
|
return Err(geth_keychain::SshSigchainError::JsonlTooLarge(text.len()).into());
|
|
}
|
|
Ok(geth_keychain::decode_sshsigchain_jsonl(&text)?)
|
|
}
|
|
|
|
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"
|
|
);
|
|
assert_eq!(
|
|
local_control::node_error_code(&NodeError::DaemonAlreadyRunning(PathBuf::from(
|
|
"/tmp/geth"
|
|
))),
|
|
"daemon_already_running"
|
|
);
|
|
assert_eq!(
|
|
local_control::node_error_code(&NodeError::SshSigchain(
|
|
geth_keychain::SshSigchainError::EmptyChain
|
|
)),
|
|
"sshsigchain_error"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_backend_status_reflects_runtime_availability() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let node = init_node(&GethPaths::from_home(dir.path())).expect("init node");
|
|
let statuses = native_backend_statuses(&node).expect("backend status");
|
|
|
|
assert!(!statuses.is_empty());
|
|
assert!(statuses.iter().all(|status| status.status == "unavailable"));
|
|
assert!(
|
|
statuses
|
|
.iter()
|
|
.all(|status| status.current_backend == "unavailable")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sshsigchain_verifies_real_openssh_sshsig_records() {
|
|
if geth_ssh_identity::ensure_ssh_keygen_available().is_err() {
|
|
return;
|
|
}
|
|
let dir = tempfile::tempdir().expect("temporary SSHSIGCHAIN directory");
|
|
let private_key = dir.path().join("root");
|
|
let generated = std::process::Command::new("ssh-keygen")
|
|
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
|
|
.arg(&private_key)
|
|
.output()
|
|
.expect("run ssh-keygen");
|
|
assert!(
|
|
generated.status.success(),
|
|
"ssh-keygen key generation failed: {}",
|
|
String::from_utf8_lossy(&generated.stderr)
|
|
);
|
|
let generated_public_key =
|
|
std::fs::read_to_string(format!("{}.pub", private_key.display()))
|
|
.expect("read public key");
|
|
let mut fields = generated_public_key.split_ascii_whitespace();
|
|
let root_public_key = format!(
|
|
"{} {}",
|
|
fields.next().expect("key type"),
|
|
fields.next().expect("key blob")
|
|
);
|
|
let trust = geth_keychain::SshSigchainTrust::new(
|
|
geth_keychain::SshSigchainChainId([0x42; 32]),
|
|
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
|
|
&root_public_key,
|
|
)
|
|
.expect("trust tuple");
|
|
|
|
let sign = |record: geth_keychain::SshSigchainRecord| {
|
|
let payload = dir.path().join("record");
|
|
std::fs::write(&payload, record.signing_bytes().expect("signing bytes"))
|
|
.expect("write signing payload");
|
|
let output = geth_ssh_identity::sign_command(
|
|
&private_key,
|
|
geth_keychain::SSH_SIGCHAIN_NAMESPACE,
|
|
&payload,
|
|
)
|
|
.output()
|
|
.expect("sign SSHSIGCHAIN record");
|
|
assert!(
|
|
output.status.success(),
|
|
"ssh-keygen signing failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let signature =
|
|
std::fs::read(format!("{}.sig", payload.display())).expect("read SSHSIG signature");
|
|
record.with_signature(signature).expect("signed record")
|
|
};
|
|
|
|
let init = sign(
|
|
geth_keychain::SshSigchainRecord::unsigned(
|
|
trust.chain_id,
|
|
None,
|
|
&root_public_key,
|
|
geth_keychain::AuthorityTransition::Genesis {
|
|
device_id: "device:root".to_owned(),
|
|
root_key: geth_keychain::AuthorityKey {
|
|
public_key: root_public_key.clone(),
|
|
permissions: vec![geth_keychain::Permission::All],
|
|
delegable_permissions: vec![geth_keychain::Permission::All],
|
|
},
|
|
anchor_policy: geth_keychain::AnchorPolicy::default(),
|
|
},
|
|
vec![],
|
|
)
|
|
.expect("unsigned init"),
|
|
);
|
|
|
|
let verified = verify_keychain_sshsigchain_with_ssh(&[init.clone()], &trust)
|
|
.expect("verify real OpenSSH SSHSIG chain");
|
|
assert_eq!(verified.records, 1);
|
|
assert_eq!(verified.state.active_key_count(), 1);
|
|
|
|
let mut tampered = init;
|
|
tampered.chain_id = geth_keychain::SshSigchainChainId([0x43; 32]);
|
|
assert!(verify_keychain_sshsigchain_with_ssh(&[tampered], &trust).is_err());
|
|
}
|
|
|
|
#[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) = tokio::io::duplex(64 * 1024);
|
|
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,
|
|
Box::new(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) = tokio::io::split(stream);
|
|
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,
|
|
Box::new(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;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guided_enrollment_imports_creates_and_submits_in_one_request() {
|
|
if skip_iroh_integration_tests() {
|
|
eprintln!("skipping Iroh integration test because GETH_TEST_SKIP_IROH is set");
|
|
return;
|
|
}
|
|
let requester_home = tempfile::tempdir().expect("requester home");
|
|
let owner_home = tempfile::tempdir().expect("owner home");
|
|
let requester_paths = GethPaths::from_home(requester_home.path());
|
|
let owner_paths = GethPaths::from_home(owner_home.path());
|
|
let mut requester = init_node(&requester_paths).expect("init requester");
|
|
let mut owner = init_node(&owner_paths).expect("init owner");
|
|
write_offline_iroh_config(&requester_paths);
|
|
write_offline_iroh_config(&owner_paths);
|
|
|
|
let Some(requester_endpoint) = start_daemon_iroh_endpoint(&mut requester)
|
|
.await
|
|
.expect("requester iroh")
|
|
else {
|
|
eprintln!("skipping guided enrollment; requester endpoint unavailable");
|
|
return;
|
|
};
|
|
let Some(owner_endpoint) = start_daemon_iroh_endpoint(&mut owner)
|
|
.await
|
|
.expect("owner iroh")
|
|
else {
|
|
eprintln!("skipping guided enrollment; owner endpoint unavailable");
|
|
requester_endpoint.shutdown().await;
|
|
return;
|
|
};
|
|
spawn_iroh_control_accept_loop(owner.clone(), owner_endpoint.clone());
|
|
|
|
let exported = handle_request_async(&owner, ControlRequest::PeerCardExport { out: None })
|
|
.await
|
|
.expect("export owner peer card");
|
|
let owner_card = match exported {
|
|
ControlResponse::PeerCardExported { card, .. } => card,
|
|
other => panic!("unexpected peer export response: {other:?}"),
|
|
};
|
|
let card_path = requester_home.path().join("owner.peer.json");
|
|
std::fs::write(
|
|
&card_path,
|
|
serde_json::to_string_pretty(&owner_card).expect("card json"),
|
|
)
|
|
.expect("write owner peer card");
|
|
let admin_key_path = requester_home.path().join("owner-admin.pub");
|
|
std::fs::write(
|
|
&admin_key_path,
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n",
|
|
)
|
|
.expect("write owner admin public key");
|
|
|
|
let joined = handle_request_async(
|
|
&requester,
|
|
ControlRequest::NodeEnrollJoin {
|
|
peer_card_path: card_path,
|
|
admin_key_path,
|
|
node_name: "workstation".to_owned(),
|
|
capabilities: vec!["resource:kv:preferences=kv.read".to_owned()],
|
|
reason: Some("new workstation".to_owned()),
|
|
out: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("guided enrollment");
|
|
let request_id = match joined {
|
|
ControlResponse::NodeEnrollmentJoined {
|
|
request,
|
|
owner_node_id,
|
|
admin_key_id,
|
|
accepted,
|
|
peer,
|
|
..
|
|
} => {
|
|
assert!(accepted);
|
|
assert_eq!(owner_node_id, owner.node_id.to_string());
|
|
assert_eq!(peer.card.node_id.to_string(), owner.node_id);
|
|
assert!(admin_key_id.starts_with("ssh:blake3:"));
|
|
request.id.to_string()
|
|
}
|
|
other => panic!("unexpected guided enrollment response: {other:?}"),
|
|
};
|
|
|
|
let received = handle_request(&owner, ControlRequest::NodeEnrollList { status: None })
|
|
.expect("list owner requests");
|
|
match received {
|
|
ControlResponse::NodeEnrollmentList { requests, .. } => {
|
|
assert!(
|
|
requests
|
|
.iter()
|
|
.any(|request| request.id.to_string() == request_id)
|
|
);
|
|
}
|
|
other => panic!("unexpected enrollment list response: {other:?}"),
|
|
}
|
|
|
|
requester_endpoint.shutdown().await;
|
|
owner_endpoint.shutdown().await;
|
|
}
|
|
|
|
#[test]
|
|
fn guided_enrollment_preparation_records_explicit_trust_peer_and_request() {
|
|
assert!(validate_openssh_public_key_text("not-a-public-key").is_err());
|
|
let home = tempfile::tempdir().expect("requester home");
|
|
let paths = GethPaths::from_home(home.path());
|
|
let requester = init_node(&paths).expect("init requester");
|
|
let owner_key = AgentKey::generate();
|
|
let owner_card = PeerCard::signed(
|
|
"node:owner".into(),
|
|
&owner_key,
|
|
vec![EndpointCandidate {
|
|
endpoint_id: "endpoint:owner".to_owned(),
|
|
relay_url: None,
|
|
direct_addresses: Vec::new(),
|
|
source: DiscoverySource::Manual,
|
|
}],
|
|
UnixMillis(1),
|
|
)
|
|
.expect("owner peer card");
|
|
let card_path = home.path().join("owner.peer.json");
|
|
std::fs::write(
|
|
&card_path,
|
|
serde_json::to_string_pretty(&owner_card).expect("card json"),
|
|
)
|
|
.expect("write card");
|
|
let admin_key_path = home.path().join("owner-admin.pub");
|
|
std::fs::write(
|
|
&admin_key_path,
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n",
|
|
)
|
|
.expect("write admin key");
|
|
let request_path = home.path().join("request.json");
|
|
|
|
let (peer, request, admin_key_id) = prepare_node_enrollment_join(
|
|
&requester,
|
|
&card_path,
|
|
&admin_key_path,
|
|
"workstation".to_owned(),
|
|
vec!["resource:kv:preferences=kv.read".to_owned()],
|
|
Some("new workstation".to_owned()),
|
|
Some(&request_path),
|
|
)
|
|
.expect("prepare guided enrollment");
|
|
assert_eq!(peer.card.node_id.to_string(), "node:owner");
|
|
assert!(admin_key_id.starts_with("ssh:blake3:"));
|
|
assert_eq!(request.requested_node_name, "workstation");
|
|
assert!(request.provenance.is_some());
|
|
assert!(request_path.is_file());
|
|
|
|
let store = Store::open(&paths.metadata_db()).expect("open store");
|
|
assert!(
|
|
store
|
|
.get_peer_card("node:owner")
|
|
.expect("get peer")
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
store
|
|
.get_node_enrollment_request(request.id.as_str())
|
|
.expect("get request")
|
|
.is_some()
|
|
);
|
|
let status =
|
|
handle_request(&requester, ControlRequest::KeychainStatus).expect("keychain status");
|
|
match status {
|
|
ControlResponse::KeychainStatus(status) => assert_eq!(status.admin_keys, 1),
|
|
other => panic!("unexpected keychain status: {other:?}"),
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|