Live sync SSH metadata in background

This commit is contained in:
Eric Wendland 2026-05-18 18:29:45 +02:00
commit 68153be5d5
9 changed files with 318 additions and 13 deletions

View file

@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
base64.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true

View file

@ -33,8 +33,9 @@ use geth_ssh_identity::{
};
use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredPeerCard, StoredResource,
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState,
StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
@ -43,6 +44,7 @@ use geth_types::{
use std::collections::{BTreeMap, VecDeque};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -148,8 +150,14 @@ struct PipeRuntime {
}
const PUBSUB_RING_LIMIT: usize = 256;
const LIVE_SYNC_INTERVAL: Duration = Duration::from_secs(30);
const PIPE_CONNECTION_RING_LIMIT: usize = 256;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct LiveSyncCursor {
cursor_ms: i64,
}
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?;
if !paths.config_file().exists() {
@ -194,6 +202,7 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
.clone()
{
spawn_iroh_control_accept_loop(node.clone(), endpoint);
spawn_background_live_sync(node.clone());
}
let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
if Path::new(&paths.socket_path()).exists() {
@ -778,8 +787,17 @@ async fn ssh_cert_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> 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, nonce }
PeerControlRequest::SshCertSync {
peer_card,
since_ms,
nonce,
}
})
.await?;
match response {
@ -789,6 +807,7 @@ async fn ssh_cert_sync_from_peer(
endpoint_id,
requests,
certificates,
high_water_ms,
allowed,
reason,
note,
@ -815,6 +834,7 @@ async fn ssh_cert_sync_from_peer(
for certificate in &certificates {
store.insert_ssh_certificate(&stored_from_ssh_certificate(certificate))?;
}
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,
@ -837,11 +857,20 @@ async fn ssh_revocation_sync_from_peer(
node: &LocalNode,
peer_node: &str,
) -> 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, nonce },
|peer_card, nonce| PeerControlRequest::SshRevocationSync {
peer_card,
since_ms,
nonce,
},
)
.await?;
match response {
@ -850,6 +879,7 @@ async fn ssh_revocation_sync_from_peer(
agent_id,
endpoint_id,
revocations,
high_water_ms,
allowed,
reason,
note,
@ -871,6 +901,7 @@ async fn ssh_revocation_sync_from_peer(
for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
}
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,
@ -888,6 +919,33 @@ async fn ssh_revocation_sync_from_peer(
}
}
fn live_sync_cursor_key(peer_node: &str, stream: &str) -> String {
format!("live-sync:{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(())
}
async fn request_peer_control(
node: &LocalNode,
peer_node: &str,
@ -975,6 +1033,42 @@ fn spawn_iroh_control_accept_loop(node: LocalNode, endpoint: GethIrohEndpoint) {
});
}
fn spawn_background_live_sync(node: LocalNode) {
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(LIVE_SYNC_INTERVAL);
loop {
interval.tick().await;
if let Err(error) = run_live_sync_once(&node).await {
tracing::debug!(%error, "live sync tick failed");
}
}
});
}
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 {
if let Err(error) = ssh_cert_sync_from_peer(node, &peer.peer_id).await {
tracing::debug!(peer = %peer.peer_id, %error, "SSH cert live sync failed");
}
if let Err(error) = ssh_revocation_sync_from_peer(node, &peer.peer_id).await {
tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed");
}
}
Ok(())
}
async fn handle_iroh_control_connection(
node: LocalNode,
incoming: iroh::endpoint::Incoming,
@ -1132,7 +1226,11 @@ async fn handle_iroh_control_connection(
}
}
}
PeerControlRequest::SshCertSync { peer_card, nonce } => {
PeerControlRequest::SshCertSync {
peer_card,
since_ms,
nonce,
} => {
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
@ -1148,6 +1246,7 @@ async fn handle_iroh_control_connection(
})?;
let resource = "resource:ssh:certs".to_owned();
let capability = "ssh_cert.sync".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()),
@ -1157,12 +1256,12 @@ async fn handle_iroh_control_connection(
let (requests, certificates) = if explanation.allowed {
(
store
.list_ssh_cert_requests()?
.list_ssh_cert_requests_since(since_ms)?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
store
.list_ssh_certificates()?
.list_ssh_certificates_since(since_ms)?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
@ -1177,6 +1276,7 @@ async fn handle_iroh_control_connection(
remote_endpoint_id,
requests,
certificates,
high_water_ms,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
@ -1184,7 +1284,11 @@ async fn handle_iroh_control_connection(
note: "SSH certificate metadata sync authenticated endpoint/card binding and required ssh_cert.sync on resource:ssh:certs".to_owned(),
}
}
PeerControlRequest::SshRevocationSync { peer_card, nonce } => {
PeerControlRequest::SshRevocationSync {
peer_card,
since_ms,
nonce,
} => {
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
@ -1200,6 +1304,7 @@ async fn handle_iroh_control_connection(
})?;
let resource = "resource:ssh:revocations".to_owned();
let capability = "ssh_revocation.sync".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()),
@ -1208,7 +1313,7 @@ async fn handle_iroh_control_connection(
);
let revocations = if explanation.allowed {
store
.list_ssh_revocations()?
.list_ssh_revocations_since(since_ms)?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?
@ -1221,6 +1326,7 @@ async fn handle_iroh_control_connection(
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,
@ -3138,6 +3244,62 @@ mod tests {
.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()),
},
)
.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()),
},
)
.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:?}"),
};
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())
);
left_endpoint.shutdown().await;
right_endpoint.shutdown().await;
}