Live sync SSH metadata in background
This commit is contained in:
parent
9887b47a40
commit
68153be5d5
9 changed files with 318 additions and 13 deletions
|
|
@ -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
1
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue