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

@ -108,7 +108,8 @@ Roadmap items should be actionable and checkable:
types, manual signed peer-card export/import/list commands, `geth peer ping` types, manual signed peer-card export/import/list commands, `geth peer ping`
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads, and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
authorized `geth cas fetch`, `geth ssh cert sync`, and authorized `geth cas fetch`, `geth ssh cert sync`, and
`geth ssh revocation sync` over the Iroh control ALPN, untrusted `geth ssh revocation sync` over the Iroh control ALPN, a background SSH
metadata live-sync loop with per-peer cursors in `module_state`, untrusted
discovery-backend trait, custom relay-map config, and Iroh local-network discovery-backend trait, custom relay-map config, and Iroh local-network
discovery toggle exist. discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature - Canonical signed-operation envelopes exist for keychain/auth signature
@ -160,7 +161,8 @@ Roadmap items should be actionable and checkable:
public-key and certificate binary KRL revocations when `ssh-keygen` is public-key and certificate binary KRL revocations when `ssh-keygen` is
available. Authorized peers can pull SSH certificate-flow metadata with available. Authorized peers can pull SSH certificate-flow metadata with
`ssh_cert.sync` on `resource:ssh:certs` and revocation metadata with `ssh_cert.sync` on `resource:ssh:certs` and revocation metadata with
`ssh_revocation.sync` on `resource:ssh:revocations`; this is pull-only `ssh_revocation.sync` on `resource:ssh:revocations`. The daemon live-syncs
known peers every 30 seconds using per-peer cursors; this is pull-only
metadata sync, not yet a CRDT/resource-log replication model. metadata sync, not yet a CRDT/resource-log replication model.
- cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth - cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth
enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items

1
Cargo.lock generated
View file

@ -1249,6 +1249,7 @@ dependencies = [
"geth-store", "geth-store",
"geth-types", "geth-types",
"iroh", "iroh",
"serde",
"serde_json", "serde_json",
"swarm-discovery", "swarm-discovery",
"tempfile", "tempfile",

View file

@ -138,7 +138,10 @@ bootstrap transfer path; future work will move provider/fetch behavior to
at the peer. `geth ssh revocation sync <node-id>` requires at the peer. `geth ssh revocation sync <node-id>` requires
`ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge `ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge
authorized peer metadata into the local store for offline listing and later authorized peer metadata into the local store for offline listing and later
approval/signing workflows. approval/signing workflows. While the daemon is running, it also performs a
background live-sync tick for known peers every 30 seconds. Live-sync stores
per-peer high-water cursors in local metadata so repeated ticks request only
newer SSH certificate-flow and revocation records.
Importing or pinging a peer card never grants capabilities by itself. Importing or pinging a peer card never grants capabilities by itself.
When `[iroh].local_discovery = true`, the daemon also advertises and discovers When `[iroh].local_discovery = true`, the daemon also advertises and discovers
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is

View file

@ -513,10 +513,12 @@ pub enum PeerControlRequest {
}, },
SshCertSync { SshCertSync {
peer_card: PeerCard, peer_card: PeerCard,
since_ms: i64,
nonce: String, nonce: String,
}, },
SshRevocationSync { SshRevocationSync {
peer_card: PeerCard, peer_card: PeerCard,
since_ms: i64,
nonce: String, nonce: String,
}, },
} }
@ -567,6 +569,7 @@ pub enum PeerControlResponse {
remote_endpoint_id: String, remote_endpoint_id: String,
requests: Vec<SshCertRequest>, requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>, certificates: Vec<SshCertificateRecord>,
high_water_ms: i64,
allowed: bool, allowed: bool,
reason: String, reason: String,
evaluated_ops: usize, evaluated_ops: usize,
@ -579,6 +582,7 @@ pub enum PeerControlResponse {
endpoint_id: String, endpoint_id: String,
remote_endpoint_id: String, remote_endpoint_id: String,
revocations: Vec<SshRevocationEntry>, revocations: Vec<SshRevocationEntry>,
high_water_ms: i64,
allowed: bool, allowed: bool,
reason: String, reason: String,
evaluated_ops: usize, evaluated_ops: usize,
@ -873,6 +877,7 @@ mod tests {
remote_endpoint_id: "endpoint:caller".to_owned(), remote_endpoint_id: "endpoint:caller".to_owned(),
requests: Vec::new(), requests: Vec::new(),
certificates: Vec::new(), certificates: Vec::new(),
high_water_ms: 42,
allowed: false, allowed: false,
reason: "no grant".to_owned(), reason: "no grant".to_owned(),
evaluated_ops: 0, evaluated_ops: 0,
@ -891,6 +896,7 @@ mod tests {
endpoint_id: "endpoint:peer".to_owned(), endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(), remote_endpoint_id: "endpoint:caller".to_owned(),
revocations: Vec::new(), revocations: Vec::new(),
high_water_ms: 42,
allowed: false, allowed: false,
reason: "no grant".to_owned(), reason: "no grant".to_owned(),
evaluated_ops: 0, evaluated_ops: 0,

View file

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

View file

@ -33,8 +33,9 @@ use geth_ssh_identity::{
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict,
StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredPeerCard, StoredResource, StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState,
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
@ -43,6 +44,7 @@ use geth_types::{
use std::collections::{BTreeMap, VecDeque}; use std::collections::{BTreeMap, VecDeque};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
@ -148,8 +150,14 @@ struct PipeRuntime {
} }
const PUBSUB_RING_LIMIT: usize = 256; const PUBSUB_RING_LIMIT: usize = 256;
const LIVE_SYNC_INTERVAL: Duration = Duration::from_secs(30);
const PIPE_CONNECTION_RING_LIMIT: usize = 256; 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> { pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?; paths.ensure_base_dirs()?;
if !paths.config_file().exists() { if !paths.config_file().exists() {
@ -194,6 +202,7 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
.clone() .clone()
{ {
spawn_iroh_control_accept_loop(node.clone(), endpoint); 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; let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
if Path::new(&paths.socket_path()).exists() { if Path::new(&paths.socket_path()).exists() {
@ -778,8 +787,17 @@ async fn ssh_cert_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
) -> Result<ControlResponse, NodeError> { ) -> 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| { 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?; .await?;
match response { match response {
@ -789,6 +807,7 @@ async fn ssh_cert_sync_from_peer(
endpoint_id, endpoint_id,
requests, requests,
certificates, certificates,
high_water_ms,
allowed, allowed,
reason, reason,
note, note,
@ -815,6 +834,7 @@ async fn ssh_cert_sync_from_peer(
for certificate in &certificates { for certificate in &certificates {
store.insert_ssh_certificate(&stored_from_ssh_certificate(certificate))?; store.insert_ssh_certificate(&stored_from_ssh_certificate(certificate))?;
} }
store_live_sync_cursor(&store, peer_node, "ssh-certs", high_water_ms)?;
Ok(ControlResponse::SshCertSynced { Ok(ControlResponse::SshCertSynced {
peer_node_id: node_id, peer_node_id: node_id,
peer_agent_id: agent_id, peer_agent_id: agent_id,
@ -837,11 +857,20 @@ async fn ssh_revocation_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
) -> Result<ControlResponse, NodeError> { ) -> 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( let response = request_peer_control(
node, node,
peer_node, peer_node,
"ssh-revocation-sync", "ssh-revocation-sync",
|peer_card, nonce| PeerControlRequest::SshRevocationSync { peer_card, nonce }, |peer_card, nonce| PeerControlRequest::SshRevocationSync {
peer_card,
since_ms,
nonce,
},
) )
.await?; .await?;
match response { match response {
@ -850,6 +879,7 @@ async fn ssh_revocation_sync_from_peer(
agent_id, agent_id,
endpoint_id, endpoint_id,
revocations, revocations,
high_water_ms,
allowed, allowed,
reason, reason,
note, note,
@ -871,6 +901,7 @@ async fn ssh_revocation_sync_from_peer(
for revocation in &revocations { for revocation in &revocations {
store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?; store.insert_ssh_revocation(&stored_from_ssh_revocation(revocation))?;
} }
store_live_sync_cursor(&store, peer_node, "ssh-revocations", high_water_ms)?;
Ok(ControlResponse::SshRevocationSynced { Ok(ControlResponse::SshRevocationSynced {
peer_node_id: node_id, peer_node_id: node_id,
peer_agent_id: agent_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( async fn request_peer_control(
node: &LocalNode, node: &LocalNode,
peer_node: &str, 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( async fn handle_iroh_control_connection(
node: LocalNode, node: LocalNode,
incoming: iroh::endpoint::Incoming, 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()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate( let discovered = DiscoveredPeer::candidate(
@ -1148,6 +1246,7 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = "resource:ssh:certs".to_owned(); let resource = "resource:ssh:certs".to_owned();
let capability = "ssh_cert.sync".to_owned(); let capability = "ssh_cert.sync".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops( let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?, &load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()), PrincipalId::new(peer_card.node_id.to_string()),
@ -1157,12 +1256,12 @@ async fn handle_iroh_control_connection(
let (requests, certificates) = if explanation.allowed { let (requests, certificates) = if explanation.allowed {
( (
store store
.list_ssh_cert_requests()? .list_ssh_cert_requests_since(since_ms)?
.into_iter() .into_iter()
.map(ssh_cert_request_from_stored) .map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
store store
.list_ssh_certificates()? .list_ssh_certificates_since(since_ms)?
.into_iter() .into_iter()
.map(ssh_certificate_from_stored) .map(ssh_certificate_from_stored)
.collect(), .collect(),
@ -1177,6 +1276,7 @@ async fn handle_iroh_control_connection(
remote_endpoint_id, remote_endpoint_id,
requests, requests,
certificates, certificates,
high_water_ms,
allowed: explanation.allowed, allowed: explanation.allowed,
reason: explanation.reason, reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops, 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(), 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()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate( let discovered = DiscoveredPeer::candidate(
@ -1200,6 +1304,7 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = "resource:ssh:revocations".to_owned(); let resource = "resource:ssh:revocations".to_owned();
let capability = "ssh_revocation.sync".to_owned(); let capability = "ssh_revocation.sync".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops( let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?, &load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()), PrincipalId::new(peer_card.node_id.to_string()),
@ -1208,7 +1313,7 @@ async fn handle_iroh_control_connection(
); );
let revocations = if explanation.allowed { let revocations = if explanation.allowed {
store store
.list_ssh_revocations()? .list_ssh_revocations_since(since_ms)?
.into_iter() .into_iter()
.map(ssh_revocation_from_stored) .map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
@ -1221,6 +1326,7 @@ async fn handle_iroh_control_connection(
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id, remote_endpoint_id,
revocations, revocations,
high_water_ms,
allowed: explanation.allowed, allowed: explanation.allowed,
reason: explanation.reason, reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops, evaluated_ops: explanation.evaluated_ops,
@ -3138,6 +3244,62 @@ mod tests {
.any(|revocation| revocation.revocation_id == right_revocation_id.as_str()) .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; left_endpoint.shutdown().await;
right_endpoint.shutdown().await; right_endpoint.shutdown().await;
} }

View file

@ -709,6 +709,31 @@ impl Store {
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn put_module_state(&self, state: &StoredModuleState) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO module_state(module, state_json, updated_at_ms)
VALUES (?1, ?2, ?3)"#,
params![state.module, state.state_json, state.updated_at_ms],
)?;
Ok(())
}
pub fn get_module_state(&self, module: &str) -> Result<Option<StoredModuleState>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT module, state_json, updated_at_ms FROM module_state WHERE module = ?1",
)?;
let mut rows = stmt.query(params![module])?;
if let Some(row) = rows.next()? {
Ok(Some(StoredModuleState {
module: row.get(0)?,
state_json: row.get(1)?,
updated_at_ms: row.get(2)?,
}))
} else {
Ok(None)
}
}
pub fn insert_auth_op(&self, op: &StoredAuthOp) -> Result<(), StoreError> { pub fn insert_auth_op(&self, op: &StoredAuthOp) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO auth_ops(op_id, resource_id, op_json, created_at_ms) r#"INSERT OR REPLACE INTO auth_ops(op_id, resource_id, op_json, created_at_ms)
@ -846,6 +871,20 @@ impl Store {
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn list_ssh_cert_requests_since(
&self,
since_ms: i64,
) -> Result<Vec<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
FROM ssh_cert_requests WHERE created_at_ms >= ?1 ORDER BY created_at_ms, request_id"#,
)?;
let rows = stmt.query_map(params![since_ms], stored_ssh_cert_request_from_row)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_certificate( pub fn insert_ssh_certificate(
&self, &self,
certificate: &StoredSshCertificate, certificate: &StoredSshCertificate,
@ -883,6 +922,27 @@ impl Store {
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn list_ssh_certificates_since(
&self,
since_ms: i64,
) -> Result<Vec<StoredSshCertificate>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms
FROM ssh_certificates WHERE imported_at_ms >= ?1 ORDER BY imported_at_ms, cert_id"#,
)?;
let rows = stmt.query_map(params![since_ms], |row| {
Ok(StoredSshCertificate {
cert_id: row.get(0)?,
request_id: row.get(1)?,
certificate: row.get(2)?,
certificate_fingerprint: row.get(3)?,
imported_at_ms: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_revocation( pub fn insert_ssh_revocation(
&self, &self,
revocation: &StoredSshRevocation, revocation: &StoredSshRevocation,
@ -921,6 +981,28 @@ impl Store {
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from) .map_err(StoreError::from)
} }
pub fn list_ssh_revocations_since(
&self,
since_ms: i64,
) -> Result<Vec<StoredSshRevocation>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT revocation_id, kind, target, reason, created_at_ms, published
FROM ssh_revocations WHERE created_at_ms >= ?1 ORDER BY created_at_ms, revocation_id"#,
)?;
let rows = stmt.query_map(params![since_ms], |row| {
Ok(StoredSshRevocation {
revocation_id: row.get(0)?,
kind: row.get(1)?,
target: row.get(2)?,
reason: row.get(3)?,
created_at_ms: row.get(4)?,
published: row.get::<_, i64>(5)? != 0,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
} }
fn stored_ssh_cert_request_from_row( fn stored_ssh_cert_request_from_row(
@ -1064,6 +1146,13 @@ pub struct StoredPeerCard {
pub updated_at_ms: i64, pub updated_at_ms: i64,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredModuleState {
pub module: String,
pub state_json: String,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredAuthOp { pub struct StoredAuthOp {
pub op_id: String, pub op_id: String,
@ -1173,6 +1262,18 @@ mod tests {
.expect("insert revocation"); .expect("insert revocation");
assert_eq!( assert_eq!(
store.list_ssh_revocations().expect("list revocations"), store.list_ssh_revocations().expect("list revocations"),
vec![revocation.clone()]
);
assert_eq!(
store
.list_ssh_cert_requests_since(0)
.expect("list requests since"),
vec![request]
);
assert_eq!(
store
.list_ssh_revocations_since(1)
.expect("list revocations since"),
vec![revocation] vec![revocation]
); );
} }
@ -1193,6 +1294,23 @@ mod tests {
assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]); assert_eq!(store.list_peer_cards().expect("list"), vec![peer_card]);
} }
#[test]
fn module_state_roundtrip() {
let store = Store::open_memory().expect("open");
let state = StoredModuleState {
module: "live-sync:node:laptop:ssh-certs".to_owned(),
state_json: r#"{"cursor_ms":42}"#.to_owned(),
updated_at_ms: 43,
};
store.put_module_state(&state).expect("put state");
assert_eq!(
store
.get_module_state("live-sync:node:laptop:ssh-certs")
.expect("get state"),
Some(state)
);
}
#[test] #[test]
fn auth_ops_roundtrip_by_resource() { fn auth_ops_roundtrip_by_resource() {
let store = Store::open_memory().expect("open"); let store = Store::open_memory().expect("open");

View file

@ -84,7 +84,11 @@ revocations are stored as signed-list-ready records. The bootstrap can pull
certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the certificate-flow metadata over Iroh with `geth ssh cert sync <node-id>` when the
peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with peer grants `ssh_cert.sync` on `resource:ssh:certs`, and revocation metadata with
`geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync` `geth ssh revocation sync <node-id>` when the peer grants `ssh_revocation.sync`
on `resource:ssh:revocations`. on `resource:ssh:revocations`. The daemon also runs a 30-second background
live-sync tick for known peers and records per-peer high-water cursors in
`module_state`, so repeated ticks request only records at or beyond the last
remote cursor. Boundary duplicates are harmless because records are keyed by
stable IDs and inserted with replace semantics.
## Resource Model ## Resource Model
@ -169,6 +173,8 @@ not enumerable through OpenSSH tooling, so geth treats binary import as
unsupported and asks for JSONL or the spec source. Revocation lists are not yet unsupported and asks for JSONL or the spec source. Revocation lists are not yet
full CRDT-replicated resources, but the daemon can already pull cert-flow and full CRDT-replicated resources, but the daemon can already pull cert-flow and
revocation metadata from authorized peers over the protected Iroh control ALPN. revocation metadata from authorized peers over the protected Iroh control ALPN.
Manual sync commands and the background live-sync loop share the same capability
checks and cursor state.
## Keychain, Auth, And Secrets ## Keychain, Auth, And Secrets

View file

@ -214,6 +214,10 @@ resource-scoped capability decisions.
`geth ssh cert sync <node-id>`. `geth ssh cert sync <node-id>`.
- `[x]` Authorized peers can pull SSH revocation metadata with - `[x]` Authorized peers can pull SSH revocation metadata with
`geth ssh revocation sync <node-id>`. `geth ssh revocation sync <node-id>`.
- `[x]` The daemon background live-sync loop refreshes known peers without a
manual command.
- `[x]` SSH metadata live-sync stores per-peer high-water cursors in
`module_state` and requests only records at or beyond the cursor.
- `[ ]` Future completion requires auth checks for local request, approve, - `[ ]` Future completion requires auth checks for local request, approve,
import, publish, and read capabilities. import, publish, and read capabilities.
@ -318,6 +322,8 @@ Goal: add authorized stream-oriented management workflows over Iroh.
`resource:ssh:revocations`. `resource:ssh:revocations`.
- `[x]` Consumers can list current certs/revocations from local state while - `[x]` Consumers can list current certs/revocations from local state while
offline after sync. offline after sync.
- `[x]` Background live-sync uses the same protected Iroh path and cursor
state as manual sync.
- `[ ]` Replace pull-only metadata sync with a resource log or CRDT model. - `[ ]` Replace pull-only metadata sync with a resource log or CRDT model.
- `[ ]` Conflicting or unsigned records are rejected or quarantined. - `[ ]` Conflicting or unsigned records are rejected or quarantined.