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],