From b35b87b6d2926732b36bdaa3dfdea607b2b1f9f4 Mon Sep 17 00:00:00 2001 From: Ethan Jackson Date: Sun, 31 May 2026 03:07:13 +0000 Subject: [PATCH] intent: replace daemon-owned adhoc with submitted intent Before this change, local desired endorsements came from three privileged channels: daemon-owned adhoc membership and token join, the daemon startup self-IP path, and the debug endorsement injection RPC. Core had multiple sources of local signed state, while operators had no single durable intent set that restart and reconciliation could treat as the source of truth. This commit makes submitted endorsement intent that source of truth. The admin CLI persists one durable intent set in State, and the manager reads that set directly when reconciling local endorsements. With that path in place, the entire daemon-owned adhoc module is removed, including its gRPC service, token plumbing, membership state, CLI, proto, and state-dump surface. The debug injection RPC is removed as well, and mesh setup in tests now submits ordinary intent instead. Future demo or join UX should be rebuilt as tooling above `intermesh intent`, not as daemon state or RPC plumbing. --- Cargo.lock | 134 +-- Cargo.toml | 1 - build.rs | 1 - context/interfaces/src/admin.md | 20 +- context/interfaces/src/connect.md | 3 - context/interfaces/src/manager.md | 45 +- context/interfaces/src/modules.md | 10 - context/interfaces/src/modules/adhoc.md | 92 -- context/interfaces/src/modules/adhoc/cli.md | 29 - context/interfaces/src/modules/adhoc/token.md | 34 - context/interfaces/src/state.md | 52 +- context/interfaces/src/state_dump.md | 18 +- proto/adhoc.proto | 76 -- proto/intermesh.proto | 35 +- sandbox/README.md | 2 +- src/admin.rs | 430 ++++++- src/cli.rs | 133 +- src/cmd_report.rs | 53 +- src/connect.rs | 83 +- src/daemon.rs | 79 +- src/gossip.rs | 7 +- src/lib.rs | 1 - src/manager.rs | 239 +--- src/modules.rs | 1 - src/modules/adhoc.rs | 1069 ----------------- src/modules/adhoc/cli.rs | 438 ------- src/modules/adhoc/service.rs | 139 --- src/modules/adhoc/token.rs | 233 ---- src/proto.rs | 4 - src/state.rs | 492 ++++++-- src/state_dump.rs | 93 +- src/test_utils.rs | 10 +- tests/e2e.rs | 872 ++++++-------- 33 files changed, 1496 insertions(+), 3432 deletions(-) delete mode 100644 context/interfaces/src/modules.md delete mode 100644 context/interfaces/src/modules/adhoc.md delete mode 100644 context/interfaces/src/modules/adhoc/cli.md delete mode 100644 context/interfaces/src/modules/adhoc/token.md delete mode 100644 proto/adhoc.proto delete mode 100644 src/modules.rs delete mode 100644 src/modules/adhoc.rs delete mode 100644 src/modules/adhoc/cli.rs delete mode 100644 src/modules/adhoc/service.rs delete mode 100644 src/modules/adhoc/token.rs diff --git a/Cargo.lock b/Cargo.lock index d780dc7c..8fd702e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -277,12 +277,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.0" @@ -383,7 +377,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -563,7 +557,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -587,7 +581,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn", ] [[package]] @@ -598,7 +592,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -628,7 +622,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -659,7 +653,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -715,7 +709,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -822,7 +816,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -958,7 +952,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1529,7 +1523,6 @@ dependencies = [ "ipnet", "itertools", "libc", - "local-ip-address", "p256", "pest", "pest_derive", @@ -1669,18 +1662,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" -[[package]] -name = "local-ip-address" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b3b27f8893f7bbf9485148ff9a65f019e3f33bd5cdc87c83cab16b3fd9ec8" -dependencies = [ - "libc", - "neli", - "thiserror", - "windows-sys 0.59.0", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -1764,31 +1745,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "neli" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93062a0dce6da2517ea35f301dfc88184ce18d3601ec786a727a87bf535deca9" -dependencies = [ - "byteorder", - "libc", - "log", - "neli-proc-macros", -] - -[[package]] -name = "neli-proc-macros" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8034b7fbb6f9455b2a96c19e6edf8dc9fc34c70449938d8ee3b4df363f61fe" -dependencies = [ - "either", - "proc-macro2", - "quote", - "serde", - "syn 1.0.109", -] - [[package]] name = "nom" version = "7.1.3" @@ -1999,7 +1955,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2039,7 +1995,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2156,7 +2112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.110", + "syn", ] [[package]] @@ -2187,7 +2143,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2227,7 +2183,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.110", + "syn", "tempfile", ] @@ -2241,7 +2197,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2467,7 +2423,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2737,7 +2693,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2813,7 +2769,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2965,17 +2921,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.110" @@ -3001,7 +2946,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3027,7 +2972,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3076,7 +3021,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3162,7 +3107,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3190,7 +3135,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3305,7 +3250,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3330,7 +3275,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.110", + "syn", "tempfile", "tonic-build", ] @@ -3386,7 +3331,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3610,7 +3555,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn", "wasm-bindgen-shared", ] @@ -3672,7 +3617,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3683,7 +3628,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3734,15 +3679,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -4054,7 +3990,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", "synstructure", ] @@ -4075,7 +4011,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -4095,7 +4031,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", "synstructure", ] @@ -4116,7 +4052,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -4149,5 +4085,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] diff --git a/Cargo.toml b/Cargo.toml index 922d0d8b..704b092a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,6 @@ inquire = "0.9.1" ipnet = { version = "2.10.1", features = ["serde"] } itertools = "0.14.0" libc = "0.2" -local-ip-address = "0.6.5" prost = "0.14.1" rand = "0.9" serde = { version = "1.0.221", features = ["derive"] } diff --git a/build.rs b/build.rs index cd90d698..a91189eb 100644 --- a/build.rs +++ b/build.rs @@ -4,7 +4,6 @@ fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=build.rs"); std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); tonic_prost_build::compile_protos("proto/intermesh.proto")?; - tonic_prost_build::compile_protos("proto/adhoc.proto")?; // Embed git commit hash at build time println!("cargo:rerun-if-changed=.git/HEAD"); diff --git a/context/interfaces/src/admin.md b/context/interfaces/src/admin.md index 3ff30023..0af70ee6 100644 --- a/context/interfaces/src/admin.md +++ b/context/interfaces/src/admin.md @@ -4,7 +4,8 @@ ## Responsible for - Serving the local Unix-socket admin API. - Providing a client used by CLI commands. -- Dispatching admin commands to daemon state and modules. +- Dispatching admin commands to daemon state. +- Enforcing local self-endorsement intent boundaries at the admin API. ## Public interface ```rust @@ -19,7 +20,6 @@ impl AdminServer { pub(crate) async fn run( self, grpc: GrpcService, - adhoc: adhoc::Handle, cancel: CancellationToken, ) -> anyhow::Result<()>; } @@ -31,13 +31,19 @@ impl AdminClient { /// Create a client for the configured or default admin socket. pub(crate) fn new(socket_path: Option) -> Self; - /// Return a client for ad-hoc admin RPCs. - pub(crate) async fn adhoc_admin_service_client(&self) -> anyhow::Result>; - /// Fetch a structured debug/status dump from the daemon. pub(crate) async fn debug_dump(&self) -> anyhow::Result; - /// Submit one unsigned endorsement intent to the daemon for local signing. - pub(crate) async fn endorse(&self, endorsement: endor::Base) -> anyhow::Result<()>; + /// Get unsigned endorsement intents. + pub(crate) async fn intent_get(&self) -> anyhow::Result>; + + /// Add unsigned endorsement intents for the daemon to sign and install. + pub(crate) async fn intent_add(&self, endorsements: Vec) -> anyhow::Result<()>; + + /// Remove unsigned endorsement intents by exact match. + pub(crate) async fn intent_remove(&self, endorsements: Vec) -> anyhow::Result<()>; + + /// Replace the unsigned endorsement intent set. + pub(crate) async fn intent_replace(&self, endorsements: Vec) -> anyhow::Result<()>; } ``` diff --git a/context/interfaces/src/connect.md b/context/interfaces/src/connect.md index 79a2f2e9..6e1cf083 100644 --- a/context/interfaces/src/connect.md +++ b/context/interfaces/src/connect.md @@ -18,9 +18,6 @@ impl IntermeshClient { trust_engine: Arc, ) -> Self; - /// Add a single-use IP hint for bootstrapping an unknown IMID. - pub(crate) fn with_bootstrap_hint(self, imid: Imid, ip: IpAddr) -> Self; - /// Connect to either `.imid` or a mesh name. pub(crate) async fn connect(&mut self, host: &str, port: u16) -> anyhow::Result>; diff --git a/context/interfaces/src/manager.md b/context/interfaces/src/manager.md index 44bb2ed5..1ae73995 100644 --- a/context/interfaces/src/manager.md +++ b/context/interfaces/src/manager.md @@ -3,55 +3,30 @@ ## Responsible for - Owning the endorsement lifecycle and invoking the trust engine. -- Storing local endorsement intent from modules. -- Reconciling local intent against observed signed state. +- Reading durable endorsement intent from `State`. +- Reconciling durable intent against observed signed state. - Publishing local endorsements and revocations through `State`. - Preprocessing observed signed endorsements into effective derivation input. - Calling `TrustEngine::update`. - Garbage collecting expired signed state. -- Persisting durable state after observed signed state changes. +- Notifying durable state changes for asynchronous persistence. -`Intent` owns source-keyed local intent. `Manager` owns the lifecycle loop that -reacts to store and intent changes, signs missing or renewable local -endorsements, publishes local signed state, revokes retained self-endorsements -that fell out of intent, preprocesses observed signed state, updates trust -derivation, removes expired signed state, and persists signed-state changes -through `State`. +`Manager` owns the lifecycle loop that reacts to observed-message and durable-intent changes, signs missing or renewable local endorsements, revokes retained self-endorsements that fell out of intent, updates the trust derivation, and removes expired signed state through `State`. ## Public interface ```rust -/// Source-keyed local endorsement intent. -pub(crate) struct Intent; - -impl Intent { - /// Create an empty local intent set signed by this node. - pub(crate) fn new(keypair: ImidKeypair) -> Self; - - /// Declare intended adhoc membership endorsements. - pub(crate) async fn set_adhoc(&self, bases: BTreeSet); - - /// Declare intended daemon self-IP endorsements. - pub(crate) async fn set_daemon(&self, bases: BTreeSet); - - /// Add a one-off admin-injected debug endorsement. - pub(crate) async fn add_debug(&self, base: endor::Base); -} - -/// Lifecycle coordinator for local intent and observed signed state. +/// Lifecycle coordinator for durable intent and observed signed state. pub(crate) struct Manager; impl Manager { - /// Create a lifecycle manager for local intent and observed signed state. - pub(crate) fn new(state: Arc, trust_engine: Arc) -> Self; - - /// Return the module-facing local intent handle. - pub(crate) fn intent(&self) -> Arc; + /// Create a lifecycle manager for durable intent and observed signed state. + pub(crate) fn new(state: Arc, trust_engine: Arc) -> Self; /// Run the lifecycle loop until cancelled. /// - /// The loop reconciles local intent, publishes local endorsements and - /// revocations, preprocesses observed signed state, updates `TrustEngine`, - /// and removes expired signed state through `State`. + /// The loop reconciles `State::intents()`, preprocesses observed signed + /// state, updates `TrustEngine`, then publishes local signed-state changes + /// through `State` so subscribers wake with the new derivation. pub(crate) async fn run(&self, cancel: CancellationToken); } ``` diff --git a/context/interfaces/src/modules.md b/context/interfaces/src/modules.md deleted file mode 100644 index e1dd2190..00000000 --- a/context/interfaces/src/modules.md +++ /dev/null @@ -1,10 +0,0 @@ -# `src/modules.rs` - - -## Responsible for -- Exposing optional feature modules. - -## Public interface -```rust -pub mod adhoc; -``` diff --git a/context/interfaces/src/modules/adhoc.md b/context/interfaces/src/modules/adhoc.md deleted file mode 100644 index 719fd618..00000000 --- a/context/interfaces/src/modules/adhoc.md +++ /dev/null @@ -1,92 +0,0 @@ -# `src/modules/adhoc.rs` - - -## Responsible for -- Ad-hoc mesh initialization, joining, and membership changes. -- Producing local endorsement intent for the manager. -- Encoding and decoding join tokens. -- Serving and calling ad-hoc admin and join RPCs. - -## Public interface -```rust -pub(crate) mod cli; -pub(crate) mod service; -pub mod token; - -/// Runtime handle for the ad-hoc membership module. -pub(crate) struct Handle; - -impl Handle { - /// Construct the module from daemon state and runtime services. - pub(crate) fn new( - state: Arc, - te: Arc, - intent: Arc, - ) -> Self; - - /// Replace membership wholesale, persist to disk, and sync intent. - pub(crate) async fn set_membership(&self, membership: Membership); - - /// Initialize a new ad-hoc mesh as root and return a bootstrap token. - pub(crate) async fn root_cli_init( - &self, - my_name: &Name, - mesh: &NamePattern, - ) -> Result; - - /// Accept a token-based join request as root. - pub(crate) async fn root_accept_join( - &self, - token: &token::Token, - joiner_imid: &Imid, - joiner_name: &Name, - joiner_ip: Option, - ) -> Result<(), Status>; - - /// Add a member by IMID and name without token exchange. - pub(crate) async fn root_add( - &self, - name: &Name, - imid: &Imid, - ip: IpAddr, - ) -> Result<(), Status>; - - /// Join an ad-hoc mesh using a bootstrap token. - pub(crate) async fn client_cli_join( - &self, - token_proto: &proto::Token, - node_name: &str, - ) -> Result<(), Status>; - - /// Join using root identity and IP provisioned out of band. - pub(crate) async fn client_direct_join( - &self, - mesh: &NamePattern, - root_imid: &Imid, - root_ip: IpAddr, - ) -> Result<(), Status>; -} - -/// Local ad-hoc mesh membership state. -pub(crate) struct Membership { - pub(crate) mesh: NamePattern, - pub(crate) root_imid: Imid, - pub(crate) root_ip: IpAddr, - pub(crate) root_name: Option, - pub(crate) joiners: BTreeMap, -} - -/// A joined node known by the root. -pub(crate) struct Joiner { - pub(crate) name: Name, - pub(crate) ip: Option, -} - -/// Extension trait to convert errors to gRPC status with context. -pub(crate) trait ToStatusExt { - fn to_status(self, code: Code, context: &str) -> Result; -} - -/// Return whether this is a supported ad-hoc mesh identity pattern. -pub(crate) fn is_valid_mesh(pattern: &NamePattern) -> bool; -``` diff --git a/context/interfaces/src/modules/adhoc/cli.md b/context/interfaces/src/modules/adhoc/cli.md deleted file mode 100644 index 5bbddfd2..00000000 --- a/context/interfaces/src/modules/adhoc/cli.md +++ /dev/null @@ -1,29 +0,0 @@ -# `src/modules/adhoc/cli.rs` - - -## Responsible for -- Parsing and executing ad-hoc CLI subcommands. - -## Public interface -```rust -/// CLI argument structs for ad-hoc subcommands. -pub struct InitArgs; -pub struct JoinArgs; -pub struct AddArgs; - -/// CLI entry points for ad-hoc subcommands. -pub async fn cmd_init( - args: InitArgs, - admin_socket: Option, -) -> anyhow::Result<()>; - -pub async fn cmd_join( - args: JoinArgs, - admin_socket: Option, -) -> anyhow::Result<()>; - -pub async fn cmd_add( - args: AddArgs, - admin_socket: Option, -) -> anyhow::Result<()>; -``` diff --git a/context/interfaces/src/modules/adhoc/token.md b/context/interfaces/src/modules/adhoc/token.md deleted file mode 100644 index aca43ce1..00000000 --- a/context/interfaces/src/modules/adhoc/token.md +++ /dev/null @@ -1,34 +0,0 @@ -# `src/modules/adhoc/token.rs` - - -## Responsible for -- Creating short-lived ad-hoc bootstrap tokens. -- Encoding, decoding, and verifying token protobufs. - -## Public interface -```rust -/// Verified ad-hoc bootstrap token data. -pub struct Token { - pub ip: IpAddr, - pub imid: Imid, - pub exp: u64, - pub mesh: NamePattern, -} - -impl Token { - /// Create a token for this root identity and mesh pattern. - pub fn create(ip: IpAddr, mesh: NamePattern, keypair: &ImidKeypair) -> Self; - - /// Convert to a signed protobuf token for gRPC transmission. - pub fn to_proto(&self, keypair: &ImidKeypair) -> proto::Token; - - /// Verify a protobuf token and return token data. - pub fn verify(proto: &proto::Token) -> anyhow::Result; - - /// Decode a base64 token string to protobuf. - pub fn decode(token_str: &str) -> anyhow::Result; - - /// Encode a protobuf token to base64. - pub fn encode(proto: &proto::Token) -> String; -} -``` diff --git a/context/interfaces/src/state.md b/context/interfaces/src/state.md index 78898d05..d3090422 100644 --- a/context/interfaces/src/state.md +++ b/context/interfaces/src/state.md @@ -4,12 +4,10 @@ ## Responsible for - Loading durable daemon state. - Applying startup arguments over persisted defaults. -- Acting as the daemon's durable runtime handle: modules receive `Arc` - when they need to read or update their persisted slice of daemon state. -- Owning durable node identity, static daemon configuration, and persisted - module state. -- Saving coherent state-file snapshots, including the observed message set. -- Notifying subscribers when mutable persisted state changes. +- Acting as the daemon's durable runtime handle. +- Owning mutable persisted state: observed signed messages and durable endorsement intents. +- Notifying subscribers when mutable state changes. +- Persisting coherent state-file snapshots on demand and from the daemon persistence loop. ## Public interface ```rust @@ -19,16 +17,17 @@ pub(crate) struct Args { pub(crate) admin_socket: Option, pub(crate) log_file: Option, pub(crate) listen_addr: Option, - pub(crate) local_ip: Option, - pub(crate) endorse_local_ip: Option, pub(crate) intercept: Option, } /// Durable daemon state plus constructed runtime handles. /// /// Static configuration and identity live directly on `State`; runtime-mutable -/// persisted data is owned behind typed accessors so state-file snapshots are -/// taken coherently. +/// persisted data is owned behind typed accessors. Persistence happens +/// asynchronously from the daemon persistence loop, while `save()` remains +/// available for explicit flushes. +/// +/// Subscribers are notified when in-memory mutable state changes. pub(crate) struct State { pub(crate) keypair: ImidKeypair, pub(crate) imid: Imid, @@ -36,41 +35,50 @@ pub(crate) struct State { pub(crate) admin_socket: Option, pub(crate) log_file: Option, pub(crate) listen_addr: SocketAddr, - pub(crate) local_ip: IpAddr, /// Unix time when this state was created (daemon start). Not persisted. pub(crate) started_at_unix: u64, - pub(crate) endorse_local_ip: bool, pub(crate) intercept: bool, } impl State { /// Load or initialize daemon state from args and persistent storage. - pub(crate) async fn new(args: Args) -> anyhow::Result; - - /// Return the current ad-hoc membership, if any. - pub(crate) async fn adhoc_membership(&self) -> Option; + pub(crate) async fn new(args: state::Args) -> anyhow::Result; - /// Replace ad-hoc membership in memory and notify subscribers when changed. - pub(crate) async fn set_adhoc_membership(&self, membership: Option); + /// Return the durable endorsement intent set. + pub(crate) async fn intents(&self) -> BTreeSet; /// Return latest endorsements and revocations from one coherent state read. - pub(crate) async fn messages(&self) -> (BTreeSet, BTreeSet); + pub(crate) async fn messages( + &self, + ) -> (BTreeSet, BTreeSet); /// Return messages plus retained self-endorsement history from one coherent /// state read. pub(crate) async fn messages_with_retained( &self, - ) -> (BTreeSet, BTreeSet); + ) -> (BTreeSet, BTreeSet); /// Apply removals and additions as one observed message set update. - pub(crate) async fn update_messages(&self, update: MessageUpdate) -> anyhow::Result<()>; + pub(crate) async fn update_messages(&self, update: state::MessageUpdate); - /// Return the current monotonic message generation. + /// Monotonic message generation bumped on every message mutation. pub(crate) async fn message_generation(&self) -> u64; /// Subscribe to mutable state changes. pub(crate) fn subscribe(&self) -> watch::Receiver<()>; + /// Add durable endorsement intents and queue them for persistence. + pub(crate) async fn add_intents(&self, bases: BTreeSet) -> anyhow::Result<()>; + + /// Remove durable endorsement intents and queue them for persistence. + pub(crate) async fn remove_intents(&self, bases: &BTreeSet) -> anyhow::Result<()>; + + /// Replace durable endorsement intents and queue them for persistence. + pub(crate) async fn replace_intents(&self, bases: BTreeSet) -> anyhow::Result<()>; + + /// Persist state changes until cancelled, retrying failed writes. + pub(crate) async fn persist_loop(&self, cancel: CancellationToken); + /// Persist durable daemon state atomically. pub(crate) async fn save(&self) -> anyhow::Result<()>; } diff --git a/context/interfaces/src/state_dump.md b/context/interfaces/src/state_dump.md index 6d71d5cc..76e5bbe8 100644 --- a/context/interfaces/src/state_dump.md +++ b/context/interfaces/src/state_dump.md @@ -2,14 +2,15 @@ ## Responsible for -- Building structured status/debug snapshots from daemon state. +- Building structured status/debug snapshots from daemon derivation and runtime config paths. +- Formatting full debug output and compact status-table output. +- Converting admin RPC dump responses to and from Rust structures. ## Public interface ```rust /// Structured snapshot of daemon state for status/debug output. pub struct StateDump { pub derivation: Derivation, - pub adhoc_membership: Vec, pub admin_socket: Option, pub log_file: Option, /// Daemon self-description: build hash and start time reported by the @@ -19,19 +20,10 @@ pub struct StateDump { pub daemon_started_at_unix: u64, } -/// Structured snapshot of ad-hoc membership state. -pub struct AdhocMembershipDump { - pub mesh_domain: String, - pub root_imid: String, - pub self_name: Option, - pub is_root: bool, -} - impl StateDump { - /// Build a dump from daemon derivation and optional membership state. - pub fn new( + /// Build a dump from daemon derivation and runtime config paths. + pub(crate) fn new( derivation: Derivation, - membership: Option, admin_socket: Option, log_file: Option, daemon_build: String, diff --git a/proto/adhoc.proto b/proto/adhoc.proto deleted file mode 100644 index e909d689..00000000 --- a/proto/adhoc.proto +++ /dev/null @@ -1,76 +0,0 @@ -syntax = "proto3"; - -package intermesh.adhoc; - -// CLI-to-daemon admin API over the unix socket. -service AdminService { - rpc Init(InitRequest) returns (InitResponse); - rpc Join(JoinRequest) returns (JoinResponse); - rpc Add(AddRequest) returns (AddResponse); -} - -message InitRequest { - string mesh_pattern = 1; // (NamePattern) e.g., "**.test.mesh" - string node_name = 2; // (Name) Full node name (e.g., "root.test.mesh") -} - -message InitResponse { - Token token = 1; -} - -// Daemon-to-daemon join acceptor API. -service JoinService { - rpc Accept(JoinTokenRequest) returns (JoinResponse); -} - -// CLI-to-daemon join: one operation, two modes. -message JoinRequest { - oneof mode { - JoinTokenRequest token = 1; - JoinDirectRequest direct = 2; - } -} - -// Token-based join: joiner presents token to root over mTLS. -message JoinTokenRequest { - Token token = 1; - string requested_name = 2; // (Name) Name the joiner wants (e.g., "db.test.mesh") -} - -// Direct join: joiner writes local membership from explicit root identity. -// No token, no gRPC to root. CLI-to-daemon only. Joiner learns its name -// from root's endorsement, not from this request. -message JoinDirectRequest { - string mesh_pattern = 1; // (NamePattern) e.g., "**.test.mesh" - string root_imid = 2; // (IMID) Root node's IMID - string root_ip = 3; // (IP) Root node's IP address -} - -message JoinResponse { -} - -message TokenData { - string ip = 1; // (IP) Root node's IP address (e.g., "192.168.1.10") - string imid = 2; // (IMID) Root node's IMID - uint64 exp = 3; // (Timestamp) Expiration - string mesh_pattern = 4; // (NamePattern) e.g., "**.test.mesh" -} - -message Token { - bytes token_data = 1; - bytes signature = 2; -} - -message AddRequest { - string name = 1; // (Name) Full node name (e.g., "db.test.mesh") - string imid = 2; // (IMID) Node's IMID - string ip = 3; // (IP) Node's IP address -} - -message AddResponse {} - -message Membership { - string mesh_pattern = 1; // (NamePattern) e.g., "**.test.mesh" - string root_imid = 2; // (IMID) Root node's IMID - string self_name = 3; // (Name) e.g., "db.test.mesh" (may be empty) -} diff --git a/proto/intermesh.proto b/proto/intermesh.proto index 41db298a..3baeb072 100644 --- a/proto/intermesh.proto +++ b/proto/intermesh.proto @@ -2,8 +2,6 @@ syntax = "proto3"; package intermesh; -import "adhoc.proto"; - service GossipService { rpc Exchange(GossipUpdate) returns (GossipUpdate); } @@ -144,7 +142,12 @@ message Revocation { // commands should not be exposed to the mesh. service AdminService { rpc StateDump(StateDumpRequest) returns (StateDumpResponse); - rpc Endorse(EndorseRequest) returns (EndorseResponse); + + // Submitted endorsement intent queries and mutations. + rpc IntentGet(IntentGetRequest) returns (IntentGetResponse); + rpc IntentAdd(IntentAddRequest) returns (IntentAddResponse); + rpc IntentRemove(IntentRemoveRequest) returns (IntentRemoveResponse); + rpc IntentReplace(IntentReplaceRequest) returns (IntentReplaceResponse); } message StringList { @@ -172,8 +175,6 @@ message StateDumpResponse { string admin_socket = 7; optional string log_file = 8; - repeated intermesh.adhoc.Membership adhoc_membership = 9; - // Daemon self-description. The daemon reports its own build and start // time because the running daemon may predate the binary on disk. string daemon_build = 14; // git commit hash of the running daemon @@ -190,10 +191,28 @@ message ShowResponse { repeated string ips = 2; } -message EndorseRequest { - EndorsementData data = 1; +message IntentGetRequest {} + +message IntentGetResponse { + repeated EndorsementData data = 1; +} + +message IntentAddRequest { + repeated EndorsementData data = 1; +} + +message IntentAddResponse {} + +message IntentRemoveRequest { + repeated EndorsementData data = 1; +} + +message IntentRemoveResponse {} + +message IntentReplaceRequest { + repeated EndorsementData data = 1; } -message EndorseResponse {} +message IntentReplaceResponse {} diff --git a/sandbox/README.md b/sandbox/README.md index 26ccd9ce..49f7d9fb 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -1,7 +1,7 @@ # Intermesh sandbox This directory contains a small Docker Compose sandbox for trying Intermesh -manually (bring up a few nodes, exec into them, run `intermesh adhoc up`, etc.). +manually (bring up a few nodes, exec into them, run `intermesh intent add`, etc.). ## Start the sandbox diff --git a/src/admin.rs b/src/admin.rs index 15d5865b..c54212cc 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use hyper_util::rt::TokioIo; +use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; use tokio::net::{UnixListener, UnixStream}; @@ -12,14 +13,16 @@ use tracing::{debug, info, warn}; use crate::daemon::GrpcService; use crate::endor; -use crate::modules::adhoc; use crate::proto::intermesh as proto; +use crate::state::State; use crate::state_dump::StateDump; -use proto::adhoc::admin_service_client::AdminServiceClient as AdhocAdminServiceClient; -use proto::adhoc::admin_service_server::AdminServiceServer as AdhocAdminServiceServer; use proto::admin_service_client::AdminServiceClient; use proto::admin_service_server::{AdminService, AdminServiceServer}; +fn intent_update_error(err: &anyhow::Error) -> Status { + Status::internal(format!("failed to save intent: {err}")) +} + #[tonic::async_trait] impl AdminService for GrpcService { async fn state_dump( @@ -29,10 +32,8 @@ impl AdminService for GrpcService { debug!("processing admin state_dump request"); let derivation = (*self.te.derivation()).clone(); - let membership = self.state.adhoc_membership().await; let dump = StateDump::new( derivation, - membership, self.state .admin_socket .as_ref() @@ -48,29 +49,89 @@ impl AdminService for GrpcService { Ok(Response::new(dump.to_proto())) } - async fn endorse( + async fn intent_get( &self, - request: Request, - ) -> Result, Status> { - debug!("processing admin endorse request"); + _request: Request, + ) -> Result, Status> { + debug!("processing admin intent_get request"); - let data = request - .into_inner() - .data - .ok_or_else(|| Status::invalid_argument("missing endorsement data"))?; + let bases = self.state.intents().await; + let data = bases.iter().map(Into::into).collect(); - let base: endor::Base = data.try_into().map_err(|e: anyhow::Error| { - Status::invalid_argument(format!("invalid endorsement: {e}")) - })?; + Ok(Response::new(proto::IntentGetResponse { data })) + } - if base.endorser != self.state.keypair.to_imid() { - return Err(Status::invalid_argument("endorser must be self")); - } + async fn intent_add( + &self, + request: Request, + ) -> Result, Status> { + debug!("processing admin intent_add request"); - self.intent.add_debug(base).await; + let request = request.into_inner(); + let bases = parse_self_endorsed_bases(&self.state, request.data)?; + self.state + .add_intents(bases) + .await + .map_err(|err| intent_update_error(&err))?; + + Ok(Response::new(proto::IntentAddResponse {})) + } + + async fn intent_remove( + &self, + request: Request, + ) -> Result, Status> { + debug!("processing admin intent_remove request"); + + let request = request.into_inner(); + let bases = parse_self_endorsed_bases(&self.state, request.data)?; + self.state + .remove_intents(&bases) + .await + .map_err(|err| intent_update_error(&err))?; - Ok(Response::new(proto::EndorseResponse {})) + Ok(Response::new(proto::IntentRemoveResponse {})) + } + + async fn intent_replace( + &self, + request: Request, + ) -> Result, Status> { + debug!("processing admin intent_replace request"); + + let request = request.into_inner(); + let bases = parse_self_endorsed_bases(&self.state, request.data)?; + self.state + .replace_intents(bases) + .await + .map_err(|err| intent_update_error(&err))?; + + Ok(Response::new(proto::IntentReplaceResponse {})) + } +} + +fn parse_bases(data: Vec) -> Result, Status> { + data.into_iter() + .map(|data| { + data.try_into().map_err(|e: anyhow::Error| { + Status::invalid_argument(format!("invalid endorsement: {e}")) + }) + }) + .collect() +} + +fn parse_self_endorsed_bases( + state: &State, + data: Vec, +) -> Result, Status> { + let bases = parse_bases(data)?; + let me = state.keypair.to_imid(); + for base in &bases { + if base.endorser != me { + return Err(Status::invalid_argument("endorser must be self")); + } } + Ok(bases) } /// Default path to the admin Unix socket @@ -117,13 +178,11 @@ impl AdminServer { pub(crate) async fn run( self, grpc: GrpcService, - adhoc: adhoc::Handle, cancel: sync::CancellationToken, ) -> Result<()> { info!("listening on unix socket {}", self.socket_path.display()); Server::builder() .add_service(AdminServiceServer::new(grpc)) - .add_service(AdhocAdminServiceServer::new(adhoc)) .serve_with_incoming_shutdown( UnixListenerStream::new(self.listener), cancel.cancelled(), @@ -173,15 +232,6 @@ impl AdminClient { Ok(AdminServiceClient::new(self.create_admin_channel().await?)) } - /// Connect to the adhoc admin service and return a gRPC client - pub(crate) async fn adhoc_admin_service_client( - &self, - ) -> Result> { - Ok(AdhocAdminServiceClient::new( - self.create_admin_channel().await?, - )) - } - /// Dump the trust engine state pub(crate) async fn debug_dump(&self) -> Result { let mut client = self.connect().await?; @@ -195,16 +245,320 @@ impl AdminClient { StateDump::from_proto(response.into_inner()) } - /// Submit an unsigned endorsement; the daemon signs and installs it. - pub(crate) async fn endorse(&self, endorsement: endor::Base) -> Result<()> { - let data: proto::EndorsementData = (&endorsement).into(); - let request = Request::new(proto::EndorseRequest { data: Some(data) }); + /// Get unsigned endorsement intents. + pub(crate) async fn intent_get(&self) -> Result> { + let request = Request::new(proto::IntentGetRequest {}); + + let mut client = self.connect().await?; + let response = client + .intent_get(request) + .await + .context("failed to get endorsement intents")?; + + response + .into_inner() + .data + .into_iter() + .map(TryInto::try_into) + .collect::>>() + .context("daemon returned invalid endorsement intent") + } + + /// Add unsigned endorsement intents for the daemon to sign and install. + pub(crate) async fn intent_add(&self, endorsements: Vec) -> Result<()> { + let data = endorsements.iter().map(Into::into).collect(); + let request = Request::new(proto::IntentAddRequest { data }); + + let mut client = self.connect().await?; + client + .intent_add(request) + .await + .context("failed to add endorsement intent")?; + Ok(()) + } + + /// Remove unsigned endorsement intents by exact match. + pub(crate) async fn intent_remove(&self, endorsements: Vec) -> Result<()> { + let data = endorsements.iter().map(Into::into).collect(); + let request = Request::new(proto::IntentRemoveRequest { data }); + + let mut client = self.connect().await?; + client + .intent_remove(request) + .await + .context("failed to remove endorsement intent")?; + Ok(()) + } + + /// Replace the unsigned endorsement intent set. + pub(crate) async fn intent_replace(&self, endorsements: Vec) -> Result<()> { + let data = endorsements.iter().map(Into::into).collect(); + let request = Request::new(proto::IntentReplaceRequest { data }); let mut client = self.connect().await?; client - .endorse(request) + .intent_replace(request) .await - .context("failed to create endorsement")?; + .context("failed to replace endorsement intents")?; Ok(()) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tonic::Code; + + use super::*; + use crate::assert::UnwrapAssert; + use crate::test_utils::{MockState, TestEndorsement, TestFixture}; + use crate::trust_engine::TrustEngine; + + struct AdminFixture { + grpc: GrpcService, + _mock: MockState, + } + + async fn admin_fixture() -> AdminFixture { + let mock = MockState::new().await; + let trust_engine = Arc::new(TrustEngine::new(mock.state.imid.clone())); + let grpc = GrpcService { + state: mock.state.clone(), + te: trust_engine, + }; + + AdminFixture { grpc, _mock: mock } + } + + #[tokio::test] + async fn intent_add_records_self_endorsed_intent() { + let admin = admin_fixture().await; + let mut fix = TestFixture::new().with_keypair("me", admin.grpc.state.keypair.clone()); + let base = TestEndorsement { + endorser: "me", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + let request = proto::IntentAddRequest { + data: vec![(&base).into()], + }; + admin.grpc.intent_add(Request::new(request)).await.assert(); + + assert!(admin.grpc.state.intents().await.contains(&base)); + } + + #[tokio::test] + async fn intent_get_returns_intent() { + let admin = admin_fixture().await; + let mut fix = TestFixture::new().with_keypair("me", admin.grpc.state.keypair.clone()); + let base = TestEndorsement { + endorser: "me", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + admin + .grpc + .state + .replace_intents(BTreeSet::from([base.clone()])) + .await + .assert(); + let response = admin + .grpc + .intent_get(Request::new(proto::IntentGetRequest {})) + .await + .assert() + .into_inner(); + let bases: BTreeSet = response + .data + .into_iter() + .map(TryInto::try_into) + .collect::>() + .assert(); + + assert_eq!(bases, BTreeSet::from([base])); + } + + #[tokio::test] + async fn intent_add_rejects_foreign_endorser() { + let admin = admin_fixture().await; + let mut fix = TestFixture::new().with_keypair("me", admin.grpc.state.keypair.clone()); + let valid = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + // An intent endorsed by another node, not us. + let foreign = TestEndorsement { + endorser: "bob", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + let request = proto::IntentAddRequest { + data: vec![(&valid).into(), (&foreign).into()], + }; + let status = admin + .grpc + .intent_add(Request::new(request)) + .await + .err() + .assert(); + + assert_eq!(status.code(), Code::InvalidArgument); + assert_eq!(status.message(), "endorser must be self"); + assert!(admin.grpc.state.intents().await.is_empty()); + } + + #[tokio::test] + async fn intent_add_rejects_invalid_endorsement_data() { + let admin = admin_fixture().await; + let request = proto::IntentAddRequest { + data: vec![proto::EndorsementData::default()], + }; + let status = admin + .grpc + .intent_add(Request::new(request)) + .await + .err() + .assert(); + + assert_eq!(status.code(), Code::InvalidArgument); + assert!(status.message().contains("invalid endorsement")); + assert!(admin.grpc.state.intents().await.is_empty()); + } + + #[tokio::test] + async fn intent_replace_validates_all_before_write() { + let admin = admin_fixture().await; + let mut fix = TestFixture::new().with_keypair("me", admin.grpc.state.keypair.clone()); + let original = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let replacement = TestEndorsement { + endorser: "me", + target_imids: "carol", + names: "carol.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let foreign = TestEndorsement { + endorser: "bob", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + admin + .grpc + .state + .replace_intents(BTreeSet::from([original.clone()])) + .await + .assert(); + let request = proto::IntentReplaceRequest { + data: vec![(&replacement).into(), (&foreign).into()], + }; + let status = admin + .grpc + .intent_replace(Request::new(request)) + .await + .err() + .assert(); + + assert_eq!(status.code(), Code::InvalidArgument); + assert_eq!(status.message(), "endorser must be self"); + assert_eq!(admin.grpc.state.intents().await, BTreeSet::from([original])); + } + + #[tokio::test] + async fn intent_remove_deletes_exact_intent() { + let admin = admin_fixture().await; + let mut fix = TestFixture::new().with_keypair("me", admin.grpc.state.keypair.clone()); + let remove = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let keep = TestEndorsement { + endorser: "me", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + admin + .grpc + .state + .replace_intents(BTreeSet::from([remove.clone(), keep.clone()])) + .await + .assert(); + let request = proto::IntentRemoveRequest { + data: vec![(&remove).into()], + }; + admin + .grpc + .intent_remove(Request::new(request)) + .await + .assert(); + + assert_eq!(admin.grpc.state.intents().await, BTreeSet::from([keep])); + } + + #[tokio::test] + async fn intent_remove_rejects_foreign_endorser() { + let admin = admin_fixture().await; + let mut fix = TestFixture::new().with_keypair("me", admin.grpc.state.keypair.clone()); + let keep = TestEndorsement { + endorser: "me", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let foreign = TestEndorsement { + endorser: "alice", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + admin + .grpc + .state + .replace_intents(BTreeSet::from([keep.clone()])) + .await + .assert(); + let request = proto::IntentRemoveRequest { + data: vec![(&foreign).into()], + }; + let status = admin + .grpc + .intent_remove(Request::new(request)) + .await + .err() + .assert(); + + assert_eq!(status.code(), Code::InvalidArgument); + assert_eq!(status.message(), "endorser must be self"); + assert_eq!(admin.grpc.state.intents().await, BTreeSet::from([keep])); + } +} diff --git a/src/cli.rs b/src/cli.rs index 50d84e21..2a6b6897 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,9 +10,9 @@ use crate::admin::AdminClient; use crate::cmd_report; use crate::cmd_status; use crate::daemon; -use crate::dsl::parse_endorsements; +use crate::dsl::{format_endorsements, parse_endorsements}; +use crate::endor; use crate::fmt; -use crate::modules::adhoc::cli::{cmd_add, cmd_init, cmd_join, AddArgs, InitArgs, JoinArgs}; use crate::state; const GIT_COMMIT: &str = env!("GIT_COMMIT"); @@ -55,14 +55,6 @@ enum Commands { command: Vec, }, - /// Initialize a new mesh network - #[command(hide = true)] - Init(InitArgs), - - /// Join an existing mesh network - #[command(hide = true)] - Join(JoinArgs), - Status { #[arg(short, long)] watch: bool, @@ -71,15 +63,14 @@ enum Commands { /// Print the local node's full IMID Imid, - Debug { + /// Manage submitted endorsement intents + Intent { #[command(subcommand)] - command: DebugCommands, + command: IntentCommands, }, - - /// Ad-hoc mesh network commands - Adhoc { + Debug { #[command(subcommand)] - command: AdhocCommands, + command: DebugCommands, }, /// Print version information @@ -87,15 +78,18 @@ enum Commands { } #[derive(Subcommand)] -enum AdhocCommands { - /// Initialize a new mesh network - Init(InitArgs), +enum IntentCommands { + /// Print submitted endorsement intents as DSL. + Get, + + /// Add self-endorsed endorsement intents from DSL (use "-" for stdin). + Add { input: String }, - /// Join an existing mesh network - Join(JoinArgs), + /// Remove endorsement intents by exact DSL match. + Remove { input: String }, - /// Add a member to the mesh (root only) - Add(AddArgs), + /// Replace self-endorsed endorsement intents from DSL (use "-" for stdin). + Replace { input: String }, } #[derive(Subcommand)] @@ -105,9 +99,6 @@ enum DebugCommands { #[arg(long)] toml: bool, }, - /// Sign and insert endorsements from DSL input (use "-" for stdin). - Endorse { input: String }, - /// Write a shareable debug report file and print its path /// /// The header carries two fingerprints for comparing captures. "state" @@ -191,7 +182,7 @@ async fn cmd_status(color: String, watch: bool, admin_socket: Option) - cmd_status::run(admin_socket, watch).await } -async fn cmd_debug_endorse(input: String, admin_socket: Option) -> Result<()> { +async fn read_endorsements(input: String, admin_client: &AdminClient) -> Result> { let dsl = if input == "-" { let mut buf = String::new(); io::stdin().read_to_string(&mut buf)?; @@ -199,12 +190,60 @@ async fn cmd_debug_endorse(input: String, admin_socket: Option) -> Resu } else { input }; - let admin_client = AdminClient::new(admin_socket); + if dsl.trim().is_empty() { + return Ok(Vec::new()); + } + let dump = admin_client.debug_dump().await?; - for endorsement in parse_endorsements(&dsl, &dump.derivation.my_imid)? { - admin_client.endorse(endorsement).await?; - println!("{} Created endorsement", fmt::info_icon()); + parse_endorsements(&dsl, &dump.derivation.my_imid) +} + +fn format_intent_get(endorsements: &[endor::Base]) -> String { + if endorsements.is_empty() { + String::new() + } else { + let mut out = format_endorsements(endorsements); + if !out.ends_with('\n') { + out.push('\n'); + } + out } +} + +async fn cmd_intent_get(color: String, admin_socket: Option) -> Result<()> { + configure_colors(&color); + let endorsements = AdminClient::new(admin_socket).intent_get().await?; + print!("{}", format_intent_get(&endorsements)); + Ok(()) +} + +async fn cmd_intent_add(input: String, admin_socket: Option) -> Result<()> { + let admin_client = AdminClient::new(admin_socket); + let endorsements = read_endorsements(input, &admin_client).await?; + let count = endorsements.len(); + admin_client.intent_add(endorsements).await?; + let icon = fmt::info_icon(); + println!("{icon} Added {count} endorsement intent(s)"); + Ok(()) +} + +async fn cmd_intent_remove(input: String, admin_socket: Option) -> Result<()> { + let admin_client = AdminClient::new(admin_socket); + let endorsements = read_endorsements(input, &admin_client).await?; + let count = endorsements.len(); + admin_client.intent_remove(endorsements).await?; + let icon = fmt::info_icon(); + println!("{icon} Removed {count} endorsement intent(s)"); + Ok(()) +} + +async fn cmd_intent_replace(input: String, admin_socket: Option) -> Result<()> { + let admin_client = AdminClient::new(admin_socket); + let endorsements = read_endorsements(input, &admin_client).await?; + let count = endorsements.len(); + admin_client.intent_replace(endorsements).await?; + let icon = fmt::info_icon(); + println!("{icon} Replaced endorsement intents with {count} item(s)"); Ok(()) } @@ -219,22 +258,26 @@ pub async fn run() -> Result<()> { args.intercept = Some(true); cmd_daemon(args, cli.admin_socket, command).await?; } - Some(Commands::Init(args)) => cmd_init(args, cli.admin_socket).await?, - Some(Commands::Join(args)) => cmd_join(args, cli.admin_socket).await?, Some(Commands::Status { watch }) => cmd_status(cli.color, watch, cli.admin_socket).await?, Some(Commands::Imid) => cmd_imid(cli.admin_socket).await?, - Some(Commands::Adhoc { command }) => match command { - AdhocCommands::Init(args) => cmd_init(args, cli.admin_socket).await?, - AdhocCommands::Join(args) => cmd_join(args, cli.admin_socket).await?, - AdhocCommands::Add(args) => cmd_add(args, cli.admin_socket).await?, + Some(Commands::Intent { command }) => match command { + IntentCommands::Get => { + cmd_intent_get(cli.color, cli.admin_socket).await?; + } + IntentCommands::Add { input } => { + cmd_intent_add(input, cli.admin_socket).await?; + } + IntentCommands::Remove { input } => { + cmd_intent_remove(input, cli.admin_socket).await?; + } + IntentCommands::Replace { input } => { + cmd_intent_replace(input, cli.admin_socket).await?; + } }, Some(Commands::Debug { command }) => match command { DebugCommands::Dump { toml } => { cmd_debug_dump(cli.color, toml, cli.admin_socket).await?; } - DebugCommands::Endorse { input } => { - cmd_debug_endorse(input, cli.admin_socket).await?; - } DebugCommands::Report => cmd_report::run(cli.admin_socket).await?, }, Some(Commands::Version) => println!("intermesh {GIT_COMMIT}"), @@ -243,3 +286,13 @@ pub async fn run() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_intent_get_empty_view() { + assert_eq!(format_intent_get(&[]), ""); + } +} diff --git a/src/cmd_report.rs b/src/cmd_report.rs index 1318406b..bd338484 100644 --- a/src/cmd_report.rs +++ b/src/cmd_report.rs @@ -152,19 +152,6 @@ impl Report { .assert(); row(out, "✓", "daemon running", &detail); - match self.dump.adhoc_membership.first() { - Some(m) => { - let role = if m.is_root { "root" } else { "a member" }; - let detail = format!( - "**.{}, root {}, this node is {role}", - m.mesh_domain, - short(&m.root_imid) - ); - row(out, "✓", "mesh joined", &detail); - } - None => row(out, "⚠", "mesh joined", "not joined to any mesh"), - } - let names = self.dump.derivation.name_to_imid.len(); let with_ips = self.names_with_ips(); if names == 0 { @@ -405,15 +392,13 @@ pub fn node_name(dump: &StateDump) -> String { /// Canonical, node-independent view of derived state used for /// `state_fingerprint`. Node-local fields (`my_imid`, `iteration`, -/// `derived_at`, `my_constraints`, the `my_authz*` views, membership -/// `self_name` / `is_root`) are deliberately excluded so two nodes -/// that agree on the mesh produce the same fingerprint. +/// `derived_at`, `my_constraints`, and the `my_authz*` views) are deliberately +/// excluded so two nodes that agree on the mesh produce the same fingerprint. #[derive(Serialize)] struct FingerprintView { names: BTreeMap>, imids: BTreeMap>, ips: BTreeMap>, - mesh: BTreeSet<(String, String)>, } /// Hash of the shared derived view: the "do these nodes agree?" @@ -426,11 +411,6 @@ pub fn state_fingerprint(dump: &StateDump) -> String { names: canonical_map(d.name_to_imid.iter()), imids: canonical_map(d.imid_to_names.iter()), ips: canonical_map(d.imid_to_ip.iter()), - mesh: dump - .adhoc_membership - .iter() - .map(|m| (m.mesh_domain.clone(), m.root_imid.clone())) - .collect(), }; sha256_short(&serde_json::to_vec(&view).assert()) } @@ -718,18 +698,16 @@ fn unix_now() -> Duration { mod tests { use super::*; use crate::imid::ImidKeypair; - use crate::state_dump::AdhocMembershipDump; use crate::test_utils::{TestConstraint, TestEndorsement, TestFixture}; use crate::trust_engine::Derivation; use std::net::IpAddr; /// A three-node dump: me (10.0.0.1), db (10.0.0.2), and web - /// (10.0.0.3 + `fd00::1`), joined to **.test.mesh under `root`. + /// (10.0.0.3 + `fd00::1`). fn test_dump() -> StateDump { let me = ImidKeypair::test_keypair("me"); let db = ImidKeypair::test_keypair("db"); let web = ImidKeypair::test_keypair("web"); - let root = ImidKeypair::test_keypair("root"); let name_to_imid = BTreeMap::from([ ( @@ -791,12 +769,6 @@ mod tests { derived_at: 0, input: BTreeSet::new(), }, - adhoc_membership: vec![AdhocMembershipDump { - mesh_domain: "test.mesh".to_string(), - root_imid: root.to_imid().to_string(), - self_name: Some("me.test.mesh".to_string()), - is_root: false, - }], admin_socket: Some("/run/intermesh/admin.sock".to_string()), log_file: None, daemon_build: "a1b2c3d".to_string(), @@ -857,7 +829,6 @@ SUMMARY legend: ✓ ok ⚠ attention ✗ problem ○ planned (not yet built in any release) ✓ daemon running admin socket reachable, up 3h12m, build a1b2c3d - ✓ mesh joined **.test.mesh, root A3L-mN23j3FJOvXZ…, this node is a member ✓ name resolution 3 names, 3 with IPs ○ peers 2 known · reachability (planned — future release) ○ tunnels (planned — future release) @@ -914,9 +885,8 @@ RECENT EVENTS #[test] fn render_degraded_view() { - // Not joined, intercept off, older daemon, log tail present. + // Intercept off, older daemon, log tail present. let mut dump = test_dump(); - dump.adhoc_membership.clear(); dump.daemon_build = String::new(); dump.daemon_started_at_unix = 0; dump.log_file = Some("/var/log/intermesh.log".to_string()); @@ -939,8 +909,6 @@ RECENT EVENTS assert!(out.contains("daemon build: unknown")); assert!(out.contains("admin socket reachable, build unknown")); assert!(!out.contains(", up ")); - assert!(out.contains("⚠ mesh joined")); - assert!(out.contains("not joined to any mesh")); assert!(out.contains("gossip :9898 · intercept off")); assert!(!out.contains("external")); assert!(out.contains("log tail: /var/log/intermesh.log")); @@ -987,9 +955,8 @@ RECENT EVENTS let mut fix = TestFixture::new(); let dump_a = test_dump(); - // Same shared view from another node's perspective: different - // self identity, iteration, derived_at, constraints, input, and - // membership role. + // Same shared view from another node's perspective: different self + // identity, iteration, derived_at, constraints, and input. let mut dump_b = dump_a.clone(); dump_b.derivation.my_imid = ImidKeypair::test_keypair("db").to_imid(); dump_b.derivation.iteration = 99; @@ -1009,8 +976,6 @@ RECENT EVENTS ..Default::default() } .to_base(&mut fix)]); - dump_b.adhoc_membership[0].self_name = Some("db.test.mesh".to_string()); - dump_b.adhoc_membership[0].is_root = true; dump_b.daemon_build = "fffffff".to_string(); dump_b.daemon_started_at_unix = 42; @@ -1027,12 +992,6 @@ RECENT EVENTS .insert("10.9.9.9".parse().assert()); assert_ne!(state_fingerprint(&dump_a), state_fingerprint(&dump_c)); - // So does a different mesh root. - let mut dump_d = dump_a.clone(); - dump_d.adhoc_membership[0].root_imid = - ImidKeypair::test_keypair("other").to_imid().to_string(); - assert_ne!(state_fingerprint(&dump_a), state_fingerprint(&dump_d)); - // Format: sha256: prefix plus 16 hex chars. let fp = state_fingerprint(&dump_a); assert!(fp.starts_with("sha256:")); diff --git a/src/connect.rs b/src/connect.rs index e944469d..3147e279 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -9,7 +9,6 @@ use hyper_util::rt::TokioIo; use rustls::pki_types::PrivateKeyDer; use rustls::{ClientConfig, ServerConfig}; use std::io; -use std::net::IpAddr; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; @@ -23,17 +22,11 @@ use tokio_stream::{wrappers::TcpListenerStream, StreamExt}; use tonic::transport::server::Connected; use tonic::transport::Uri; use tower::Service; -use tracing::warn; #[derive(Clone)] pub(crate) struct IntermeshClient { tls_connector: TlsConnector, te: Arc, - - // Bootstrap hint for an IMID not yet in the trust engine. Used for - // bootstrapping where a connection needs to an IMID not yet in the trust - // engine. Single-use: cleared after first connection. - bootstrap_hint: Option<(Imid, IpAddr)>, } impl IntermeshClient { @@ -63,28 +56,13 @@ impl IntermeshClient { IntermeshClient { tls_connector, te: trust_engine, - bootstrap_hint: None, } } - /// Set a bootstrap hint for connecting to an IMID not yet in the trust engine. - /// - /// This is used for bootstrap a connection must be made to an IMID whose IP - /// is not yet in the trust engine. The hint is single-use and cleared after - /// the first connection attempt. - /// - /// If the trust engine already knows the IMID's IP, the hint is ignored and a - /// warning is logged. - #[must_use] - pub(crate) fn with_bootstrap_hint(mut self, imid: Imid, ip: IpAddr) -> Self { - self.bootstrap_hint = Some((imid, ip)); - self - } - /// Connect to a host. Host can be an IMID DNS name (.imid) or a /// registered name. Resolves through trust engine to find the IP address. pub(crate) async fn connect( - &mut self, + &self, host: &str, port: u16, ) -> Result> { @@ -98,7 +76,7 @@ impl IntermeshClient { /// Connect to a mesh name. pub(crate) async fn connect_name( - &mut self, + &self, name: &Name, port: u16, ) -> Result> { @@ -115,28 +93,15 @@ impl IntermeshClient { } /// Connect to an IMID. - async fn connect_imid(&mut self, imid: &Imid, port: u16) -> Result> { - // Trust engine is authoritative; bootstrap hint is fallback for bootstrap. - let te_ip = self + async fn connect_imid(&self, imid: &Imid, port: u16) -> Result> { + let ip = self .te .derivation() .imid_to_ip .get(imid) .and_then(|s| s.iter().next()) - .copied(); - let hint_ip = self - .bootstrap_hint - .take_if(|(hint_imid, _)| hint_imid == imid) - .map(|(_, ip)| ip); - - let ip = match (te_ip, hint_ip) { - (Some(ip), Some(hint)) => { - warn!(%imid, %ip, %hint, "bootstrap hint ignored"); - ip - } - (Some(ip), None) | (None, Some(ip)) => ip, - (None, None) => anyhow::bail!("failed to find IP for IMID: {imid}"), - }; + .copied() + .ok_or_else(|| anyhow!("failed to find IP for IMID: {imid}"))?; let tcp_stream = TcpStream::connect((ip, port)) .await @@ -163,7 +128,7 @@ impl Service for IntermeshClient { } fn call(&mut self, dst: Uri) -> Self::Future { - let mut client = self.clone(); + let client = self.clone(); let uri = dst.clone(); Box::pin(async move { let host = uri.host().ok_or_else(|| anyhow!("uri has no host"))?; @@ -313,7 +278,7 @@ mod tests { let trust_engine = Arc::new(TrustEngine::new(client_imid)); let verifier = Arc::new(IntermeshVerifier::new_permissive()); - let mut client = IntermeshClient::new(&client_keypair, verifier, trust_engine); + let client = IntermeshClient::new(&client_keypair, verifier, trust_engine); let result = client.connect(&target_imid.to_dns_name(), 8080).await; @@ -335,7 +300,7 @@ mod tests { let trust_engine = Arc::new(TrustEngine::new(client_imid)); let verifier = Arc::new(IntermeshVerifier::new_permissive()); - let mut client = IntermeshClient::new(&client_keypair, verifier, trust_engine); + let client = IntermeshClient::new(&client_keypair, verifier, trust_engine); let result = client.connect("192.68.1.1", 8080).await; @@ -391,38 +356,12 @@ mod tests { let trust_engine = Arc::new(TrustEngine::new(client_imid)); let client_verifier = Arc::new(IntermeshVerifier::new_permissive()); - // Test bootstrap hint: trust engine doesn't know server's IP yet - let mut client_with_hint = IntermeshClient::new( - &client_keypair, - client_verifier.clone(), - trust_engine.clone(), - ) - .with_bootstrap_hint(server_imid.clone(), server_addr.ip()); - let mut stream = client_with_hint - .connect_imid(&server_imid, server_addr.port()) - .await - .assert(); - stream.write_all(b"bootstrap_hint").await.assert(); - let mut buf = [0u8; 1024]; - let n = stream.read(&mut buf).await.assert(); - assert_eq!(&buf[..n], b"bootstrap_hint"); - - // Bootstrap hint is single-use: second connection fails - let result = client_with_hint - .connect_imid(&server_imid, server_addr.port()) - .await; - assert!(result - .unwrap_err() - .to_string() - .contains("failed to find IP")); - // Test connect_imid: IMID → IP resolution via trust engine trust_engine.set_ip_map(BTreeMap::from([( server_imid.clone(), vec![server_addr.ip()], )])); - let mut client = - IntermeshClient::new(&client_keypair, client_verifier, trust_engine.clone()); + let client = IntermeshClient::new(&client_keypair, client_verifier, trust_engine.clone()); for _ in 0..10 { let mut client_stream = client .connect_imid(&server_imid, server_addr.port()) @@ -645,7 +584,7 @@ mod tests { let server_port = server_addr.port(); let handle = tokio::spawn(async move { - let mut client = IntermeshClient::new( + let client = IntermeshClient::new( &client_keypair, client_verifier.clone(), trust_engine.clone(), diff --git a/src/daemon.rs b/src/daemon.rs index 394e03ee..fd036556 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,6 +1,5 @@ use anyhow::{Context, Result}; use futures::TryStreamExt; -use std::collections::BTreeSet; use std::fs; use std::process; use std::sync::Arc; @@ -16,11 +15,10 @@ use tracing_subscriber::fmt::layer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use crate::{ - admin::AdminServer, assert::UnwrapAssert, connect::intermesh_server_stream, endor, gossip, - manager, modules::adhoc, proto::intermesh as proto, proxy, state::State, - trust_engine::TrustEngine, verifier::IntermeshVerifier, + admin::AdminServer, assert::UnwrapAssert, connect::intermesh_server_stream, gossip, manager, + proto::intermesh as proto, proxy, state::State, trust_engine::TrustEngine, + verifier::IntermeshVerifier, }; -use proto::adhoc::join_service_server::JoinServiceServer as AdhocJoinServiceServer; use proto::gossip_service_server::GossipServiceServer; /// gRPC service context — exists because tonic trait impls need a named type. @@ -28,7 +26,6 @@ use proto::gossip_service_server::GossipServiceServer; pub(crate) struct GrpcService { pub(crate) state: Arc, pub(crate) te: Arc, - pub(crate) intent: Arc, } /// Run the daemon. If `on_ready` is provided, the command is spawned once @@ -37,15 +34,12 @@ pub(crate) async fn run(state: State, on_ready: Option) -> Result<()> { let te = Arc::new(TrustEngine::new(state.imid.clone())); let state = Arc::new(state); let manager = Arc::new(manager::Manager::new(state.clone(), te.clone())); - let intent = manager.intent(); - let adhoc = adhoc::Handle::new(state.clone(), te.clone(), intent.clone()); let gossip_state = Arc::new(Mutex::new(gossip::GossipState::new())); let peer_pool = Arc::new(gossip::PeerChannelPool::new()); let proxy = proxy::Handle::new(state.clone(), te.clone()); let grpc = GrpcService { state: state.clone(), te: te.clone(), - intent: intent.clone(), }; let tracing_reg = tracing_subscriber::registry() @@ -69,20 +63,6 @@ pub(crate) async fn run(state: State, on_ready: Option) -> Result<()> { let imid = &state.imid; info!(?imid, "starting daemon"); - // Sync adhoc intent from persisted membership. - if let Some(membership) = state.adhoc_membership().await { - adhoc.set_membership(membership).await; - } - - if state.endorse_local_ip { - let ip = state.local_ip; - let mut self_ip = endor::Base::new(state.imid.clone()); - self_ip.target_imids.insert(state.imid.clone()); - self_ip.ips.insert(ip); - intent.set_daemon(BTreeSet::from([self_ip])).await; - info!(%ip, "declared self-IP intent"); - } - // Create cancellation token for threads. let cancel = sync::CancellationToken::new(); @@ -100,7 +80,6 @@ pub(crate) async fn run(state: State, on_ready: Option) -> Result<()> { let mut gossip_server_handle = tokio::spawn( Server::builder() .add_service(GossipServiceServer::new(grpc.clone())) - .add_service(AdhocJoinServiceServer::new(adhoc.clone())) .serve_with_incoming_shutdown(incoming, cancel.clone().cancelled_owned()), ); @@ -115,15 +94,17 @@ pub(crate) async fn run(state: State, on_ready: Option) -> Result<()> { gossip_state.clone(), cancel.clone(), )); - let mut admin_handle = tokio::spawn(admin_server.run(grpc, adhoc, cancel.clone())); + let persist_state = state.clone(); + let persist_cancel = cancel.clone(); + let mut persist_handle = tokio::spawn(async move { + persist_state.persist_loop(persist_cancel).await; + }); + let mut admin_handle = tokio::spawn(admin_server.run(grpc, cancel.clone())); let mut proxy_handle = tokio::spawn(proxy.run(cancel.clone())); - if let Some(mut cmd) = on_ready { - tokio::spawn(async move { - let status = cmd.status().await; - process::exit(status.map_or(1, |s| s.code().unwrap_or(1))); - }); - } + let mut on_ready_handle = + on_ready.map(|mut cmd| tokio::spawn(async move { cmd.status().await })); + let mut on_ready_exit_code = None; // Wait for shutdown signal or unexpected task exit. Signal results are // ignored - recv() only returns None if "already received" which can't @@ -136,20 +117,50 @@ pub(crate) async fn run(state: State, on_ready: Option) -> Result<()> { r = &mut gossip_server_handle => error!(?r, "gossip server exited"), r = &mut manager_handle => error!(?r, "manager exited"), r = &mut gossip_handle => error!(?r, "gossip loop exited"), + r = &mut persist_handle => error!(?r, "state persist loop exited"), r = &mut admin_handle => error!(?r, "admin server exited"), r = &mut proxy_handle => error!(?r, "proxy exited"), + r = async { on_ready_handle.as_mut().assert().await }, if on_ready_handle.is_some() => { + on_ready_exit_code = Some(match r { + Ok(Ok(status)) => status.code().unwrap_or(1), + Ok(Err(err)) => { + error!(?err, "on-ready command failed"); + 1 + } + Err(err) => { + error!(?err, "on-ready task failed"); + 1 + } + }); + on_ready_handle = None; + }, } info!("shutting down"); cancel.cancel(); + if let Some(handle) = &on_ready_handle { + handle.abort(); + } + // Wait for remaining tasks to finish + if let Some(handle) = on_ready_handle { + match handle.await { + Ok(Ok(_)) => {} + Ok(Err(err)) => error!(?err, "on-ready command failed during shutdown"), + Err(err) if err.is_cancelled() => {} + Err(err) => error!(?err, "on-ready task failed during shutdown"), + } + } if let Err(err) = manager_handle.await { error!(?err, "manager task failed"); } if let Err(err) = gossip_handle.await { error!(?err, "gossip loop task failed"); } + if let Err(err) = persist_handle.await { + error!(?err, "state persist loop task failed"); + } if let Err(err) = gossip_server_handle.await { error!(?err, "gossip server task failed"); } @@ -160,11 +171,15 @@ pub(crate) async fn run(state: State, on_ready: Option) -> Result<()> { error!(?err, "proxy task failed"); } - // Final state save to catch any changes in the debounce window + // Final flush in case the persist loop exited before draining the latest + // mutation or while waiting to retry a failed write. if let Err(err) = state.save().await { error!(?err, "failed to save state on shutdown"); } info!("shutdown complete"); + if let Some(code) = on_ready_exit_code { + process::exit(code); + } Ok(()) } diff --git a/src/gossip.rs b/src/gossip.rs index 4aec5ee4..30f7296e 100644 --- a/src/gossip.rs +++ b/src/gossip.rs @@ -165,16 +165,13 @@ async fn extend_protos(state: &State, protos: proto::GossipUpdate) { .filter(|endor| !carries_authority(endor)) .collect(); - if let Err(err) = state + state .update_messages(MessageUpdate { add_endors: endorsements, add_revocations: revocations, ..MessageUpdate::default() }) - .await - { - error!(?err, "failed to save gossiped state"); - } + .await; } /// Exchanges endorsements with peers when the state message generation is ahead diff --git a/src/lib.rs b/src/lib.rs index 33e6bf70..f079413a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,6 @@ pub(crate) mod gossip; pub mod ident; pub mod imid; mod manager; -pub mod modules; pub mod proto; pub(crate) mod proxy; pub mod revocation; diff --git a/src/manager.rs b/src/manager.rs index ee774ed5..3ab55703 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -1,18 +1,15 @@ //! Endorsement lifecycle coordinator. //! -//! [`Intent`] holds keyed intent from local modules. [`Manager`] drives the -//! lifecycle loop that signs missing or expiring endorsements into [`State`], -//! updates [`TrustEngine`], and garbage collects expired observed messages. +//! [`Manager`] drives the lifecycle loop that signs missing or expiring +//! durable intent into [`State`], updates [`TrustEngine`], and garbage collects +//! expired observed messages. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::watch::error::SendError; -use tokio::sync::{watch, Mutex}; use tokio::time; use tokio_util::sync::CancellationToken; -use tracing::{error, trace}; use crate::assert::UnwrapAssert; use crate::endor::{Base, Endor}; @@ -30,126 +27,8 @@ const MANAGER_INTERVAL: Duration = Duration::from_mins(1); /// Accept modest clock skew on endorsement issue timestamps. const ISSUED_SKEW_TOLERANCE: u64 = 300; -/// Identifies which module owns a set of intended endorsements. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum IntentKey { - /// Adhoc mesh membership endorsements. - Adhoc, - /// Daemon self-IP endorsement. - Daemon, - /// One-off admin-injected endorsements from `intermesh debug endorse`. - Debug, -} - -/// Source-keyed local endorsement intent. -pub(crate) struct Intent { - intents: Mutex>>, - keypair: ImidKeypair, - change_tx: watch::Sender<()>, -} - -impl Intent { - #[must_use] - pub(crate) fn new(keypair: ImidKeypair) -> Self { - let (change_tx, _) = watch::channel(()); - Self { - intents: Mutex::new(BTreeMap::new()), - keypair, - change_tx, - } - } - - /// Subscribe to notifications when local intent changes. - fn subscribe(&self) -> watch::Receiver<()> { - let rx = self.change_tx.subscribe(); - - // Force new subscribers to process any pre-existing intent. - self.notify_change(); - rx - } - - /// Declare intended adhoc membership endorsements. - pub(crate) async fn set_adhoc(&self, bases: BTreeSet) { - self.set(IntentKey::Adhoc, bases).await; - } - - /// Declare intended daemon self-IP endorsements. - pub(crate) async fn set_daemon(&self, bases: BTreeSet) { - self.set(IntentKey::Daemon, bases).await; - } - - /// Add a one-off admin-injected debug endorsement. - pub(crate) async fn add_debug(&self, base: Base) { - self.add(IntentKey::Debug, base).await; - } - - /// Return intended adhoc membership endorsements. - #[cfg(test)] - pub(crate) async fn adhoc(&self) -> BTreeSet { - self.get(IntentKey::Adhoc).await - } - - /// Replaces any previous intent from the same source. Only notifies the - /// lifecycle loop if the content actually changed. - async fn set(&self, key: IntentKey, bases: BTreeSet) { - let my_imid = self.keypair.to_imid(); - for base in &bases { - assert!(base.endorser == my_imid, "intent contains foreign endorser"); - } - - let mut intents = self.intents.lock().await; - let entry = intents.entry(key).or_default(); - if *entry != bases { - *entry = bases; - drop(intents); - self.notify_change(); - } - } - - /// Return the intent for a particular source. - #[cfg(test)] - async fn get(&self, key: IntentKey) -> BTreeSet { - self.intents - .lock() - .await - .get(&key) - .cloned() - .unwrap_or_default() - } - - /// Add a single endorsement to a source intent, merging with any existing - /// entries from the same source. - async fn add(&self, key: IntentKey, base: Base) { - let my_imid = self.keypair.to_imid(); - assert!(base.endorser == my_imid, "intent contains foreign endorser"); - - let mut intents = self.intents.lock().await; - let entry = intents.entry(key).or_default(); - if entry.insert(base) { - drop(intents); - self.notify_change(); - } - } - - /// Return the union of all source-owned intended endorsements. - async fn intended(&self) -> BTreeSet { - let mut intended = BTreeSet::new(); - for bases in self.intents.lock().await.values() { - intended.extend(bases.iter().cloned()); - } - intended - } - - fn notify_change(&self) { - if let Err(SendError(())) = self.change_tx.send(()) { - trace!("no listeners on local intent update signal"); - } - } -} - /// Orchestrates endorsement lifecycle: reconcile → derive. pub(crate) struct Manager { - intent: Arc, state: Arc, trust_engine: Arc, } @@ -158,20 +37,13 @@ impl Manager { #[must_use] pub(crate) fn new(state: Arc, trust_engine: Arc) -> Self { Self { - intent: Arc::new(Intent::new(state.keypair.clone())), state, trust_engine, } } - #[must_use] - pub(crate) fn intent(&self) -> Arc { - self.intent.clone() - } - /// Run the manager loop continuously until cancelled. pub(crate) async fn run(&self, cancel: CancellationToken) { - let mut intent_sig = self.intent.subscribe(); let mut state_sig = self.state.subscribe(); let mut interval = time::interval(MANAGER_INTERVAL); @@ -183,16 +55,11 @@ impl Manager { self.reconcile_inner(now).await; - // Wake on: periodic timer, local intent changes from modules, or - // durable state changes. + // Wake on: periodic timer, observed messages, or durable state + // changes. tokio::select! { () = cancel.cancelled() => return, _ = interval.tick() => {} - r = intent_sig.changed() => { - if r.is_err() { - return; - } - } r = state_sig.changed() => { if r.is_err() { return; @@ -203,7 +70,6 @@ impl Manager { // Drain pending signals so we don't re-trigger immediately on the // next iteration. interval.reset(); - intent_sig.borrow_and_update(); state_sig.borrow_and_update(); } } @@ -214,7 +80,7 @@ impl Manager { /// policy in stateless helpers so reconciliation, retirement, and /// trust-engine input construction stay easy to test independently. async fn reconcile_inner(&self, now: u64) { - let intended = self.intent.intended().await; + let intended = self.state.intents().await; let (mut endorsements, mut revocations) = self.state.messages_with_retained().await; // First remove expired endorsements and revocations. @@ -246,27 +112,25 @@ impl Manager { assert!(endorsements.insert(endor.clone())); } - // Persist the removals and additions computed above. - if let Err(err) = self - .state - .update_messages(MessageUpdate { - rm_endors: rm_endorsements, - add_endors: new_endors, - rm_revocations, - add_revocations: new_revocations, - }) - .await - { - error!(?err, "failed to save state after endorsement change"); - } - - // Finally, derive trust from currently usable endorsements. + // Derive before notifying state subscribers so gossip wakes with a + // derivation that includes the just-published local messages. let effective_bases = endorsements .iter() .filter(|endor| Self::endor_is_effective(endor, now)) .map(|endor| endor.base().clone()) .collect(); self.trust_engine.update(effective_bases, now).await; + + // Apply removals and additions computed above. Persistence happens + // asynchronously from State's in-memory change notification. + self.state + .update_messages(MessageUpdate { + rm_endors: rm_endorsements, + add_endors: new_endors, + rm_revocations, + add_revocations: new_revocations, + }) + .await; } #[must_use] @@ -349,7 +213,7 @@ mod tests { } /// Covers the orchestration path around the stateless lifecycle helpers: - /// publish new local signatures, update derivation, and persist state. + /// publish new local signatures, update derivation, and flush state. #[tokio::test] async fn lifecycle_pass_publishes_derives_and_persists_new_intent() { let (manager, _mock) = test_manager().await; @@ -367,9 +231,10 @@ mod tests { .to_base(&mut fix); manager - .intent - .set_adhoc(BTreeSet::from([base.clone()])) - .await; + .state + .replace_intents(BTreeSet::from([base.clone()])) + .await + .assert(); manager.reconcile_inner(now).await; let (endorsements, _) = manager.state.messages().await; @@ -378,28 +243,13 @@ mod tests { assert_eq!(endorsement.base(), &base); assert!(manager.trust_engine.derivation().input.contains(&base)); + manager.state.save().await.assert(); let saved = tokio::fs::read_to_string(&manager.state.state_file) .await .assert(); assert!(saved.contains(&endorsement.to_base64())); } - #[tokio::test] - #[should_panic(expected = "intent contains foreign endorser")] - async fn set_adhoc_rejects_foreign_endorser() { - let (manager, _mock) = test_manager().await; - let mut fix = TestFixture::new(); - let foreign = TestEndorsement { - endorser: "alice", - target_imids: "eve", - names: "eve.test.mesh", - ..Default::default() - } - .to_base(&mut fix); - - manager.intent.set_adhoc(BTreeSet::from([foreign])).await; - } - #[test] fn reconcile_intent_creates_missing_and_renews_unusable() { let mut fix = TestFixture::new(); @@ -496,9 +346,10 @@ mod tests { ); manager - .intent - .set_adhoc(BTreeSet::from([base.clone()])) - .await; + .state + .replace_intents(BTreeSet::from([base.clone()])) + .await + .assert(); // The store exposes both latest and retained self-history to the // manager. The latest signing is too far in the future to satisfy // intent, but the older retained signing is usable. @@ -508,8 +359,7 @@ mod tests { add_endors: vec![future_latest.clone(), usable_retained.clone()], ..MessageUpdate::default() }) - .await - .assert(); + .await; manager.reconcile_inner(now).await; @@ -552,8 +402,7 @@ mod tests { add_endors: vec![older.clone(), newer.clone(), foreign.clone()], ..MessageUpdate::default() }) - .await - .assert(); + .await; manager.reconcile_inner(now).await; @@ -615,12 +464,12 @@ mod tests { add_endors: vec![old.clone()], ..MessageUpdate::default() }) + .await; + manager + .state + .replace_intents(BTreeSet::from([new.clone()])) .await .assert(); - manager - .intent - .set_adhoc(BTreeSet::from([new.clone()])) - .await; manager.reconcile_inner(now).await; @@ -656,8 +505,7 @@ mod tests { add_endors: vec![expired.clone()], ..MessageUpdate::default() }) - .await - .assert(); + .await; manager.reconcile_inner(now).await; @@ -694,9 +542,10 @@ mod tests { let revocation = Revocation::new(revoked.clone(), &manager.state.keypair); manager - .intent - .set_adhoc(BTreeSet::from([base.clone()])) - .await; + .state + .replace_intents(BTreeSet::from([base.clone()])) + .await + .assert(); // Intent still wants the base, but the only existing signing is exactly // revoked. The store suppresses that signing from latest messages, so // reconcile must issue a fresh endorsement. @@ -706,16 +555,14 @@ mod tests { add_endors: vec![revoked], ..MessageUpdate::default() }) - .await - .assert(); + .await; manager .state .update_messages(MessageUpdate { add_revocations: vec![revocation], ..MessageUpdate::default() }) - .await - .assert(); + .await; manager.reconcile_inner(now).await; diff --git a/src/modules.rs b/src/modules.rs deleted file mode 100644 index f8fe19e2..00000000 --- a/src/modules.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod adhoc; diff --git a/src/modules/adhoc.rs b/src/modules/adhoc.rs deleted file mode 100644 index 473fae34..00000000 --- a/src/modules/adhoc.rs +++ /dev/null @@ -1,1069 +0,0 @@ -use crate::assert::UnwrapAssert; -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt::Display; -use std::net::IpAddr; -use std::sync::Arc; -use tokio::sync::Mutex; -use tonic::transport::Endpoint; -use tonic::{Code, Status}; - -use self::token::Token; -use crate::{ - authorization::Authz, - connect::IntermeshClient, - constraint::Constraint, - endor, - gossip::GOSSIP_PORT, - ident::{Name, NamePattern}, - imid::Imid, - manager, - proto::intermesh::adhoc as proto, - state::State, - trust_engine::TrustEngine, - verifier::IntermeshVerifier, -}; -use proto::join_service_client::JoinServiceClient; -use proto::JoinTokenRequest; - -pub(crate) mod cli; -pub(crate) mod service; -pub mod token; - -/// Handle for creating and joining ad-hoc mesh networks. -#[derive(Clone)] -pub(crate) struct Handle { - pub(crate) state: Arc, - pub(crate) te: Arc, - pub(crate) intent: Arc, - membership_mu: Arc>, -} - -impl Handle { - pub(crate) fn new( - state: Arc, - te: Arc, - intent: Arc, - ) -> Self { - Self { - state, - te, - intent, - membership_mu: Arc::new(Mutex::new(())), - } - } - - /// Replace membership wholesale, persist to disk, and sync intent. - pub(crate) async fn set_membership(&self, membership: Membership) { - let _guard = self.membership_mu.lock().await; - self.persist_membership(membership).await; - } - - /// Atomically add a joiner to the current membership. - async fn append_joiner( - &self, - joiner_imid: &Imid, - joiner_name: &Name, - joiner_ip: Option, - ) -> Result<(), Status> { - let _guard = self.membership_mu.lock().await; - - let mut membership = self - .state - .adhoc_membership() - .await - .ok_or_else(|| Status::failed_precondition("not initialized as root"))?; - - membership.joiners.insert( - joiner_imid.clone(), - Joiner { - name: joiner_name.clone(), - ip: joiner_ip, - }, - ); - - self.persist_membership(membership).await; - Ok(()) - } - - /// Persist membership to disk and sync intent. Caller must hold - /// `membership_mu`. - async fn persist_membership(&self, membership: Membership) { - self.state - .set_adhoc_membership(Some(membership.clone())) - .await; - self.state.save().await.assert(); - let bases = membership.to_bases(&self.state.imid); - self.intent.set_adhoc(bases).await; - } - - // ========================================================================= - // Root APIs - // ========================================================================= - - /// Initialize a new ad-hoc mesh as the root node. Implementation of the - /// cli command. - pub(crate) async fn root_cli_init( - &self, - my_name: &Name, - mesh: &NamePattern, - ) -> Result { - if !is_valid_mesh(mesh) { - return Err(Status::invalid_argument(format!( - "invalid mesh pattern: {mesh}" - ))); - } - - if !mesh.matches(my_name) { - return Err(Status::invalid_argument(format!( - "Node name '{my_name}' is not under mesh '{mesh}'" - ))); - } - - let my_imid = self.state.imid.clone(); - let my_ip = self.state.local_ip; - - // Create bootstrap token - let token = Token::create(my_ip, mesh.clone(), &self.state.keypair); - - if let Some(existing) = self.state.adhoc_membership().await { - if existing.root_imid == my_imid && existing.mesh == *mesh { - return Ok(token.to_proto(&self.state.keypair)); - } - - return Err(Status::already_exists(format!( - "already in {}", - existing.mesh - ))); - } - - let membership = Membership { - mesh: mesh.clone(), - root_imid: my_imid.clone(), - root_ip: my_ip, - root_name: Some(my_name.clone()), - joiners: BTreeMap::new(), - }; - self.set_membership(membership).await; - - Ok(token.to_proto(&self.state.keypair)) - } - - /// Accept a join request. This receives tokens from prospective mesh - /// members and adds them to the mesh. - /// - /// Security: Token expiration is verified by `Token::verify` - /// before this method is called. - pub(crate) async fn root_accept_join( - &self, - token: &Token, - joiner_imid: &Imid, - joiner_name: &Name, - joiner_ip: Option, - ) -> Result<(), Status> { - let my_imid = self.state.imid.clone(); - if token.imid != my_imid { - return Err(Status::invalid_argument("token not for this node")); - } - - if !is_valid_mesh(&token.mesh) { - return Err(Status::invalid_argument("invalid mesh pattern")); - } - - if !token.mesh.matches(joiner_name) { - return Err(Status::invalid_argument(format!( - "node name '{joiner_name}' is not in mesh '{}'", - token.mesh - ))); - } - - self.append_joiner(joiner_imid, joiner_name, joiner_ip) - .await - } - - /// Add a member by IMID and name (root-only, no token exchange). - pub(crate) async fn root_add( - &self, - name: &Name, - imid: &Imid, - ip: IpAddr, - ) -> Result<(), Status> { - let membership = self - .state - .adhoc_membership() - .await - .ok_or_else(|| Status::failed_precondition("not initialized"))?; - - if membership.root_imid != self.state.imid { - return Err(Status::permission_denied("only root can add members")); - } - - if !membership.mesh.matches(name) { - return Err(Status::invalid_argument(format!( - "name '{name}' is not under mesh '{}'", - membership.mesh - ))); - } - - self.append_joiner(imid, name, Some(ip)).await - } - - // ========================================================================= - // Joiner APIs - // ========================================================================= - - /// Join an existing mesh using a bootstrap token. Implementation of the - /// cli command. - pub(crate) async fn client_cli_join( - &self, - token_proto: &proto::Token, - node_name: &str, - ) -> Result<(), Status> { - let token = - Token::verify(token_proto).map_err(|e| Status::invalid_argument(e.to_string()))?; - - let my_name: Name = node_name - .parse() - .to_status(Code::InvalidArgument, "invalid node name")?; - - if !token.mesh.matches(&my_name) { - return Err(Status::invalid_argument(format!( - "node name '{my_name}' is not in mesh '{}'", - token.mesh - ))); - } - - if let Some(membership) = self.state.adhoc_membership().await { - if membership.root_imid == token.imid { - return Ok(()); - } - - return Err(Status::already_exists(format!( - "already in mesh '{}', cannot join '{}'", - membership.mesh, token.mesh - ))); - } - - // Create gRPC channel - let imclient = IntermeshClient::new( - &self.state.keypair, - Arc::new(IntermeshVerifier::new_permissive()), - self.te.clone(), - ) - .with_bootstrap_hint(token.imid.clone(), token.ip); - - // Note: We use "http" scheme here because IntermeshClient handles the TLS - // layer transparently. If we used "https", Tonic would attempt to wrap - // the connection in TLS again, resulting in double-encryption. - let uri = format!("http://{}:{}", token.imid.to_dns_name(), GOSSIP_PORT); - let channel = Endpoint::try_from(uri) - .to_status(Code::Internal, "invalid bootstrap root URI")? - .connect_with_connector(imclient) - .await - .to_status(Code::Internal, "failed to connect for bootstrap")?; - - let mut client = JoinServiceClient::new(channel); - - client - .accept(JoinTokenRequest { - token: Some(token_proto.clone()), - requested_name: my_name.to_string(), - }) - .await?; - - // Persist membership to state file - let membership = Membership { - mesh: token.mesh.clone(), - root_imid: token.imid.clone(), - root_ip: token.ip, - root_name: None, - joiners: BTreeMap::new(), - }; - self.set_membership(membership).await; - Ok(()) - } - - /// Join a mesh using hard-coded root identity (no token exchange). - /// - /// The orchestrator provisions root identity details out of band. - /// This writes local membership directly without any gRPC to root. - /// The joiner learns its name from root's endorsement, not from this call. - pub(crate) async fn client_direct_join( - &self, - mesh: &NamePattern, - root_imid: &Imid, - root_ip: IpAddr, - ) -> Result<(), Status> { - if let Some(membership) = self.state.adhoc_membership().await { - if membership.root_imid == *root_imid { - return Ok(()); - } - - return Err(Status::already_exists(format!( - "already in mesh '{}', cannot join '{mesh}'", - membership.mesh - ))); - } - - let membership = Membership { - mesh: mesh.clone(), - root_imid: root_imid.clone(), - root_ip, - root_name: None, - joiners: BTreeMap::new(), - }; - self.set_membership(membership).await; - Ok(()) - } -} - -/// Intended adhoc mesh membership as expressed by the user. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct Membership { - /// Mesh pattern (e.g., "**.test.mesh") - pub(crate) mesh: NamePattern, - - /// Root node's IMID - pub(crate) root_imid: Imid, - - /// Root node's IP - pub(crate) root_ip: IpAddr, - - /// Root's own name (e.g., "root.test.mesh"). Only set on root — non-root - /// nodes learn their name from root's endorsement, not from local state. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) root_name: Option, - - /// Joiners accepted by this node (only populated for root) - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub(crate) joiners: BTreeMap, -} - -/// A node that has joined the mesh via the root. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct Joiner { - pub(crate) name: Name, - pub(crate) ip: Option, -} - -impl Membership { - /// Generate intended endorsement bases from membership state. - /// - /// Pure function for easy testing. Returns the set of bases that should - /// exist given the current membership. - #[must_use] - fn to_bases(&self, my_imid: &Imid) -> BTreeSet { - let mut bases = Vec::new(); - - // Everyone grants root authority over themselves - let mut authority_base = endor::Base::new(my_imid.clone()); - authority_base.target_imids.insert(my_imid.clone()); - authority_base.constraints.insert(Constraint { - endorser_imids: BTreeSet::from([self.root_imid.clone()]), - target_imids: BTreeSet::from([my_imid.clone()]), - permitted_patterns: BTreeSet::from([self.mesh.clone()]), - authority: true, - ..Default::default() - }); - bases.push(authority_base); - - // Joiners endorse root's IP so they can gossip with it. - // Root's own IP is declared via daemon self-IP intent, so skip - // this when we are root. - if self.root_imid != *my_imid { - let mut root_ip_base = endor::Base::new(my_imid.clone()); - root_ip_base.target_imids.insert(self.root_imid.clone()); - root_ip_base.ips.insert(self.root_ip); - bases.push(root_ip_base); - } - - // Root additionally creates mesh constraints and endorses joiners - if self.root_imid == *my_imid { - self.root_bases(my_imid, &mut bases); - } - - bases.into_iter().collect() - } - - /// Append root-only bases: mesh constraints + per-joiner endorsements. - fn root_bases(&self, my_imid: &Imid, bases: &mut Vec) { - // Mesh constraints: naming authority + IP authority + authorization authority - let mut mesh_base = endor::Base::new(my_imid.clone()); - mesh_base.target_patterns.insert(self.mesh.clone()); - mesh_base.constraints.insert(Constraint { - endorser_imids: BTreeSet::from([my_imid.clone()]), - any_target: true, - permitted_patterns: BTreeSet::from([self.mesh.clone()]), - ..Default::default() - }); - mesh_base.constraints.insert(Constraint { - endorser_patterns: BTreeSet::from([self.mesh.clone()]), - target_patterns: BTreeSet::from([self.mesh.clone()]), - permitted_subnets: BTreeSet::from([ - "0.0.0.0/0".parse().assert(), - "::/0".parse().assert(), - ]), - ..Default::default() - }); - bases.push(mesh_base); - - // Default authorization: all mesh members can connect to each other - // on all ports. This provides a permissive default for ad-hoc meshes. - let mut authz_base = endor::Base::new(my_imid.clone()); - authz_base.target_patterns.insert(self.mesh.clone()); - authz_base.authz.insert(Authz { - source_patterns: BTreeSet::from([self.mesh.clone()]), - destination_patterns: BTreeSet::from([self.mesh.clone()]), - ..Default::default() - }); - bases.push(authz_base); - - // Endorse all members (self + joiners) - let self_joiner = Joiner { - name: self.root_name.clone().assert(), - ip: None, // Root's own IP is declared via daemon self-IP intent - }; - let members = std::iter::once((my_imid, &self_joiner)).chain(self.joiners.iter()); - for (imid, joiner) in members { - let mut name_base = endor::Base::new(my_imid.clone()); - name_base.target_imids.insert(imid.clone()); - name_base.names.insert(joiner.name.clone()); - bases.push(name_base); - - // Delegation: grant joiner authority to name under their subdomain - // TODO(ejj): We need this because our constraint language doesn't - // support variables. We want "X endorses **.X" but can't express - // that. - let mut delegation_base = endor::Base::new(my_imid.clone()); - delegation_base.target_patterns.insert(self.mesh.clone()); - delegation_base.constraints.insert(Constraint { - endorser_patterns: BTreeSet::from([joiner.name.to_string().parse().assert()]), - any_target: true, - permitted_patterns: BTreeSet::from([format!("**.{}", joiner.name) - .parse() - .assert()]), - ..Default::default() - }); - bases.push(delegation_base); - - // IP endorsement for joiners whose IP we know - if let Some(ip) = joiner.ip { - let mut ip_base = endor::Base::new(my_imid.clone()); - ip_base.target_imids.insert(imid.clone()); - ip_base.ips.insert(ip); - bases.push(ip_base); - } - } - } -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/// Extension trait to convert errors to gRPC Status with context. -pub(crate) trait ToStatusExt { - fn to_status(self, code: Code, context: &str) -> Result; -} - -impl ToStatusExt for Result { - fn to_status(self, code: Code, context: &str) -> Result { - self.map_err(|e| Status::new(code, format!("{context}: {e}"))) - } -} - -/// Adhoc definition of a valid mesh pattern. -/// -/// A valid mesh pattern must be an infinite wildcard pattern of the form `**..mesh` where: -/// - `` is exactly one label (the mesh name) -/// - The base domain is exactly 2 labels: `.mesh` -/// -/// This rejects delegation patterns like `**.db.test.mesh` which have the same TLD but are -/// per-node authority scopes, not mesh identity patterns. -pub(crate) fn is_valid_mesh(pattern: &NamePattern) -> bool { - // Must be an infinite wildcard pattern (**. form) - let pattern_str = pattern.to_string(); - if !pattern_str.starts_with("**.") { - return false; - } - - // Must be under the **.mesh TLD pattern - let mesh_tld: NamePattern = "**.mesh".parse().assert(); - if !mesh_tld.contains_pattern(pattern) { - return false; - } - - // Base domain must have exactly 2 labels: ".mesh" - // This distinguishes mesh identity (**.test.mesh) from delegation (**.db.test.mesh). - let Some(base) = pattern.base_domain() else { - return false; - }; - - base.0.len() == 2 -} - -#[cfg(test)] -mod tests { - use std::net::IpAddr; - - use super::*; - use crate::assert::UnwrapAssert; - use crate::imid::ImidKeypair; - use crate::test_utils::{MockState, TestFixture}; - use tonic::Code; - - async fn test_handle() -> (Handle, MockState) { - test_handle_with_state(MockState::new().await) - } - - fn test_handle_with_state(mock: MockState) -> (Handle, MockState) { - let te = Arc::new(TrustEngine::new(mock.state.imid.clone())); - let intent = Arc::new(manager::Intent::new(mock.state.keypair.clone())); - (Handle::new(mock.state.clone(), te, intent), mock) - } - - #[test] - fn test_is_valid_mesh() { - // Valid mesh patterns: exactly `**..mesh` (2 labels in base) - assert!(is_valid_mesh(&"**.test.mesh".parse().assert())); - assert!(is_valid_mesh(&"**.prod.mesh".parse().assert())); - assert!(is_valid_mesh(&"**.my-app.mesh".parse().assert())); - - // Not mesh patterns (wrong form) - assert!(!is_valid_mesh(&"test.mesh".parse().assert())); - assert!(!is_valid_mesh(&"**.test.com".parse().assert())); - assert!(!is_valid_mesh(&"*.test.mesh".parse().assert())); - assert!(!is_valid_mesh(&"*.*.test.mesh".parse().assert())); - - // Invalid: too few labels (just "mesh") - assert!(!is_valid_mesh(&"**.mesh".parse().assert())); - assert!(!is_valid_mesh(&"**".parse().assert())); - - // Invalid: too many labels (delegation patterns, not mesh identity) - // These look like mesh patterns but are per-node delegation scopes. - assert!(!is_valid_mesh(&"**.db.test.mesh".parse().assert())); - assert!(!is_valid_mesh(&"**.a.b.c.mesh".parse().assert())); - } - - #[tokio::test] - async fn test_set_membership() { - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - let root_ip: IpAddr = "10.0.0.1".parse().assert(); - - let (module, _mock) = test_handle().await; - let my_imid = module.state.imid.clone(); - - // No membership - intent should be empty - assert!(module.intent.adhoc().await.is_empty()); - - // Set up as root - let membership = Membership { - mesh: test_mesh.clone(), - root_imid: my_imid.clone(), - root_ip, - root_name: Some(root_name.clone()), - joiners: BTreeMap::new(), - }; - module.set_membership(membership).await; - - // Root creates 5 bases: - // - 1 authority (grants root authority over self) - // - 1 mesh constraints (naming + IP + authorization authority) - // - 1 authorization (allows mesh members to connect to each other) - // - 2 from joiner_bases(self) (name + delegation) - let bases = module.intent.adhoc().await; - assert_eq!(bases.len(), 5); - - // Verify mesh constraints - let mesh_base = bases.iter().find(|b| b.constraints.len() == 2).assert(); - assert!(mesh_base.constraints.iter().any(|c| c.any_target)); - assert!(mesh_base - .constraints - .iter() - .any(|c| !c.permitted_subnets.is_empty())); - - // Verify joiner_bases(self): name endorsement - let name_base = bases.iter().find(|b| !b.names.is_empty()).assert(); - assert!(name_base.names.contains(&root_name)); - - // Add a joiner - let joiner_imid = ImidKeypair::generate().to_imid(); - let joiner_name: Name = "db.test.mesh".parse().assert(); - let membership = Membership { - mesh: test_mesh.clone(), - root_imid: my_imid.clone(), - root_ip, - root_name: Some(root_name.clone()), - joiners: BTreeMap::from([( - joiner_imid.clone(), - Joiner { - name: joiner_name.clone(), - ip: Some("10.0.0.2".parse().assert()), - }, - )]), - }; - module.set_membership(membership).await; - - // Now 8 bases: 5 original + 3 for joiner (name + delegation + IP) - let bases = module.intent.adhoc().await; - assert_eq!(bases.len(), 8); - - // Verify joiner's name endorsement - let joiner_name_base = bases - .iter() - .find(|b| b.target_imids.contains(&joiner_imid) && !b.names.is_empty()) - .assert(); - assert!(joiner_name_base.names.contains(&joiner_name)); - } - - #[tokio::test] - async fn test_adhoc_up_e2e() { - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let prod_mesh: NamePattern = "**.prod.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - let root_prod_name: Name = "root.prod.mesh".parse().assert(); - - // Setup: Root initializes mesh - let (root_module, _mock) = test_handle().await; - let root_imid = root_module.state.imid.clone(); - let token_proto = root_module - .root_cli_init(&root_name, &test_mesh) - .await - .assert(); - let token1 = Token::verify(&token_proto).assert(); - assert_eq!(token1.mesh, test_mesh); - assert_eq!(token1.imid, root_imid); - - // Test: Root re-init same mesh should succeed and return new token - let token_proto2 = root_module - .root_cli_init(&root_name, &test_mesh) - .await - .assert(); - let token2 = Token::verify(&token_proto2).assert(); - assert_eq!(token2.mesh, test_mesh); - assert_eq!(token2.imid, root_imid); - - // Verify adhoc membership - let membership = root_module.state.adhoc_membership().await.assert(); - assert_eq!(membership.mesh, test_mesh); - assert_eq!(membership.root_imid, root_imid); - - // Test: Root init different mesh should fail with error - let result = root_module.root_cli_init(&root_prod_name, &prod_mesh).await; - assert_eq!(result.unwrap_err().code(), Code::AlreadyExists); - - // Setup: Member joins via bootstrap workflow - let (member_module, _mock2) = test_handle().await; - let member_imid = member_module.state.imid.clone(); - let member_name: Name = "member.test.mesh".parse().assert(); - - // Root handles bootstrap request from member - let member_ip: IpAddr = "10.0.0.2".parse().assert(); - root_module - .root_accept_join(&token1, &member_imid, &member_name, Some(member_ip)) - .await - .assert(); - - // Member finalizes join (saves membership and updates intent) - let member_membership = Membership { - mesh: token1.mesh.clone(), - root_imid: token1.imid.clone(), - root_ip: token1.ip, - root_name: None, - joiners: BTreeMap::new(), - }; - member_module.set_membership(member_membership).await; - - // Verify adhoc membership - let membership = member_module.state.adhoc_membership().await.assert(); - assert_eq!( - membership.mesh, - "**.test.mesh".parse::().assert() - ); - assert_eq!(membership.root_imid, root_imid); - - // Verify member intent has 2 bases (authority + root IP) - assert_eq!(member_module.intent.adhoc().await.len(), 2); - - // Test: set_membership is idempotent - let membership = member_module.state.adhoc_membership().await.assert(); - member_module.set_membership(membership.clone()).await; - assert_eq!( - membership.mesh, - "**.test.mesh".parse::().assert() - ); - - // Re-joining same root with a different name is idempotent (same root = - // ok). The name is just a suggestion — root's endorsement is - // authoritative. - member_module - .client_cli_join(&token_proto, "member-alt.test.mesh") - .await - .assert(); - - // Test: Member init any mesh should fail - let member_test_name: Name = "member.test.mesh".parse().assert(); - let member_prod_name: Name = "member.prod.mesh".parse().assert(); - let result = member_module - .root_cli_init(&member_test_name, &test_mesh) - .await; - assert_eq!(result.unwrap_err().code(), Code::AlreadyExists); - let result = member_module - .root_cli_init(&member_prod_name, &prod_mesh) - .await; - assert_eq!(result.unwrap_err().code(), Code::AlreadyExists); - } - - #[tokio::test] - async fn test_init_creates_valid_token() { - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - - let (module, _mock) = test_handle().await; - let my_imid = module.state.imid.clone(); - - let token_proto = module.root_cli_init(&root_name, &test_mesh).await.assert(); - - let token = Token::verify(&token_proto).assert(); - assert_eq!(token.imid, my_imid); - assert_eq!(token.mesh, test_mesh); - - // Verify intent was set (Manager will create endorsements from this) - let intent_bases = module.intent.adhoc().await; - assert!(!intent_bases.is_empty()); - } - - #[tokio::test] - async fn test_init_invalid_mesh() { - let (module, _mock) = test_handle().await; - - // Invalid mesh pattern (not **.*.mesh) should fail - let root_name: Name = "root.test.mesh".parse().assert(); - let invalid_mesh: NamePattern = "**.mesh".parse().assert(); - let result = module.root_cli_init(&root_name, &invalid_mesh).await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - - // Node name not under mesh should fail - let wrong_name: Name = "root.other.mesh".parse().assert(); - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let result = module.root_cli_init(&wrong_name, &test_mesh).await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - } - - #[tokio::test] - async fn test_client_cli_join_failures() { - let (module, _mock) = test_handle().await; - let keypair = ImidKeypair::generate(); - - // Expired token (expired 10 minutes ago) - let mut expired_token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - expired_token.exp -= 900; // Move expiration 15 min earlier (now - 10 min) - let expired_proto = expired_token.to_proto(&keypair); - let result = module - .client_cli_join(&expired_proto, "test.test.mesh") - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - - // Wrong signature - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - let other_keypair = ImidKeypair::generate(); - let wrong_sig_proto = token.to_proto(&other_keypair); - let result = module - .client_cli_join(&wrong_sig_proto, "test.test.mesh") - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - - // Node name not in mesh - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - let token_proto = token.to_proto(&keypair); - let result = module - .client_cli_join(&token_proto, "test.other.mesh") - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - } - - #[tokio::test] - async fn test_accept_join_success() { - let (module, _mock) = test_handle().await; - let mut fix = TestFixture::new(); - let peer_imid = fix.imid("peer"); - let test_pattern: NamePattern = "**.test.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - let joiner_name: Name = "db.test.mesh".parse().assert(); - - // Initialize as root first (required before accepting joins) - module - .root_cli_init(&root_name, &test_pattern) - .await - .assert(); - let initial_intent = module.intent.adhoc().await; - - // Create a token and accept a join - let token = Token::create( - "192.168.1.1".parse().assert(), - test_pattern, - &module.state.keypair, - ); - - let joiner_ip: IpAddr = "10.0.0.2".parse().assert(); - module - .root_accept_join(&token, &peer_imid, &joiner_name, Some(joiner_ip)) - .await - .assert(); - - // Intent should have 3 new bases (name + delegation + IP for joiner) - let updated_intent = module.intent.adhoc().await; - assert_eq!(updated_intent.len(), initial_intent.len() + 3); - - // Find the joiner's name base in intent - let module_imid = module.state.imid.clone(); - let name_base = updated_intent - .iter() - .find(|b| !b.names.is_empty() && b.target_imids.contains(&peer_imid)) - .assert(); - assert_eq!(name_base.endorser, module_imid); - assert_eq!(name_base.target_imids, BTreeSet::from([peer_imid.clone()])); - assert_eq!(name_base.names.len(), 1); - let name: Name = "db.test.mesh".parse().assert(); - assert!(name_base.names.contains(&name)); - - // Find the joiner's delegation constraint base in intent - let constraint_base = updated_intent - .iter() - .find(|b| { - b.constraints.iter().any(|c| { - c.permitted_patterns - .iter() - .any(|p| p.to_string() == "**.db.test.mesh") - }) - }) - .assert(); - assert_eq!(constraint_base.constraints.len(), 1); - } - - #[tokio::test] - async fn test_accept_join_failures() { - let (module, _mock) = test_handle().await; - let peer_imid = ImidKeypair::generate().to_imid(); - let joiner_name: Name = "db.test.mesh".parse().assert(); - - // Token with wrong IMID (not this module's IMID) - let other_keypair = ImidKeypair::generate(); - let wrong_imid_token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &other_keypair, - ); - let result = module - .root_accept_join(&wrong_imid_token, &peer_imid, &joiner_name, None) - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - - // Invalid mesh pattern (**.mesh) - // - // This should be rejected with a clear user-facing error because it does not - // identify a concrete mesh domain (e.g. "test.mesh"). - let invalid_mesh_token = Token::create( - "192.168.1.1".parse().assert(), - "**.mesh".parse().assert(), - &module.state.keypair, - ); - let result = module - .root_accept_join(&invalid_mesh_token, &peer_imid, &joiner_name, None) - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - - // Node name not in mesh - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &module.state.keypair, - ); - let wrong_name: Name = "db.other.mesh".parse().assert(); - let result = module - .root_accept_join(&token, &peer_imid, &wrong_name, None) - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - } - - #[tokio::test] - async fn test_root_add() { - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - - let (handle, _mock) = test_handle().await; - handle.root_cli_init(&root_name, &test_mesh).await.assert(); - - let bases_before = handle.intent.adhoc().await; - assert_eq!(bases_before.len(), 5); - - // Add a member - let new_imid = ImidKeypair::generate().to_imid(); - let new_name: Name = "db.test.mesh".parse().assert(); - let new_ip: IpAddr = "10.0.0.2".parse().assert(); - handle.root_add(&new_name, &new_imid, new_ip).await.assert(); - - // Should gain 3 bases (name + delegation + IP) - let bases_after = handle.intent.adhoc().await; - assert_eq!(bases_after.len(), 8); - - // Verify the member is in membership - let membership = handle.state.adhoc_membership().await.assert(); - assert_eq!(membership.joiners[&new_imid].name, new_name); - assert_eq!(membership.joiners[&new_imid].ip, Some(new_ip)); - } - - #[tokio::test] - async fn test_root_add_failures() { - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - - // Not initialized - let (handle, _mock) = test_handle().await; - let result = handle - .root_add( - &"db.test.mesh".parse().assert(), - &ImidKeypair::generate().to_imid(), - "10.0.0.2".parse().assert(), - ) - .await; - assert_eq!(result.unwrap_err().code(), Code::FailedPrecondition); - - // Initialize as root - handle.root_cli_init(&root_name, &test_mesh).await.assert(); - - // Name outside mesh - let result = handle - .root_add( - &"db.other.mesh".parse().assert(), - &ImidKeypair::generate().to_imid(), - "10.0.0.2".parse().assert(), - ) - .await; - assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); - - // Non-root caller - let (non_root, _mock2) = test_handle().await; - let root_imid = ImidKeypair::generate().to_imid(); - let membership = Membership { - mesh: test_mesh, - root_imid, - root_ip: "10.0.0.1".parse().assert(), - root_name: None, - joiners: BTreeMap::new(), - }; - non_root.set_membership(membership).await; - let result = non_root - .root_add( - &"web.test.mesh".parse().assert(), - &ImidKeypair::generate().to_imid(), - "10.0.0.3".parse().assert(), - ) - .await; - assert_eq!(result.unwrap_err().code(), Code::PermissionDenied); - } - - #[tokio::test] - async fn test_client_direct_join() { - let (handle, _mock) = test_handle().await; - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let root_imid = ImidKeypair::generate().to_imid(); - let root_ip: IpAddr = "10.0.0.1".parse().assert(); - - handle - .client_direct_join(&test_mesh, &root_imid, root_ip) - .await - .assert(); - - // Verify membership was set correctly - let membership = handle.state.adhoc_membership().await.assert(); - assert_eq!(membership.mesh, test_mesh); - assert_eq!(membership.root_imid, root_imid); - assert_eq!(membership.root_ip, root_ip); - assert_eq!(membership.root_name, None); - - // Idempotent re-join with same root - handle - .client_direct_join(&test_mesh, &root_imid, root_ip) - .await - .assert(); - - // Different root should fail - let other_root = ImidKeypair::generate().to_imid(); - let result = handle - .client_direct_join(&test_mesh, &other_root, root_ip) - .await; - assert_eq!(result.unwrap_err().code(), Code::AlreadyExists); - } - - /// Test that adhoc's intended endorsements can be reconstructed from - /// persisted Membership after a restart. - /// - /// This is critical for the reconciliation system: if a node reboots, it - /// must be able to reconstruct what endorsements it should have so the - /// reconciler can re-sign them. - #[tokio::test] - async fn adhoc_intent_survives_restart() { - let test_mesh: NamePattern = "**.test.mesh".parse().assert(); - let root_name: Name = "root.test.mesh".parse().assert(); - - // Setup: Root initializes mesh - let (handle1, mock1) = test_handle().await; - let state1 = mock1.state.clone(); - - // Initialize mesh - sets intent (Manager will create endorsements) - handle1.root_cli_init(&root_name, &test_mesh).await.assert(); - - // Add a joiner so restart covers persisted joiner-specific intent. - let joiner_imid = ImidKeypair::generate().to_imid(); - let joiner_name: Name = "db.test.mesh".parse().assert(); - let joiner_ip: IpAddr = "10.0.0.2".parse().assert(); - handle1 - .root_add(&joiner_name, &joiner_imid, joiner_ip) - .await - .assert(); - - // Record intent bases and membership - let original_bases = handle1.intent.adhoc().await; - let original_membership = state1.adhoc_membership().await.assert(); - assert_eq!(original_membership.joiners[&joiner_imid].name, joiner_name); - assert_eq!( - original_membership.joiners[&joiner_imid].ip, - Some(joiner_ip) - ); - - // Simulate restart: create new State from same file - // This simulates a node reboot - fresh in-memory state, loaded from disk - let (handle2, mock2) = test_handle_with_state(mock1.restart().await); - let state2 = mock2.state.clone(); - - // Verify membership was loaded from disk - let loaded_membership = state2.adhoc_membership().await.assert(); - assert_eq!(loaded_membership, original_membership); - - // Reconstruct intended endorsements via set_membership - // This is what the daemon does on startup - handle2.set_membership(loaded_membership).await; - let reconstructed_bases = handle2.intent.adhoc().await; - - assert_eq!( - original_bases, reconstructed_bases, - "reconstructed endorsements should match original semantic content" - ); - } -} diff --git a/src/modules/adhoc/cli.rs b/src/modules/adhoc/cli.rs deleted file mode 100644 index 95fd40b3..00000000 --- a/src/modules/adhoc/cli.rs +++ /dev/null @@ -1,438 +0,0 @@ -use anyhow::{ensure, Result}; -use clap::{ArgGroup, Args}; -use dialoguer::theme::ColorfulTheme; -use dialoguer::{Confirm, Input}; -use std::fmt::Write; -use std::net::IpAddr; -use std::path::PathBuf; -use std::str; - -use crate::ident::{Name, NamePattern}; -use crate::imid::Imid; -use crate::modules::adhoc::token::Token; -use crate::{admin::AdminClient, assert::UnwrapAssert, fmt, proto::intermesh::adhoc}; - -use adhoc::join_request::Mode as JoinMode; - -/// Arguments for the `init` command -#[derive(Args, Clone)] -pub(crate) struct InitArgs { - /// Node name - #[arg(long)] - pub name: Option, - - /// Mesh name - #[arg(long)] - pub mesh: Option, - - /// Output only the token (for scripting) - #[arg(long)] - pub quiet: bool, -} - -/// Arguments for the `join` command -#[derive(Args, Clone)] -#[command(group = ArgGroup::new("join_mode").required(true).args(["token", "mesh"]))] -pub(crate) struct JoinArgs { - /// Bootstrap token from the mesh root (token mode) - #[arg(conflicts_with_all = ["mesh", "root_imid", "root_ip"])] - pub token: Option, - - /// Node name - #[arg(long)] - pub name: Option, - - /// Mesh domain for direct join (e.g., "test.mesh") - #[arg(long, requires = "root_imid", requires = "root_ip")] - pub mesh: Option, - - /// Root node's IMID (direct join mode) - #[arg(long, requires = "mesh", requires = "root_ip")] - pub root_imid: Option, - - /// Root node's IP address (direct join mode) - #[arg(long, requires = "mesh", requires = "root_imid")] - pub root_ip: Option, - - /// Skip confirmation prompts - #[arg(long)] - pub yes: bool, -} - -/// Arguments for the `add` command -#[derive(Args, Clone)] -pub(crate) struct AddArgs { - /// Full node name (e.g., "db.test.mesh") - #[arg(long)] - pub name: String, - - /// Node's IMID - #[arg(long)] - pub imid: String, - - /// Node's IP address - #[arg(long)] - pub ip: String, -} - -// ============================================================================ -// Init command -// ============================================================================ - -pub(crate) async fn cmd_init(args: InitArgs, admin_socket: Option) -> Result<()> { - let admin_client = AdminClient::new(admin_socket); - - if !args.quiet { - print_status(&admin_client).await?; - } - - ensure!( - !args.quiet || args.mesh.is_some(), - "--quiet requires --mesh" - ); - - let mesh_domain_str = if let Some(name) = &args.mesh { - name.clone() - } else { - Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Mesh domain") - .default("my.mesh".to_string()) - .interact_text()? - }; - let mesh: NamePattern = format!("**.{mesh_domain_str}").parse()?; - - let node_name: Name = match &args.name { - Some(name) => name.parse()?, - None => prompt_node_name(&mesh_domain_str)?, - }; - - ensure!( - mesh.matches(&node_name), - "Node name '{node_name}' is not in mesh '{mesh}'" - ); - - let mut adhoc_client = admin_client.adhoc_admin_service_client().await?; - let response = adhoc_client - .init(adhoc::InitRequest { - mesh_pattern: mesh.to_string(), - node_name: node_name.to_string(), - }) - .await? - .into_inner(); - - let token_proto = response - .token - .ok_or_else(|| anyhow::anyhow!("missing token in response"))?; - let token_str = Token::encode(&token_proto); - - // Assert the token we just generated is parseable. - Token::verify(&Token::decode(&token_str).assert()).assert(); - - if args.quiet { - println!("{token_str}"); - } else { - print!("{}", render_root_token_instructions(&token_str)); - } - Ok(()) -} - -// ============================================================================ -// Join command -// ============================================================================ - -pub(crate) async fn cmd_join(args: JoinArgs, admin_socket: Option) -> Result<()> { - // Clap enforces: token or mesh required (join_mode group), token conflicts - // with direct args, direct args require each other. - let admin_client = AdminClient::new(admin_socket); - if args.token.is_some() { - cmd_join_token(args, &admin_client).await - } else { - cmd_join_direct(args, &admin_client).await - } -} - -async fn cmd_join_token(args: JoinArgs, admin_client: &AdminClient) -> Result<()> { - print_status(admin_client).await?; - - let token_str = args.token.as_ref().assert(); - let token_proto = Token::decode(token_str)?; - let token = Token::verify(&token_proto)?; - - let mesh_domain = token - .mesh - .base_domain() - .ok_or_else(|| anyhow::anyhow!("invalid token mesh"))? - .to_string(); - - let node_name: Name = match &args.name { - Some(name) => name.parse()?, - None => prompt_node_name(&mesh_domain)?, - }; - print!( - "{}", - render_join_confirmation_prompt( - &mesh_domain, - &node_name.to_string(), - &token.imid, - token.ip - ) - ); - - if !args.yes { - let confirmed = Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Continue") - .default(false) - .interact()?; - - if !confirmed { - println!("Aborted."); - return Ok(()); - } - } - - let mut adhoc_client = admin_client.adhoc_admin_service_client().await?; - adhoc_client - .join(adhoc::JoinRequest { - mode: Some(JoinMode::Token(adhoc::JoinTokenRequest { - token: Some(token_proto), - requested_name: node_name.to_string(), - })), - }) - .await?; - - print!("{}", render_join_success()); - Ok(()) -} - -async fn cmd_join_direct(args: JoinArgs, admin_client: &AdminClient) -> Result<()> { - // Clap's `requires` ensures all three are present when any one is. - let mesh_domain_str = args.mesh.as_ref().assert(); - let root_imid_str = args.root_imid.as_ref().assert(); - let root_ip_str = args.root_ip.as_ref().assert(); - - let mesh: NamePattern = format!("**.{mesh_domain_str}").parse()?; - - let mut adhoc_client = admin_client.adhoc_admin_service_client().await?; - adhoc_client - .join(adhoc::JoinRequest { - mode: Some(JoinMode::Direct(adhoc::JoinDirectRequest { - mesh_pattern: mesh.to_string(), - root_imid: root_imid_str.clone(), - root_ip: root_ip_str.clone(), - })), - }) - .await?; - - println!("Joined {mesh_domain_str}"); - Ok(()) -} - -// ============================================================================ -// Add command -// ============================================================================ - -pub(crate) async fn cmd_add(args: AddArgs, admin_socket: Option) -> Result<()> { - let admin_client = AdminClient::new(admin_socket); - let mut adhoc_client = admin_client.adhoc_admin_service_client().await?; - adhoc_client - .add(adhoc::AddRequest { - name: args.name.clone(), - imid: args.imid.clone(), - ip: args.ip.clone(), - }) - .await?; - println!("Added {}", args.name); - Ok(()) -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/// Prompt for node name, defaulting to hostname with mesh suffix. -fn prompt_node_name(mesh_domain: &str) -> Result { - let hostname = hostname::get() - .ok() - .and_then(|h| h.into_string().ok()) - .unwrap_or_default(); - let default = format!("{hostname}.{mesh_domain}"); - let input: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Node name") - .default(default) - .interact_text()?; - input.parse() -} - -/// Fetch current state from daemon and print status. -async fn print_status(admin_client: &AdminClient) -> Result<()> { - let dump = admin_client.debug_dump().await?; - let my_imid = dump.derivation.my_imid.clone(); - - let membership = dump.adhoc_membership.first(); - let self_name = membership.and_then(|m| m.self_name.as_deref()); - let root_imid: Option = membership.map(|m| m.root_imid.parse()).transpose()?; - - print!( - "{}", - render_mesh_status(&my_imid, self_name, root_imid.as_ref()) - ); - Ok(()) -} - -// ============================================================================ -// Rendering -// ============================================================================ -// -// These functions exist so we can test CLI output without running the full -// manual flow. Without them it was easy to make changes that looked fine in -// code but looked weird when printed. Keep these as minimal string formatting; -// all logic should live outside them. - -/// Status block showing node identity and optional mesh membership. -fn render_mesh_status(imid: &Imid, self_name: Option<&str>, root_imid: Option<&Imid>) -> String { - let mut out = String::new(); - - writeln!(out, "My IMID: {}", fmt::imid(imid)).assert(); - if let Some(name) = self_name { - writeln!(out, "My Name: {}", fmt::name(name)).assert(); - } - if let Some(root) = root_imid { - writeln!(out, "Root IMID: {}", fmt::imid(root)).assert(); - } - writeln!(out).assert(); - out -} - -/// Root-specific suffix with token instructions. -fn render_root_token_instructions(token: &str) -> String { - let mut out = String::new(); - writeln!(out).assert(); - writeln!(out, "Run the following command on member nodes to join:").assert(); - writeln!(out).assert(); - writeln!(out, "intermesh join '").assert(); - for chunk in token.as_bytes().chunks(60) { - writeln!(out, "{}", str::from_utf8(chunk).expect("token is ascii")).assert(); - } - writeln!(out, "'").assert(); - out -} - -fn render_join_confirmation_prompt( - mesh_domain: &str, - full_node_name: &str, - token_imid: &Imid, - token_ip: IpAddr, -) -> String { - let mut out = String::new(); - - writeln!(out, "Bootstrap token from:").assert(); - writeln!(out, " IMID: {}", fmt::imid(token_imid)).assert(); - writeln!(out, " IP: {}", fmt::ip(&token_ip.to_string())).assert(); - writeln!(out).assert(); - - writeln!(out, "Join {mesh_domain} as {full_node_name}?").assert(); - - let short = fmt::imid_short(token_imid); - writeln!( - out, - "Joining trusts {short} as the mesh root, allowing it to:" - ) - .assert(); - - let mesh_pattern = format!("**.{mesh_domain}"); - writeln!(out, " - Assign names under {}", fmt::name(&mesh_pattern)).assert(); - writeln!(out, " - Assign constraints (trust rules)").assert(); - writeln!(out).assert(); - - out -} - -fn render_join_success() -> &'static str { - "\nSuccessfully joined.\n" -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::imid::ImidKeypair; - - #[test] - fn mesh_status_rendering() { - // Purpose: validate status block with full mesh membership. - fmt::set_colors(false); - - let my_imid = ImidKeypair::test_keypair("my-node").to_imid(); - let root_imid = ImidKeypair::test_keypair("root-node").to_imid(); - - let out = render_mesh_status(&my_imid, Some("db.test.mesh"), Some(&root_imid)); - - let expected = r" -My IMID: AsXgYLhIrH2Y2_0O-TaY2Ng1BPXKDRF2yJX8wiRAPrR71 -My Name: db.test.mesh -Root IMID: A5iLdSS6EI5gHm14NqbBeaprrpBehMFKMn1ObhVeevHo1 - -"; - - assert_eq!(out, expected.strip_prefix('\n').assert()); - - // When no mesh membership, only show self IMID. - let out = render_mesh_status(&my_imid, None, None); - let expected = "My IMID: AsXgYLhIrH2Y2_0O-TaY2Ng1BPXKDRF2yJX8wiRAPrR71\n\n"; - assert_eq!(out, expected); - } - - #[test] - fn root_token_instructions_rendering() { - // Purpose: validate the root-specific token instructions. - fmt::set_colors(false); - - // Representative token length (real tokens are ~200 chars) - let token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccc\ - ccccccccccccccccccccccdddddddddddddddddddddddddddd"; - - let out = render_root_token_instructions(token); - - let expected = r" - -Run the following command on member nodes to join: - -intermesh join ' -aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbcccc -ccccccccccccccccccccccccccdddddddddddddddddddddddddddd -' -"; - - assert_eq!(out, expected.strip_prefix('\n').assert()); - } - - #[test] - fn join_confirmation_rendering() { - // Purpose: validate that the confirmation prompt shown to the user - // during `intermesh join` looks reasonable. - fmt::set_colors(false); - - let token_imid = ImidKeypair::test_keypair("join-root").to_imid(); - let out = render_join_confirmation_prompt( - "test.mesh", - "db.test.mesh", - &token_imid, - "192.0.2.10".parse().assert(), - ); - let expected = r" -Bootstrap token from: - IMID: Ahn52S8k85DMXYAM-QP6NBj5nDoemmNXpy-Wex2mPBje1 - IP: 192.0.2.10 - -Join test.mesh as db.test.mesh? -Joining trusts Ahn52S8k85DMXYAM... as the mesh root, allowing it to: - - Assign names under **.test.mesh - - Assign constraints (trust rules) - -"; - - assert_eq!(out, expected.strip_prefix('\n').assert()); - - // On success, we print a one-line confirmation. - assert_eq!(render_join_success(), "\nSuccessfully joined.\n"); - } -} diff --git a/src/modules/adhoc/service.rs b/src/modules/adhoc/service.rs deleted file mode 100644 index 83db24b1..00000000 --- a/src/modules/adhoc/service.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::net::IpAddr; - -use tonic::{Request, Response, Status}; -use tracing::info; - -use crate::{ - connect::IntermeshConnectInfo, - ident::{Name, NamePattern}, - imid::Imid, - modules::adhoc::{token::Token, Handle}, - proto::intermesh::adhoc as proto, - proto::intermesh::adhoc::admin_service_server::AdminService as AdhocAdminService, - proto::intermesh::adhoc::join_service_server::JoinService, -}; - -use proto::join_request::Mode as JoinMode; - -#[tonic::async_trait] -impl AdhocAdminService for Handle { - async fn init( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - info!("adhoc init request (admin): node_name={}", req.node_name); - - let my_name: Name = req - .node_name - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid node name: {e}")))?; - let mesh: NamePattern = req - .mesh_pattern - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid mesh pattern: {e}")))?; - - let token = self.root_cli_init(&my_name, &mesh).await?; - Ok(Response::new(proto::InitResponse { token: Some(token) })) - } - - async fn join( - &self, - request: Request, - ) -> Result, Status> { - let mode = request - .into_inner() - .mode - .ok_or_else(|| Status::invalid_argument("missing join mode"))?; - - match mode { - JoinMode::Token(req) => { - info!( - "adhoc join request (admin): requested_name={}", - req.requested_name - ); - let token = req - .token - .ok_or_else(|| Status::invalid_argument("missing token"))?; - self.client_cli_join(&token, &req.requested_name).await?; - } - JoinMode::Direct(req) => { - info!("adhoc direct join request (admin)"); - let mesh: NamePattern = req - .mesh_pattern - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid mesh pattern: {e}")))?; - let root_imid: Imid = req - .root_imid - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid root imid: {e}")))?; - let root_ip: IpAddr = req - .root_ip - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid root ip: {e}")))?; - self.client_direct_join(&mesh, &root_imid, root_ip).await?; - } - } - - Ok(Response::new(proto::JoinResponse {})) - } - - async fn add( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - info!( - "adhoc add request (admin): name={} imid={}", - req.name, req.imid - ); - - let name: Name = req - .name - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid name: {e}")))?; - let imid: Imid = req - .imid - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid imid: {e}")))?; - let ip: IpAddr = req - .ip - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid ip: {e}")))?; - self.root_add(&name, &imid, ip).await?; - Ok(Response::new(proto::AddResponse {})) - } -} - -#[tonic::async_trait] -impl JoinService for Handle { - async fn accept( - &self, - request: Request, - ) -> Result, Status> { - let joiner_ip = request.remote_addr().map(|addr| addr.ip()); - info!(?joiner_ip, "adhoc join accept request"); - - let connect_info = request - .extensions() - .get::() - .cloned() - .ok_or_else(|| Status::internal("connection error"))?; - let req = request.into_inner(); - - let token_proto = req - .token - .ok_or_else(|| Status::invalid_argument("missing token"))?; - let token = Token::verify(&token_proto) - .map_err(|e| Status::invalid_argument(format!("invalid token: {e}")))?; - - let joiner_name: Name = req - .requested_name - .parse() - .map_err(|e| Status::invalid_argument(format!("invalid node name: {e}")))?; - - self.root_accept_join(&token, &connect_info.peer, &joiner_name, joiner_ip) - .await?; - Ok(Response::new(proto::JoinResponse {})) - } -} diff --git a/src/modules/adhoc/token.rs b/src/modules/adhoc/token.rs deleted file mode 100644 index a592060a..00000000 --- a/src/modules/adhoc/token.rs +++ /dev/null @@ -1,233 +0,0 @@ -use crate::{ - assert::UnwrapAssert, - ident::NamePattern, - imid::{Imid, ImidKeypair}, - proto::intermesh::adhoc as proto, -}; - -use super::is_valid_mesh; -use anyhow::{ensure, Context, Result}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use prost::Message; -use std::net::IpAddr; -use std::time::{SystemTime, UNIX_EPOCH}; - -const TOKEN_VALIDITY_SECONDS: u64 = 5 * 60; // 5 minutes - -#[derive(Debug)] -pub struct Token { - pub ip: IpAddr, - pub imid: Imid, - pub exp: u64, - pub mesh: NamePattern, -} - -impl Token { - #[must_use] - pub fn create(ip: IpAddr, mesh: NamePattern, keypair: &ImidKeypair) -> Self { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .assert() - .as_secs(); - - Token { - ip, - imid: keypair.to_imid(), - exp: now + TOKEN_VALIDITY_SECONDS, - mesh, - } - } - - /// Convert to `proto::Token` for gRPC transmission. - #[must_use] - pub fn to_proto(&self, keypair: &ImidKeypair) -> proto::Token { - let data = proto::TokenData { - ip: self.ip.to_string(), - imid: self.imid.to_string(), - exp: self.exp, - mesh_pattern: self.mesh.to_string(), - }; - - let token_data = data.encode_to_vec(); - let signature = keypair.sign(&token_data); - - proto::Token { - token_data, - signature, - } - } - - /// Verify a `proto::Token` and return a `Token`. - pub fn verify(proto: &proto::Token) -> Result { - let data = ::decode(&proto.token_data[..]) - .context("token protobuf parse error")?; - - let imid = Imid::from_string(data.imid.clone()) - .with_context(|| format!("token imid parse error: {}", data.imid))?; - let ip: IpAddr = data - .ip - .parse() - .with_context(|| format!("token ip parse error: {}", data.ip))?; - let mesh: NamePattern = data - .mesh_pattern - .parse() - .with_context(|| format!("token mesh parse error: {}", data.mesh_pattern))?; - - let token = Token { - ip, - imid, - exp: data.exp, - mesh, - }; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .assert() - .as_secs(); - ensure!(now <= token.exp, "token expired"); - ensure!( - is_valid_mesh(&token.mesh), - "token has unsupported mesh: {}", - token.mesh - ); - token - .imid - .verify(&proto.token_data, &proto.signature) - .map_err(|_| anyhow::anyhow!("token signature verification failed"))?; - - Ok(token) - } - - /// Decode a base64 string to `proto::Token`. - /// - /// Strips any non-base64 characters (whitespace, newlines, etc.) to allow - /// tokens that have been line-wrapped or copy-pasted with extra formatting. - pub fn decode(token_str: &str) -> Result { - let cleaned: String = token_str - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') - .collect(); - let buf = URL_SAFE_NO_PAD - .decode(&cleaned) - .context("token base64 parse error")?; - ::decode(&buf[..]).context("token protobuf parse error") - } - - /// Encode a `proto::Token` to base64 string. - #[must_use] - pub fn encode(proto: &proto::Token) -> String { - URL_SAFE_NO_PAD.encode(proto.encode_to_vec()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn decode_and_verify(s: &str) -> Result { - Token::verify(&Token::decode(s)?) - } - - fn to_base64(token: &Token, keypair: &ImidKeypair) -> String { - Token::encode(&token.to_proto(keypair)) - } - - #[test] - fn test_token_round_trip() { - let keypair = ImidKeypair::generate(); - - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - - let encoded = to_base64(&token, &keypair); - let decoded = decode_and_verify(&encoded).assert(); - - assert_eq!(decoded.ip, "192.168.1.1".parse::().assert()); - assert_eq!(decoded.imid, keypair.to_imid()); - assert_eq!(decoded.exp, token.exp); - assert_eq!(decoded.mesh, "**.test.mesh".parse::().assert()); - } - - #[test] - fn test_token_wrong_signature() { - let keypair = ImidKeypair::generate(); - let other_keypair = ImidKeypair::generate(); - - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - - // Encode with different keypair (wrong signature) - let encoded = to_base64(&token, &other_keypair); - - // Decode should fail due to signature mismatch - assert!(decode_and_verify(&encoded).is_err()); - } - - #[test] - fn test_token_expired() { - let keypair = ImidKeypair::generate(); - - let mut token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - - // Set expiration to 10 minutes ago - token.exp -= 900; - - let encoded = to_base64(&token, &keypair); - - // Decode should fail due to expiration - assert!(decode_and_verify(&encoded).is_err()); - } - - #[test] - fn test_token_malformed() { - // Empty string - assert!(decode_and_verify("").is_err()); - - // Random garbage - assert!(decode_and_verify("random_garbage_!@#$").is_err()); - - // Valid base64 but not protobuf - assert!(decode_and_verify("aGVsbG93b3JsZA").is_err()); - - // Truncated valid token - let keypair = ImidKeypair::generate(); - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - let encoded = to_base64(&token, &keypair); - let truncated = &encoded[..encoded.len() / 2]; - assert!(decode_and_verify(truncated).is_err()); - } - - #[test] - fn test_token_signature_errors() { - let keypair = ImidKeypair::generate(); - let token = Token::create( - "192.168.1.1".parse().assert(), - "**.test.mesh".parse().assert(), - &keypair, - ); - - // Token signed by wrong keypair should fail decode - let other_keypair = ImidKeypair::generate(); - let wrong_sig_encoded = to_base64(&token, &other_keypair); - assert!(decode_and_verify(&wrong_sig_encoded).is_err()); - - // Verify a correctly signed token succeeds - let correct_encoded = to_base64(&token, &keypair); - assert!(decode_and_verify(&correct_encoded).is_ok()); - } -} diff --git a/src/proto.rs b/src/proto.rs index 55e0aa82..97507236 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -10,8 +10,4 @@ )] pub mod intermesh { tonic::include_proto!("intermesh"); - - pub mod adhoc { - tonic::include_proto!("intermesh.adhoc"); - } } diff --git a/src/state.rs b/src/state.rs index 01504932..2fe542db 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,16 +8,14 @@ use crate::assert::UnwrapAssert; use crate::endor; use crate::gossip::GOSSIP_PORT; use crate::imid::{Imid, ImidKeypair}; -use crate::modules::adhoc; use crate::revocation::Revocation; use anyhow::{Context, Result}; -use local_ip_address::local_ip; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::io::Write; -use std::net::{IpAddr, SocketAddr}; +use std::net::SocketAddr; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[cfg(unix)] use std::fs::Permissions; @@ -27,14 +25,16 @@ use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; use tokio::fs; use tokio::sync::{watch, Mutex}; -use tokio::task; -use tracing::{info, warn}; +use tokio::{task, time}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; mod messages; pub(crate) use messages::Update as MessageUpdate; const STATE_FILE_VERSION: u32 = 1; +const PERSIST_RETRY_DELAY: Duration = Duration::from_secs(1); /// CLI arguments for the daemon command. /// Uses clap's env feature for automatic env var fallback. @@ -63,14 +63,6 @@ pub(crate) struct Args { #[arg(long, env = "IM_LISTEN_ADDR")] pub(crate) listen_addr: Option, - /// Override the auto-detected local IP address. - #[arg(long, env = "IM_LOCAL_IP")] - pub(crate) local_ip: Option, - - /// Self-endorse local IP at startup (default: true). - #[arg(long, env = "IM_ENDORSE_LOCAL_IP", num_args = 0..=1, default_missing_value = "true")] - pub(crate) endorse_local_ip: Option, - /// Enable traffic interception (injects nftables redirect rules). /// Use --intercept or --intercept=true to enable, --intercept=false to /// disable. If not specified, uses state file value (default: false). @@ -91,11 +83,8 @@ pub(crate) struct State { pub(crate) admin_socket: Option, pub(crate) log_file: Option, pub(crate) listen_addr: SocketAddr, - /// This node's IP, resolved at startup. Not persisted. - pub(crate) local_ip: IpAddr, /// Unix time when this state was created (daemon start). Not persisted. pub(crate) started_at_unix: u64, - pub(crate) endorse_local_ip: bool, pub(crate) intercept: bool, mutable: Mutex, save_mu: Mutex<()>, @@ -104,7 +93,10 @@ pub(crate) struct State { struct MutableState { messages: messages::Messages, - adhoc_membership: Option, + intents: BTreeSet, + /// Dirty counter for whole-state persistence. This is distinct from the + /// message generation used by gossip. + persist_generation: u64, } /// Minimal struct for version check before full parse @@ -126,8 +118,8 @@ struct StateFile { #[serde(default)] revocations: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - adhoc_membership: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + intents: Vec, } impl State { @@ -150,7 +142,7 @@ impl State { let mut intercept = None; let mut endorsements_b64 = Vec::new(); let mut revocations_b64 = Vec::new(); - let mut adhoc_membership = None; + let mut intents = Vec::new(); if args.state_file.exists() { let file = fs::read_to_string(&args.state_file) @@ -177,7 +169,7 @@ impl State { intercept = sf.intercept; endorsements_b64 = sf.endorsements; revocations_b64 = sf.revocations; - adhoc_membership = sf.adhoc_membership; + intents = sf.intents; } let keypair = if let Some(pem) = private_key_pem { @@ -190,6 +182,14 @@ impl State { let log_file = args.log_file.or(log_file); let imid = keypair.to_imid(); + intents.retain(|base| { + let keep = base.endorser == imid; + if !keep { + warn!(?base, "dropping foreign intent from statefile"); + } + keep + }); + let admin_socket = args.admin_socket.or(admin_socket); let listen_addr = args @@ -197,13 +197,6 @@ impl State { .or(listen_addr) .unwrap_or_else(|| format!("0.0.0.0:{GOSSIP_PORT}").parse().assert()); - let local_ip = match args.local_ip { - Some(ip) => ip, - None => local_ip().context("failed to detect local IP; set --local-ip")?, - }; - - let endorse_local_ip = args.endorse_local_ip.unwrap_or(true); - let intercept = args.intercept.or(intercept).unwrap_or(false); let mut endorsements = Vec::new(); @@ -246,13 +239,12 @@ impl State { admin_socket, log_file, listen_addr, - local_ip, started_at_unix, - endorse_local_ip, intercept, mutable: Mutex::new(MutableState { messages, - adhoc_membership, + intents: intents.into_iter().collect(), + persist_generation: 0, }), save_mu: Mutex::new(()), change_tx, @@ -263,21 +255,9 @@ impl State { Ok(state) } - /// Returns the current adhoc membership. - pub(crate) async fn adhoc_membership(&self) -> Option { - self.mutable.lock().await.adhoc_membership.clone() - } - - /// Sets the adhoc membership. Call `save()` to persist. - pub(crate) async fn set_adhoc_membership(&self, membership: Option) { - { - let mut mutable = self.mutable.lock().await; - if mutable.adhoc_membership == membership { - return; - } - mutable.adhoc_membership = membership; - } - self.notify_change(); + /// Return the durable endorsement intent set. + pub(crate) async fn intents(&self) -> BTreeSet { + self.mutable.lock().await.intents.clone() } /// Return latest endorsements and revocations from one coherent state read. @@ -294,19 +274,17 @@ impl State { } /// Apply removals and additions as one observed message set update. - pub(crate) async fn update_messages(&self, update: MessageUpdate) -> Result<()> { + pub(crate) async fn update_messages(&self, update: MessageUpdate) { let mut mutable = self.mutable.lock().await; let generation = mutable.messages.generation(); mutable.messages.apply(update, &self.imid); let new_generation = mutable.messages.generation(); - if new_generation == generation { - return Ok(()); + if new_generation != generation { + assert!(new_generation > generation); + mutable.persist_generation += 1; + drop(mutable); + self.notify_change(); } - assert!(new_generation > generation); - drop(mutable); - self.save().await?; - self.notify_change(); - Ok(()) } /// Monotonic message generation bumped on every message mutation. @@ -314,28 +292,137 @@ impl State { self.mutable.lock().await.messages.generation() } - /// Subscribe to mutable state changes. + /// Subscribe to in-memory mutable state changes. pub(crate) fn subscribe(&self) -> watch::Receiver<()> { self.change_tx.subscribe() } + /// Add durable endorsement intents and persist them. + pub(crate) async fn add_intents(&self, bases: BTreeSet) -> Result<()> { + let mut mutable = self.mutable.lock().await; + let mut next = mutable.intents.clone(); + next.extend(bases); + let changed = self.set_intents_locked(&mut mutable, next)?; + drop(mutable); + if changed { + self.notify_change(); + } + Ok(()) + } + + /// Remove durable endorsement intents and persist them. + pub(crate) async fn remove_intents(&self, bases: &BTreeSet) -> Result<()> { + let mut mutable = self.mutable.lock().await; + let mut next = mutable.intents.clone(); + for base in bases { + next.remove(base); + } + let changed = self.set_intents_locked(&mut mutable, next)?; + drop(mutable); + if changed { + self.notify_change(); + } + Ok(()) + } + + /// Replace durable endorsement intents and persist them. + pub(crate) async fn replace_intents(&self, bases: BTreeSet) -> Result<()> { + let mut mutable = self.mutable.lock().await; + let changed = self.set_intents_locked(&mut mutable, bases)?; + drop(mutable); + if changed { + self.notify_change(); + } + Ok(()) + } + + fn set_intents_locked( + &self, + mutable: &mut MutableState, + next: BTreeSet, + ) -> Result { + if next.iter().any(|base| base.endorser != self.imid) { + anyhow::bail!("intent contains foreign endorser"); + } + if mutable.intents == next { + return Ok(false); + } + mutable.intents = next; + mutable.persist_generation += 1; + Ok(true) + } + fn notify_change(&self) { let _ = self.change_tx.send(()); } + /// Persist state changes until cancelled, retrying failed writes. + pub(crate) async fn persist_loop(&self, cancel: CancellationToken) { + let mut change_sig = self.subscribe(); + let mut saved_generation = 0; + + loop { + if !self.flush_dirty(&mut saved_generation, &cancel).await { + return; + } + + tokio::select! { + () = cancel.cancelled() => return, + r = change_sig.changed() => { + if r.is_err() { + return; + } + change_sig.borrow_and_update(); + } + } + } + } + + async fn flush_dirty(&self, saved_generation: &mut u64, cancel: &CancellationToken) -> bool { + loop { + if self.mutable_generation().await == *saved_generation { + return true; + } + + match self.save_snapshot().await { + Ok(generation) => *saved_generation = generation, + Err(err) => { + error!(?err, "failed to persist state"); + tokio::select! { + () = cancel.cancelled() => return false, + () = time::sleep(PERSIST_RETRY_DELAY) => {} + } + } + } + } + } + + async fn mutable_generation(&self) -> u64 { + self.mutable.lock().await.persist_generation + } + + /// Persists all state to the state file. + pub(crate) async fn save(&self) -> Result<()> { + self.save_snapshot().await.map(|_| ()) + } + /// Persists all state to the state file. `mutable` is held only long /// enough to snapshot in-memory state, so readers are never blocked on /// the disk write. `save_mu` serializes writers, and each writer /// snapshots at its turn, so the file always converges to the newest /// state. - pub(crate) async fn save(&self) -> Result<()> { + async fn save_snapshot(&self) -> Result { let _guard = self.save_mu.lock().await; - let state_file = { + let (generation, state_file) = { let mutable = self.mutable.lock().await; - self.state_file_contents(&mutable) + ( + mutable.persist_generation, + self.state_file_contents(&mutable), + ) }; let content = toml::to_string_pretty(&state_file)?; - self.write_state_file(content).await + self.write_state_file(content).await?; + Ok(generation) } fn state_file_contents(&self, mutable: &MutableState) -> StateFile { @@ -367,7 +454,7 @@ impl State { intercept: Some(self.intercept), endorsements: endorsements_b64, revocations: revocations_b64, - adhoc_membership: mutable.adhoc_membership.clone(), + intents: mutable.intents.iter().cloned().collect(), } } @@ -420,9 +507,11 @@ impl State { mod tests { use super::*; use crate::test_utils::{TestEndorsement, TestFixture}; - use std::collections::{BTreeMap, BTreeSet}; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::collections::BTreeSet; + use std::sync::Arc; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tempfile::TempDir; + use tokio::time; #[tokio::test] async fn test_roundtrip() { @@ -441,8 +530,6 @@ mod tests { admin_socket: Some(admin_socket.clone()), log_file: Some(log_file.clone()), listen_addr: Some(listen_addr), - local_ip: None, - endorse_local_ip: None, intercept: None, }; let state1 = State::new(args1).await.unwrap(); @@ -454,7 +541,6 @@ mod tests { assert_eq!(state1.admin_socket, Some(admin_socket.clone())); assert_eq!(state1.log_file, Some(log_file.clone())); assert_eq!(state1.listen_addr, listen_addr); - assert!(state1.endorse_local_ip); // defaults to true // State file should exist (new() writes it) assert!(state_path.exists()); @@ -513,8 +599,8 @@ mod tests { add_revocations: vec![revocation.clone()], ..MessageUpdate::default() }) - .await - .unwrap(); + .await; + state1.save().await.assert(); let sf_after_save: StateFile = toml::from_str(&fs::read_to_string(&state_path).await.unwrap()).unwrap(); @@ -539,8 +625,6 @@ mod tests { admin_socket: Some(admin_socket.clone()), log_file: Some(log_file.clone()), listen_addr: Some(listen_addr), - local_ip: None, - endorse_local_ip: None, intercept: None, }; let state2 = State::new(args2).await.unwrap(); @@ -569,14 +653,11 @@ mod tests { // Explicit args still override persisted config on restart. let new_listen_addr: SocketAddr = "0.0.0.0:8080".parse().unwrap(); - let override_ip: IpAddr = "10.99.99.99".parse().unwrap(); let args3 = Args { state_file: state_path.clone(), admin_socket: Some(admin_socket.clone()), log_file: Some(log_file.clone()), listen_addr: Some(new_listen_addr), - local_ip: Some(override_ip), - endorse_local_ip: Some(false), intercept: None, }; let state3 = State::new(args3).await.unwrap(); @@ -585,8 +666,6 @@ mod tests { assert_eq!(state3.imid, imid1); // Config should be from args (overwriting file) assert_eq!(state3.listen_addr, new_listen_addr); - assert_eq!(state3.local_ip, override_ip); - assert!(!state3.endorse_local_ip); // Without overrides, persisted config is used again. let args4 = Args { @@ -594,15 +673,10 @@ mod tests { admin_socket: Some(admin_socket.clone()), log_file: Some(log_file.clone()), listen_addr: None, - local_ip: None, - endorse_local_ip: None, intercept: None, }; let state4 = State::new(args4).await.unwrap(); assert_eq!(state4.listen_addr, new_listen_addr); - // local_ip is not persisted — should auto-detect, not the override - assert_ne!(state4.local_ip, override_ip); - // Endorsements should still be present, including retained history. let (latest, _) = state4.messages().await; let (retained, _) = state4.messages_with_retained().await; @@ -611,10 +685,8 @@ mod tests { } #[tokio::test] - async fn test_adhoc_membership_persistence() { - // Ad-hoc membership saves to the state file, reloads on restart, and - // can be cleared again. - let tmpdir = TempDir::new().unwrap(); + async fn persist_loop_flushes_message_updates() { + let tmpdir = TempDir::new().assert(); let state_path = tmpdir.path().join("state.toml"); let args = Args { @@ -622,50 +694,246 @@ mod tests { admin_socket: None, log_file: None, listen_addr: None, - local_ip: None, - endorse_local_ip: None, intercept: None, }; - let state = State::new(args).await.unwrap(); + let state = Arc::new(State::new(args).await.assert()); + let cancel = CancellationToken::new(); + let persist_state = state.clone(); + let persist_cancel = cancel.clone(); + let persist_handle = tokio::spawn(async move { + persist_state.persist_loop(persist_cancel).await; + }); + + let mut fix = TestFixture::new(); + let endorsement = TestEndorsement { + endorser: "alice", + target_imids: "bob", + ips: "192.168.1.1", + names: "bob.test.mesh", + ..Default::default() + } + .sign(&mut fix); + let endorsement_b64 = endorsement.to_base64(); + state + .update_messages(MessageUpdate { + add_endors: vec![endorsement], + ..MessageUpdate::default() + }) + .await; + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let content = fs::read_to_string(&state_path).await.assert(); + let sf: StateFile = toml::from_str(&content).assert(); + if sf.endorsements.contains(&endorsement_b64) { + break; + } + time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .assert(); - // A fresh state file has no ad-hoc membership. - assert!(state.adhoc_membership().await.is_none()); + cancel.cancel(); + persist_handle.await.assert(); + } - // Save membership and confirm it is visible immediately. - let test_membership = adhoc::Membership { - mesh: "**.test.mesh".parse().unwrap(), - root_imid: ImidKeypair::generate().to_imid(), - root_ip: "192.168.1.1".parse().unwrap(), - root_name: None, - joiners: BTreeMap::new(), + #[tokio::test] + async fn persist_loop_flushes_intents() { + let tmpdir = TempDir::new().assert(); + let state_path = tmpdir.path().join("state.toml"); + + let args = Args { + state_file: state_path.clone(), + admin_socket: None, + log_file: None, + listen_addr: None, + intercept: None, }; + let state = Arc::new(State::new(args).await.assert()); + let cancel = CancellationToken::new(); + let persist_state = state.clone(); + let persist_cancel = cancel.clone(); + let persist_handle = tokio::spawn(async move { + persist_state.persist_loop(persist_cancel).await; + }); + + let mut fix = TestFixture::new().with_keypair("me", state.keypair.clone()); + let base = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); state - .set_adhoc_membership(Some(test_membership.clone())) - .await; + .add_intents(BTreeSet::from([base.clone()])) + .await + .assert(); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let content = fs::read_to_string(&state_path).await.assert(); + let sf: StateFile = toml::from_str(&content).assert(); + if sf.intents.contains(&base) { + break; + } + time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .assert(); + + cancel.cancel(); + persist_handle.await.assert(); + } + + #[tokio::test] + async fn test_intent_persistence() { + let tmpdir = TempDir::new().unwrap(); + let state_path = tmpdir.path().join("state.toml"); + let args = |state_file| Args { + state_file, + admin_socket: None, + log_file: None, + listen_addr: None, + intercept: None, + }; + + let state = State::new(args(state_path.clone())).await.unwrap(); + let mut fix = TestFixture::new().with_keypair("me", state.keypair.clone()); + let first = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let second = TestEndorsement { + endorser: "me", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + state + .add_intents(BTreeSet::from([first.clone(), second.clone()])) + .await + .unwrap(); state.save().await.unwrap(); - // Read membership back + let reloaded = State::new(args(state_path.clone())).await.unwrap(); assert_eq!( - state.adhoc_membership().await, - Some(test_membership.clone()) + reloaded.intents().await, + BTreeSet::from([first.clone(), second.clone()]) ); - // Restart and confirm the membership reloads from disk. - let args2 = Args { - state_file: state_path.clone(), + reloaded + .remove_intents(&BTreeSet::from([first.clone()])) + .await + .unwrap(); + assert_eq!(reloaded.intents().await, BTreeSet::from([second.clone()])); + + let third = TestEndorsement { + endorser: "me", + target_imids: "carol", + names: "carol.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + reloaded + .replace_intents(BTreeSet::from([third.clone()])) + .await + .unwrap(); + assert_eq!(reloaded.intents().await, BTreeSet::from([third])); + + reloaded.replace_intents(BTreeSet::new()).await.unwrap(); + assert!(reloaded.intents().await.is_empty()); + } + + #[tokio::test] + async fn test_intent_rejects_foreign_endorser_without_changing_memory() { + let tmpdir = TempDir::new().unwrap(); + let state_path = tmpdir.path().join("state.toml"); + let args = Args { + state_file: state_path, admin_socket: None, log_file: None, listen_addr: None, - local_ip: None, - endorse_local_ip: None, intercept: None, }; - let state2 = State::new(args2).await.unwrap(); - assert_eq!(state2.adhoc_membership().await, Some(test_membership)); + let state = State::new(args).await.unwrap(); + let mut fix = TestFixture::new().with_keypair("me", state.keypair.clone()); + let first = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let foreign = TestEndorsement { + endorser: "bob", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + + state + .add_intents(BTreeSet::from([first.clone()])) + .await + .unwrap(); + let result = state.add_intents(BTreeSet::from([foreign])).await; + + assert!(result.is_err()); + assert_eq!(state.intents().await, BTreeSet::from([first])); + } + + #[tokio::test] + async fn test_intent_load_drops_foreign_endorser() { + let tmpdir = TempDir::new().unwrap(); + let state_path = tmpdir.path().join("state.toml"); + let keypair = ImidKeypair::generate(); + let mut fix = TestFixture::new().with_keypair("me", keypair.clone()); + let local = TestEndorsement { + endorser: "me", + target_imids: "alice", + names: "alice.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let foreign = TestEndorsement { + endorser: "bob", + target_imids: "bob", + names: "bob.test.mesh", + ..Default::default() + } + .to_base(&mut fix); + let state_file = StateFile { + version: STATE_FILE_VERSION, + private_key_pem: Some(String::from_utf8(keypair.to_pem()).unwrap()), + admin_socket: None, + log_file: None, + listen_addr: None, + intercept: None, + endorsements: Vec::new(), + revocations: Vec::new(), + intents: vec![local.clone(), foreign], + }; + fs::write(&state_path, toml::to_string_pretty(&state_file).unwrap()) + .await + .unwrap(); + + let args = Args { + state_file: state_path, + admin_socket: None, + log_file: None, + listen_addr: None, + intercept: None, + }; + let state = State::new(args).await.unwrap(); - // Clear membership and persist the removal. - state2.set_adhoc_membership(None).await; - state2.save().await.unwrap(); - assert!(state2.adhoc_membership().await.is_none()); + assert_eq!(state.intents().await, BTreeSet::from([local])); } } diff --git a/src/state_dump.rs b/src/state_dump.rs index 07e39a47..794f2960 100644 --- a/src/state_dump.rs +++ b/src/state_dump.rs @@ -2,11 +2,9 @@ use crate::assert::UnwrapAssert; use crate::dsl::{format_constraint, format_endorsements}; use crate::endor; use crate::fmt; -use crate::ident::{Name, NamePattern}; +use crate::ident::Name; use crate::imid::Imid; -use crate::modules::adhoc::Membership; use crate::proto::intermesh; -use crate::proto::intermesh::adhoc as proto; use crate::trust_engine::Derivation; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -25,9 +23,6 @@ fn sorted_strings<'a, T: ToString + 'a>(iter: impl Iterator) -> Ve pub struct StateDump { pub derivation: Derivation, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub adhoc_membership: Vec, - #[serde(skip_serializing_if = "Option::is_none")] pub admin_socket: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -42,15 +37,6 @@ pub struct StateDump { pub daemon_started_at_unix: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AdhocMembershipDump { - pub mesh_domain: String, - pub root_imid: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub self_name: Option, - pub is_root: bool, -} - #[derive(tabled::Tabled)] struct StatusRow { #[tabled(rename = "IMID")] @@ -65,28 +51,13 @@ impl StateDump { #[must_use] pub(crate) fn new( derivation: Derivation, - membership: Option, admin_socket: Option, log_file: Option, daemon_build: String, daemon_started_at_unix: u64, ) -> Self { - let adhoc_membership = membership - .and_then(|m| { - let mesh_domain = m.mesh.base_domain()?.to_string(); - Some(AdhocMembershipDump { - mesh_domain, - root_imid: m.root_imid.to_string(), - self_name: m.root_name.as_ref().map(ToString::to_string), - is_root: m.root_imid == derivation.my_imid, - }) - }) - .into_iter() - .collect(); - Self { derivation, - adhoc_membership, admin_socket, log_file, daemon_build, @@ -113,20 +84,6 @@ impl StateDump { } out.push('\n'); - // Adhoc section - if !self.adhoc_membership.is_empty() { - writeln!(out, "{}", fmt::header("Adhoc")).assert(); - for m in &self.adhoc_membership { - let role = if m.is_root { "root" } else { "member" }; - let self_name = m.self_name.as_deref().unwrap_or(""); - writeln!(out, "Mesh: {}", fmt::name(&m.mesh_domain)).assert(); - writeln!(out, "Role: {role}").assert(); - writeln!(out, "Root: {}", fmt::imid(&m.root_imid.parse().assert())).assert(); - writeln!(out, "Name: {}", fmt::name(self_name)).assert(); - } - out.push('\n'); - } - // Derivation section writeln!( out, @@ -295,16 +252,6 @@ impl StateDump { let endorsements: Vec = self.derivation.input.iter().map(Into::into).collect(); - let adhoc_membership = self - .adhoc_membership - .iter() - .map(|m| proto::Membership { - mesh_pattern: format!("**.{}", m.mesh_domain), - root_imid: m.root_imid.clone(), - self_name: m.self_name.clone().unwrap_or_default(), - }) - .collect(); - intermesh::StateDumpResponse { my_imid: self.derivation.my_imid.to_string(), iteration: self.derivation.iteration, @@ -330,7 +277,6 @@ impl StateDump { .collect(), admin_socket: self.admin_socket.clone().unwrap_or_default(), log_file: self.log_file.clone(), - adhoc_membership, daemon_build: self.daemon_build.clone(), daemon_started_at_unix: self.daemon_started_at_unix, } @@ -402,26 +348,6 @@ impl StateDump { Some(resp.admin_socket) }; - let adhoc_membership = resp - .adhoc_membership - .into_iter() - .filter_map(|m| { - // mesh_pattern is "**.domain.mesh", extract base domain - let pattern: NamePattern = m.mesh_pattern.parse().ok()?; - let mesh_domain = pattern.base_domain()?.to_string(); - Some(AdhocMembershipDump { - mesh_domain, - root_imid: m.root_imid.clone(), - self_name: if m.self_name.is_empty() { - None - } else { - Some(m.self_name) - }, - is_root: m.root_imid == my_imid.to_string(), - }) - }) - .collect(); - let derivation = Derivation { my_imid, iteration: resp.iteration, @@ -442,7 +368,6 @@ impl StateDump { Ok(StateDump { derivation, - adhoc_membership, admin_socket, log_file: resp.log_file.filter(|s| !s.is_empty()), daemon_build: resp.daemon_build, @@ -560,7 +485,6 @@ mod tests { let original = StateDump::new( derivation, - None, // no mesh membership in this test Some("/run/intermesh/admin.sock".to_string()), Some("/var/log/intermesh.log".to_string()), "a1b2c3d".to_string(), @@ -762,16 +686,8 @@ mod tests { input, }; - let adhoc_membership = vec![AdhocMembershipDump { - mesh_domain: "test.mesh".to_string(), - root_imid: root.to_imid().to_string(), - self_name: Some("me.test.mesh".to_string()), - is_root: false, - }]; - let dump = StateDump { derivation, - adhoc_membership, admin_socket: Some("/run/intermesh/admin.sock".to_string()), log_file: Some("/var/log/intermesh.log".to_string()), daemon_build: String::new(), @@ -786,12 +702,6 @@ Self: Aj1Lizc7Xdv-DjFv6wlk9-ocRmzqejaHz-9H27ngqcXf1 Admin socket: /run/intermesh/admin.sock Log file: /var/log/intermesh.log -Adhoc -Mesh: test.mesh -Role: member -Root: A3L-mN23j3FJOvXZkXt45fsAMxeRa1RN4Tgd-m0_9Xp81 -Name: me.test.mesh - Derivation 3 Names db.test.mesh → A1UqiwUlUpEHC0b_xtjSRHollrXSPmkXNasb40dX9xVi1 @@ -871,7 +781,6 @@ Aj1Lizc7Xdv-DjFv6wlk9-ocRmzqejaHz-9H27ngqcXf1 endorses AoHQqnbGQu_8OBYXtpQQLvyrA let dump = StateDump { derivation, - adhoc_membership: vec![], admin_socket: None, log_file: None, daemon_build: String::new(), diff --git a/src/test_utils.rs b/src/test_utils.rs index e4c71926..a08e379d 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -18,7 +18,7 @@ use { #[cfg(test)] pub(crate) struct MockState { pub(crate) state: Arc, - tmpdir: Arc, + _tmpdir: Arc, } #[cfg(test)] @@ -27,23 +27,17 @@ impl MockState { Self::open(Arc::new(TempDir::new().assert())).await } - pub(crate) async fn restart(&self) -> Self { - Self::open(self.tmpdir.clone()).await - } - async fn open(tmpdir: Arc) -> Self { let args = state::Args { state_file: tmpdir.path().join("state.toml"), admin_socket: None, log_file: None, listen_addr: None, - local_ip: None, - endorse_local_ip: None, intercept: None, }; Self { state: Arc::new(state::State::new(args).await.assert()), - tmpdir, + _tmpdir: tmpdir, } } } diff --git a/tests/e2e.rs b/tests/e2e.rs index e019dddd..20fcdb39 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -3,8 +3,7 @@ use helpers::{wait, StateDumpExt, TestSection}; use intermesh::assert::UnwrapAssert; use intermesh::ident::Name; use intermesh::imid::Imid; -use intermesh::modules::adhoc::token::Token; -use rayon::prelude::*; + use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv4Addr}; @@ -25,358 +24,6 @@ fn endorsement_count(node: &Node, path: &str) -> usize { .len() } -#[test] -#[ignore = "e2e test requiring docker"] -fn test_bootstrap_token() { - let mut section = TestSection::new(); - let harness = Harness::new(); - - let root = harness.launch("root", CADDY).assert(); - let db = harness.launch("db", CADDY).assert(); - let web = harness.launch("web", CADDY).assert(); - - section.next("Start daemons"); - for node in [&root, &db, &web] { - node.start_daemon(&[]).assert(); - } - - let mesh = "test.mesh"; - - section.next("Root generates token"); - let output = root - .exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("root.{mesh}"), - "--quiet", - ]) - .assert(); - let token = output.trim().to_string(); - - let original_token = Token::verify(&Token::decode(&token).assert()).assert(); - - section.next("Nodes join mesh"); - let root_imid = root.imid().assert(); - let joining_nodes = [&db, &web]; - - let mut expected_names = BTreeMap::new(); - let mut expected_ips = BTreeMap::new(); - let mut per_node_delegation_constraints = BTreeSet::new(); - - // Add root to expected state - expected_names.insert( - format!("root.{mesh}").parse().assert(), - BTreeSet::from([root_imid.clone()]), - ); - expected_ips.insert(root_imid.clone(), BTreeSet::from([root.ip])); - - per_node_delegation_constraints.insert(parse_constraint!( - "constraint(root.{mesh} endorses any as **.root.{mesh})" - )); - - for node in &joining_nodes { - // The user-visible token output includes quotes and newlines for - // readability (see render_root_token_instructions). Verify the CLI - // correctly strips these before sending to the daemon. - node.exec(&[ - "intermesh", - "adhoc", - "join", - &format!("'\n{token}\n'"), - "--name", - &format!("{}.{mesh}", node.name), - "--yes", - ]) - .assert(); - - let node_imid = node.imid().assert(); - let node_full_name = format!("{}.{mesh}", node.name); - expected_names.insert( - node_full_name.parse().assert(), - BTreeSet::from([node_imid.clone()]), - ); - expected_ips.insert(node_imid, BTreeSet::from([node.ip])); - - per_node_delegation_constraints.insert(parse_constraint!( - "constraint({node_full_name} endorses any as **.{node_full_name})" - )); - } - - section.next("Validate all nodes"); - let shared_constraints: BTreeSet<_> = [ - parse_constraint!("constraint(**.{mesh} endorses **.{mesh} as 0.0.0.0/0, ::/0)"), - parse_constraint!("constraint({root_imid} endorses any as **.{mesh})"), - ] - .into_iter() - .chain(per_node_delegation_constraints.iter().cloned()) - .collect(); - - // Validate root - // Root has: global authority + self-authority (from member_bases) + shared - let mut root_constraints = BTreeSet::from([ - parse_constraint!("constraint({root_imid} endorses any as **, 0.0.0.0/0, authority)"), - parse_constraint!("constraint({root_imid} endorses {root_imid} as **.{mesh}, authority)"), - ]); - root_constraints.extend(shared_constraints.iter().cloned()); - - wait(|| { - let dump = root.get_dump()?; - dump.check_names(&root.name, &expected_names)?; - dump.check_ips(&root.name, &expected_ips)?; - dump.check_constraints(&root.name, &root_constraints) - }) - .assert(); - - // Validate leaf nodes in parallel - joining_nodes.par_iter().for_each(|leaf| { - let leaf_imid = leaf.imid().assert(); - let mut leaf_constraints = BTreeSet::from([ - parse_constraint!("constraint({leaf_imid} endorses any as **, 0.0.0.0/0, authority)"), - parse_constraint!( - "constraint({root_imid} endorses {leaf_imid} as **.{mesh}, authority)" - ), - ]); - leaf_constraints.extend(shared_constraints.iter().cloned()); - - wait(|| { - let dump = leaf.get_dump()?; - dump.check_names(&leaf.name, &expected_names)?; - dump.check_ips(&leaf.name, &expected_ips)?; - dump.check_constraints(&leaf.name, &leaf_constraints) - }) - .assert(); - }); - - section.next("Idempotent re-up"); - - // Root should be able to regenerate a token (re-init same mesh) - let output = root - .exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("root.{mesh}"), - "--quiet", - ]) - .assert(); - let reup_token = output.trim().to_string(); - - let decoded = Token::verify(&Token::decode(&reup_token).assert()).assert(); - assert_eq!(decoded.mesh, original_token.mesh); - assert_eq!(decoded.imid, original_token.imid); - - section.next("Conflict scenarios"); - - // Root should NOT be able to init a different mesh - let result = root.exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - "prod.mesh", - "--name", - "root.prod.mesh", - "--quiet", - ]); - assert!( - result.is_err(), - "Root should not be able to init different mesh" - ); - - // Member should NOT be able to init any mesh (same or different) - let result = db.exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - "prod.mesh", - "--name", - "db.prod.mesh", - "--quiet", - ]); - assert!( - result.is_err(), - "Member should not be able to init different mesh" - ); - - let result = db.exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("db.{mesh}"), - "--quiet", - ]); - assert!( - result.is_err(), - "Member should not be able to init same mesh" - ); - - section.next("Re-join scenarios"); - - // Member should be able to re-join same mesh (idempotent) - db.exec(&[ - "intermesh", - "adhoc", - "join", - &token, - "--name", - &format!("db.{mesh}"), - "--yes", - ]) - .assert(); - - // Note: Cross-mesh conflict testing (member tries to join different mesh) - // is covered by unit tests in src/modules/adhoc/mod.rs::test_adhoc_up_e2e -} - -/// Test manual (hard-coded) bootstrap alongside token bootstrap in the same -/// mesh. -/// -/// Three-node mesh: root inits, db joins via token (existing flow), web -/// joins via hard-coded mode (root adds web explicitly, web joins with -/// --root-imid/--root-ip). Includes add error cases. -#[test] -#[ignore = "e2e test requiring docker"] -fn test_adhoc_manual() { - let mut section = TestSection::new(); - let harness = Harness::new(); - - let root = harness.launch("root", CADDY).assert(); - let db = harness.launch("db", CADDY).assert(); - let web = harness.launch("web", CADDY).assert(); - - section.next("Start daemons"); - for node in [&root, &db, &web] { - node.start_daemon(&[]).assert(); - } - - let mesh = "test.mesh"; - let root_imid = root.imid().assert(); - let root_ip = root.ip; - let db_imid = db.imid().assert(); - let db_ip = db.ip; - let web_imid = web.imid().assert(); - let web_ip = web.ip; - - section.next("Root initializes mesh"); - let token = root - .exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("root.{mesh}"), - "--quiet", - ]) - .assert(); - let token = token.trim().to_string(); - - section.next("db joins via token (existing flow)"); - db.exec(&[ - "intermesh", - "adhoc", - "join", - &token, - "--name", - &format!("db.{mesh}"), - "--yes", - ]) - .assert(); - - section.next("Root adds web via explicit identity"); - root.exec(&[ - "intermesh", - "adhoc", - "add", - "--name", - &format!("web.{mesh}"), - "--imid", - &web_imid.to_string(), - "--ip", - &web_ip.to_string(), - ]) - .assert(); - - section.next("web joins hard-coded"); - web.exec(&[ - "intermesh", - "adhoc", - "join", - "--mesh", - mesh, - "--root-imid", - &root_imid.to_string(), - "--root-ip", - &root_ip.to_string(), - ]) - .assert(); - - section.next("Validate all three nodes converge"); - let expected_names = BTreeMap::from([ - ( - format!("root.{mesh}").parse().assert(), - BTreeSet::from([root_imid.clone()]), - ), - ( - format!("db.{mesh}").parse().assert(), - BTreeSet::from([db_imid.clone()]), - ), - ( - format!("web.{mesh}").parse().assert(), - BTreeSet::from([web_imid.clone()]), - ), - ]); - let expected_ips = BTreeMap::from([ - (root_imid.clone(), BTreeSet::from([root_ip])), - (db_imid.clone(), BTreeSet::from([db_ip])), - (web_imid.clone(), BTreeSet::from([web_ip])), - ]); - - wait_for_convergence(&[&root, &db, &web], &expected_names, &expected_ips); - - section.next("Add error cases"); - - // Name outside mesh - let result = root.exec(&[ - "intermesh", - "adhoc", - "add", - "--name", - "db.other.mesh", - "--imid", - &db_imid.to_string(), - "--ip", - &db_ip.to_string(), - ]); - assert!(result.is_err(), "add name outside mesh should fail"); - - // Non-root tries add - let result = db.exec(&[ - "intermesh", - "adhoc", - "add", - "--name", - &format!("extra.{mesh}"), - "--imid", - &db_imid.to_string(), - "--ip", - &db_ip.to_string(), - ]); - assert!(result.is_err(), "non-root add should fail"); -} - #[test] #[ignore = "e2e test requiring docker"] fn test_daemon_log_file() { @@ -433,38 +80,28 @@ fn test_runtime_endorsements_are_saved() { section.next("Start daemon"); node.start_daemon(&[]).assert(); - section.next("Wait for daemon self-endorsement to reach the state file"); - wait(|| { - let count = endorsement_count(&node, state_file); - if count == 1 { - Ok(()) - } else { - anyhow::bail!("expected one daemon self-endorsement, found {count}") - } - }) - .assert(); - section.next("Create endorsements while daemon is running"); + let imid = node.imid().assert(); node.exec(&[ "intermesh", - "adhoc", - "init", - "--mesh", - "test.mesh", - "--name", - "root.test.mesh", - "--quiet", + "intent", + "add", + &format!( + "{imid} endorses {imid} as root.test.mesh, {}; \ + {imid} endorses {imid} as constraint({imid} endorses any as **.test.mesh)", + node.ip + ), ]) .assert(); - section.next("Wait for adhoc endorsements to reach the state file"); + section.next("Wait for intent endorsements to reach the state file"); wait(|| { let count = endorsement_count(&node, state_file); - if count > 1 { + if count == 2 { Ok(()) } else { - anyhow::bail!("state file only has daemon self-endorsement") + anyhow::bail!("expected two intent endorsements, found {count}") } }) .assert(); @@ -488,18 +125,7 @@ fn test_config_persistence() { let mode = node.exec(&["stat", "-c", "%a", state_file]).assert(); assert_eq!(mode, "600"); - // Create state via adhoc init - node.exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - "test.mesh", - "--name", - "root.test.mesh", - "--quiet", - ]) - .assert(); + bootstrap_mesh(&node, &[], "test.mesh"); let expected_names = BTreeMap::from([( "root.test.mesh".parse().assert(), @@ -604,34 +230,11 @@ fn test_proxy_basic() { ); } - section.next("Bootstrap mesh: n1 initializes, n2 joins"); + section.next("Bootstrap mesh via intent"); let mesh = "test.mesh"; let n1_hostname = &n1.name; let n2_hostname = &n2.name; - - let token = n1 - .exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("{n1_hostname}.{mesh}"), - "--quiet", - ]) - .assert(); - - n2.exec(&[ - "intermesh", - "adhoc", - "join", - &token, - "--name", - &format!("{n2_hostname}.{mesh}"), - "--yes", - ]) - .assert(); + bootstrap_mesh(&n1, &[&n2], mesh); section.next("Wait for gossip convergence"); let n1_imid = n1.imid().assert(); @@ -729,31 +332,9 @@ fn test_short_name_dns() { wait(|| root.exec(&["intermesh", "status"])).assert(); wait(|| db.exec(&["intermesh", "status"])).assert(); - section.next("Bootstrap mesh"); + section.next("Bootstrap mesh via intent"); let mesh = "test.mesh"; - let token = root - .exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("root.{mesh}"), - "--quiet", - ]) - .assert(); - - db.exec(&[ - "intermesh", - "adhoc", - "join", - &token, - "--name", - &format!("db.{mesh}"), - "--yes", - ]) - .assert(); + bootstrap_mesh(&root, &[&db], mesh); section.next("Wait for gossip convergence"); let root_imid = root.imid().assert(); @@ -902,31 +483,9 @@ fn test_dns_upstream_stall_does_not_block_mesh_names() { wait(|| root.exec(&["intermesh", "status"])).assert(); wait(|| db.exec(&["intermesh", "status"])).assert(); - section.next("Bootstrap mesh"); + section.next("Bootstrap mesh via intent"); let mesh = "test.mesh"; - let token = root - .exec(&[ - "intermesh", - "adhoc", - "init", - "--mesh", - mesh, - "--name", - &format!("root.{mesh}"), - "--quiet", - ]) - .assert(); - - db.exec(&[ - "intermesh", - "adhoc", - "join", - &token, - "--name", - &format!("db.{mesh}"), - "--yes", - ]) - .assert(); + bootstrap_mesh(&root, &[&db], mesh); section.next("Wait for gossip convergence"); let root_imid = root.imid().assert(); @@ -978,9 +537,337 @@ fn test_dns_upstream_stall_does_not_block_mesh_names() { ); } +#[test] +#[ignore = "e2e test requiring docker"] +fn test_intent_cli_mutations() { + let mut section = TestSection::new(); + let harness = Harness::new(); + let node = harness.launch("node", CADDY).assert(); + + section.next("Start daemon"); + node.start_daemon(&[]).assert(); + + let imid = node.imid().assert(); + let target_imid: Imid = "A1UqiwUlUpEHC0b_xtjSRHollrXSPmkXNasb40dX9xVi1" + .parse() + .assert(); + let self_name = "self.test.mesh"; + let target_name = "target.test.mesh"; + let alias_name = "alias.test.mesh"; + let absent_name = "absent.test.mesh"; + let extra_name = "extra.test.mesh"; + let foreign_name = "foreign.test.mesh"; + let node_ip = node.ip; + + // Build self-authored intent facts for both the local IMID and another + // IMID. The endorser is always the local node because submitted intent + // must be locally signed, but targets can be either self or another + // identity. `self_dsl` includes an IP so debug dump proves reconciliation + // materialized the signed endorsement into derived state. The other facts + // are name-only so each mutation has a simple observable add/remove effect. + let self_dsl = format!("{imid} endorses {imid} as {self_name}, {node_ip}"); + let target_dsl = format!("{imid} endorses {target_imid} as {target_name}"); + let alias_dsl = format!("{imid} endorses {target_imid} as {alias_name}"); + let absent_dsl = format!("{imid} endorses {target_imid} as {absent_name}"); + let extra_dsl = format!("{imid} endorses {target_imid} as {extra_name}"); + let expected_ips = BTreeMap::from([(imid.clone(), BTreeSet::from([node_ip]))]); + let name = |s: &str| s.parse::().assert(); + + section.next("Replace intent set"); + // Replace the full submitted intent set with three facts. This exercises + // `intent replace` and gives later steps a known baseline. + let initial_intent = format!("{self_dsl}; {target_dsl}; {alias_dsl}"); + node.exec(&["intermesh", "intent", "replace", &initial_intent]) + .assert(); + + // First validate the stored unsigned intent directly via `intent get`. + // This checks the admin/CLI intent API before looking at reconciliation. + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert!(intent.contains(self_name), "intent get: {intent}"); + assert!(intent.contains(target_name), "intent get: {intent}"); + assert!(intent.contains(alias_name), "intent get: {intent}"); + + // Then wait for the manager to sign the intent and for debug dump to show + // the derived names/IPs produced from those signed endorsements. + wait(|| { + let expected_names = BTreeMap::from([ + (name(self_name), BTreeSet::from([imid.clone()])), + (name(target_name), BTreeSet::from([target_imid.clone()])), + (name(alias_name), BTreeSet::from([target_imid.clone()])), + ]); + let dump = node.get_dump()?; + dump.check_names(&node.name, &expected_names)?; + dump.check_ips(&node.name, &expected_ips) + }) + .assert(); + + section.next("Restart preserves submitted intent"); + node.kill_daemon().assert(); + node.start_daemon(&[]).assert(); + + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert!(intent.contains(self_name), "intent get: {intent}"); + assert!(intent.contains(target_name), "intent get: {intent}"); + assert!(intent.contains(alias_name), "intent get: {intent}"); + + wait(|| { + let expected_names = BTreeMap::from([ + (name(self_name), BTreeSet::from([imid.clone()])), + (name(target_name), BTreeSet::from([target_imid.clone()])), + (name(alias_name), BTreeSet::from([target_imid.clone()])), + ]); + let dump = node.get_dump()?; + dump.check_names(&node.name, &expected_names)?; + dump.check_ips(&node.name, &expected_ips) + }) + .assert(); + + section.next("Reject invalid intent input"); + let malformed = node.exec(&["intermesh", "intent", "add", "not valid intent"]); + assert!(malformed.is_err(), "malformed intent add should fail"); + + let foreign_dsl = format!("{target_imid} endorses {target_imid} as {foreign_name}"); + let foreign = node.exec(&["intermesh", "intent", "add", &foreign_dsl]); + assert!(foreign.is_err(), "foreign-endorser intent add should fail"); + + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert!(!intent.contains(foreign_name), "intent get: {intent}"); + + section.next("No-op mutations leave intent unchanged"); + // Replacing with the same complete set should be accepted and leave the + // stored intent unchanged. + node.exec(&["intermesh", "intent", "replace", &initial_intent]) + .assert(); + + // Removing a fact that is not present should also be accepted. Remove is + // intentionally idempotent. + node.exec(&["intermesh", "intent", "remove", &absent_dsl]) + .assert(); + + // Adding an already-present fact should be accepted but should not create + // a duplicate in `intent get`. + node.exec(&["intermesh", "intent", "add", &alias_dsl]) + .assert(); + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert_eq!( + intent.matches(alias_name).count(), + 1, + "intent get: {intent}" + ); + assert!(!intent.contains(absent_name), "intent get: {intent}"); + + section.next("Remove intent by exact match"); + // Remove only the alias fact. Removal is by exact endorsement base, so the + // self name/IP intent and target name intent should remain untouched. + node.exec(&["intermesh", "intent", "remove", &alias_dsl]) + .assert(); + + // `intent get` should reflect the submitted intent set immediately: the + // self/target facts remain and the alias fact is gone. + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert!(intent.contains(self_name), "intent get: {intent}"); + assert!(intent.contains(target_name), "intent get: {intent}"); + assert!(!intent.contains(alias_name), "intent get: {intent}"); + + // Reconciliation should revoke/drop the removed alias endorsement, leaving + // the self name/IP and target name in derived state. + wait(|| { + let expected_names = BTreeMap::from([ + (name(self_name), BTreeSet::from([imid.clone()])), + (name(target_name), BTreeSet::from([target_imid.clone()])), + ]); + let dump = node.get_dump()?; + dump.check_names(&node.name, &expected_names)?; + dump.check_ips(&node.name, &expected_ips) + }) + .assert(); + + section.next("Add intent"); + // Add a new name-only fact without replacing the existing self or target + // intents. This verifies additive mutation rather than wholesale replace. + node.exec(&["intermesh", "intent", "add", &extra_dsl]) + .assert(); + + // The stored unsigned intent should now contain the retained self/target + // facts and the newly added extra fact. + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert!(intent.contains(self_name), "intent get: {intent}"); + assert!(intent.contains(target_name), "intent get: {intent}"); + assert!(intent.contains(extra_name), "intent get: {intent}"); + + // Finally, verify reconciliation materialized the added fact while keeping + // the original IP endorsement live. + wait(|| { + let expected_names = BTreeMap::from([ + (name(self_name), BTreeSet::from([imid.clone()])), + (name(target_name), BTreeSet::from([target_imid.clone()])), + (name(extra_name), BTreeSet::from([target_imid.clone()])), + ]); + let dump = node.get_dump()?; + dump.check_names(&node.name, &expected_names)?; + dump.check_ips(&node.name, &expected_ips) + }) + .assert(); + + section.next("Replace intent set with empty set"); + // Replacing with empty input clears submitted intent entirely. + node.exec(&["intermesh", "intent", "replace", ""]).assert(); + + // The unsigned intent set should now render as empty output. + let intent = node + .exec(&["intermesh", "--color", "never", "intent", "get"]) + .assert(); + assert!(intent.is_empty(), "intent get: {intent}"); + + // Reconciliation should remove submitted names and IPs. + wait(|| { + let dump = node.get_dump()?; + dump.check_names(&node.name, &BTreeMap::new())?; + dump.check_ips(&node.name, &BTreeMap::new()) + }) + .assert(); +} + +#[test] +#[ignore = "e2e test requiring docker"] +fn test_intent_cli_bootstraps_mesh() { + let mut section = TestSection::new(); + let harness = Harness::new(); + + let root = harness.launch("root", CADDY).assert(); + let db = harness.launch("db", CADDY).assert(); + let web = harness.launch("web", CADDY).assert(); + + section.next("Start daemons"); + for node in [&root, &db, &web] { + node.start_daemon(&[]).assert(); + } + + let mesh = "test.mesh"; + let root_imid = root.imid().assert(); + let db_imid = db.imid().assert(); + let web_imid = web.imid().assert(); + + let expected_names = BTreeMap::from([ + ( + format!("root.{mesh}").parse().assert(), + BTreeSet::from([root_imid.clone()]), + ), + ( + format!("db.{mesh}").parse().assert(), + BTreeSet::from([db_imid.clone()]), + ), + ( + format!("web.{mesh}").parse().assert(), + BTreeSet::from([web_imid.clone()]), + ), + ]); + let expected_ips = BTreeMap::from([ + (root_imid, BTreeSet::from([root.ip])), + (db_imid, BTreeSet::from([db.ip])), + (web_imid, BTreeSet::from([web.ip])), + ]); + + section.next("Bootstrap mesh via intent add"); + bootstrap_mesh(&root, &[&db, &web], mesh); + wait_for_convergence(&[&root, &db, &web], &expected_names, &expected_ips); + + section.next("Repeat bootstrap is idempotent"); + bootstrap_mesh(&root, &[&db, &web], mesh); + wait_for_convergence(&[&root, &db, &web], &expected_names, &expected_ips); + + section.next("Restart preserves bootstrap intent"); + db.kill_daemon().assert(); + db.start_daemon(&[]).assert(); + wait_for_convergence(&[&root, &db, &web], &expected_names, &expected_ips); +} + +#[test] +#[ignore = "e2e test requiring docker"] +fn test_intent_remove_propagates_revocation() { + let mut section = TestSection::new(); + let harness = Harness::new(); + + let root = harness.launch("root", CADDY).assert(); + let db = harness.launch("db", CADDY).assert(); + let web = harness.launch("web", CADDY).assert(); + + section.next("Start daemons"); + for node in [&root, &db, &web] { + node.start_daemon(&[]).assert(); + } + + let mesh = "test.mesh"; + let root_imid = root.imid().assert(); + let db_imid = db.imid().assert(); + let web_imid = web.imid().assert(); + + section.next("Bootstrap mesh via intent add"); + bootstrap_mesh(&root, &[&db, &web], mesh); + + let expected_names = BTreeMap::from([ + ( + format!("root.{mesh}").parse().assert(), + BTreeSet::from([root_imid.clone()]), + ), + ( + format!("db.{mesh}").parse().assert(), + BTreeSet::from([db_imid.clone()]), + ), + ( + format!("web.{mesh}").parse().assert(), + BTreeSet::from([web_imid.clone()]), + ), + ]); + let expected_ips = BTreeMap::from([ + (root_imid.clone(), BTreeSet::from([root.ip])), + (db_imid.clone(), BTreeSet::from([db.ip])), + (web_imid.clone(), BTreeSet::from([web.ip])), + ]); + wait_for_convergence(&[&root, &db, &web], &expected_names, &expected_ips); + + section.next("Remove db intent"); + let db_ip = db.ip; + root.exec(&[ + "intermesh", + "intent", + "remove", + &format!("{root_imid} endorses {db_imid} as db.{mesh}, {db_ip}"), + ]) + .assert(); + + let expected_names = BTreeMap::from([ + ( + format!("root.{mesh}").parse().assert(), + BTreeSet::from([root_imid.clone()]), + ), + ( + format!("web.{mesh}").parse().assert(), + BTreeSet::from([web_imid.clone()]), + ), + ]); + let expected_ips = BTreeMap::from([ + (root_imid, BTreeSet::from([root.ip])), + (web_imid, BTreeSet::from([web.ip])), + ]); + wait_for_convergence(&[&root, &web], &expected_names, &expected_ips); +} + /// Test that authorization policies enforce mTLS connectivity. /// -/// Bootstraps the mesh via `debug endorse` rather than the adhoc module. +/// Bootstraps the mesh via `intent add`. /// Each node starts with no authz endorsements (blank slate), so curl fails /// in both directions. After root issues authz `web → db`, web can reach /// db but db still cannot reach web. @@ -1006,7 +893,7 @@ fn test_proxy_authz_directionality() { let db_ip = db.ip; let web_ip = web.ip; - section.next("Bootstrap trust via debug endorse"); + section.next("Bootstrap trust via intent add"); // Every node self-endorses a constraint granting root authority over the // test mesh scope. That single local trust declaration lets each node // accept root's name, IP, and authz endorsements as they arrive via @@ -1015,8 +902,8 @@ fn test_proxy_authz_directionality() { for node in [&root, &db, &web] { node.exec(&[ "intermesh", - "debug", - "endorse", + "intent", + "add", &format!( "self endorses self as \ constraint({root_imid} endorses any as \ @@ -1031,22 +918,21 @@ fn test_proxy_authz_directionality() { let node_imid = node.imid().assert(); node.exec(&[ "intermesh", - "debug", - "endorse", + "intent", + "add", &format!("{node_imid} endorses {root_imid} as root.{mesh}, {root_ip}"), ]) .assert(); } - // Root publishes every node's name (including its own) and the joiners' - // IPs. Root's own IP is auto-endorsed by the daemon. Gossip carries these - // to db and web, whose self-constraint permits them. + // Root publishes every node's name and IP. Gossip carries these to db and + // web, whose self-constraint permits them. root.exec(&[ "intermesh", - "debug", - "endorse", + "intent", + "add", &format!( - "{root_imid} endorses {root_imid} as root.{mesh}; \ + "{root_imid} endorses {root_imid} as root.{mesh}, {root_ip}; \ {root_imid} endorses {db_imid} as db.{mesh}, {db_ip}; \ {root_imid} endorses {web_imid} as web.{mesh}, {web_ip}" ), @@ -1094,8 +980,8 @@ fn test_proxy_authz_directionality() { section.next("Create authorization: web → db"); root.exec(&[ "intermesh", - "debug", - "endorse", + "intent", + "add", &format!( "{root_imid} endorses web.{mesh} as \ authz(web.{mesh} connects db.{mesh}); \ @@ -1133,6 +1019,44 @@ fn test_proxy_authz_directionality() { ); } +/// Bootstrap a permissive test mesh through submitted endorsement intents. +fn bootstrap_mesh(root: &Node, members: &[&Node], mesh: &str) { + let root_imid = root.imid().assert(); + let root_ip = root.ip; + + for node in std::iter::once(root).chain(members.iter().copied()) { + let node_imid = node.imid().assert(); + node.exec(&[ + "intermesh", + "intent", + "add", + &format!( + "{node_imid} endorses {node_imid} as \ + constraint({root_imid} endorses any as \ + **.{mesh}, 0.0.0.0/0, ::/0, authority); \ + {node_imid} endorses {root_imid} as root.{mesh}, {root_ip}" + ), + ]) + .assert(); + } + + let mut root_intents = Vec::new(); + for node in members { + let node_imid = node.imid().assert(); + let node_name = &node.name; + let node_ip = node.ip; + root_intents.push(format!( + "{root_imid} endorses {node_imid} as {node_name}.{mesh}, {node_ip}" + )); + } + root_intents.push(format!( + "{root_imid} endorses **.{mesh} as authz(**.{mesh} connects **.{mesh})" + )); + + root.exec(&["intermesh", "intent", "add", &root_intents.join("; ")]) + .assert(); +} + /// Helper: wait for all nodes to converge on expected names and IPs. fn wait_for_convergence( nodes: &[&Node],