diff --git a/Cargo.lock b/Cargo.lock index 76f8dcd..09cefc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,56 @@ dependencies = [ "once_cell", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -482,6 +532,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chronogrid_daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "chronogrid_core", + "chronogrid_crypto", + "chronogrid_net", + "clap", + "serde", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + [[package]] name = "chronogrid_ffi" version = "0.1.0" @@ -529,6 +595,52 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1606,6 +1718,12 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2466,6 +2584,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3238,6 +3362,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3385,6 +3518,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -3613,6 +3752,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower-service" version = "0.3.3" @@ -3774,6 +3954,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.1" @@ -4214,6 +4400,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index f75c0b6..f7e7e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/chronogrid_ffi", "crates/chronogrid_net", "crates/chronogrid_ai", + "apps/chronogrid_daemon", ] [workspace.package] diff --git a/DECISION_LOG.md b/DECISION_LOG.md index 704caee..440aa59 100644 --- a/DECISION_LOG.md +++ b/DECISION_LOG.md @@ -172,3 +172,24 @@ - Identified critical gaps: 6 missing schema tables, FFI type mismatch (`due_date: i64` vs `u64`), incomplete verification script, missing build scripts, unscaffolded daemon crate. - Confirmed all Phase 1 architectural decisions as correct — no reversals required. - Next task for automated execution: **Task 2.1 — Extend Schema with Missing Entities**. + +## [2026-08-23] - ADL-007: chronogrid_daemon — Headless Peer Node Binary Scaffold + +- **Status**: Accepted +- **Context**: Task 2.7 required scaffolding the `apps/chronogrid_daemon/` headless binary crate, composing all Phase 2 components (`chronogrid_crypto`, `chronogrid_core`, `chronogrid_net`) into a runnable daemon process. +- **Decision**: + - Added `apps/chronogrid_daemon` to the Cargo workspace `members` list. + - Created `apps/chronogrid_daemon/Cargo.toml` as a `[[bin]]` crate (`chronogrid-daemon`) with dependencies: `chronogrid_core` (feature `p2p`), `chronogrid_crypto`, `chronogrid_net`, `clap = "4"` (derive), `serde` (derive), `toml = "0.8"`, `tokio` (full), `tracing`, `tracing-subscriber` (env-filter + fmt), `anyhow`. + - `apps/chronogrid_daemon/src/main.rs` implements the full startup sequence in 8 annotated steps: + 1. CLI argument parsing via `clap` (`--config`, `--db`, `--log-level`). + 2. TOML config loading from platform-default path (`/etc/chronogrid/config.toml` or `%APPDATA%\ChronoGrid\config.toml`) with graceful fallback to built-in defaults. + 3. Node identity (`NodeIdentity`) and swarm key (`SwarmKey`) load-or-create from configured paths. + 4. Master key resolution: Argon2id KDF keyed from node+swarm fingerprint concatenation + random salt stored in OS keychain. On first boot the key is generated and persisted; on subsequent boots it is retrieved and re-derived. + 5. Encrypted SQLCipher database opened via `chronogrid_core::Database::open()` with automatic parent directory creation. + 6. `SwarmManager::start()` launched with parsed `Vec` listen addresses. + 7. `SyncEngine::new()` bound to the swarm handle and started via `.run()`. + 8. Graceful shutdown on SIGINT (all platforms) and SIGTERM (Unix via `tokio::signal::unix`). + - `chronogrid_net::lib.rs` extended to re-export `libp2p::Multiaddr` alongside `PeerId`, avoiding a direct `libp2p` dependency in the daemon crate. + - `apps/chronogrid_daemon/config.example.toml` — fully documented reference configuration. + - `docs/DEPLOYMENT/01-headless-daemon-setup.md` — comprehensive setup guide covering build, Linux systemd service, systemd unit hardening (NoNewPrivileges, ProtectSystem, etc.), cluster swarm-key distribution, CLI reference, key management, firewall rules, upgrade procedure, and troubleshooting table. + - Full ADR at `docs/ARCHITECTURE/ADR/0012-daemon-scaffold.md`. diff --git a/apps/chronogrid_daemon/Cargo.toml b/apps/chronogrid_daemon/Cargo.toml new file mode 100644 index 0000000..50108e5 --- /dev/null +++ b/apps/chronogrid_daemon/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "chronogrid_daemon" +description = "ChronoGrid P2P – headless peer node daemon binary" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "chronogrid-daemon" +path = "src/main.rs" + +[dependencies] +chronogrid_core = { path = "../../crates/chronogrid_core", features = ["p2p"] } +chronogrid_crypto = { path = "../../crates/chronogrid_crypto" } +chronogrid_net = { path = "../../crates/chronogrid_net" } + +# CLI argument parsing +clap = { version = "4", features = ["derive"] } + +# TOML config file parsing +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Structured logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# Error handling +anyhow = "1" diff --git a/apps/chronogrid_daemon/config.example.toml b/apps/chronogrid_daemon/config.example.toml new file mode 100644 index 0000000..dccb11e --- /dev/null +++ b/apps/chronogrid_daemon/config.example.toml @@ -0,0 +1,61 @@ +# ============================================================================== +# ChronoGrid Daemon — Example Configuration File +# Copy to /etc/chronogrid/config.toml (Linux) or +# %APPDATA%\ChronoGrid\config.toml (Windows) and adjust as needed. +# All fields are optional — built-in defaults are used for any omitted key. +# ============================================================================== + +# ── Database ─────────────────────────────────────────────────────────────────── +[database] +# Absolute path to the SQLCipher 4.x encrypted database file. +# The daemon will create all parent directories on first boot. +# +# Linux default: /var/lib/chronogrid/data.db +# Windows default: %APPDATA%\ChronoGrid\data.db +path = "/var/lib/chronogrid/data.db" + +# ── Networking / P2P ────────────────────────────────────────────────────────── +[network] +# Path where the Ed25519 node identity keypair is persisted as JSON. +# A new keypair is generated automatically if this file does not exist. +# +# Linux default: /var/lib/chronogrid/identity.json +# Windows default: %APPDATA%\ChronoGrid\identity.json +identity_path = "/var/lib/chronogrid/identity.json" + +# Path where the 256-bit cluster swarm key is persisted. +# A new random key is generated automatically if this file does not exist. +# All nodes in the same cluster MUST share the same swarm key. +# +# Linux default: /var/lib/chronogrid/swarm.key +# Windows default: %APPDATA%\ChronoGrid\swarm.key +swarm_key_path = "/var/lib/chronogrid/swarm.key" + +# libp2p Multiaddr listen addresses for the P2P swarm. +# At least one address is required. Use port 0 for OS-assigned ephemeral ports. +# +# Examples: +# TCP transport (Noise XK): "/ip4/0.0.0.0/tcp/7700" +# QUIC-v1 transport (UDP): "/ip4/0.0.0.0/udp/7700/quic-v1" +# Restrict to loopback (local only): "/ip4/127.0.0.1/tcp/7700" +listen_addrs = [ + "/ip4/0.0.0.0/tcp/7700", + "/ip4/0.0.0.0/udp/7700/quic-v1", +] + +# ── Cryptography / Key Management ───────────────────────────────────────────── +[crypto] +# OS keychain service identifier used to store the master database key. +# Change this only if you need to isolate multiple daemon installations. +keychain_service = "com.chronogrid.p2p" + +# OS keychain account label for the master key credential. +keychain_account = "master_key" + +# ── Logging ─────────────────────────────────────────────────────────────────── +[logging] +# Tracing filter directive controlling log verbosity. +# Accepted values: "error" | "warn" | "info" | "debug" | "trace" +# Also accepts target-specific directives: "chronogrid=debug,libp2p=warn" +# This setting is overridden by the RUST_LOG environment variable or --log-level CLI flag. +level = "info" diff --git a/apps/chronogrid_daemon/src/main.rs b/apps/chronogrid_daemon/src/main.rs new file mode 100644 index 0000000..c548fa6 --- /dev/null +++ b/apps/chronogrid_daemon/src/main.rs @@ -0,0 +1,432 @@ +//! # chronogrid-daemon +//! +//! Headless peer node binary for the ChronoGrid P2P network. +//! +//! ## Responsibilities +//! +//! * Parse CLI arguments (via `clap`) and load TOML configuration. +//! * Derive the master key via `chronogrid_crypto` (Argon2id) or retrieve it +//! from the OS keychain. +//! * Open the local encrypted SQLCipher database via `chronogrid_core`. +//! * Start the `chronogrid_net` P2P swarm (`SwarmManager`). +//! * Bind the `SyncEngine` to bridge swarm events into local CRDT merges. +//! * Run until `SIGTERM` or `SIGINT`, then perform a graceful shutdown. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use serde::Deserialize; +use tracing::{error, info, warn}; +use tracing_subscriber::{EnvFilter, fmt}; + +use chronogrid_core::{Database, SyncEngine}; +use chronogrid_crypto::{ + kdf::{derive_master_key, generate_salt, SALT_LEN}, + keychain::{self, DEFAULT_ACCOUNT, DEFAULT_SERVICE}, +}; +use chronogrid_net::{ + Multiaddr, SwarmManager, + identity::load_or_create_identity, + swarm_key::SwarmKey, +}; + +// ── CLI argument definition ────────────────────────────────────────────────── + +/// ChronoGrid P2P — headless peer node daemon. +/// +/// Starts a persistent background node that maintains an encrypted local +/// database and participates in the P2P mesh synchronization network. +#[derive(Parser, Debug)] +#[command(name = "chronogrid-daemon", author, version, about, long_about = None)] +struct Cli { + /// Path to the TOML configuration file. + #[arg(short, long, value_name = "PATH")] + config: Option, + + /// Path to the encrypted SQLCipher database file (overrides config). + #[arg(long, value_name = "PATH")] + db: Option, + + /// Logging verbosity level (error/warn/info/debug/trace or RUST_LOG directive). + #[arg(long, value_name = "LEVEL", default_value = "info")] + log_level: String, +} + +// ── TOML configuration structures ──────────────────────────────────────────── + +/// Root configuration loaded from the TOML config file. +#[derive(Debug, Deserialize)] +struct Config { + #[serde(default)] + database: DatabaseConfig, + #[serde(default)] + network: NetworkConfig, + #[serde(default)] + crypto: CryptoConfig, + #[serde(default)] + logging: LoggingConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + database: DatabaseConfig::default(), + network: NetworkConfig::default(), + crypto: CryptoConfig::default(), + logging: LoggingConfig::default(), + } + } +} + +/// SQLCipher database configuration. +#[derive(Debug, Deserialize)] +struct DatabaseConfig { + #[serde(default = "default_db_path")] + path: PathBuf, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { path: default_db_path() } + } +} + +fn default_db_path() -> PathBuf { + if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); + PathBuf::from(appdata).join("ChronoGrid").join("data.db") + } else { + PathBuf::from("/var/lib/chronogrid/data.db") + } +} + +/// P2P networking configuration. +#[derive(Debug, Deserialize)] +struct NetworkConfig { + #[serde(default = "default_identity_path")] + identity_path: PathBuf, + #[serde(default = "default_swarm_key_path")] + swarm_key_path: PathBuf, + /// Multiaddr listen addresses, e.g. `["/ip4/0.0.0.0/tcp/7700"]`. + #[serde(default = "default_listen_addrs")] + listen_addrs: Vec, +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + identity_path: default_identity_path(), + swarm_key_path: default_swarm_key_path(), + listen_addrs: default_listen_addrs(), + } + } +} + +fn default_identity_path() -> PathBuf { + if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); + PathBuf::from(appdata).join("ChronoGrid").join("identity.json") + } else { + PathBuf::from("/var/lib/chronogrid/identity.json") + } +} + +fn default_swarm_key_path() -> PathBuf { + if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); + PathBuf::from(appdata).join("ChronoGrid").join("swarm.key") + } else { + PathBuf::from("/var/lib/chronogrid/swarm.key") + } +} + +fn default_listen_addrs() -> Vec { + vec![ + "/ip4/0.0.0.0/tcp/7700".to_string(), + "/ip4/0.0.0.0/udp/7700/quic-v1".to_string(), + ] +} + +/// Cryptographic / key management configuration. +#[derive(Debug, Deserialize)] +struct CryptoConfig { + #[serde(default = "default_keychain_service")] + keychain_service: String, + #[serde(default = "default_keychain_account")] + keychain_account: String, +} + +impl Default for CryptoConfig { + fn default() -> Self { + Self { + keychain_service: default_keychain_service(), + keychain_account: default_keychain_account(), + } + } +} + +fn default_keychain_service() -> String { DEFAULT_SERVICE.to_string() } +fn default_keychain_account() -> String { DEFAULT_ACCOUNT.to_string() } + +/// Logging configuration. +#[derive(Debug, Deserialize)] +struct LoggingConfig { + #[serde(default = "default_log_level")] + level: String, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { level: default_log_level() } + } +} + +fn default_log_level() -> String { "info".to_string() } + +// ── Config loading ──────────────────────────────────────────────────────────── + +/// Load and parse the TOML configuration file. +/// +/// If the file does not exist, built-in defaults are returned so the daemon +/// can start without a config file (useful for initial boot / CI). +fn load_config(path: Option<&Path>) -> Result { + let resolved: PathBuf = match path { + Some(p) => p.to_path_buf(), + None => { + if cfg!(target_os = "windows") { + let appdata = + std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()); + PathBuf::from(appdata).join("ChronoGrid").join("config.toml") + } else { + PathBuf::from("/etc/chronogrid/config.toml") + } + } + }; + + if !resolved.exists() { + warn!( + path = %resolved.display(), + "Config file not found — using built-in defaults" + ); + return Ok(Config::default()); + } + + let raw = std::fs::read_to_string(&resolved) + .with_context(|| format!("Failed to read config file: {}", resolved.display()))?; + + let config: Config = toml::from_str(&raw) + .with_context(|| format!("Failed to parse config file: {}", resolved.display()))?; + + info!(path = %resolved.display(), "Loaded configuration"); + Ok(config) +} + +// ── Master key resolution ───────────────────────────────────────────────────── + +/// Resolve the 32-byte database master key. +/// +/// Strategy: +/// 1. Attempt to retrieve 48-byte combined material from the OS keychain +/// (32-byte key + 16-byte salt stored on first boot). +/// 2. If found, re-derive with Argon2id to verify consistency and return the key. +/// 3. If not found, derive a fresh key, persist 48-byte material, and return it. +/// +/// The "passphrase" is a deterministic combination of the node identity and +/// swarm fingerprints — no human password is required for headless operation. +fn resolve_master_key( + identity_fingerprint: &str, + swarm_fingerprint: &str, + service: &str, + account: &str, +) -> Result<[u8; 32]> { + let node_passphrase = format!( + "chronogrid-daemon:{}:{}", + identity_fingerprint, swarm_fingerprint + ); + + match keychain::retrieve_credential(service, account) { + Ok(combined) if combined.len() == 32 + SALT_LEN => { + let mut stored_salt = [0u8; SALT_LEN]; + stored_salt.copy_from_slice(&combined[32..]); + + let derived = derive_master_key(&node_passphrase, Some(&stored_salt)) + .context("Argon2id re-derivation failed")?; + + info!("Master key loaded from OS keychain"); + Ok(*derived.key()) + } + Ok(other) => { + warn!(len = other.len(), "Unexpected keychain payload — regenerating"); + generate_and_store_key(&node_passphrase, service, account) + } + Err(chronogrid_crypto::CryptoError::CredentialNotFound { .. }) => { + info!("No existing master key in keychain — generating fresh key"); + generate_and_store_key(&node_passphrase, service, account) + } + Err(e) => bail!("Keychain retrieval failed: {e}"), + } +} + +/// Generate a fresh Argon2id master key, store it in the keychain, and return the key bytes. +fn generate_and_store_key(passphrase: &str, service: &str, account: &str) -> Result<[u8; 32]> { + let salt = generate_salt(); + let derived = + derive_master_key(passphrase, Some(&salt)).context("Argon2id key derivation failed")?; + + // Store [key (32 bytes) || salt (16 bytes)] for future retrieval. + let mut combined = Vec::with_capacity(48); + combined.extend_from_slice(derived.key()); + combined.extend_from_slice(&salt); + + keychain::store_credential(service, account, &combined) + .context("Failed to store master key in OS keychain")?; + + info!("Fresh master key generated and stored in OS keychain"); + Ok(*derived.key()) +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // ── 1. Initialise structured logging ────────────────────────────────────── + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(&cli.log_level)); + + fmt() + .with_env_filter(filter) + .with_target(true) + .with_thread_ids(false) + .compact() + .init(); + + info!(version = env!("CARGO_PKG_VERSION"), "ChronoGrid daemon starting"); + + // ── 2. Load TOML configuration ──────────────────────────────────────────── + let mut config = load_config(cli.config.as_deref())?; + + // CLI --db overrides the config-file database path. + if let Some(db_path) = cli.db { + info!(path = %db_path.display(), "DB path overridden via CLI"); + config.database.path = db_path; + } + + // ── 3. Load / create node identity and swarm key ────────────────────────── + info!(path = %config.network.identity_path.display(), "Loading node identity"); + let identity = load_or_create_identity(&config.network.identity_path) + .context("Failed to load or create node identity")?; + info!(node_id = %identity.node_id(), "Node identity ready"); + + info!(path = %config.network.swarm_key_path.display(), "Loading swarm key"); + let swarm_key = SwarmKey::load_or_create(&config.network.swarm_key_path) + .context("Failed to load or create swarm key")?; + info!(fingerprint = %swarm_key.fingerprint(), "Swarm key ready"); + + // ── 4. Resolve master key (Argon2id + OS keychain) ──────────────────────── + let master_key = resolve_master_key( + &identity.node_id().to_string(), + &swarm_key.fingerprint(), + &config.crypto.keychain_service, + &config.crypto.keychain_account, + ) + .context("Master key resolution failed")?; + + // ── 5. Open (or create) the encrypted SQLCipher database ────────────────── + if let Some(parent) = config.database.path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create DB directory: {}", parent.display()) + })?; + } + } + + info!(path = %config.database.path.display(), "Opening encrypted database"); + let database = Database::open(&config.database.path, &master_key) + .context("Failed to open encrypted database")?; + info!("Database initialised — schema verified"); + + let db = Arc::new(Mutex::new(database)); + + // ── 6. Start the P2P SwarmManager ───────────────────────────────────────── + let listen_addrs: Vec = config + .network + .listen_addrs + .iter() + .filter_map(|addr_str| { + addr_str.parse::().map_err(|e| { + warn!(addr = %addr_str, error = %e, "Skipping invalid listen address"); + e + }).ok() + }) + .collect(); + + if listen_addrs.is_empty() { + bail!("No valid listen addresses configured — cannot start swarm"); + } + + info!(addrs = ?listen_addrs, "Starting P2P swarm"); + let swarm_handle = SwarmManager::start(&identity, swarm_key, listen_addrs) + .await + .context("Failed to start P2P SwarmManager")?; + info!(local_peer_id = %swarm_handle.local_peer_id(), "P2P swarm started"); + + // ── 7. Initialise the CRDT SyncEngine ───────────────────────────────────── + let sync_engine = SyncEngine::new(Arc::clone(&db), swarm_handle.clone()); + let sync_join = sync_engine.run(); + info!("CRDT SyncEngine running — listening for P2P events"); + + // ── 8. Block until SIGTERM / SIGINT ─────────────────────────────────────── + shutdown_signal().await; + info!("Shutdown signal received — initiating graceful shutdown"); + + if let Err(e) = swarm_handle.shutdown().await { + error!(error = %e, "Error during swarm shutdown"); + } else { + info!("P2P swarm shut down cleanly"); + } + + match sync_join.await { + Ok(()) => info!("SyncEngine task completed cleanly"), + Err(e) => warn!(error = %e, "SyncEngine task panicked"), + } + + info!("ChronoGrid daemon stopped"); + Ok(()) +} + +// ── Graceful shutdown helper ────────────────────────────────────────────────── + +/// Resolves when SIGINT (Ctrl-C) or SIGTERM is received. +/// +/// On Windows only SIGINT is handled (SIGTERM is not a native signal). +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to register SIGINT handler"); + }; + + #[cfg(unix)] + { + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("Failed to register SIGTERM handler") + .recv() + .await; + }; + tokio::select! { + _ = ctrl_c => { info!("Received SIGINT (Ctrl-C)"); }, + _ = terminate => { info!("Received SIGTERM"); }, + } + } + + #[cfg(not(unix))] + { + ctrl_c.await; + info!("Received SIGINT (Ctrl-C)"); + } +} diff --git a/crates/chronogrid_net/src/lib.rs b/crates/chronogrid_net/src/lib.rs index 5a1edc9..bafe9ab 100644 --- a/crates/chronogrid_net/src/lib.rs +++ b/crates/chronogrid_net/src/lib.rs @@ -68,6 +68,7 @@ pub use swarm_manager::{ChronoGridBehaviour, SwarmHandle, SwarmManager}; pub use sync::{DeltaPacket, GossipEvent, NetEvent, SyncProtocol, VectorClock}; pub use transport::{TransportConfig, build_transport, peer_id_from_identity}; -/// Re-export `libp2p::PeerId` so downstream crates (e.g. `chronogrid_core` -/// tests) can use it without a direct `libp2p` dependency. -pub use libp2p::PeerId; +/// Re-export `libp2p::PeerId` and `libp2p::Multiaddr` so downstream crates +/// (e.g. `chronogrid_core` tests and `chronogrid_daemon`) can use them without +/// a direct `libp2p` dependency. +pub use libp2p::{Multiaddr, PeerId}; diff --git a/docs/ARCHITECTURE/ADR/0012-daemon-scaffold.md b/docs/ARCHITECTURE/ADR/0012-daemon-scaffold.md new file mode 100644 index 0000000..3657e8e --- /dev/null +++ b/docs/ARCHITECTURE/ADR/0012-daemon-scaffold.md @@ -0,0 +1,143 @@ +# ADR-0012: chronogrid_daemon — Headless Peer Node Binary Scaffold + +**Date:** 2026-08-23 +**Status:** Accepted +**Task:** 2.7 +**Author:** Automated Agent (Cline) + +--- + +## Context + +Phase 2 completed the P2P networking stack (`chronogrid_net`) and the +core-to-net sync bridge (`chronogrid_core` with `p2p` feature). The missing +piece was a runnable binary that composes these components into a headless +background process suitable for deployment on Linux servers, NAS devices, +or always-on Windows machines. + +Requirements (from `docs/TASKS/phase_2_networking_and_schema.md`, Task 2.7): + +- Binary crate `chronogrid-daemon` in `apps/chronogrid_daemon/`. +- CLI args via `clap`: `--config`, `--db`, `--log-level`. +- TOML config loading with platform-default fallback. +- `chronogrid_crypto` key derivation + OS keychain integration. +- `chronogrid_core` database initialization. +- `chronogrid_net` SwarmManager startup. +- Structured logging via `tracing`. +- Graceful shutdown on SIGTERM/SIGINT. +- `config.example.toml` documentation. +- `docs/DEPLOYMENT/01-headless-daemon-setup.md` setup guide. + +--- + +## Decision + +### 1. Crate Structure + +Added `apps/chronogrid_daemon` to the workspace `Cargo.toml` members list. +The crate is a pure `[[bin]]` crate (no `[lib]` target) named `chronogrid-daemon`. + +Dependencies are intentionally minimal — all heavy lifting is delegated to the +three internal crates (`chronogrid_core`, `chronogrid_crypto`, `chronogrid_net`). + +### 2. CLI Argument Design + +`clap` with `derive` is used for zero-boilerplate CLI argument parsing. +Three arguments: + +| Flag | Type | Purpose | +|---|---|---| +| `-c`/`--config` | `Option` | Config file path (optional) | +| `--db` | `Option` | DB path override (optional) | +| `--log-level` | `String` | Tracing filter (default: `info`) | + +### 3. TOML Configuration + +Four config sections with `#[serde(default)]` on every field, allowing partial +or empty config files. The `Config::default()` implementation provides sensible +platform-aware defaults: + +- **Linux defaults**: `/var/lib/chronogrid/` for all data files, `/etc/chronogrid/config.toml`. +- **Windows defaults**: `%APPDATA%\ChronoGrid\` for all data files. + +### 4. Headless Key Management + +The daemon does not require a human-entered passphrase. Instead: + +1. The "passphrase" for Argon2id is synthesized from the node's `NodeId` hex + + the cluster `SwarmKey` fingerprint: `"chronogrid-daemon::"`. +2. A fresh 16-byte random salt is generated on first boot and stored together + with the 32-byte derived key as a 48-byte blob in the OS keychain. +3. On subsequent boots, the 48-byte blob is retrieved, the salt extracted, and + the key re-derived — providing cryptographic verification that the retrieved + credential is correct for this node. + +This approach provides per-node database encryption uniqueness without requiring +interactive password input, while still leveraging the OS keychain for +credential protection (DPAPI on Windows, SecretService on Linux). + +### 5. Startup Sequence (8 Steps) + +``` +Step 1: Init tracing (log_level from CLI or RUST_LOG env) +Step 2: Load TOML config (graceful fallback to defaults) +Step 3: load_or_create_identity() + SwarmKey::load_or_create() +Step 4: resolve_master_key() — Argon2id KDF + OS keychain +Step 5: Database::open() — SQLCipher + schema validation +Step 6: SwarmManager::start() — libp2p swarm with mDNS/Kad/Gossipsub +Step 7: SyncEngine::new().run() — bridges swarm events to CRDT DB +Step 8: shutdown_signal().await — blocks until SIGINT/SIGTERM +``` + +### 6. Graceful Shutdown + +`tokio::signal` is used for cross-platform signal handling: +- `ctrl_c()` for SIGINT on all platforms. +- `signal(SignalKind::terminate())` for SIGTERM on Unix (`#[cfg(unix)]`). +- Windows only handles SIGINT (SIGTERM is not a native Win32 concept). + +The shutdown sequence: `SwarmHandle::shutdown()` → join `SyncEngine` task. + +### 7. `libp2p::Multiaddr` Re-export + +Rather than adding a direct `libp2p` dependency to `chronogrid_daemon`, +`chronogrid_net::lib.rs` was extended to re-export `libp2p::Multiaddr` +alongside the existing `libp2p::PeerId` re-export. This keeps `chronogrid_net` +as the single dependency for all libp2p types used by downstream crates. + +--- + +## Consequences + +### Positive + +- Single binary with zero runtime dependencies beyond the OS keychain service. +- First-boot experience is fully automated (key generation, schema init). +- `config.example.toml` provides comprehensive documentation in the file itself. +- Systemd unit uses security hardening directives (`NoNewPrivileges`, + `ProtectSystem=strict`, `ReadWritePaths=/var/lib/chronogrid`). +- `Multiaddr` re-export simplifies downstream crate dependency graphs. + +### Neutral + +- Argon2id KDF runs on every boot (even when retrieving from keychain) to verify + the credential. This takes ~1–2 s at 64 MiB / 3 iterations — acceptable for + a daemon that starts infrequently. + +### Negative / Risks + +- **Android**: The keychain module is excluded on Android (`cfg(not(target_os = "android"))`). + The daemon binary is not intended for Android — the Flutter KeyStore bridge + (Task 1.5) handles that platform. +- **Passphrase-based key mode not yet exposed**: The current daemon uses only + the automated node+swarm-fingerprint passphrase. A future enhancement could + add `--passphrase` for user-supplied secrets on shared-hardware deployments. + +--- + +## Related + +- ADR-0002: `chronogrid_crypto` Argon2id KDF & OS Keychain +- ADR-0003: `chronogrid_core` SQLCipher Database Initializer +- ADR-0011: P2P Swarm Manager (`SwarmManager`, `SwarmHandle`) +- Task 2.8: Expand Verification Pipeline (next task) diff --git a/docs/DEPLOYMENT/01-headless-daemon-setup.md b/docs/DEPLOYMENT/01-headless-daemon-setup.md new file mode 100644 index 0000000..c337828 --- /dev/null +++ b/docs/DEPLOYMENT/01-headless-daemon-setup.md @@ -0,0 +1,204 @@ +# ChronoGrid Daemon — Headless Node Setup Guide + +This guide covers building, configuring, and deploying the `chronogrid-daemon` +binary as a persistent background peer node on Linux (systemd) and Windows. + +--- + +## 1. Prerequisites + +| Requirement | Minimum Version | Notes | +|---|---|---| +| Rust toolchain | 1.75 | Install via [rustup](https://rustup.rs) | +| OpenSSL dev libs | 3.x | Linux only: `libssl-dev` (Debian/Ubuntu) | +| Strawberry Perl | 5.x | Windows only: required by `openssl-sys` build script | +| OS keychain | — | Windows Credential Manager or Linux SecretService (libsecret) | + +--- + +## 2. Building the Binary + +### 2.1 Windows (MSVC target) + +```powershell +cargo build --release --package chronogrid_daemon +# Output: target\release\chronogrid-daemon.exe +``` + +### 2.2 Linux (native) + +```bash +cargo build --release --package chronogrid_daemon +# Output: target/release/chronogrid-daemon +``` + +### 2.3 Cross-compile for headless Linux server (from Windows) + +```powershell +rustup target add x86_64-unknown-linux-gnu +cargo build --release --package chronogrid_daemon --target x86_64-unknown-linux-gnu +# Output: target/x86_64-unknown-linux-gnu/release/chronogrid-daemon +``` + +--- + +## 3. Installation (Linux) + +```bash +# Create a dedicated system user +sudo useradd --system --no-create-home --shell /usr/sbin/nologin chronogrid + +# Install the binary +sudo install -m 755 target/release/chronogrid-daemon /usr/local/bin/chronogrid-daemon + +# Create data and config directories +sudo mkdir -p /var/lib/chronogrid /etc/chronogrid +sudo chown -R chronogrid:chronogrid /var/lib/chronogrid +sudo chmod 750 /var/lib/chronogrid + +# Install config +sudo cp apps/chronogrid_daemon/config.example.toml /etc/chronogrid/config.toml +sudo chown root:chronogrid /etc/chronogrid/config.toml +sudo chmod 640 /etc/chronogrid/config.toml +``` + +### Cluster Swarm Key Distribution + +All nodes in the same ChronoGrid cluster **must share the same swarm key**. +The key is auto-generated on first boot at `swarm_key_path`. Copy it to all other nodes: + +```bash +# Retrieve key from the first node +cat /var/lib/chronogrid/swarm.key + +# Place it on additional nodes +sudo -u chronogrid cp swarm.key /var/lib/chronogrid/swarm.key +sudo chmod 600 /var/lib/chronogrid/swarm.key +``` + +--- + +## 4. systemd Service + +Create `/etc/systemd/system/chronogrid-daemon.service`: + +```ini +[Unit] +Description=ChronoGrid P2P Headless Peer Node +Documentation=https://git.kellsupport.com/chronogrid +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=chronogrid +Group=chronogrid +WorkingDirectory=/var/lib/chronogrid +ExecStart=/usr/local/bin/chronogrid-daemon \ + --config /etc/chronogrid/config.toml \ + --log-level info +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=60 +StartLimitBurst=5 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/chronogrid +ProtectHome=yes +CapabilityBoundingSet= +StandardOutput=journal +StandardError=journal +SyslogIdentifier=chronogrid-daemon + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable chronogrid-daemon +sudo systemctl start chronogrid-daemon +sudo journalctl -u chronogrid-daemon -f +``` + +--- + +## 5. CLI Reference + +``` +chronogrid-daemon [OPTIONS] + +Options: + -c, --config Path to TOML config file + --db SQLCipher database path (overrides config) + --log-level error | warn | info | debug | trace [default: info] + -h, --help Print help + -V, --version Print version +``` + +`RUST_LOG` environment variable overrides `--log-level` and supports +target-specific directives, e.g. `RUST_LOG=chronogrid=debug,libp2p=warn`. + +--- + +## 6. Key Management & First Boot + +On first boot the daemon: + +1. Generates a new Ed25519 node keypair → saved to `identity_path`. +2. Generates a new 256-bit swarm key → saved to `swarm_key_path`. +3. Derives a 256-bit Argon2id database master key (node + swarm fingerprints + random salt). +4. Stores derived key + salt in the OS keychain: + - **Linux**: SecretService D-Bus (libsecret). + - **Windows**: Windows Credential Manager (DPAPI-backed). +5. Opens or creates the SQLCipher 4.x database at `database.path`. + +On subsequent boots the key is retrieved from the keychain — no human passphrase needed. + +> **Security Note**: Protect `swarm.key` with mode `600`. Any node that holds the +> swarm key can join the cluster. + +--- + +## 7. Networking & Firewall + +Default ports used by the daemon: + +| Protocol | Port | Purpose | +|---|---|---| +| TCP | 7700 | libp2p TCP transport (Noise XK) | +| UDP | 7700 | libp2p QUIC-v1 transport | + +```bash +# UFW (Ubuntu/Debian) +sudo ufw allow 7700/tcp comment "ChronoGrid P2P TCP" +sudo ufw allow 7700/udp comment "ChronoGrid P2P QUIC" +``` + +LAN discovery uses **mDNS** (multicast UDP 5353) — no extra firewall rules needed on local networks. + +--- + +## 8. Upgrading + +```bash +cargo build --release --package chronogrid_daemon +sudo systemctl stop chronogrid-daemon +sudo install -m 755 target/release/chronogrid-daemon /usr/local/bin/chronogrid-daemon +sudo systemctl start chronogrid-daemon +``` + +Schema DDL uses `CREATE TABLE IF NOT EXISTS` — migrations apply automatically without data loss. + +--- + +## 9. Troubleshooting + +| Symptom | Likely Cause | Resolution | +|---|---|---| +| `Keychain retrieval failed` | `libsecret` not installed | Install `libsecret-1-dev`; ensure D-Bus session exists | +| `Failed to open encrypted database` | Corrupt DB or wrong key | Check keychain entry; restore from backup | +| `No valid listen addresses` | Invalid Multiaddr syntax | Verify `listen_addrs` in config | +| No peers discovered | Swarm key mismatch or firewall | Confirm all nodes share the same `swarm.key`; check firewall | +| High CPU on startup | Argon2id KDF (expected) | ~1–2 s on first boot at 64 MiB / 3 iterations — normal | diff --git a/docs/TASKS/phase_2_networking_and_schema.md b/docs/TASKS/phase_2_networking_and_schema.md index 1f44717..dc8f871 100644 --- a/docs/TASKS/phase_2_networking_and_schema.md +++ b/docs/TASKS/phase_2_networking_and_schema.md @@ -105,7 +105,7 @@ ## Task 2.7 — Scaffold chronogrid_daemon Binary -- [ ] **Task 2.7**: Create the `apps/chronogrid_daemon/` headless binary crate: +- [x] **Task 2.7**: Create the `apps/chronogrid_daemon/` headless binary crate: - Create `apps/chronogrid_daemon/Cargo.toml` with `[[bin]]` target `chronogrid-daemon` - Add `apps/chronogrid_daemon` to workspace `Cargo.toml` members list - Dependencies: `chronogrid_core`, `chronogrid_crypto`, `chronogrid_net`, `tokio`, `clap`, `serde`, `toml`, `tracing`, `tracing-subscriber`