diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index f615a20..3a53166 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -3,6 +3,7 @@ mod daemon; pub mod doctor; mod local_control; mod peer_client; +mod resource_contracts; mod runtime; pub mod service; mod sync; @@ -5588,6 +5589,7 @@ pub fn handle_request( node: &LocalNode, request: ControlRequest, ) -> Result { + debug_assert!(!resource_contracts::contracts().is_empty()); let store = Store::open(&node.paths.metadata_db())?; match request { ControlRequest::Status => { diff --git a/crates/geth-node/src/local_control.rs b/crates/geth-node/src/local_control.rs index 1f854dd..f03ea80 100644 --- a/crates/geth-node/src/local_control.rs +++ b/crates/geth-node/src/local_control.rs @@ -15,7 +15,55 @@ pub async fn handle_request_async( stream = trace.stream.as_deref().unwrap_or(""), "control request started" ); - let result = match request { + let result = route_request_async(node, request).await; + match &result { + Ok(_) => tracing::info!( + command = trace.command, + peer_node = trace.peer_node.as_deref().unwrap_or(""), + resource = trace.resource.as_deref().unwrap_or(""), + capability = trace.capability.as_deref().unwrap_or(""), + stream = trace.stream.as_deref().unwrap_or(""), + "control request completed" + ), + Err(error) => tracing::warn!( + command = trace.command, + peer_node = trace.peer_node.as_deref().unwrap_or(""), + resource = trace.resource.as_deref().unwrap_or(""), + capability = trace.capability.as_deref().unwrap_or(""), + stream = trace.stream.as_deref().unwrap_or(""), + error_code = node_error_code(error), + %error, + "control request failed" + ), + } + result +} + +enum AsyncRoute { + Handled(Box), + Continue(ControlRequest), +} + +async fn route_request_async( + node: &LocalNode, + request: ControlRequest, +) -> Result { + let request = match route_peer_family(node, request).await? { + AsyncRoute::Handled(response) => return Ok(*response), + AsyncRoute::Continue(request) => request, + }; + let request = match route_resource_sync_family(node, request).await? { + AsyncRoute::Handled(response) => return Ok(*response), + AsyncRoute::Continue(request) => request, + }; + route_local_with_native_mirrors(node, request).await +} + +async fn route_peer_family( + node: &LocalNode, + request: ControlRequest, +) -> Result { + let response = 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 { @@ -36,6 +84,16 @@ pub async fn handle_request_async( ControlRequest::NodeEnrollSync { owner_node } => { node_enrollment_sync_from_owner(node, &owner_node).await } + other => return Ok(AsyncRoute::Continue(other)), + }?; + Ok(AsyncRoute::Handled(Box::new(response))) +} + +async fn route_resource_sync_family( + node: &LocalNode, + request: ControlRequest, +) -> Result { + let response = match request { ControlRequest::CasFetch { node: peer_node, hash, @@ -124,61 +182,46 @@ pub async fn handle_request_async( mtu, } => overlay_runtime_up(node, name, bearer_secret, mtu).await, ControlRequest::OverlayDown { name } => overlay_runtime_down(node, &name), - other => { - let response = handle_request(node, other)?; - match &response { - ControlResponse::CasAdded { hash, .. } => { - mirror_local_blob_to_iroh_blobs(node, hash).await?; - } - ControlResponse::CasPrivateAdded { encrypted_hash, .. } => { - mirror_local_blob_to_iroh_blobs(node, encrypted_hash).await?; - } - ControlResponse::KvCreated { kv } => { - mirror_kv_store_to_iroh_docs(node, kv.name.as_str()).await?; - } - ControlResponse::KvSet { entry } => { - let name = entry - .store - .as_str() - .strip_prefix("kv:") - .unwrap_or(entry.store.as_str()); - mirror_kv_store_to_iroh_docs(node, name).await?; - } - ControlResponse::PubsubPublished { message } => { - broadcast_pubsub_gossip( - node, - message.topic.as_str(), - message.message.as_str(), - Vec::new(), - ) - .await?; - } - _ => {} - } - Ok(response) + other => return Ok(AsyncRoute::Continue(other)), + }?; + Ok(AsyncRoute::Handled(Box::new(response))) +} + +async fn route_local_with_native_mirrors( + node: &LocalNode, + request: ControlRequest, +) -> Result { + let response = handle_request(node, request)?; + match &response { + ControlResponse::CasAdded { hash, .. } => { + mirror_local_blob_to_iroh_blobs(node, hash).await?; } - }; - match &result { - Ok(_) => tracing::info!( - command = trace.command, - peer_node = trace.peer_node.as_deref().unwrap_or(""), - resource = trace.resource.as_deref().unwrap_or(""), - capability = trace.capability.as_deref().unwrap_or(""), - stream = trace.stream.as_deref().unwrap_or(""), - "control request completed" - ), - Err(error) => tracing::warn!( - command = trace.command, - peer_node = trace.peer_node.as_deref().unwrap_or(""), - resource = trace.resource.as_deref().unwrap_or(""), - capability = trace.capability.as_deref().unwrap_or(""), - stream = trace.stream.as_deref().unwrap_or(""), - error_code = node_error_code(error), - %error, - "control request failed" - ), + ControlResponse::CasPrivateAdded { encrypted_hash, .. } => { + mirror_local_blob_to_iroh_blobs(node, encrypted_hash).await?; + } + ControlResponse::KvCreated { kv } => { + mirror_kv_store_to_iroh_docs(node, kv.name.as_str()).await?; + } + ControlResponse::KvSet { entry } => { + let name = entry + .store + .as_str() + .strip_prefix("kv:") + .unwrap_or(entry.store.as_str()); + mirror_kv_store_to_iroh_docs(node, name).await?; + } + ControlResponse::PubsubPublished { message } => { + broadcast_pubsub_gossip( + node, + message.topic.as_str(), + message.message.as_str(), + Vec::new(), + ) + .await?; + } + _ => {} } - result + Ok(response) } #[derive(Debug, PartialEq, Eq)] diff --git a/crates/geth-node/src/resource_contracts.rs b/crates/geth-node/src/resource_contracts.rs new file mode 100644 index 0000000..3f8fd52 --- /dev/null +++ b/crates/geth-node/src/resource_contracts.rs @@ -0,0 +1,163 @@ +//! Reviewable resource-family contracts for daemon handlers. +//! +//! These contracts are intentionally small and static. They make every +//! resource family name its resource ID pattern, remote capabilities, and +//! mutation or host-access points in one place while the implementation +//! continues to live in the focused handler functions in `lib.rs` and +//! `local_control.rs`. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ResourceModuleContract { + pub(crate) family: &'static str, + pub(crate) resource_ids: &'static [&'static str], + pub(crate) capabilities: &'static [&'static str], + pub(crate) mutation_points: &'static [&'static str], +} + +pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ + ResourceModuleContract { + family: "cas-file-root", + resource_ids: &["resource:cas:local", "resource:cas-tree:"], + capabilities: &["cas.fetch"], + mutation_points: &[ + "local CAS blob writes", + "local file-root registration and scans", + "remote file-root imports stored as peer-qualified metadata", + "file-root apply writes only missing paths and records conflicts", + ], + }, + ResourceModuleContract { + family: "kv", + resource_ids: &["resource:kv:"], + capabilities: &["kv.read", "kv.write_prefix:", "kv.write_key:"], + mutation_points: &[ + "local SQLite-backed KV create/set", + "authorized sync imports newer or equal timestamp entries idempotently", + "Iroh Documents mirror when native docs are enabled", + ], + }, + ResourceModuleContract { + family: "db", + resource_ids: &["resource:db:"], + capabilities: &["db.sync"], + mutation_points: &[ + "local DB resource registration", + "read-only schema and crsql_changes inspection", + "authorized crsql_changes apply into compatible local databases", + ], + }, + ResourceModuleContract { + family: "document", + resource_ids: &["resource:document:"], + capabilities: &["document.read"], + mutation_points: &[ + "local JSON document create/set", + "authorized sync imports last-writer-wins or Automerge-compatible state", + ], + }, + ResourceModuleContract { + family: "pubsub", + resource_ids: &["resource:pubsub:"], + capabilities: &["pubsub.publish", "pubsub.subscribe"], + mutation_points: &[ + "daemon-lifetime local topic ring buffer", + "authorized remote publish appends to the serving peer snapshot", + "Iroh gossip broadcast when native gossip is enabled", + ], + }, + ResourceModuleContract { + family: "pipe", + resource_ids: &[ + "resource:pipe:", + "resource:pipe-tcp:", + "resource:pipe-unix:", + ], + capabilities: &["pipe.connect", "pipe.listen", "pipe.forward"], + mutation_points: &[ + "daemon-lifetime pipe listener registry", + "daemon-lifetime message ring buffer", + "authorized loopback TCP or absolute Unix socket connection opening", + ], + }, + ResourceModuleContract { + family: "ssh", + resource_ids: &[ + "resource:ssh:certs", + "resource:ssh:revocations", + "resource:ssh-proxy:local", + ], + capabilities: &[ + "ssh_cert.sync", + "ssh_revocation.sync", + "ssh_proxy.connect", + "ssh_proxy.admin_shell", + ], + mutation_points: &[ + "local SSH certificate request/certificate metadata", + "local SSH revocation metadata", + "authorized SSH proxy connects only to 127.0.0.1:22", + "restricted admin shell executes only built-in geth commands", + ], + }, + ResourceModuleContract { + family: "overlay", + resource_ids: &["resource:overlay:"], + capabilities: &["overlay.route"], + mutation_points: &[ + "local overlay network membership metadata", + "daemon-lifetime packet queue and optional TUN/Wintun injection", + "authorized packet route over the overlay ALPN", + ], + }, +]; + +pub(crate) fn contracts() -> &'static [ResourceModuleContract] { + RESOURCE_MODULE_CONTRACTS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_module_contracts_cover_review_families() { + let families = RESOURCE_MODULE_CONTRACTS + .iter() + .map(|contract| contract.family) + .collect::>(); + + for expected in [ + "cas-file-root", + "kv", + "db", + "document", + "pubsub", + "pipe", + "ssh", + "overlay", + ] { + assert!( + families.contains(expected), + "missing resource contract for {expected}" + ); + } + + for contract in RESOURCE_MODULE_CONTRACTS { + assert!( + !contract.resource_ids.is_empty(), + "{} must document resource IDs", + contract.family + ); + assert!( + !contract.capabilities.is_empty(), + "{} must document capabilities", + contract.family + ); + assert!( + !contract.mutation_points.is_empty(), + "{} must document mutation points", + contract.family + ); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 703e435..676b9ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,10 +10,13 @@ Within `geth-node`, daemon lifecycle code is separated from feature handlers: `daemon.rs` owns `geth daemon run` startup, local socket binding, shutdown signal handling, Iroh endpoint startup, the Iroh accept loop, and background live-sync task spawning. `local_control.rs` owns async local `ControlRequest` -routing and safe trace-field classification before delegating to feature -handlers. Runtime registries for pubsub, pipes, and overlays live behind narrow -mutex-protected structs in `runtime.rs`. Command-family handler modules and -protected peer-control feature dispatch remain separate refactor targets. +routing, safe trace-field classification, and named peer/resource/local handler +families before delegating to feature implementations. `resource_contracts.rs` +records the review boundary for each resource family: resource ID patterns, +capabilities, and mutation or host-access points. Runtime registries for +pubsub, pipes, and overlays live behind narrow mutex-protected structs in +`runtime.rs`. Protected peer-control ALPN dispatch remains a separate refactor +target. The local metadata store is SQLite product state. `geth-store` tracks a numeric `schema_version` in the `meta` table and applies ordered migrations up to the diff --git a/docs/production-readiness-roadmap.md b/docs/production-readiness-roadmap.md index 53b4d90..6e482bd 100644 --- a/docs/production-readiness-roadmap.md +++ b/docs/production-readiness-roadmap.md @@ -45,11 +45,11 @@ behavior. ownership and locking rules. - `[x]` Existing daemon startup and status tests pass unchanged. -- `[~]` Extract local control routing. +- `[x]` Extract local control routing. Acceptance criteria: - `[x]` Local `ControlRequest` dispatch is a routing layer, not the home of every feature implementation. - - `[ ]` Each command family has a small handler module or function group. + - `[x]` Each command family has a small handler module or function group. - `[x]` Local-only behavior remains covered by existing integration tests. - `[~]` Extract protected peer-control routing. @@ -64,13 +64,13 @@ behavior. repeating peer-card boilerplate. - `[x]` Remote request tests still prove discovery alone grants no access. -- `[ ]` Extract resource module handlers. +- `[x]` Extract resource module handlers. Acceptance criteria: - - `[ ]` CAS/file-root, KV, DB, document, pubsub, pipe, SSH, and overlay + - `[x]` CAS/file-root, KV, DB, document, pubsub, pipe, SSH, and overlay handlers are separated enough that each can be reviewed independently. - - `[ ]` Each module documents its resource IDs, capabilities, and mutation + - `[x]` Each module documents its resource IDs, capabilities, and mutation points. - - `[ ]` No generic `geth-common` crate is introduced. + - `[x]` No generic `geth-common` crate is introduced. - `[x]` Extract live-sync engine. Acceptance criteria: