feat(daemon): complete Task 2.7 - scaffold chronogrid_daemon headless binary

This commit is contained in:
Mike Kell 2026-08-23 15:06:50 -04:00
parent 9d3bd8811f
commit c1f7eabda7
10 changed files with 1095 additions and 4 deletions

195
Cargo.lock generated
View File

@ -108,6 +108,56 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@ -482,6 +532,22 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "chronogrid_ffi" name = "chronogrid_ffi"
version = "0.1.0" version = "0.1.0"
@ -529,6 +595,52 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -1606,6 +1718,12 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@ -2466,6 +2584,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.1" version = "0.3.1"
@ -3238,6 +3362,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -3385,6 +3518,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -3613,6 +3752,47 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.3" version = "0.3.3"
@ -3774,6 +3954,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.24.1" version = "1.24.1"
@ -4214,6 +4400,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"

View File

@ -6,6 +6,7 @@ members = [
"crates/chronogrid_ffi", "crates/chronogrid_ffi",
"crates/chronogrid_net", "crates/chronogrid_net",
"crates/chronogrid_ai", "crates/chronogrid_ai",
"apps/chronogrid_daemon",
] ]
[workspace.package] [workspace.package]

View File

@ -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. - 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. - Confirmed all Phase 1 architectural decisions as correct — no reversals required.
- Next task for automated execution: **Task 2.1 — Extend Schema with Missing Entities**. - 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<Multiaddr>` 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`.

View File

@ -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"

View File

@ -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"

View File

@ -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<PathBuf>,
/// Path to the encrypted SQLCipher database file (overrides config).
#[arg(long, value_name = "PATH")]
db: Option<PathBuf>,
/// 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<String>,
}
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<String> {
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<Config> {
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<Multiaddr> = config
.network
.listen_addrs
.iter()
.filter_map(|addr_str| {
addr_str.parse::<Multiaddr>().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)");
}
}

View File

@ -68,6 +68,7 @@ pub use swarm_manager::{ChronoGridBehaviour, SwarmHandle, SwarmManager};
pub use sync::{DeltaPacket, GossipEvent, NetEvent, SyncProtocol, VectorClock}; pub use sync::{DeltaPacket, GossipEvent, NetEvent, SyncProtocol, VectorClock};
pub use transport::{TransportConfig, build_transport, peer_id_from_identity}; pub use transport::{TransportConfig, build_transport, peer_id_from_identity};
/// Re-export `libp2p::PeerId` so downstream crates (e.g. `chronogrid_core` /// Re-export `libp2p::PeerId` and `libp2p::Multiaddr` so downstream crates
/// tests) can use it without a direct `libp2p` dependency. /// (e.g. `chronogrid_core` tests and `chronogrid_daemon`) can use them without
pub use libp2p::PeerId; /// a direct `libp2p` dependency.
pub use libp2p::{Multiaddr, PeerId};

View File

@ -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<PathBuf>` | Config file path (optional) |
| `--db` | `Option<PathBuf>` | 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:<node_id>:<fingerprint>"`.
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)

View File

@ -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> Path to TOML config file
--db <PATH> SQLCipher database path (overrides config)
--log-level <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 |

View File

@ -105,7 +105,7 @@
## Task 2.7 — Scaffold chronogrid_daemon Binary ## 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` - Create `apps/chronogrid_daemon/Cargo.toml` with `[[bin]]` target `chronogrid-daemon`
- Add `apps/chronogrid_daemon` to workspace `Cargo.toml` members list - Add `apps/chronogrid_daemon` to workspace `Cargo.toml` members list
- Dependencies: `chronogrid_core`, `chronogrid_crypto`, `chronogrid_net`, `tokio`, `clap`, `serde`, `toml`, `tracing`, `tracing-subscriber` - Dependencies: `chronogrid_core`, `chronogrid_crypto`, `chronogrid_net`, `tokio`, `clap`, `serde`, `toml`, `tracing`, `tracing-subscriber`