11364 lines
412 KiB
Rust
11364 lines
412 KiB
Rust
pub mod service;
|
|
|
|
use base64::Engine;
|
|
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, RelayMode};
|
|
use geth_control::{
|
|
CasBlob, CasProvider, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse,
|
|
PeerControlRequest, PeerControlResponse, PipeWireRequest, PipeWireResponse, StatusResponse,
|
|
SyncPeerRun, SyncPeerStatus, SyncStreamRun, SyncStreamStatus, 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, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
|
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,
|
|
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 std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream};
|
|
|
|
#[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("control error: {0}")]
|
|
Control(#[from] geth_control::ControlError),
|
|
#[error("codec error: {0}")]
|
|
Codec(#[from] geth_codec::CodecError),
|
|
#[error("json error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[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),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct LocalNode {
|
|
pub paths: GethPaths,
|
|
pub agent_id: String,
|
|
pub node_id: String,
|
|
pub iroh_status: EndpointStatus,
|
|
iroh_endpoint: Arc<Mutex<Option<GethIrohEndpoint>>>,
|
|
runtime: Arc<NodeRuntime>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct NodeRuntime {
|
|
pubsub: Mutex<PubsubRuntime>,
|
|
pipes: Mutex<PipeRuntime>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct PubsubRuntime {
|
|
messages: VecDeque<PubsubMessage>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct PipeRuntime {
|
|
listeners: BTreeMap<String, PipeListener>,
|
|
connections: VecDeque<PipeConnection>,
|
|
messages: VecDeque<PipeMessage>,
|
|
}
|
|
|
|
const PUBSUB_RING_LIMIT: usize = 256;
|
|
const PIPE_CONNECTION_RING_LIMIT: usize = 256;
|
|
const PIPE_MESSAGE_RING_LIMIT: usize = 1024;
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct LiveSyncCursor {
|
|
cursor_ms: i64,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
|
struct LiveSyncHealth {
|
|
last_attempt_ms: i64,
|
|
last_success_ms: Option<i64>,
|
|
last_error: Option<String>,
|
|
last_imported: usize,
|
|
last_rejected: usize,
|
|
}
|
|
|
|
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,
|
|
iroh_status: EndpointStatus::scaffolded(),
|
|
iroh_endpoint: Arc::new(Mutex::new(None)),
|
|
runtime: Arc::new(NodeRuntime {
|
|
pubsub: Mutex::new(PubsubRuntime::default()),
|
|
pipes: Mutex::new(PipeRuntime::default()),
|
|
}),
|
|
})
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
|
|
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 run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
|
let mut node = init_node(&paths)?;
|
|
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
|
|
if let Some(endpoint) = node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.clone()
|
|
{
|
|
spawn_iroh_control_accept_loop(node.clone(), endpoint);
|
|
let config = GethConfig::load(&node.paths.config_file())?;
|
|
if config.sync.live_sync_enabled {
|
|
spawn_background_live_sync(
|
|
node.clone(),
|
|
Duration::from_millis(config.sync.live_sync_interval_ms),
|
|
);
|
|
}
|
|
}
|
|
let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
|
|
if Path::new(&paths.socket_path()).exists() {
|
|
std::fs::remove_file(paths.socket_path())?;
|
|
}
|
|
let listener = UnixListener::bind(paths.socket_path())?;
|
|
tracing::info!(socket = %paths.socket_path().display(), "geth daemon listening");
|
|
|
|
loop {
|
|
let (stream, _) = listener.accept().await?;
|
|
let node = node.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(error) = handle_stream(node, stream).await {
|
|
tracing::warn!(%error, "control request failed");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
pub async fn send_control(
|
|
paths: &GethPaths,
|
|
request: ControlRequest,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let mut stream = UnixStream::connect(paths.socket_path()).await?;
|
|
stream
|
|
.write_all(geth_control::encode_request(&request)?.as_bytes())
|
|
.await?;
|
|
stream.shutdown().await?;
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).await?;
|
|
Ok(geth_control::decode_response(&line)?)
|
|
}
|
|
|
|
pub async fn stream_ssh_proxy(
|
|
paths: &GethPaths,
|
|
peer_node: String,
|
|
bearer_secret: Option<String>,
|
|
) -> Result<(), NodeError> {
|
|
let mut stream = UnixStream::connect(paths.socket_path()).await?;
|
|
stream
|
|
.write_all(
|
|
geth_control::encode_request(&ControlRequest::SshProxyStream {
|
|
node: peer_node,
|
|
bearer_secret,
|
|
})?
|
|
.as_bytes(),
|
|
)
|
|
.await?;
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).await?;
|
|
match geth_control::decode_response(&line)? {
|
|
ControlResponse::SshProxyConnected { allowed: true, .. } => {
|
|
let stream = reader.into_inner();
|
|
let (mut local_read, mut local_write) = stream.into_split();
|
|
let mut stdin = tokio::io::stdin();
|
|
let mut stdout = tokio::io::stdout();
|
|
let upload = async {
|
|
tokio::io::copy(&mut stdin, &mut local_write).await?;
|
|
local_write.shutdown().await
|
|
};
|
|
let download = async {
|
|
tokio::io::copy(&mut local_read, &mut stdout).await?;
|
|
stdout.flush().await
|
|
};
|
|
tokio::try_join!(upload, download)?;
|
|
Ok(())
|
|
}
|
|
ControlResponse::SshProxyConnected {
|
|
allowed: false,
|
|
reason,
|
|
..
|
|
} => Err(NodeError::Unauthorized(reason)),
|
|
ControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
other => Err(NodeError::IrohPeer(format!(
|
|
"daemon returned unexpected SSH proxy stream response: {other:?}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn run_tcp_forward(
|
|
paths: &GethPaths,
|
|
listen_addr: String,
|
|
peer_node: String,
|
|
target_addr: String,
|
|
bearer_secret: Option<String>,
|
|
) -> Result<(), NodeError> {
|
|
let listen_addr = geth_pipe::validate_tcp_forward_listen_addr(&listen_addr)?;
|
|
geth_pipe::validate_tcp_forward_target_addr(&target_addr)?;
|
|
let listener = TcpListener::bind(listen_addr).await?;
|
|
tracing::info!(
|
|
listen = %listen_addr,
|
|
peer = %peer_node,
|
|
target = %target_addr,
|
|
"tcp forward listening"
|
|
);
|
|
loop {
|
|
let (client, client_addr) = listener.accept().await?;
|
|
let paths = paths.clone();
|
|
let peer_node = peer_node.clone();
|
|
let target_addr = target_addr.clone();
|
|
let bearer_secret = bearer_secret.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(error) =
|
|
stream_pipe_tcp(&paths, peer_node, target_addr, bearer_secret, client).await
|
|
{
|
|
tracing::warn!(%error, %client_addr, "tcp forward connection failed");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn stream_pipe_tcp(
|
|
paths: &GethPaths,
|
|
peer_node: String,
|
|
target_addr: String,
|
|
bearer_secret: Option<String>,
|
|
client: TcpStream,
|
|
) -> Result<(), NodeError> {
|
|
let mut stream = UnixStream::connect(paths.socket_path()).await?;
|
|
stream
|
|
.write_all(
|
|
geth_control::encode_request(&ControlRequest::PipeTcpStream {
|
|
node: peer_node,
|
|
target_addr,
|
|
bearer_secret,
|
|
})?
|
|
.as_bytes(),
|
|
)
|
|
.await?;
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).await?;
|
|
match geth_control::decode_response(&line)? {
|
|
ControlResponse::PipeRemoteConnected { allowed: true, .. } => {
|
|
let daemon_stream = reader.into_inner();
|
|
let (mut daemon_read, mut daemon_write) = daemon_stream.into_split();
|
|
let (mut client_read, mut client_write) = client.into_split();
|
|
let upload = async {
|
|
tokio::io::copy(&mut client_read, &mut daemon_write).await?;
|
|
daemon_write.shutdown().await
|
|
};
|
|
let download = async {
|
|
tokio::io::copy(&mut daemon_read, &mut client_write).await?;
|
|
client_write.shutdown().await
|
|
};
|
|
tokio::try_join!(upload, download)?;
|
|
Ok(())
|
|
}
|
|
ControlResponse::PipeRemoteConnected {
|
|
allowed: false,
|
|
reason,
|
|
..
|
|
} => Err(NodeError::Unauthorized(reason)),
|
|
ControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
other => Err(NodeError::IrohPeer(format!(
|
|
"daemon returned unexpected pipe TCP stream response: {other:?}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn run_unix_forward(
|
|
paths: &GethPaths,
|
|
listen_path: PathBuf,
|
|
peer_node: String,
|
|
target_path: PathBuf,
|
|
bearer_secret: Option<String>,
|
|
) -> Result<(), NodeError> {
|
|
let listen_path = geth_pipe::validate_unix_forward_path(&listen_path)?;
|
|
geth_pipe::validate_unix_forward_path(&target_path)?;
|
|
let listener = UnixListener::bind(&listen_path)?;
|
|
tracing::info!(
|
|
listen = %listen_path.display(),
|
|
peer = %peer_node,
|
|
target = %target_path.display(),
|
|
"unix socket forward listening"
|
|
);
|
|
loop {
|
|
let (client, _) = listener.accept().await?;
|
|
let paths = paths.clone();
|
|
let peer_node = peer_node.clone();
|
|
let target_path = target_path.clone();
|
|
let bearer_secret = bearer_secret.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(error) =
|
|
stream_pipe_unix(&paths, peer_node, target_path, bearer_secret, client).await
|
|
{
|
|
tracing::warn!(%error, "unix socket forward connection failed");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn stream_pipe_unix(
|
|
paths: &GethPaths,
|
|
peer_node: String,
|
|
target_path: PathBuf,
|
|
bearer_secret: Option<String>,
|
|
client: UnixStream,
|
|
) -> Result<(), NodeError> {
|
|
let mut stream = UnixStream::connect(paths.socket_path()).await?;
|
|
stream
|
|
.write_all(
|
|
geth_control::encode_request(&ControlRequest::PipeUnixStream {
|
|
node: peer_node,
|
|
target_path,
|
|
bearer_secret,
|
|
})?
|
|
.as_bytes(),
|
|
)
|
|
.await?;
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).await?;
|
|
match geth_control::decode_response(&line)? {
|
|
ControlResponse::PipeRemoteConnected { allowed: true, .. } => {
|
|
let daemon_stream = reader.into_inner();
|
|
let (mut daemon_read, mut daemon_write) = daemon_stream.into_split();
|
|
let (mut client_read, mut client_write) = client.into_split();
|
|
let upload = async {
|
|
tokio::io::copy(&mut client_read, &mut daemon_write).await?;
|
|
daemon_write.shutdown().await
|
|
};
|
|
let download = async {
|
|
tokio::io::copy(&mut daemon_read, &mut client_write).await?;
|
|
client_write.shutdown().await
|
|
};
|
|
tokio::try_join!(upload, download)?;
|
|
Ok(())
|
|
}
|
|
ControlResponse::PipeRemoteConnected {
|
|
allowed: false,
|
|
reason,
|
|
..
|
|
} => Err(NodeError::Unauthorized(reason)),
|
|
ControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
other => Err(NodeError::IrohPeer(format!(
|
|
"daemon returned unexpected pipe Unix stream response: {other:?}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub async fn handle_request_async(
|
|
node: &LocalNode,
|
|
request: ControlRequest,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
match request {
|
|
ControlRequest::PeerCardExport { out } => export_peer_card(node, out, true).await,
|
|
ControlRequest::PeerPing { node: peer_node } => peer_ping(node, &peer_node).await,
|
|
ControlRequest::PeerAuthCheck {
|
|
node: peer_node,
|
|
resource,
|
|
capability,
|
|
} => peer_auth_check(node, &peer_node, resource, capability).await,
|
|
ControlRequest::KeychainSync { node: peer_node } => {
|
|
keychain_sync_from_peer(node, &peer_node).await
|
|
}
|
|
ControlRequest::AuthSync { node: peer_node } => auth_sync_from_peer(node, &peer_node).await,
|
|
ControlRequest::SyncNow { node: peer_node } => sync_now(node, peer_node).await,
|
|
ControlRequest::NodeEnrollSubmit {
|
|
owner_node,
|
|
request_id,
|
|
path,
|
|
} => node_enrollment_submit_to_peer(node, &owner_node, request_id, path).await,
|
|
ControlRequest::NodeEnrollSync { owner_node } => {
|
|
node_enrollment_sync_from_owner(node, &owner_node).await
|
|
}
|
|
ControlRequest::CasFetch {
|
|
node: peer_node,
|
|
hash,
|
|
bearer_secret,
|
|
} => cas_fetch_from_peer(node, &peer_node, hash, bearer_secret).await,
|
|
ControlRequest::CasRootSync {
|
|
node: peer_node,
|
|
name,
|
|
bearer_secret,
|
|
} => cas_root_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
|
|
ControlRequest::SshCertSync {
|
|
node: peer_node,
|
|
bearer_secret,
|
|
} => ssh_cert_sync_from_peer(node, &peer_node, bearer_secret).await,
|
|
ControlRequest::SshRevocationSync {
|
|
node: peer_node,
|
|
bearer_secret,
|
|
} => ssh_revocation_sync_from_peer(node, &peer_node, bearer_secret).await,
|
|
ControlRequest::KvSync {
|
|
node: peer_node,
|
|
name,
|
|
bearer_secret,
|
|
} => kv_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
|
|
ControlRequest::DbSync {
|
|
node: peer_node,
|
|
name,
|
|
limit,
|
|
bearer_secret,
|
|
} => db_sync_from_peer(node, &peer_node, &name, limit, bearer_secret).await,
|
|
ControlRequest::PubsubPub {
|
|
topic,
|
|
message,
|
|
node: Some(peer_node),
|
|
bearer_secret,
|
|
} => pubsub_publish_to_peer(node, &peer_node, topic, message, bearer_secret).await,
|
|
ControlRequest::PubsubSub {
|
|
topic,
|
|
node: Some(peer_node),
|
|
bearer_secret,
|
|
} => pubsub_subscribe_from_peer(node, &peer_node, topic, bearer_secret).await,
|
|
ControlRequest::PipeListen {
|
|
name,
|
|
node: Some(peer_node),
|
|
bearer_secret,
|
|
} => pipe_listen_on_peer(node, &peer_node, name, bearer_secret).await,
|
|
ControlRequest::PipeConnect {
|
|
target,
|
|
node: Some(peer_node),
|
|
bearer_secret,
|
|
} => pipe_connect_to_peer(node, &peer_node, target, bearer_secret).await,
|
|
ControlRequest::PipeSend {
|
|
target,
|
|
data_base64,
|
|
node: Some(peer_node),
|
|
bearer_secret,
|
|
} => pipe_send_to_peer(node, &peer_node, target, data_base64, bearer_secret).await,
|
|
ControlRequest::PipeTcpForward { .. }
|
|
| ControlRequest::PipeTcpStream { .. }
|
|
| ControlRequest::PipeUnixForward { .. }
|
|
| ControlRequest::PipeUnixStream { .. } => Err(NodeError::IrohPeer(
|
|
"pipe forwarding is a streaming control command; run without --json/--jsonl".to_owned(),
|
|
)),
|
|
ControlRequest::SshProxyConnect {
|
|
node: peer_node,
|
|
bearer_secret,
|
|
} => ssh_proxy_connect_to_peer(node, &peer_node, bearer_secret).await,
|
|
ControlRequest::SshAdminShell {
|
|
node: peer_node,
|
|
command,
|
|
bearer_secret,
|
|
} => ssh_admin_shell_to_peer(node, &peer_node, command, bearer_secret).await,
|
|
ControlRequest::DocumentSync {
|
|
node: peer_node,
|
|
name,
|
|
bearer_secret,
|
|
} => document_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
|
|
other => handle_request(node, other),
|
|
}
|
|
}
|
|
|
|
async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeError> {
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).await?;
|
|
let request = geth_control::decode_request(&line)?;
|
|
if let ControlRequest::SshProxyStream {
|
|
node: peer_node,
|
|
bearer_secret,
|
|
} = request
|
|
{
|
|
let stream = reader.into_inner();
|
|
return handle_local_ssh_proxy_stream(node, &peer_node, bearer_secret, stream).await;
|
|
}
|
|
if let ControlRequest::PipeTcpStream {
|
|
node: peer_node,
|
|
target_addr,
|
|
bearer_secret,
|
|
} = request
|
|
{
|
|
let stream = reader.into_inner();
|
|
return handle_local_pipe_tcp_stream(node, &peer_node, target_addr, bearer_secret, stream)
|
|
.await;
|
|
}
|
|
if let ControlRequest::PipeUnixStream {
|
|
node: peer_node,
|
|
target_path,
|
|
bearer_secret,
|
|
} = request
|
|
{
|
|
let stream = reader.into_inner();
|
|
return handle_local_pipe_unix_stream(node, &peer_node, target_path, bearer_secret, stream)
|
|
.await;
|
|
}
|
|
let response = match handle_request_async(&node, request).await {
|
|
Ok(response) => response,
|
|
Err(error) => ControlResponse::Error {
|
|
message: error.to_string(),
|
|
},
|
|
};
|
|
let mut stream = reader.into_inner();
|
|
stream
|
|
.write_all(geth_control::encode_response(&response)?.as_bytes())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
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(error.to_string()))?;
|
|
let alpn = conn
|
|
.alpn()
|
|
.map(display_alpn)
|
|
.unwrap_or_else(|| "unknown".to_owned());
|
|
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()))?;
|
|
let bytes = recv
|
|
.read_to_end(64 * 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::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, 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()))?;
|
|
let bytes = recv
|
|
.read_to_end(64 * 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::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, 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()))?;
|
|
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 content = content_base64
|
|
.ok_or_else(|| NodeError::IrohPeer("peer omitted CAS content".to_owned()))
|
|
.and_then(|content| {
|
|
base64::engine::general_purpose::STANDARD
|
|
.decode(content)
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
})?;
|
|
if content.len() as u64 != size_bytes {
|
|
return Err(NodeError::IrohPeer(format!(
|
|
"peer announced {size_bytes} CAS bytes but returned {} bytes",
|
|
content.len()
|
|
)));
|
|
}
|
|
let cas = LocalCas::new(node.paths.cas_dir());
|
|
let info = cas.add_bytes(&content)?;
|
|
if info.hash != response_hash {
|
|
return Err(NodeError::IrohPeer(format!(
|
|
"peer returned content hash {} for requested {}",
|
|
info.hash, response_hash
|
|
)));
|
|
}
|
|
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 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 {
|
|
if 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)?)
|
|
{
|
|
if 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,
|
|
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 mut requests_imported = 0;
|
|
let mut certificates_imported = 0;
|
|
for request in &requests {
|
|
if insert_ssh_cert_request_if_not_conflicting(&store, request)? {
|
|
requests_imported += 1;
|
|
}
|
|
}
|
|
for certificate in &certificates {
|
|
if insert_ssh_certificate_if_not_conflicting(&store, certificate)? {
|
|
certificates_imported += 1;
|
|
}
|
|
}
|
|
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,
|
|
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 mut revocations_imported = 0;
|
|
for revocation in &revocations {
|
|
if insert_ssh_revocation_if_not_conflicting(&store, revocation)? {
|
|
revocations_imported += 1;
|
|
}
|
|
}
|
|
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,
|
|
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 store = Store::open(&node.paths.metadata_db())?;
|
|
let kv = ensure_local_kv_store(&store, name)?;
|
|
let mut entries_imported = 0;
|
|
for entry in entries {
|
|
geth_kv::validate_kv_key(&entry.key)
|
|
.map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?;
|
|
let should_import = store
|
|
.get_kv_entry(&kv.kv_id, &entry.key)?
|
|
.is_none_or(|local| entry.updated_at_ms >= local.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)?;
|
|
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(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
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 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 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 handle_local_pipe_tcp_stream(
|
|
node: LocalNode,
|
|
peer_node: &str,
|
|
target_addr: String,
|
|
bearer_secret: Option<String>,
|
|
local_stream: UnixStream,
|
|
) -> Result<(), NodeError> {
|
|
geth_pipe::validate_tcp_forward_target_addr(&target_addr)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let stored = store
|
|
.get_peer_card(peer_node)?
|
|
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
|
|
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
|
peer_card.validate_candidate()?;
|
|
let candidate = peer_card
|
|
.endpoints
|
|
.first()
|
|
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
|
|
let node_addr = iroh_node_addr_from_candidate(candidate)?;
|
|
let endpoint = node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.clone()
|
|
.ok_or(NodeError::IrohEndpointUnavailable)?;
|
|
let self_card = local_peer_card(&node, DiscoverySource::PeerExchange, true).await?;
|
|
let nonce = geth_crypto::blake3_hex(
|
|
format!(
|
|
"{}\0{}\0pipe-tcp-stream\0{}\0{}",
|
|
node.node_id,
|
|
peer_node,
|
|
target_addr,
|
|
geth_store::now_ms()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let resource = format!("resource:pipe-tcp:{target_addr}");
|
|
let request = PipeWireRequest::TcpConnect {
|
|
peer_card: self_card,
|
|
target_addr: target_addr.clone(),
|
|
nonce: nonce.clone(),
|
|
bearer_proof: bearer_proof(bearer_secret, &resource, "pipe.forward", &nonce),
|
|
};
|
|
let conn = endpoint
|
|
.endpoint()
|
|
.connect(node_addr, geth_iroh::ALPN_PIPE)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let (mut remote_send, remote_recv) = conn
|
|
.open_bi()
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
remote_send
|
|
.write_all(geth_control::encode_pipe_wire_request(&request)?.as_bytes())
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let mut remote_reader = BufReader::new(remote_recv);
|
|
let mut line = String::new();
|
|
remote_reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let response = geth_control::decode_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 remote_recv = remote_reader.into_inner();
|
|
let (mut local_read, mut local_write) = local_stream.into_split();
|
|
let upload = async {
|
|
tokio::io::copy(&mut local_read, &mut remote_send)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
remote_send
|
|
.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
};
|
|
let mut remote_recv = remote_recv;
|
|
let download = async {
|
|
tokio::io::copy(&mut remote_recv, &mut local_write)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
local_write.shutdown().await.map_err(NodeError::from)
|
|
};
|
|
tokio::try_join!(upload, download)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_local_pipe_unix_stream(
|
|
node: LocalNode,
|
|
peer_node: &str,
|
|
target_path: PathBuf,
|
|
bearer_secret: Option<String>,
|
|
local_stream: UnixStream,
|
|
) -> Result<(), NodeError> {
|
|
let target_path = geth_pipe::validate_unix_forward_path(&target_path)?;
|
|
let target_display = target_path.display().to_string();
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let stored = store
|
|
.get_peer_card(peer_node)?
|
|
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
|
|
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
|
peer_card.validate_candidate()?;
|
|
let candidate = peer_card
|
|
.endpoints
|
|
.first()
|
|
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
|
|
let node_addr = iroh_node_addr_from_candidate(candidate)?;
|
|
let endpoint = node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.clone()
|
|
.ok_or(NodeError::IrohEndpointUnavailable)?;
|
|
let self_card = local_peer_card(&node, DiscoverySource::PeerExchange, true).await?;
|
|
let nonce = geth_crypto::blake3_hex(
|
|
format!(
|
|
"{}\0{}\0pipe-unix-stream\0{}\0{}",
|
|
node.node_id,
|
|
peer_node,
|
|
target_display,
|
|
geth_store::now_ms()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let resource = format!("resource:pipe-unix:{target_display}");
|
|
let request = PipeWireRequest::UnixConnect {
|
|
peer_card: self_card,
|
|
target_path: target_path.clone(),
|
|
nonce: nonce.clone(),
|
|
bearer_proof: bearer_proof(bearer_secret, &resource, "pipe.forward", &nonce),
|
|
};
|
|
let conn = endpoint
|
|
.endpoint()
|
|
.connect(node_addr, geth_iroh::ALPN_PIPE)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let (mut remote_send, remote_recv) = conn
|
|
.open_bi()
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
remote_send
|
|
.write_all(geth_control::encode_pipe_wire_request(&request)?.as_bytes())
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let mut remote_reader = BufReader::new(remote_recv);
|
|
let mut line = String::new();
|
|
remote_reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let response = geth_control::decode_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 remote_recv = remote_reader.into_inner();
|
|
let (mut local_read, mut local_write) = local_stream.into_split();
|
|
let upload = async {
|
|
tokio::io::copy(&mut local_read, &mut remote_send)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
remote_send
|
|
.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
};
|
|
let mut remote_recv = remote_recv;
|
|
let download = async {
|
|
tokio::io::copy(&mut remote_recv, &mut local_write)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
local_write.shutdown().await.map_err(NodeError::from)
|
|
};
|
|
tokio::try_join!(upload, download)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn ssh_proxy_connect_to_peer(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
bearer_secret: Option<String>,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let response =
|
|
request_peer_control(node, peer_node, "ssh-proxy-connect", |peer_card, nonce| {
|
|
PeerControlRequest::SshProxyConnect {
|
|
peer_card,
|
|
nonce: nonce.clone(),
|
|
bearer_proof: bearer_proof(
|
|
bearer_secret,
|
|
"resource:ssh-proxy:local",
|
|
"ssh_proxy.connect",
|
|
&nonce,
|
|
),
|
|
}
|
|
})
|
|
.await?;
|
|
match response {
|
|
PeerControlResponse::SshProxyConnected {
|
|
node_id,
|
|
agent_id,
|
|
endpoint_id,
|
|
connection,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
..
|
|
} => Ok(ControlResponse::SshProxyConnected {
|
|
peer_node_id: node_id,
|
|
peer_agent_id: agent_id,
|
|
endpoint_id,
|
|
connection,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
}),
|
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
_ => Err(NodeError::IrohPeer(
|
|
"peer returned wrong response type to SSH proxy connect".to_owned(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn ssh_admin_shell_to_peer(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
command: String,
|
|
bearer_secret: Option<String>,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let response = request_peer_control(node, peer_node, "ssh-admin-shell", |peer_card, nonce| {
|
|
PeerControlRequest::SshAdminShell {
|
|
peer_card,
|
|
command: command.clone(),
|
|
nonce: nonce.clone(),
|
|
bearer_proof: bearer_proof(
|
|
bearer_secret,
|
|
"resource:ssh-proxy:local",
|
|
"ssh_proxy.admin_shell",
|
|
&nonce,
|
|
),
|
|
}
|
|
})
|
|
.await?;
|
|
match response {
|
|
PeerControlResponse::SshAdminShellOutput {
|
|
node_id,
|
|
agent_id,
|
|
endpoint_id,
|
|
command,
|
|
output,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
..
|
|
} => Ok(ControlResponse::SshAdminShellOutput {
|
|
peer_node_id: node_id,
|
|
peer_agent_id: agent_id,
|
|
endpoint_id,
|
|
command,
|
|
output,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
}),
|
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
_ => Err(NodeError::IrohPeer(
|
|
"peer returned wrong response type to SSH admin shell".to_owned(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn handle_local_ssh_proxy_stream(
|
|
node: LocalNode,
|
|
peer_node: &str,
|
|
bearer_secret: Option<String>,
|
|
local_stream: UnixStream,
|
|
) -> Result<(), NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let stored = store
|
|
.get_peer_card(peer_node)?
|
|
.ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?;
|
|
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
|
|
peer_card.validate_candidate()?;
|
|
let candidate = peer_card
|
|
.endpoints
|
|
.first()
|
|
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
|
|
let node_addr = iroh_node_addr_from_candidate(candidate)?;
|
|
let endpoint = node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.clone()
|
|
.ok_or(NodeError::IrohEndpointUnavailable)?;
|
|
let self_card = local_peer_card(&node, DiscoverySource::PeerExchange, true).await?;
|
|
let nonce = geth_crypto::blake3_hex(
|
|
format!(
|
|
"{}\0{}\0ssh-proxy-stream\0{}",
|
|
node.node_id,
|
|
peer_node,
|
|
geth_store::now_ms()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let request = PeerControlRequest::SshProxyConnect {
|
|
peer_card: self_card,
|
|
nonce: nonce.clone(),
|
|
bearer_proof: bearer_proof(
|
|
bearer_secret,
|
|
"resource:ssh-proxy:local",
|
|
"ssh_proxy.connect",
|
|
&nonce,
|
|
),
|
|
};
|
|
let conn = endpoint
|
|
.endpoint()
|
|
.connect(node_addr, geth_iroh::ALPN_SSH_PROXY)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let (mut remote_send, remote_recv) = conn
|
|
.open_bi()
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
remote_send
|
|
.write_all(geth_control::encode_peer_request(&request)?.as_bytes())
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let mut remote_reader = BufReader::new(remote_recv);
|
|
let mut line = String::new();
|
|
remote_reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let response = geth_control::decode_peer_response(&line)?;
|
|
let local_response = match response {
|
|
PeerControlResponse::SshProxyConnected {
|
|
node_id,
|
|
agent_id,
|
|
endpoint_id,
|
|
connection,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
nonce: response_nonce,
|
|
..
|
|
} if response_nonce == nonce => ControlResponse::SshProxyConnected {
|
|
peer_node_id: node_id,
|
|
peer_agent_id: agent_id,
|
|
endpoint_id,
|
|
connection,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
},
|
|
PeerControlResponse::Error { message } => ControlResponse::Error { message },
|
|
_ => ControlResponse::Error {
|
|
message: "peer returned wrong response type to SSH proxy stream".to_owned(),
|
|
},
|
|
};
|
|
let mut local_stream = local_stream;
|
|
local_stream
|
|
.write_all(geth_control::encode_response(&local_response)?.as_bytes())
|
|
.await?;
|
|
let ControlResponse::SshProxyConnected { allowed: true, .. } = local_response else {
|
|
return Ok(());
|
|
};
|
|
let remote_recv = remote_reader.into_inner();
|
|
let (mut local_read, mut local_write) = local_stream.into_split();
|
|
let upload = async {
|
|
tokio::io::copy(&mut local_read, &mut remote_send)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
remote_send
|
|
.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
};
|
|
let mut remote_recv = remote_recv;
|
|
let download = async {
|
|
tokio::io::copy(&mut remote_recv, &mut local_write)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
local_write.shutdown().await.map_err(NodeError::from)
|
|
};
|
|
tokio::try_join!(upload, download)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn document_sync_from_peer(
|
|
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 state.updated_at.0 >= local.updated_at_ms {
|
|
let normalized = geth_document::normalize_document_state(&state.state_json)?;
|
|
store.insert_document_resource(&StoredDocumentResource {
|
|
document_id: local.document_id,
|
|
resource_id: local.resource_id,
|
|
name: local.name,
|
|
state_json: normalized,
|
|
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(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
|| !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 = load_keychain_ops(&store)?
|
|
.into_iter()
|
|
.find(|existing| existing.id == op.id);
|
|
if existing.as_ref().is_some_and(|existing| existing != &op) {
|
|
invalid_ops_rejected += 1;
|
|
continue;
|
|
}
|
|
store_keychain_op(&store, &op)?;
|
|
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(),
|
|
);
|
|
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,
|
|
})?;
|
|
signatures_imported += usize::from(known_signatures.insert(signature_key));
|
|
}
|
|
}
|
|
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 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 = load_auth_ops(&store)?
|
|
.into_iter()
|
|
.find(|existing| existing.id == op.id);
|
|
if existing.as_ref().is_some_and(|existing| existing != &op) {
|
|
invalid_ops_rejected += 1;
|
|
continue;
|
|
}
|
|
store_auth_op(&store, &op)?;
|
|
ops_imported += usize::from(existing.is_none());
|
|
for signature in valid_signatures {
|
|
let signature_key = (
|
|
signature.op_id.to_string(),
|
|
signature.signer.to_string(),
|
|
signature.namespace.clone(),
|
|
);
|
|
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,
|
|
})?;
|
|
signatures_imported += usize::from(known_signatures.insert(signature_key));
|
|
}
|
|
}
|
|
store_live_sync_cursor(&store, peer_node, "auth", high_water_ms)?;
|
|
Ok(ControlResponse::AuthSynced {
|
|
peer_node_id: node_id,
|
|
peer_agent_id: agent_id,
|
|
endpoint_id,
|
|
ops_imported,
|
|
signatures_imported,
|
|
invalid_ops_rejected,
|
|
high_water_ms,
|
|
note: format!("{note}; imported only auth ops signed by trusted admin SSH keys"),
|
|
})
|
|
}
|
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
_ => Err(NodeError::IrohPeer(
|
|
"peer returned wrong response type to auth sync".to_owned(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn node_enrollment_submit_to_peer(
|
|
node: &LocalNode,
|
|
owner_node: &str,
|
|
request_id: Option<String>,
|
|
path: Option<PathBuf>,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let request = if let Some(path) = path {
|
|
let request = read_node_enrollment_request_file(&path)?;
|
|
insert_node_enrollment_request_if_not_conflicting(&store, &request)?;
|
|
request
|
|
} else {
|
|
let request_id = request_id
|
|
.or_else(|| {
|
|
latest_pending_node_enrollment_request(&store).map(|request| request.id.to_string())
|
|
})
|
|
.ok_or_else(|| NodeError::NodeEnrollmentRequestNotFound("latest-pending".to_owned()))?;
|
|
load_node_enrollment_request(&store, &request_id)?
|
|
};
|
|
let response = request_peer_control(
|
|
node,
|
|
owner_node,
|
|
"node-enrollment-submit",
|
|
|peer_card, nonce| PeerControlRequest::NodeEnrollmentSubmit {
|
|
peer_card,
|
|
request: request.clone(),
|
|
nonce,
|
|
},
|
|
)
|
|
.await?;
|
|
match response {
|
|
PeerControlResponse::NodeEnrollmentSubmitted {
|
|
node_id,
|
|
accepted,
|
|
note,
|
|
..
|
|
} => Ok(ControlResponse::NodeEnrollmentSubmitted {
|
|
request_id: request.id.to_string(),
|
|
owner_node_id: node_id,
|
|
accepted,
|
|
note,
|
|
}),
|
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
|
_ => Err(NodeError::IrohPeer(
|
|
"peer returned wrong response type to node enrollment submit".to_owned(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn node_enrollment_sync_from_owner(
|
|
node: &LocalNode,
|
|
owner_node: &str,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let keychain = keychain_sync_from_peer(node, owner_node).await?;
|
|
let auth = auth_sync_from_peer(node, owner_node).await?;
|
|
let (keychain_ops_imported, keychain_signatures_imported, mut invalid_ops_rejected) =
|
|
match keychain {
|
|
ControlResponse::KeychainSynced {
|
|
ops_imported,
|
|
signatures_imported,
|
|
invalid_ops_rejected,
|
|
..
|
|
} => (ops_imported, signatures_imported, invalid_ops_rejected),
|
|
other => {
|
|
return Err(NodeError::IrohPeer(format!(
|
|
"unexpected keychain sync response during enrollment sync: {other:?}"
|
|
)));
|
|
}
|
|
};
|
|
let (auth_ops_imported, auth_signatures_imported, auth_invalid) = match auth {
|
|
ControlResponse::AuthSynced {
|
|
ops_imported,
|
|
signatures_imported,
|
|
invalid_ops_rejected,
|
|
..
|
|
} => (ops_imported, signatures_imported, invalid_ops_rejected),
|
|
other => {
|
|
return Err(NodeError::IrohPeer(format!(
|
|
"unexpected auth sync response during enrollment sync: {other:?}"
|
|
)));
|
|
}
|
|
};
|
|
invalid_ops_rejected += auth_invalid;
|
|
Ok(ControlResponse::NodeEnrollmentSynced {
|
|
owner_node: owner_node.to_owned(),
|
|
keychain_ops_imported,
|
|
keychain_signatures_imported,
|
|
auth_ops_imported,
|
|
auth_signatures_imported,
|
|
invalid_ops_rejected,
|
|
note:
|
|
"enrollment sync pulled signed keychain and signed auth operations from the owner node"
|
|
.to_owned(),
|
|
})
|
|
}
|
|
|
|
async fn db_sync_from_peer(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
name: &str,
|
|
limit: u32,
|
|
bearer_secret: Option<String>,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
geth_db::validate_db_name(name).map_err(|_| NodeError::InvalidDbName(name.to_owned()))?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let local = store
|
|
.get_db_resource_by_name(name)?
|
|
.ok_or_else(|| NodeError::DbNotFound(name.to_owned()))?;
|
|
let stream = format!("db:{name}");
|
|
let cursor = load_live_sync_cursor(&store, peer_node, &stream)?;
|
|
let after_db_version = (cursor > 0).then_some(cursor);
|
|
let response = request_peer_control(node, peer_node, "db-sync", |peer_card, nonce| {
|
|
PeerControlRequest::DbSync {
|
|
peer_card,
|
|
name: name.to_owned(),
|
|
after_db_version,
|
|
limit,
|
|
nonce: nonce.clone(),
|
|
bearer_proof: bearer_proof(
|
|
bearer_secret,
|
|
&format!("resource:db:{name}"),
|
|
"db.sync",
|
|
&nonce,
|
|
),
|
|
}
|
|
})
|
|
.await?;
|
|
match response {
|
|
PeerControlResponse::DbSynced {
|
|
node_id,
|
|
agent_id,
|
|
endpoint_id,
|
|
name: response_name,
|
|
batch,
|
|
high_water_db_version,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
..
|
|
} if response_name == name => {
|
|
if !allowed {
|
|
return Ok(ControlResponse::DbSynced {
|
|
peer_node_id: node_id,
|
|
peer_agent_id: agent_id,
|
|
endpoint_id,
|
|
name: response_name,
|
|
changes_received: 0,
|
|
changes_applied: 0,
|
|
max_db_version: None,
|
|
schema_match: false,
|
|
allowed,
|
|
reason,
|
|
note,
|
|
});
|
|
}
|
|
let local_schema = geth_db::schema_metadata(Path::new(&local.path))?;
|
|
let (changes_received, changes_applied, max_db_version, schema_match) =
|
|
if let Some(batch) = batch {
|
|
let schema_match = batch.schema_metadata == local_schema;
|
|
let changes_received = batch.changes.len();
|
|
let max_db_version = batch.max_db_version;
|
|
let changes_applied = if schema_match {
|
|
geth_db::apply_crsqlite_changes(Path::new(&local.path), &batch)?
|
|
} else {
|
|
0
|
|
};
|
|
if schema_match {
|
|
if 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 live_sync_cursor_key(peer_node: &str, stream: &str) -> String {
|
|
format!("live-sync:{peer_node}:{stream}")
|
|
}
|
|
|
|
fn live_sync_status_prefix(peer_node: &str) -> String {
|
|
format!("live-sync-status:{peer_node}:")
|
|
}
|
|
|
|
fn live_sync_status_key(peer_node: &str, stream: &str) -> String {
|
|
format!("{}{}", live_sync_status_prefix(peer_node), stream)
|
|
}
|
|
|
|
fn load_live_sync_cursor(store: &Store, peer_node: &str, stream: &str) -> Result<i64, NodeError> {
|
|
let key = live_sync_cursor_key(peer_node, stream);
|
|
let Some(state) = store.get_module_state(&key)? else {
|
|
return Ok(0);
|
|
};
|
|
let cursor: LiveSyncCursor = serde_json::from_str(&state.state_json)?;
|
|
Ok(cursor.cursor_ms)
|
|
}
|
|
|
|
fn store_live_sync_cursor(
|
|
store: &Store,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
cursor_ms: i64,
|
|
) -> Result<(), NodeError> {
|
|
store.put_module_state(&StoredModuleState {
|
|
module: live_sync_cursor_key(peer_node, stream),
|
|
state_json: serde_json::to_string(&LiveSyncCursor { cursor_ms })?,
|
|
updated_at_ms: geth_store::now_ms(),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn record_live_sync_success(
|
|
store: &Store,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
imported: usize,
|
|
rejected: usize,
|
|
cursor_ms: Option<i64>,
|
|
) -> Result<i64, NodeError> {
|
|
let now = geth_store::now_ms();
|
|
if let Some(cursor_ms) = cursor_ms {
|
|
store_live_sync_cursor(store, peer_node, stream, cursor_ms)?;
|
|
}
|
|
store.put_module_state(&StoredModuleState {
|
|
module: live_sync_status_key(peer_node, stream),
|
|
state_json: serde_json::to_string(&LiveSyncHealth {
|
|
last_attempt_ms: now,
|
|
last_success_ms: Some(now),
|
|
last_error: None,
|
|
last_imported: imported,
|
|
last_rejected: rejected,
|
|
})?,
|
|
updated_at_ms: now,
|
|
})?;
|
|
load_live_sync_cursor(store, peer_node, stream)
|
|
}
|
|
|
|
fn record_live_sync_failure(
|
|
store: &Store,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
error: &NodeError,
|
|
) -> Result<i64, NodeError> {
|
|
let now = geth_store::now_ms();
|
|
let previous = store
|
|
.get_module_state(&live_sync_status_key(peer_node, stream))?
|
|
.and_then(|state| serde_json::from_str::<LiveSyncHealth>(&state.state_json).ok());
|
|
store.put_module_state(&StoredModuleState {
|
|
module: live_sync_status_key(peer_node, stream),
|
|
state_json: serde_json::to_string(&LiveSyncHealth {
|
|
last_attempt_ms: now,
|
|
last_success_ms: previous.and_then(|health| health.last_success_ms),
|
|
last_error: Some(error.to_string()),
|
|
last_imported: 0,
|
|
last_rejected: 0,
|
|
})?,
|
|
updated_at_ms: now,
|
|
})?;
|
|
load_live_sync_cursor(store, peer_node, stream)
|
|
}
|
|
|
|
fn sync_status_local(store: &Store) -> Result<ControlResponse, NodeError> {
|
|
let peers = store
|
|
.list_peer_cards()?
|
|
.into_iter()
|
|
.map(|peer| {
|
|
let prefix = live_sync_status_prefix(&peer.peer_id);
|
|
let mut streams = store
|
|
.list_module_states_with_prefix(&prefix)?
|
|
.into_iter()
|
|
.map(|state| {
|
|
let stream = state.module.trim_start_matches(&prefix).to_owned();
|
|
let health: LiveSyncHealth = serde_json::from_str(&state.state_json)?;
|
|
let cursor_ms = load_live_sync_cursor(store, &peer.peer_id, &stream)?;
|
|
Ok(SyncStreamStatus {
|
|
stream,
|
|
cursor_ms,
|
|
last_attempt_ms: Some(health.last_attempt_ms),
|
|
last_success_ms: health.last_success_ms,
|
|
last_error: health.last_error,
|
|
last_imported: health.last_imported,
|
|
last_rejected: health.last_rejected,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, NodeError>>()?;
|
|
streams.sort_by(|left, right| left.stream.cmp(&right.stream));
|
|
Ok(SyncPeerStatus {
|
|
peer_node_id: peer.peer_id,
|
|
streams,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, NodeError>>()?;
|
|
Ok(ControlResponse::SyncStatus {
|
|
peers,
|
|
note: "sync status is local daemon health for best-effort live sync; signed logs remain the durable source of truth".to_owned(),
|
|
})
|
|
}
|
|
|
|
fn should_live_sync_stream(
|
|
store: &Store,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
) -> Result<bool, NodeError> {
|
|
let Some(remote_watermarks) = remote_watermarks else {
|
|
return Ok(true);
|
|
};
|
|
let Some(remote_high_water) = remote_watermarks.get(stream) else {
|
|
return Ok(false);
|
|
};
|
|
if *remote_high_water == 0 {
|
|
return Ok(false);
|
|
}
|
|
let local_cursor = load_live_sync_cursor(store, peer_node, stream)?;
|
|
if stream.starts_with("db:") {
|
|
Ok(*remote_high_water > local_cursor)
|
|
} else {
|
|
Ok(*remote_high_water >= local_cursor)
|
|
}
|
|
}
|
|
|
|
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")? {
|
|
if 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 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,
|
|
};
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
async fn request_peer_control(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
operation: &str,
|
|
build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest,
|
|
) -> Result<PeerControlResponse, 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)?;
|
|
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,
|
|
operation,
|
|
geth_store::now_ms()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let request = build_request(self_card, 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, 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()))?;
|
|
let bytes = recv
|
|
.read_to_end(16 * 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()))?;
|
|
let response = geth_control::decode_peer_response(text)?;
|
|
match &response {
|
|
PeerControlResponse::SshCertSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::KeychainSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::AuthSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::NodeEnrollmentSubmitted {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::SshRevocationSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::KvSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::PubsubPublished {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::PubsubSubscribed {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::PipeConnected {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::PipeListening {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::CasRootSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::SshProxyConnected {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::DocumentSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::DbSynced {
|
|
nonce: response_nonce,
|
|
..
|
|
}
|
|
| PeerControlResponse::SyncStatus {
|
|
nonce: response_nonce,
|
|
..
|
|
} if response_nonce == &nonce => Ok(response),
|
|
PeerControlResponse::Error { .. } => Ok(response),
|
|
_ => Err(NodeError::IrohPeer(format!(
|
|
"peer {operation} response did not match request"
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn request_pipe_wire(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
operation: &str,
|
|
build_request: impl FnOnce(PeerCard, String) -> PipeWireRequest,
|
|
) -> Result<PipeWireResponse, 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,
|
|
operation,
|
|
geth_store::now_ms()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let request = build_request(self_card, nonce.clone());
|
|
let conn = endpoint
|
|
.endpoint()
|
|
.connect(node_addr, geth_iroh::ALPN_PIPE)
|
|
.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_pipe_wire_request(&request)?.as_bytes())
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let bytes = recv
|
|
.read_to_end(16 * 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()))?;
|
|
let response = geth_control::decode_pipe_wire_response(text)?;
|
|
match &response {
|
|
PipeWireResponse::Sent {
|
|
nonce: response_nonce,
|
|
..
|
|
} if response_nonce == &nonce => Ok(response),
|
|
PipeWireResponse::Error { .. } => Ok(response),
|
|
_ => Err(NodeError::IrohPeer(format!(
|
|
"peer {operation} response did not match request"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn spawn_iroh_control_accept_loop(node: LocalNode, endpoint: GethIrohEndpoint) {
|
|
let raw_endpoint = endpoint.endpoint();
|
|
tokio::spawn(async move {
|
|
while let Some(incoming) = raw_endpoint.accept().await {
|
|
let node = node.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(error) = handle_iroh_control_connection(node, incoming).await {
|
|
tracing::warn!(%error, "iroh control request failed");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
fn spawn_background_live_sync(node: LocalNode, interval_duration: Duration) {
|
|
tokio::spawn(async move {
|
|
if let Err(error) = run_live_sync_once(&node).await {
|
|
tracing::debug!(%error, "initial live sync tick failed");
|
|
}
|
|
let mut interval = tokio::time::interval(interval_duration);
|
|
loop {
|
|
interval.tick().await;
|
|
if let Err(error) = run_live_sync_once(&node).await {
|
|
tracing::debug!(%error, "live sync tick failed");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn run_sync_for_peer(node: &LocalNode, peer_node: &str) -> Result<SyncPeerRun, NodeError> {
|
|
let kv_stores = Store::open(&node.paths.metadata_db())?.list_kv_stores()?;
|
|
let documents = Store::open(&node.paths.metadata_db())?.list_document_resources()?;
|
|
let dbs = Store::open(&node.paths.metadata_db())?.list_db_resources()?;
|
|
let mut streams = Vec::new();
|
|
let remote_watermarks = match sync_status_from_peer(node, peer_node)
|
|
.await
|
|
.map(|watermarks| {
|
|
watermarks
|
|
.into_iter()
|
|
.map(|watermark| (watermark.stream, watermark.high_water))
|
|
.collect::<BTreeMap<_, _>>()
|
|
}) {
|
|
Ok(watermarks) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor_ms = record_live_sync_success(&store, peer_node, "sync-status", 0, 0, None)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: "sync-status".to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: 0,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Some(watermarks)
|
|
}
|
|
Err(error) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor_ms = record_live_sync_failure(&store, peer_node, "sync-status", &error)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: "sync-status".to_owned(),
|
|
attempted: true,
|
|
success: false,
|
|
imported: 0,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: Some(error.to_string()),
|
|
});
|
|
None
|
|
}
|
|
};
|
|
|
|
sync_keychain_stream(node, peer_node, remote_watermarks.as_ref(), &mut streams).await?;
|
|
sync_auth_stream(node, peer_node, remote_watermarks.as_ref(), &mut streams).await?;
|
|
sync_ssh_cert_stream(node, peer_node, remote_watermarks.as_ref(), &mut streams).await?;
|
|
sync_ssh_revocation_stream(node, peer_node, remote_watermarks.as_ref(), &mut streams).await?;
|
|
|
|
if let Some(remote_watermarks) = remote_watermarks.as_ref() {
|
|
for stream in remote_watermarks
|
|
.keys()
|
|
.filter(|stream| stream.starts_with("cas-tree:"))
|
|
{
|
|
sync_cas_tree_stream(
|
|
node,
|
|
peer_node,
|
|
stream,
|
|
Some(remote_watermarks),
|
|
&mut streams,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
for kv in &kv_stores {
|
|
let stream = format!("kv:{}", kv.name);
|
|
sync_kv_stream(
|
|
node,
|
|
peer_node,
|
|
&kv.name,
|
|
&stream,
|
|
remote_watermarks.as_ref(),
|
|
&mut streams,
|
|
)
|
|
.await?;
|
|
}
|
|
for document in &documents {
|
|
let stream = format!("document:{}", document.name);
|
|
sync_document_stream(
|
|
node,
|
|
peer_node,
|
|
&document.name,
|
|
&stream,
|
|
remote_watermarks.as_ref(),
|
|
&mut streams,
|
|
)
|
|
.await?;
|
|
}
|
|
for db in &dbs {
|
|
let stream = format!("db:{}", db.name);
|
|
sync_db_stream(
|
|
node,
|
|
peer_node,
|
|
&db.name,
|
|
&stream,
|
|
remote_watermarks.as_ref(),
|
|
&mut streams,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(SyncPeerRun {
|
|
peer_node_id: peer_node.to_owned(),
|
|
streams,
|
|
})
|
|
}
|
|
|
|
async fn sync_now(
|
|
node: &LocalNode,
|
|
requested_peer: Option<String>,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
if node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.is_none()
|
|
{
|
|
return Err(NodeError::IrohEndpointUnavailable);
|
|
}
|
|
let peers = if let Some(peer) = requested_peer {
|
|
vec![peer]
|
|
} else {
|
|
Store::open(&node.paths.metadata_db())?
|
|
.list_peer_cards()?
|
|
.into_iter()
|
|
.map(|peer| peer.peer_id)
|
|
.collect()
|
|
};
|
|
let mut runs = Vec::new();
|
|
for peer in peers {
|
|
runs.push(run_sync_for_peer(node, &peer).await?);
|
|
}
|
|
Ok(ControlResponse::SyncRan {
|
|
peers: runs,
|
|
note: "ran best-effort sync over Iroh; each imported keychain/auth operation was still verified from signed operation logs".to_owned(),
|
|
})
|
|
}
|
|
|
|
fn push_skipped_sync_stream(
|
|
store: &Store,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: false,
|
|
success: true,
|
|
imported: 0,
|
|
rejected: 0,
|
|
cursor_ms: load_live_sync_cursor(store, peer_node, stream)?,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn push_failed_sync_stream(
|
|
store: &Store,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
error: NodeError,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let cursor_ms = record_live_sync_failure(store, peer_node, stream, &error)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: false,
|
|
imported: 0,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: Some(error.to_string()),
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn sync_keychain_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let stream = "keychain";
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
let since_ms = load_live_sync_cursor(&store, peer_node, stream)?;
|
|
match keychain_sync_from_peer_since(node, peer_node, since_ms).await {
|
|
Ok(ControlResponse::KeychainSynced {
|
|
ops_imported,
|
|
signatures_imported,
|
|
invalid_ops_rejected,
|
|
high_water_ms,
|
|
..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
ops_imported + signatures_imported,
|
|
invalid_ops_rejected,
|
|
Some(high_water_ms),
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: ops_imported + signatures_imported,
|
|
rejected: invalid_ops_rejected,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected keychain sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_auth_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let stream = "auth";
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
let since_ms = load_live_sync_cursor(&store, peer_node, stream)?;
|
|
match auth_sync_from_peer_since(node, peer_node, since_ms).await {
|
|
Ok(ControlResponse::AuthSynced {
|
|
ops_imported,
|
|
signatures_imported,
|
|
invalid_ops_rejected,
|
|
high_water_ms,
|
|
..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
ops_imported + signatures_imported,
|
|
invalid_ops_rejected,
|
|
Some(high_water_ms),
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: ops_imported + signatures_imported,
|
|
rejected: invalid_ops_rejected,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected auth sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_ssh_cert_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let stream = "ssh-certs";
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
match ssh_cert_sync_from_peer(node, peer_node, None).await {
|
|
Ok(ControlResponse::SshCertSynced {
|
|
requests_imported,
|
|
certificates_imported,
|
|
..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
requests_imported + certificates_imported,
|
|
0,
|
|
None,
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: requests_imported + certificates_imported,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected SSH cert sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_ssh_revocation_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let stream = "ssh-revocations";
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
match ssh_revocation_sync_from_peer(node, peer_node, None).await {
|
|
Ok(ControlResponse::SshRevocationSynced {
|
|
revocations_imported,
|
|
..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor_ms =
|
|
record_live_sync_success(&store, peer_node, stream, revocations_imported, 0, None)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: revocations_imported,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!(
|
|
"unexpected SSH revocation sync response: {other:?}"
|
|
)),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_cas_tree_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
stream: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
let name = stream.trim_start_matches("cas-tree:");
|
|
match cas_root_sync_from_peer(node, peer_node, name, None).await {
|
|
Ok(ControlResponse::CasRootSynced {
|
|
tree_bytes_imported,
|
|
sync_conflicts,
|
|
..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor = load_live_sync_cursor(&store, peer_node, stream)?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
usize::from(tree_bytes_imported),
|
|
sync_conflicts.len(),
|
|
Some(cursor),
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: usize::from(tree_bytes_imported),
|
|
rejected: sync_conflicts.len(),
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected file-root sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_kv_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
name: &str,
|
|
stream: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
match kv_sync_from_peer(node, peer_node, name, None).await {
|
|
Ok(ControlResponse::KvSynced {
|
|
entries_imported, ..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor = load_live_sync_cursor(&store, peer_node, stream)?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
entries_imported,
|
|
0,
|
|
Some(cursor),
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: entries_imported,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected KV sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_document_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
name: &str,
|
|
stream: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
match document_sync_from_peer(node, peer_node, name, None).await {
|
|
Ok(ControlResponse::DocumentSynced { updated, .. }) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor = load_live_sync_cursor(&store, peer_node, stream)?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
usize::from(updated),
|
|
0,
|
|
Some(cursor),
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: usize::from(updated),
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected document sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn sync_db_stream(
|
|
node: &LocalNode,
|
|
peer_node: &str,
|
|
name: &str,
|
|
stream: &str,
|
|
remote_watermarks: Option<&BTreeMap<String, i64>>,
|
|
streams: &mut Vec<SyncStreamRun>,
|
|
) -> Result<(), NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
if !should_live_sync_stream(&store, peer_node, stream, remote_watermarks)? {
|
|
return push_skipped_sync_stream(&store, peer_node, stream, streams);
|
|
}
|
|
match db_sync_from_peer(node, peer_node, name, 100, None).await {
|
|
Ok(ControlResponse::DbSynced {
|
|
changes_applied,
|
|
max_db_version,
|
|
..
|
|
}) => {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
let cursor = load_live_sync_cursor(&store, peer_node, stream)?;
|
|
let cursor_ms = record_live_sync_success(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
changes_applied,
|
|
0,
|
|
Some(max_db_version.unwrap_or(cursor)),
|
|
)?;
|
|
streams.push(SyncStreamRun {
|
|
stream: stream.to_owned(),
|
|
attempted: true,
|
|
success: true,
|
|
imported: changes_applied,
|
|
rejected: 0,
|
|
cursor_ms,
|
|
error: None,
|
|
});
|
|
Ok(())
|
|
}
|
|
Ok(other) => push_failed_sync_stream(
|
|
&store,
|
|
peer_node,
|
|
stream,
|
|
NodeError::IrohPeer(format!("unexpected DB sync response: {other:?}")),
|
|
streams,
|
|
),
|
|
Err(error) => push_failed_sync_stream(&store, peer_node, stream, error, streams),
|
|
}
|
|
}
|
|
|
|
async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
|
|
if node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)?
|
|
.is_none()
|
|
{
|
|
return Ok(());
|
|
}
|
|
let peers = Store::open(&node.paths.metadata_db())?.list_peer_cards()?;
|
|
for peer in peers {
|
|
let run = run_sync_for_peer(node, &peer.peer_id).await?;
|
|
for stream in run.streams.iter().filter(|stream| !stream.success) {
|
|
if let Some(error) = &stream.error {
|
|
tracing::debug!(peer = %peer.peer_id, stream = %stream.stream, %error, "live sync stream failed");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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_node_id()
|
|
.map(|node_id| node_id.to_string())
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let alpn = conn
|
|
.alpn()
|
|
.map(display_alpn)
|
|
.unwrap_or_else(|| "unknown".to_owned());
|
|
let (mut send, mut recv) = conn
|
|
.accept_bi()
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
if alpn == display_alpn(geth_iroh::ALPN_SSH_PROXY.to_vec()) {
|
|
return handle_ssh_proxy_wire_connection(node, remote_endpoint_id, send, recv).await;
|
|
}
|
|
if alpn == display_alpn(geth_iroh::ALPN_PIPE.to_vec()) {
|
|
return handle_pipe_wire_connection(node, remote_endpoint_id, send, recv).await;
|
|
}
|
|
let bytes = recv
|
|
.read_to_end(64 * 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()))?;
|
|
let response = match geth_control::decode_peer_request(text)? {
|
|
PeerControlRequest::Ping { peer_card, nonce } => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
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,
|
|
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 } => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let watermarks = sync_watermarks_for_peer(&store, 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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let ops = load_keychain_ops(&store)?;
|
|
let signatures = load_keychain_signatures(&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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let ops = load_auth_ops(&store)?;
|
|
let signatures = load_auth_signatures(&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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let accepted = insert_node_enrollment_request_if_not_conflicting(&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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let explanation = geth_auth::explain_auth_ops(
|
|
&load_auth_ops_for_resource(&store, &resource)?,
|
|
PrincipalId::new(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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = "resource:cas:local".to_owned();
|
|
let capability = "cas.fetch".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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,
|
|
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,
|
|
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)?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = format!("resource:cas-tree:{name}");
|
|
let capability = "cas.fetch".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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) = 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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = "resource:ssh:certs".to_owned();
|
|
let capability = "ssh_cert.sync".to_owned();
|
|
let high_water_ms = geth_store::now_ms();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
peer_card.node_id.as_str(),
|
|
&resource,
|
|
&capability,
|
|
&nonce,
|
|
bearer_proof.as_ref(),
|
|
)?;
|
|
let (requests, certificates) = if explanation.allowed {
|
|
(
|
|
store
|
|
.list_ssh_cert_requests_since(since_ms)?
|
|
.into_iter()
|
|
.map(ssh_cert_request_from_stored)
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
store
|
|
.list_ssh_certificates_since(since_ms)?
|
|
.into_iter()
|
|
.map(ssh_certificate_from_stored)
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
)
|
|
} else {
|
|
(Vec::new(), Vec::new())
|
|
};
|
|
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,
|
|
requests,
|
|
certificates,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = "resource:ssh:revocations".to_owned();
|
|
let capability = "ssh_revocation.sync".to_owned();
|
|
let high_water_ms = geth_store::now_ms();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
peer_card.node_id.as_str(),
|
|
&resource,
|
|
&capability,
|
|
&nonce,
|
|
bearer_proof.as_ref(),
|
|
)?;
|
|
let revocations = if explanation.allowed {
|
|
store
|
|
.list_ssh_revocations_since(since_ms)?
|
|
.into_iter()
|
|
.map(ssh_revocation_from_stored)
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
} else {
|
|
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,
|
|
revocations,
|
|
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()))?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
if let Some(kv) = store.get_kv_store_by_name(&name)? {
|
|
let capability = "kv.read".to_owned();
|
|
let high_water_ms = geth_store::now_ms();
|
|
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()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
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,
|
|
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)?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = 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(),
|
|
)?;
|
|
let published = if explanation.allowed {
|
|
Some(record_pubsub_message(&node, topic, message)?)
|
|
} 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)?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = 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(),
|
|
)?;
|
|
let messages = if explanation.allowed {
|
|
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)?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = format!("resource:pipe:{target}");
|
|
let capability = "pipe.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(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,
|
|
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)?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = format!("resource:pipe:{name}");
|
|
let capability = "pipe.listen".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = "resource:ssh-proxy:local".to_owned();
|
|
let capability = "ssh_proxy.connect".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
peer_card.node_id.as_str(),
|
|
&resource,
|
|
&capability,
|
|
&nonce,
|
|
bearer_proof.as_ref(),
|
|
)?;
|
|
let connection = if explanation.allowed {
|
|
Some(SshProxyConnection {
|
|
target_node: 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,
|
|
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,
|
|
} => {
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = "resource:ssh-proxy:local".to_owned();
|
|
let capability = "ssh_proxy.admin_shell".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
peer_card.node_id.as_str(),
|
|
&resource,
|
|
&capability,
|
|
&nonce,
|
|
bearer_proof.as_ref(),
|
|
)?;
|
|
let output = if explanation.allowed {
|
|
restricted_admin_shell_output(&node, &command)?
|
|
} else {
|
|
String::new()
|
|
};
|
|
PeerControlResponse::SshAdminShellOutput {
|
|
node_id: node.node_id.clone(),
|
|
agent_id: node.agent_id.clone(),
|
|
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
|
|
remote_endpoint_id,
|
|
command,
|
|
output,
|
|
allowed: explanation.allowed,
|
|
reason: explanation.reason,
|
|
evaluated_ops: explanation.evaluated_ops,
|
|
nonce,
|
|
note: "restricted geth admin shell authenticated endpoint/card binding and required ssh_proxy.admin_shell; no host shell commands are executed".to_owned(),
|
|
}
|
|
}
|
|
PeerControlRequest::DocumentSync {
|
|
peer_card,
|
|
name,
|
|
since_ms,
|
|
nonce,
|
|
bearer_proof,
|
|
} => {
|
|
geth_document::validate_document_name(&name)
|
|
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
if let Some(document) = store.get_document_resource_by_name(&name)? {
|
|
let capability = "document.read".to_owned();
|
|
let high_water_ms = geth_store::now_ms();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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_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,
|
|
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; JSON LWW sync is a bootstrap before Automerge".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()))?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
if let Some(db) = store.get_db_resource_by_name(&name)? {
|
|
let capability = "db.sync".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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,
|
|
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}"),
|
|
}
|
|
}
|
|
}
|
|
};
|
|
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()))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_ssh_proxy_wire_connection(
|
|
node: LocalNode,
|
|
remote_endpoint_id: String,
|
|
mut send: iroh::endpoint::SendStream,
|
|
recv: iroh::endpoint::RecvStream,
|
|
) -> Result<(), NodeError> {
|
|
let mut reader = BufReader::new(recv);
|
|
let mut line = String::new();
|
|
reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let request = geth_control::decode_peer_request(&line)?;
|
|
let PeerControlRequest::SshProxyConnect {
|
|
peer_card,
|
|
nonce,
|
|
bearer_proof,
|
|
} = request
|
|
else {
|
|
let response = PeerControlResponse::Error {
|
|
message: "SSH proxy stream expected ssh-proxy-connect handshake".to_owned(),
|
|
};
|
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
return Ok(());
|
|
};
|
|
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = "resource:ssh-proxy:local".to_owned();
|
|
let capability = "ssh_proxy.connect".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
peer_card.node_id.as_str(),
|
|
&resource,
|
|
&capability,
|
|
&nonce,
|
|
bearer_proof.as_ref(),
|
|
)?;
|
|
let connection = if explanation.allowed {
|
|
Some(SshProxyConnection {
|
|
target_node: node.node_id.clone().into(),
|
|
connected_at: UnixMillis(geth_store::now_ms()),
|
|
local_sshd_target: Some("127.0.0.1:22".to_owned()),
|
|
admin_shell_available: 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 recv = reader.into_inner();
|
|
let (mut tcp_read, mut tcp_write) = tcp.into_split();
|
|
let inbound = async {
|
|
tokio::io::copy(&mut recv, &mut tcp_write)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
tcp_write.shutdown().await.map_err(NodeError::from)
|
|
};
|
|
let outbound = async {
|
|
tokio::io::copy(&mut tcp_read, &mut send)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
};
|
|
tokio::try_join!(inbound, outbound)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_pipe_wire_connection(
|
|
node: LocalNode,
|
|
remote_endpoint_id: String,
|
|
mut send: iroh::endpoint::SendStream,
|
|
recv: iroh::endpoint::RecvStream,
|
|
) -> Result<(), NodeError> {
|
|
let mut reader = BufReader::new(recv);
|
|
let mut line = String::new();
|
|
reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
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()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
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,
|
|
reader.into_inner(),
|
|
)
|
|
.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,
|
|
reader.into_inner(),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
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}")))?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = format!("resource:pipe:{target}");
|
|
let capability = "pipe.connect".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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(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: remote_endpoint_id.to_owned(),
|
|
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)?;
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = format!("resource:pipe-tcp:{target_addr}");
|
|
let capability = "pipe.forward".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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: remote_endpoint_id.to_owned(),
|
|
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: remote_endpoint_id.to_owned(),
|
|
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()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
};
|
|
tokio::try_join!(inbound, outbound)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_pipe_unix_wire_connection(
|
|
node: LocalNode,
|
|
remote_endpoint_id: &str,
|
|
request: PipeUnixConnectWire,
|
|
mut send: iroh::endpoint::SendStream,
|
|
recv: iroh::endpoint::RecvStream,
|
|
) -> Result<(), NodeError> {
|
|
let PipeUnixConnectWire {
|
|
peer_card,
|
|
target_path,
|
|
nonce,
|
|
bearer_proof,
|
|
} = request;
|
|
let target_path = geth_pipe::validate_unix_forward_path(&target_path)?;
|
|
let target_display = target_path.display().to_string();
|
|
peer_card.validate_candidate()?;
|
|
ensure_peer_card_matches_endpoint(&peer_card, remote_endpoint_id)?;
|
|
let discovered = DiscoveredPeer::candidate(
|
|
peer_card.clone(),
|
|
UnixMillis(geth_store::now_ms()),
|
|
DiscoverySource::PeerExchange,
|
|
)?;
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_peer_card(&StoredPeerCard {
|
|
peer_id: peer_card.node_id.to_string(),
|
|
card_json: serde_json::to_string(&peer_card)?,
|
|
updated_at_ms: discovered.discovered_at.0,
|
|
})?;
|
|
let resource = format!("resource:pipe-unix:{target_display}");
|
|
let capability = "pipe.forward".to_owned();
|
|
let explanation = explain_peer_or_bearer(
|
|
&store,
|
|
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: remote_endpoint_id.to_owned(),
|
|
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: remote_endpoint_id.to_owned(),
|
|
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()))?;
|
|
send.finish()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
};
|
|
tokio::try_join!(inbound, outbound)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn iroh_node_addr_from_candidate(
|
|
candidate: &EndpointCandidate,
|
|
) -> Result<iroh::NodeAddr, NodeError> {
|
|
let node_id = candidate
|
|
.endpoint_id
|
|
.parse::<iroh::NodeId>()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
|
let direct_addresses = candidate
|
|
.direct_addresses
|
|
.iter()
|
|
.map(|addr| {
|
|
addr.parse::<std::net::SocketAddr>()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let mut node_addr = iroh::NodeAddr::new(node_id).with_direct_addresses(direct_addresses);
|
|
if let Some(relay_url) = &candidate.relay_url {
|
|
node_addr = node_addr.with_relay_url(
|
|
relay_url
|
|
.parse::<iroh::RelayUrl>()
|
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?,
|
|
);
|
|
}
|
|
Ok(node_addr)
|
|
}
|
|
|
|
fn ensure_peer_card_matches_endpoint(card: &PeerCard, endpoint_id: &str) -> Result<(), NodeError> {
|
|
if card
|
|
.endpoints
|
|
.iter()
|
|
.any(|candidate| candidate.endpoint_id == endpoint_id)
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(NodeError::IrohPeer(format!(
|
|
"signed peer card for {} does not bind Iroh endpoint {}",
|
|
card.node_id, endpoint_id
|
|
)))
|
|
}
|
|
}
|
|
|
|
fn restricted_admin_shell_output(node: &LocalNode, command: &str) -> Result<String, NodeError> {
|
|
match command {
|
|
"help" => Ok("commands: help, status, node-id".to_owned()),
|
|
"node-id" => Ok(node.node_id.clone()),
|
|
"status" => Ok(format!(
|
|
"node_id={}\nagent_id={}\nendpoint_id={}\nhome={}",
|
|
node.node_id,
|
|
node.agent_id,
|
|
node.iroh_status
|
|
.endpoint_id
|
|
.as_deref()
|
|
.unwrap_or("unavailable"),
|
|
node.paths.home().display()
|
|
)),
|
|
other => Err(NodeError::IrohPeer(format!(
|
|
"unsupported restricted admin shell command {other:?}; supported commands: help, status, node-id"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn display_alpn(alpn: Vec<u8>) -> String {
|
|
String::from_utf8_lossy(&alpn).into_owned()
|
|
}
|
|
|
|
pub fn handle_request(
|
|
node: &LocalNode,
|
|
request: ControlRequest,
|
|
) -> Result<ControlResponse, NodeError> {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
match request {
|
|
ControlRequest::Status => 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(),
|
|
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(),
|
|
})),
|
|
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::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 nonce = geth_crypto::blake3_hex(
|
|
format!(
|
|
"{}\0{}\0{}\0{}",
|
|
resource,
|
|
secret.secret_id,
|
|
secret.epoch,
|
|
geth_store::now_ms()
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let encrypted = geth_cas::encrypt_private_blob(
|
|
&resource,
|
|
&secret.secret_id,
|
|
secret.epoch,
|
|
&nonce,
|
|
&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 prototype encrypted private blob envelope; 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 prototype private blob envelope with the latest 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 },
|
|
};
|
|
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::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,
|
|
} => {
|
|
let ops = load_auth_ops_for_resource(&store, &resource)?;
|
|
let discovered = store.get_peer_card(&subject)?.is_some();
|
|
if ops.is_empty() {
|
|
if discovered {
|
|
Ok(ControlResponse::AuthExplain(
|
|
AuthExplanation::discovered_candidate(subject, resource, capability),
|
|
))
|
|
} else {
|
|
Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
|
|
subject, resource, capability,
|
|
)))
|
|
}
|
|
} else {
|
|
let mut explanation = geth_auth::explain_auth_ops(
|
|
&ops,
|
|
PrincipalId::new(subject.clone()),
|
|
ResourceId::new(resource.clone()),
|
|
Capability::new(capability.clone()),
|
|
);
|
|
if discovered && !explanation.allowed {
|
|
explanation.reason = format!(
|
|
"subject is a discovered peer candidate only; discovery does not grant trust or authorization; {}",
|
|
explanation.reason
|
|
);
|
|
}
|
|
Ok(ControlResponse::AuthExplain(explanation))
|
|
}
|
|
}
|
|
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()))?;
|
|
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: "{}".to_owned(),
|
|
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::normalize_document_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 { .. }
|
|
| ControlRequest::PipeUnixForward { .. }
|
|
| ControlRequest::PipeUnixStream { .. } => Err(NodeError::IrohEndpointUnavailable),
|
|
ControlRequest::PipeRecv { name, peek } => {
|
|
geth_pipe::validate_pipe_name(&name)?;
|
|
let messages = pipe_messages(node, &name, !peek)?;
|
|
Ok(ControlResponse::PipeMessages {
|
|
name,
|
|
messages,
|
|
drained: !peek,
|
|
note: geth_pipe::local_pipe_runtime_note().to_owned(),
|
|
})
|
|
}
|
|
ControlRequest::SshProxyConnect { .. }
|
|
| ControlRequest::SshProxyStream { .. }
|
|
| ControlRequest::SshAdminShell { .. }
|
|
| ControlRequest::KeychainSync { .. }
|
|
| ControlRequest::AuthSync { .. }
|
|
| ControlRequest::NodeEnrollSubmit { .. }
|
|
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
|
ControlRequest::ModuleStub { module, command } => {
|
|
Ok(ControlResponse::NotImplemented { module, command })
|
|
}
|
|
}
|
|
}
|
|
|
|
fn 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> {
|
|
let message = PubsubMessage {
|
|
topic: topic.into(),
|
|
message,
|
|
published_at: UnixMillis(geth_store::now_ms()),
|
|
};
|
|
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())
|
|
}
|
|
|
|
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: "{}".to_owned(),
|
|
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: "local-only".to_owned(),
|
|
state_bytes: stored.state_json.len() as u64,
|
|
}
|
|
}
|
|
|
|
fn document_state_from_stored(stored: &StoredDocumentResource) -> DocumentState {
|
|
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)?;
|
|
}
|
|
|
|
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.to_owned(),
|
|
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(),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
|
|
store.insert_auth_op(&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,
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
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(&StoredKeychainOp {
|
|
op_id: op.id.to_string(),
|
|
op_json: serde_json::to_string(op)?,
|
|
created_at_ms: op.created_at.0,
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
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(|| 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_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,
|
|
}
|
|
}
|
|
|
|
fn keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool {
|
|
KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer
|
|
}
|
|
|
|
#[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> {
|
|
store
|
|
.list_keychain_ops()?
|
|
.into_iter()
|
|
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
|
|
.collect()
|
|
}
|
|
|
|
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}")
|
|
}
|
|
|
|
async fn start_daemon_iroh_endpoint(
|
|
node: &mut LocalNode,
|
|
) -> Result<Option<GethIrohEndpoint>, NodeError> {
|
|
let node_config = GethConfig::load(&node.paths.config_file())?;
|
|
let relay_mode = node_config.iroh.relay_mode.clone();
|
|
let relay_mode_label = relay_mode.label();
|
|
let local_discovery = node_config.iroh.local_discovery;
|
|
let iroh_relay_mode = config_relay_mode_to_iroh(&relay_mode, &node_config.iroh.relay_maps);
|
|
let mut config = GethIrohConfig::local_with_relay(node.paths.iroh_key(), iroh_relay_mode);
|
|
config.local_discovery = local_discovery;
|
|
match geth_iroh::start_endpoint(&config).await {
|
|
Ok(endpoint) => {
|
|
let status = endpoint.status();
|
|
if let Some(endpoint_id) = &status.endpoint_id {
|
|
let store = Store::open(&node.paths.metadata_db())?;
|
|
store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?;
|
|
}
|
|
node.iroh_status = status;
|
|
*node
|
|
.iroh_endpoint
|
|
.lock()
|
|
.map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(endpoint.clone());
|
|
Ok(Some(endpoint))
|
|
}
|
|
Err(error) => {
|
|
node.iroh_status = EndpointStatus {
|
|
enabled: false,
|
|
endpoint_id: None,
|
|
relay_mode: relay_mode_label,
|
|
local_discovery,
|
|
note: format!("Iroh endpoint failed to start: {error}"),
|
|
};
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn config_relay_mode_to_iroh(
|
|
mode: &RelayMode,
|
|
relay_maps: &std::collections::BTreeMap<String, geth_config::RelayMapConfig>,
|
|
) -> GethRelayMode {
|
|
match mode {
|
|
RelayMode::Disabled => GethRelayMode::Disabled,
|
|
RelayMode::Default => GethRelayMode::Default,
|
|
RelayMode::Staging => GethRelayMode::Staging,
|
|
RelayMode::Custom { map } => {
|
|
let relay_map = relay_maps
|
|
.get(map)
|
|
.expect("custom relay map was validated during config load");
|
|
GethRelayMode::Custom {
|
|
name: map.clone(),
|
|
relay_urls: relay_map.urls.clone(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
|
|
fn insert_ssh_cert_request_if_not_conflicting(
|
|
store: &Store,
|
|
request: &SshCertRequest,
|
|
) -> Result<bool, 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(false);
|
|
}
|
|
verify_ssh_cert_request_provenance(request)?;
|
|
store.insert_ssh_cert_request(&stored)?;
|
|
Ok(true)
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
|
|
fn insert_ssh_certificate_if_not_conflicting(
|
|
store: &Store,
|
|
certificate: &SshCertificateRecord,
|
|
) -> Result<bool, 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(false);
|
|
}
|
|
verify_ssh_certificate_provenance(certificate)?;
|
|
store.insert_ssh_certificate(&stored)?;
|
|
Ok(true)
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
|
|
fn insert_ssh_revocation_if_not_conflicting(
|
|
store: &Store,
|
|
revocation: &SshRevocationEntry,
|
|
) -> Result<bool, 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(false);
|
|
}
|
|
verify_ssh_revocation_provenance(revocation)?;
|
|
store.insert_ssh_revocation(&stored)?;
|
|
Ok(true)
|
|
}
|
|
|
|
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 tokio::io::AsyncReadExt;
|
|
|
|
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");
|
|
}
|
|
|
|
#[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 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"
|
|
);
|
|
}
|
|
|
|
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_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].last_imported, 3);
|
|
assert_eq!(peers[0].streams[0].last_rejected, 1);
|
|
assert!(peers[0].streams[0].last_error.is_none());
|
|
}
|
|
|
|
#[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 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() {
|
|
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");
|
|
let left_file_root = left_home.path().join("files");
|
|
std::fs::create_dir_all(&left_file_root).expect("create left file root");
|
|
std::fs::write(left_file_root.join("note.txt"), b"local tree").expect("write left file");
|
|
handle_request(
|
|
&left,
|
|
ControlRequest::CasRootAdd {
|
|
name: "shared".to_owned(),
|
|
path: left_file_root.clone(),
|
|
},
|
|
)
|
|
.expect("left file root add");
|
|
let right_file_root = right_home.path().join("files");
|
|
std::fs::create_dir_all(&right_file_root).expect("create right file root");
|
|
std::fs::write(right_file_root.join("note.txt"), b"synced tree").expect("write right file");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::CasRootAdd {
|
|
name: "shared".to_owned(),
|
|
path: right_file_root.clone(),
|
|
},
|
|
)
|
|
.expect("right file root add");
|
|
let right_tree_hash = match handle_request(
|
|
&right,
|
|
ControlRequest::CasRootScan {
|
|
name: "shared".to_owned(),
|
|
},
|
|
)
|
|
.expect("right file root scan")
|
|
{
|
|
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
|
|
other => panic!("unexpected right file root scan response: {other:?}"),
|
|
};
|
|
let right_pubkey = right_home.path().join("request.pub");
|
|
std::fs::write(
|
|
&right_pubkey,
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== node\n",
|
|
)
|
|
.expect("write right public key");
|
|
let requested = handle_request(
|
|
&right,
|
|
ControlRequest::SshCertRequest {
|
|
public_key_path: right_pubkey,
|
|
cert_kind: "user".to_owned(),
|
|
principals: vec!["eric".to_owned()],
|
|
requested_validity: Some("+52w".to_owned()),
|
|
renewal_of: None,
|
|
reason: Some("test sync".to_owned()),
|
|
subject: None,
|
|
},
|
|
)
|
|
.expect("right ssh cert request");
|
|
let right_request_id = match requested {
|
|
ControlResponse::SshCertRequested { request } => request.id,
|
|
other => panic!("unexpected SSH cert request response: {other:?}"),
|
|
};
|
|
let added_revocation = handle_request(
|
|
&right,
|
|
ControlRequest::SshRevocationAdd {
|
|
kind: "key-id".to_owned(),
|
|
target: "old-node-key".to_owned(),
|
|
reason: Some("test sync".to_owned()),
|
|
subject: None,
|
|
},
|
|
)
|
|
.expect("right ssh revocation");
|
|
let right_revocation_id = match added_revocation {
|
|
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
|
|
other => panic!("unexpected SSH revocation response: {other:?}"),
|
|
};
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::KvCreate {
|
|
name: "prefs".to_owned(),
|
|
},
|
|
)
|
|
.expect("right kv create");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::KvSet {
|
|
name: "prefs".to_owned(),
|
|
key: "apps/foo/theme".to_owned(),
|
|
value: "dark".to_owned(),
|
|
subject: None,
|
|
},
|
|
)
|
|
.expect("right kv set");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::PipeListen {
|
|
name: "inbox".to_owned(),
|
|
node: None,
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.expect("right pipe listen");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::DocumentCreate {
|
|
name: "notes".to_owned(),
|
|
},
|
|
)
|
|
.expect("right document create");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::DocumentSet {
|
|
name: "notes".to_owned(),
|
|
state_json: r#"{"title":"remote"}"#.to_owned(),
|
|
},
|
|
)
|
|
.expect("right document set");
|
|
let left_db_path = left_home.path().join("notes.sqlite");
|
|
let right_db_path = right_home.path().join("notes.sqlite");
|
|
create_mock_crsqlite_db(&left_db_path, None);
|
|
create_mock_crsqlite_db(&right_db_path, Some(7));
|
|
handle_request(
|
|
&left,
|
|
ControlRequest::DbAdd {
|
|
name: "notes".to_owned(),
|
|
path: left_db_path.clone(),
|
|
},
|
|
)
|
|
.expect("left db add");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::DbAdd {
|
|
name: "notes".to_owned(),
|
|
path: right_db_path.clone(),
|
|
},
|
|
)
|
|
.expect("right db add");
|
|
|
|
let ping = handle_request_async(
|
|
&left,
|
|
ControlRequest::PeerPing {
|
|
node: right_card.node_id.to_string(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("peer ping");
|
|
|
|
match ping {
|
|
ControlResponse::PeerPinged {
|
|
peer_node_id,
|
|
peer_agent_id,
|
|
alpn,
|
|
note,
|
|
..
|
|
} => {
|
|
assert_eq!(peer_node_id, right.node_id);
|
|
assert_eq!(peer_agent_id, right.agent_id);
|
|
assert_eq!(alpn, "/geth/control/1");
|
|
assert!(note.contains("does not grant resource capabilities"));
|
|
}
|
|
other => panic!("unexpected ping response: {other:?}"),
|
|
}
|
|
|
|
let denied = handle_request_async(
|
|
&left,
|
|
ControlRequest::PeerAuthCheck {
|
|
node: right_card.node_id.to_string(),
|
|
resource: "resource:cas:local".to_owned(),
|
|
capability: "cas.fetch".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied peer auth check");
|
|
match denied {
|
|
ControlResponse::PeerAuthChecked {
|
|
allowed,
|
|
reason,
|
|
note,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
assert!(note.contains("authenticated endpoint/card binding"));
|
|
}
|
|
other => panic!("unexpected denied auth response: {other:?}"),
|
|
}
|
|
|
|
let denied_fetch = handle_request_async(
|
|
&left,
|
|
ControlRequest::CasFetch {
|
|
node: right_card.node_id.to_string(),
|
|
hash: right_blob.hash.clone(),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied cas fetch");
|
|
match denied_fetch {
|
|
ControlResponse::CasFetched {
|
|
allowed,
|
|
reason,
|
|
size_bytes,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert_eq!(size_bytes, 0);
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
assert!(
|
|
!LocalCas::new(left.paths.cas_dir())
|
|
.has(&right_blob.hash)
|
|
.expect("left cas has after denied fetch")
|
|
);
|
|
}
|
|
other => panic!("unexpected denied CAS fetch response: {other:?}"),
|
|
}
|
|
|
|
let denied_root_sync = handle_request_async(
|
|
&left,
|
|
ControlRequest::CasRootSync {
|
|
node: right_card.node_id.to_string(),
|
|
name: "shared".to_owned(),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied file root sync");
|
|
match denied_root_sync {
|
|
ControlResponse::CasRootSynced {
|
|
allowed,
|
|
root,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(root.is_none());
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied file root sync response: {other:?}"),
|
|
}
|
|
|
|
let denied_kv_sync = handle_request_async(
|
|
&left,
|
|
ControlRequest::KvSync {
|
|
node: right_card.node_id.to_string(),
|
|
name: "prefs".to_owned(),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied kv sync");
|
|
match denied_kv_sync {
|
|
ControlResponse::KvSynced {
|
|
allowed,
|
|
entries_imported,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert_eq!(entries_imported, 0);
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied KV sync response: {other:?}"),
|
|
}
|
|
|
|
let denied_pubsub = handle_request_async(
|
|
&left,
|
|
ControlRequest::PubsubPub {
|
|
topic: "presence/test".to_owned(),
|
|
message: "hello".to_owned(),
|
|
node: Some(right_card.node_id.to_string()),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied remote pubsub publish");
|
|
match denied_pubsub {
|
|
ControlResponse::PubsubRemotePublished {
|
|
allowed,
|
|
reason,
|
|
message,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(message.message.is_empty());
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied pubsub publish response: {other:?}"),
|
|
}
|
|
|
|
let denied_pubsub_subscribe = handle_request_async(
|
|
&left,
|
|
ControlRequest::PubsubSub {
|
|
topic: "presence/test".to_owned(),
|
|
node: Some(right_card.node_id.to_string()),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied remote pubsub subscribe");
|
|
match denied_pubsub_subscribe {
|
|
ControlResponse::PubsubRemoteMessages {
|
|
allowed,
|
|
messages,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(messages.is_empty());
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied pubsub subscribe response: {other:?}"),
|
|
}
|
|
|
|
let denied_pipe = handle_request_async(
|
|
&left,
|
|
ControlRequest::PipeConnect {
|
|
target: "inbox".to_owned(),
|
|
node: Some(right_card.node_id.to_string()),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied remote pipe connect");
|
|
match denied_pipe {
|
|
ControlResponse::PipeRemoteConnected {
|
|
allowed,
|
|
reason,
|
|
connection,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(!connection.local_listener_found);
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied pipe connect response: {other:?}"),
|
|
}
|
|
|
|
let denied_pipe_listen = handle_request_async(
|
|
&left,
|
|
ControlRequest::PipeListen {
|
|
name: "remote-inbox".to_owned(),
|
|
node: Some(right_card.node_id.to_string()),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied remote pipe listen");
|
|
match denied_pipe_listen {
|
|
ControlResponse::PipeRemoteListening {
|
|
allowed,
|
|
reason,
|
|
listener,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(listener.is_none());
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied pipe listen response: {other:?}"),
|
|
}
|
|
|
|
let denied_ssh_proxy = handle_request_async(
|
|
&left,
|
|
ControlRequest::SshProxyConnect {
|
|
node: right_card.node_id.to_string(),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied SSH proxy connect");
|
|
match denied_ssh_proxy {
|
|
ControlResponse::SshProxyConnected {
|
|
allowed,
|
|
reason,
|
|
connection,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(connection.is_none());
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied SSH proxy response: {other:?}"),
|
|
}
|
|
|
|
let denied_admin_shell = handle_request_async(
|
|
&left,
|
|
ControlRequest::SshAdminShell {
|
|
node: right_card.node_id.to_string(),
|
|
command: "status".to_owned(),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied SSH admin shell");
|
|
match denied_admin_shell {
|
|
ControlResponse::SshAdminShellOutput {
|
|
allowed,
|
|
output,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(output.is_empty());
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied SSH admin shell response: {other:?}"),
|
|
}
|
|
|
|
let denied_document = handle_request_async(
|
|
&left,
|
|
ControlRequest::DocumentSync {
|
|
node: right_card.node_id.to_string(),
|
|
name: "notes".to_owned(),
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied document sync");
|
|
match denied_document {
|
|
ControlResponse::DocumentSynced {
|
|
allowed,
|
|
updated,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert!(!updated);
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied document sync response: {other:?}"),
|
|
}
|
|
|
|
let denied_db_sync = handle_request_async(
|
|
&left,
|
|
ControlRequest::DbSync {
|
|
node: right_card.node_id.to_string(),
|
|
name: "notes".to_owned(),
|
|
limit: 10,
|
|
bearer_secret: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("denied db sync");
|
|
match denied_db_sync {
|
|
ControlResponse::DbSynced {
|
|
allowed,
|
|
changes_received,
|
|
changes_applied,
|
|
schema_match,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(!allowed);
|
|
assert_eq!(changes_received, 0);
|
|
assert_eq!(changes_applied, 0);
|
|
assert!(!schema_match);
|
|
assert!(reason.contains("no active direct or group grant"));
|
|
}
|
|
other => panic!("unexpected denied DB sync response: {other:?}"),
|
|
}
|
|
|
|
let bearer_secret = match handle_request(
|
|
&right,
|
|
ControlRequest::SecretBearerCreate {
|
|
resource: "resource:cas:local".to_owned(),
|
|
capabilities: vec!["cas.fetch".to_owned()],
|
|
expires_at_ms: None,
|
|
},
|
|
)
|
|
.expect("create remote CAS bearer access")
|
|
{
|
|
ControlResponse::SecretBearerCreated { access } => access
|
|
.token
|
|
.expect("private bearer token on create")
|
|
.to_string(),
|
|
other => panic!("unexpected bearer create response: {other:?}"),
|
|
};
|
|
let bearer_fetch = handle_request_async(
|
|
&left,
|
|
ControlRequest::CasFetch {
|
|
node: right_card.node_id.to_string(),
|
|
hash: right_blob.hash.clone(),
|
|
bearer_secret: Some(bearer_secret),
|
|
},
|
|
)
|
|
.await
|
|
.expect("bearer cas fetch");
|
|
match bearer_fetch {
|
|
ControlResponse::CasFetched {
|
|
allowed,
|
|
hash,
|
|
size_bytes,
|
|
reason,
|
|
..
|
|
} => {
|
|
assert!(allowed);
|
|
assert_eq!(hash, right_blob.hash);
|
|
assert_eq!(size_bytes, right_blob.size_bytes);
|
|
assert!(reason.contains("bearer proof"));
|
|
}
|
|
other => panic!("unexpected bearer CAS fetch response: {other:?}"),
|
|
}
|
|
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:cas:local".to_owned(),
|
|
capability: "cas.fetch".to_owned(),
|
|
grant_id: Some("grant:left-cas-fetch".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left peer");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:cas-tree:shared".to_owned(),
|
|
capability: "cas.fetch".to_owned(),
|
|
grant_id: Some("grant:left-cas-tree-fetch".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left file root fetch");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:kv:prefs".to_owned(),
|
|
capability: "kv.read".to_owned(),
|
|
grant_id: Some("grant:left-kv-read".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left kv read");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:pubsub:presence/test".to_owned(),
|
|
capability: "pubsub.publish".to_owned(),
|
|
grant_id: Some("grant:left-pubsub-publish".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left pubsub publish");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:pubsub:presence/test".to_owned(),
|
|
capability: "pubsub.subscribe".to_owned(),
|
|
grant_id: Some("grant:left-pubsub-subscribe".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left pubsub subscribe");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:pipe:inbox".to_owned(),
|
|
capability: "pipe.connect".to_owned(),
|
|
grant_id: Some("grant:left-pipe-connect".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left pipe connect");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:pipe:remote-inbox".to_owned(),
|
|
capability: "pipe.listen".to_owned(),
|
|
grant_id: Some("grant:left-pipe-listen".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left pipe listen");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:ssh-proxy:local".to_owned(),
|
|
capability: "ssh_proxy.connect".to_owned(),
|
|
grant_id: Some("grant:left-ssh-proxy-connect".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left ssh proxy connect");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:ssh-proxy:local".to_owned(),
|
|
capability: "ssh_proxy.admin_shell".to_owned(),
|
|
grant_id: Some("grant:left-ssh-admin-shell".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left ssh admin shell");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:document:notes".to_owned(),
|
|
capability: "document.read".to_owned(),
|
|
grant_id: Some("grant:left-document-read".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left document read");
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: "resource:db:notes".to_owned(),
|
|
capability: "db.sync".to_owned(),
|
|
grant_id: Some("grant:left-db-sync".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left db sync");
|
|
|
|
let tcp_echo = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind tcp echo");
|
|
let tcp_target = tcp_echo.local_addr().expect("tcp echo addr").to_string();
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: format!("resource:pipe-tcp:{tcp_target}"),
|
|
capability: "pipe.forward".to_owned(),
|
|
grant_id: Some("grant:left-pipe-tcp-forward".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left pipe tcp forward");
|
|
let echo_task = tokio::spawn(async move {
|
|
let (mut socket, _) = tcp_echo.accept().await.expect("accept tcp echo");
|
|
let mut buf = [0_u8; 4];
|
|
socket.read_exact(&mut buf).await.expect("read tcp echo");
|
|
assert_eq!(&buf, b"ping");
|
|
socket.write_all(b"pong").await.expect("write tcp echo");
|
|
socket.shutdown().await.expect("shutdown tcp echo");
|
|
});
|
|
let (forward_client, forward_daemon) = UnixStream::pair().expect("unix stream pair");
|
|
let right_node_id = right_card.node_id.to_string();
|
|
let left_for_forward = left.clone();
|
|
let tcp_target_for_forward = tcp_target.clone();
|
|
let forward_task = tokio::spawn(async move {
|
|
handle_local_pipe_tcp_stream(
|
|
left_for_forward,
|
|
&right_node_id,
|
|
tcp_target_for_forward,
|
|
None,
|
|
forward_daemon,
|
|
)
|
|
.await
|
|
});
|
|
let mut forward_reader = BufReader::new(forward_client);
|
|
let mut line = String::new();
|
|
forward_reader
|
|
.read_line(&mut line)
|
|
.await
|
|
.expect("read pipe tcp response");
|
|
match geth_control::decode_response(&line).expect("decode pipe tcp response") {
|
|
ControlResponse::PipeRemoteConnected {
|
|
allowed,
|
|
connection,
|
|
..
|
|
} => {
|
|
assert!(allowed);
|
|
assert_eq!(connection.target, tcp_target);
|
|
}
|
|
other => panic!("unexpected pipe TCP stream response: {other:?}"),
|
|
}
|
|
let stream = forward_reader.into_inner();
|
|
let (mut forward_read, mut forward_write) = stream.into_split();
|
|
forward_write
|
|
.write_all(b"ping")
|
|
.await
|
|
.expect("write through pipe tcp forward");
|
|
forward_write
|
|
.shutdown()
|
|
.await
|
|
.expect("shutdown pipe tcp forward write");
|
|
let mut forwarded = Vec::new();
|
|
forward_read
|
|
.read_to_end(&mut forwarded)
|
|
.await
|
|
.expect("read through pipe tcp forward");
|
|
assert_eq!(forwarded, b"pong");
|
|
forward_task
|
|
.await
|
|
.expect("join pipe tcp forward")
|
|
.expect("pipe tcp forward");
|
|
echo_task.await.expect("join tcp echo");
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let unix_target = right_home.path().join("echo.sock");
|
|
let unix_echo = UnixListener::bind(&unix_target).expect("bind unix echo");
|
|
let unix_target_display = unix_target.display().to_string();
|
|
handle_request(
|
|
&right,
|
|
ControlRequest::AuthGrant {
|
|
subject: left.node_id.clone(),
|
|
resource: format!("resource:pipe-unix:{unix_target_display}"),
|
|
capability: "pipe.forward".to_owned(),
|
|
grant_id: Some("grant:left-pipe-unix-forward".to_owned()),
|
|
signing_key_path: None,
|
|
admin_key_path: None,
|
|
},
|
|
)
|
|
.expect("grant left pipe unix forward");
|
|
let unix_echo_task = tokio::spawn(async move {
|
|
let (mut socket, _) = unix_echo.accept().await.expect("accept unix echo");
|
|
let mut buf = [0_u8; 5];
|
|
socket.read_exact(&mut buf).await.expect("read unix echo");
|
|
assert_eq!(&buf, b"hello");
|
|
socket.write_all(b"world").await.expect("write unix echo");
|
|
socket.shutdown().await.expect("shutdown unix echo");
|
|
});
|
|
let (unix_forward_client, unix_forward_daemon) =
|
|
UnixStream::pair().expect("unix stream pair for unix forward");
|
|
let right_node_id = right_card.node_id.to_string();
|
|
let left_for_forward = left.clone();
|
|
let unix_target_for_forward = unix_target.clone();
|
|
let unix_forward_task = tokio::spawn(async move {
|
|
handle_local_pipe_unix_stream(
|
|
left_for_forward,
|
|
&right_node_id,
|
|
unix_target_for_forward,
|
|
None,
|
|
unix_forward_daemon,
|
|
)
|
|
.await
|
|
});
|
|
let mut unix_forward_reader = BufReader::new(unix_forward_client);
|
|
let mut unix_line = String::new();
|
|
unix_forward_reader
|
|
.read_line(&mut unix_line)
|
|
.await
|
|
.expect("read pipe unix response");
|
|
match geth_control::decode_response(&unix_line).expect("decode pipe unix response") {
|
|
ControlResponse::PipeRemoteConnected {
|
|
allowed,
|
|
connection,
|
|
..
|
|
} => {
|
|
assert!(allowed);
|
|
assert_eq!(connection.target, unix_target_display);
|
|
}
|
|
other => panic!("unexpected pipe Unix stream response: {other:?}"),
|
|
}
|
|
let stream = unix_forward_reader.into_inner();
|
|
let (mut unix_forward_read, mut unix_forward_write) = stream.into_split();
|
|
unix_forward_write
|
|
.write_all(b"hello")
|
|
.await
|
|
.expect("write through pipe unix forward");
|
|
unix_forward_write
|
|
.shutdown()
|
|
.await
|
|
.expect("shutdown pipe unix forward write");
|
|
let mut unix_forwarded = Vec::new();
|
|
unix_forward_read
|
|
.read_to_end(&mut unix_forwarded)
|
|
.await
|
|
.expect("read through pipe unix forward");
|
|
assert_eq!(unix_forwarded, b"world");
|
|
unix_forward_task
|
|
.await
|
|
.expect("join pipe unix forward")
|
|
.expect("pipe unix forward");
|
|
unix_echo_task.await.expect("join unix echo");
|
|
}
|
|
|
|
let allowed = handle_request_async(
|
|
&left,
|
|
ControlRequest::PeerAuthCheck {
|
|
node: right_card.node_id.to_string(),
|
|
resource: "resource:cas:local".to_owned(),
|
|
capability: "cas.fetch".to_owned(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("allowed peer auth check");
|
|
match allowed {
|
|
ControlResponse::PeerAuthChecked {
|
|
allowed,
|
|
reason,
|
|
evaluated_ops,
|
|
..
|
|
} => {
|
|
assert!(allowed);
|
|
assert!(reason.contains("direct grant"));
|
|
assert_eq!(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 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 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("JSON LWW"));
|
|
}
|
|
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| document.state_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(&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::<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(&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::<LiveSyncCursor>(&db_cursor.state_json)
|
|
.expect("parse db cursor")
|
|
.cursor_ms,
|
|
8
|
|
);
|
|
|
|
left_endpoint.shutdown().await;
|
|
right_endpoint.shutdown().await;
|
|
}
|
|
|
|
fn test_tree(entries: Vec<geth_cas::CasTreeEntry>) -> CasTreeObject {
|
|
CasTreeObject {
|
|
version: geth_cas::CAS_TREE_OBJECT_VERSION,
|
|
entries,
|
|
}
|
|
}
|
|
|
|
fn test_tree_file(path: &str, byte: &str) -> geth_cas::CasTreeEntry {
|
|
geth_cas::CasTreeEntry {
|
|
path: path.to_owned(),
|
|
kind: CasTreeEntryKind::File,
|
|
blob: Some(BlobHash::new(byte.repeat(32))),
|
|
size_bytes: 1,
|
|
executable: false,
|
|
}
|
|
}
|
|
}
|