Implement CLI configuration and runtime management
This commit is contained in:
1 parent
2ac0afa691
commit
3edea4bdff
14 files changed
+1362
-53
No files matched your search
+10
-1
@@ -3,15 +3,24 @@ name = "chronoseal-server"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "chronoseal"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
axum = "0.7"
|
||||
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
|
||||
clap_complete = "4"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.8"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
hex = "0.4"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum OutputFormat {
|
||||
Text,
|
||||
Json,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "chronoseal",
|
||||
version,
|
||||
about = "Linux-native cryptographic browser attestation service",
|
||||
long_about = "ChronoSeal runs as a composable Unix service. The CLI is the source of truth for daemon operation, health checks, configuration validation, metrics, and shell integration.",
|
||||
after_help = "Examples:\n chronoseal\n chronoseal run --bind 127.0.0.1:3000\n chronoseal status --format json\n chronoseal health --config /etc/chronoseal/config.toml\n chronoseal config check --output yaml\n chronoseal generate keypair\n chronoseal completion bash > /etc/bash_completion.d/chronoseal\n\nConfiguration precedence:\n CLI flags > CHRONOSEAL_* environment variables > config file > built-in defaults\n\nDefault config discovery:\n /etc/chronoseal/config.toml, then $XDG_CONFIG_HOME/chronoseal/config.toml, then ~/.config/chronoseal/config.toml"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(flatten)]
|
||||
pub globals: GlobalArgs,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct GlobalArgs {
|
||||
/// Path to config file.
|
||||
#[arg(long, env = "CHRONOSEAL_CONFIG", global = true)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Output format for machine-readable commands.
|
||||
#[arg(long, short = 'f', value_enum, default_value = "text", global = true)]
|
||||
pub format: OutputFormat,
|
||||
|
||||
/// Alias for --format, provided for Unix tool compatibility.
|
||||
#[arg(long, value_enum, global = true)]
|
||||
pub output: Option<OutputFormat>,
|
||||
|
||||
/// Override the logging filter, for example info, chronoseal=debug.
|
||||
#[arg(long, env = "CHRONOSEAL_LOG", global = true)]
|
||||
pub log: Option<String>,
|
||||
}
|
||||
|
||||
impl GlobalArgs {
|
||||
pub fn output_format(&self) -> OutputFormat {
|
||||
self.output.unwrap_or(self.format)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Run the ChronoSeal daemon.
|
||||
#[command(after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run")]
|
||||
Run(RunArgs),
|
||||
|
||||
/// Report whether the configured daemon is reachable and which PID file is present.
|
||||
#[command(after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid")]
|
||||
Status(RuntimeArgs),
|
||||
|
||||
/// Perform a daemon health probe.
|
||||
#[command(after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000")]
|
||||
Health(RuntimeArgs),
|
||||
|
||||
/// Validate and print effective configuration.
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCommand),
|
||||
|
||||
/// Generate operational material.
|
||||
#[command(subcommand)]
|
||||
Generate(GenerateCommand),
|
||||
|
||||
/// Print version and build information.
|
||||
#[command(after_help = "Examples:\n chronoseal version\n chronoseal version --format json")]
|
||||
Version,
|
||||
|
||||
/// Print Prometheus metrics from the running daemon.
|
||||
#[command(after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000")]
|
||||
Metrics(RuntimeArgs),
|
||||
|
||||
/// Print service statistics from the running daemon.
|
||||
#[command(after_help = "Examples:\n chronoseal stats\n chronoseal stats --format json")]
|
||||
Stats(RuntimeArgs),
|
||||
|
||||
/// Generate shell completions.
|
||||
#[command(after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal")]
|
||||
Completion { shell: clap_complete::Shell },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RunArgs {
|
||||
#[command(flatten)]
|
||||
pub runtime: RuntimeArgs,
|
||||
|
||||
/// SQLite database path. Use ':memory:' for ephemeral state.
|
||||
#[arg(long, env = "CHRONOSEAL_DB_PATH")]
|
||||
pub db_path: Option<PathBuf>,
|
||||
|
||||
/// Static frontend directory served at /.
|
||||
#[arg(long, env = "CHRONOSEAL_FRONTEND_DIR")]
|
||||
pub frontend_dir: Option<PathBuf>,
|
||||
|
||||
/// Optional structured JSON log file.
|
||||
#[arg(long, env = "CHRONOSEAL_LOG_FILE")]
|
||||
pub log_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RuntimeArgs {
|
||||
/// Socket address the daemon binds to, or that CLI probes connect to.
|
||||
#[arg(long, env = "CHRONOSEAL_BIND")]
|
||||
pub bind: Option<String>,
|
||||
|
||||
/// PID file path.
|
||||
#[arg(long, env = "CHRONOSEAL_PID_FILE")]
|
||||
pub pid_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ConfigCommand {
|
||||
/// Validate configuration and print the effective values.
|
||||
#[command(after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json")]
|
||||
Check(RuntimeArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum GenerateCommand {
|
||||
/// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text.
|
||||
#[command(after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json")]
|
||||
Keypair,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::cli::{RunArgs, RuntimeArgs};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
env, fs, io,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub bind: String,
|
||||
pub pid_file: PathBuf,
|
||||
pub db_path: PathBuf,
|
||||
pub frontend_dir: PathBuf,
|
||||
pub log_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: "0.0.0.0:3000".to_string(),
|
||||
pid_file: PathBuf::from("/run/chronoseal.pid"),
|
||||
db_path: default_state_dir().join("chronoseal.sqlite"),
|
||||
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
|
||||
log_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(config_path: Option<&Path>) -> Result<Self, ConfigError> {
|
||||
let mut config = Self::default();
|
||||
|
||||
if let Some(path) = config_path.map(Path::to_path_buf).or_else(discover_config_path) {
|
||||
let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
config = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
config.apply_env();
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn apply_runtime_args(&mut self, args: &RuntimeArgs) {
|
||||
if let Some(bind) = &args.bind {
|
||||
self.bind.clone_from(bind);
|
||||
}
|
||||
if let Some(pid_file) = &args.pid_file {
|
||||
self.pid_file = pid_file.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_run_args(&mut self, args: &RunArgs) {
|
||||
self.apply_runtime_args(&args.runtime);
|
||||
if let Some(db_path) = &args.db_path {
|
||||
self.db_path = db_path.clone();
|
||||
}
|
||||
if let Some(frontend_dir) = &args.frontend_dir {
|
||||
self.frontend_dir = frontend_dir.clone();
|
||||
}
|
||||
if let Some(log_file) = &args.log_file {
|
||||
self.log_file = Some(log_file.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
self.bind
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|source| ConfigError::InvalidBind {
|
||||
bind: self.bind.clone(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_env(&mut self) {
|
||||
if let Ok(value) = env::var("CHRONOSEAL_BIND") {
|
||||
self.bind = value;
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_PID_FILE") {
|
||||
self.pid_file = PathBuf::from(value);
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_DB_PATH") {
|
||||
self.db_path = PathBuf::from(value);
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_FRONTEND_DIR") {
|
||||
self.frontend_dir = PathBuf::from(value);
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") {
|
||||
self.log_file = Some(PathBuf::from(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Read {
|
||||
path: PathBuf,
|
||||
source: io::Error,
|
||||
},
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
source: toml::de::Error,
|
||||
},
|
||||
InvalidBind {
|
||||
bind: String,
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Read { path, source } => write!(f, "failed to read {}: {source}", path.display()),
|
||||
Self::Parse { path, source } => {
|
||||
write!(f, "failed to parse {} as TOML: {source}", path.display())
|
||||
}
|
||||
Self::InvalidBind { bind, source } => write!(f, "invalid bind address {bind}: {source}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
fn discover_config_path() -> Option<PathBuf> {
|
||||
user_config_candidates()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
pub fn user_config_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![PathBuf::from("/etc/chronoseal/config.toml")];
|
||||
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
|
||||
candidates.push(PathBuf::from(xdg).join("chronoseal/config.toml"));
|
||||
} else if let Ok(home) = env::var("HOME") {
|
||||
candidates.push(PathBuf::from(home).join(".config/chronoseal/config.toml"));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn default_state_dir() -> PathBuf {
|
||||
if let Ok(value) = env::var("CHRONOSEAL_STATE_DIR") {
|
||||
return PathBuf::from(value);
|
||||
}
|
||||
if let Ok(value) = env::var("XDG_STATE_HOME") {
|
||||
return PathBuf::from(value).join("chronoseal");
|
||||
}
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
return PathBuf::from(home).join(".local/state/chronoseal");
|
||||
}
|
||||
PathBuf::from("/var/lib/chronoseal")
|
||||
}
|
||||
+125
-29
@@ -1,47 +1,143 @@
|
||||
mod cleanup;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod fingerprint;
|
||||
mod middleware;
|
||||
mod output;
|
||||
mod ratelimit;
|
||||
mod routes;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod storage;
|
||||
mod trust;
|
||||
mod vm;
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::info;
|
||||
|
||||
use session::AppState;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use cli::{Cli, Command, ConfigCommand, GenerateCommand};
|
||||
use config::Config;
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
if let Err(err) = try_main().await {
|
||||
eprintln!("chronoseal: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let conn = storage::init_db().expect("DB init");
|
||||
let state = Arc::new(AppState {
|
||||
db: Mutex::new(conn),
|
||||
rate_limiter: Mutex::new(ratelimit::RateLimiter::new(
|
||||
shared::constants::RATE_LIMIT_COUNT,
|
||||
shared::constants::RATE_LIMIT_WINDOW_SECS,
|
||||
)),
|
||||
});
|
||||
async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
let log_filter = cli.globals.log.as_deref().unwrap_or("info");
|
||||
let log_file = log_file_for_command(&cli);
|
||||
let _log_guard = init_logging(log_filter, log_file)?;
|
||||
|
||||
// Periodic cleanup
|
||||
let bg_state = state.clone();
|
||||
tokio::spawn(async move { cleanup::cleanup_loop(bg_state).await });
|
||||
match &cli.command {
|
||||
None | Some(Command::Run(_)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
if let Some(Command::Run(args)) = &cli.command {
|
||||
config.apply_run_args(args);
|
||||
config.validate()?;
|
||||
}
|
||||
runtime::run_daemon(config).await?;
|
||||
}
|
||||
Some(Command::Status(args)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
config.validate()?;
|
||||
output::print(cli.globals.output_format(), &runtime::probe_status(&config))?;
|
||||
}
|
||||
Some(Command::Health(args)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
config.validate()?;
|
||||
let report = runtime::probe_health(&config);
|
||||
let healthy = report.status == "healthy";
|
||||
output::print(cli.globals.output_format(), &report)?;
|
||||
if !healthy {
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
Some(Command::Config(ConfigCommand::Check(args))) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
config.validate()?;
|
||||
output::print(cli.globals.output_format(), &config)?;
|
||||
}
|
||||
Some(Command::Generate(GenerateCommand::Keypair)) => {
|
||||
output::print(cli.globals.output_format(), &runtime::generate_keypair())?;
|
||||
}
|
||||
Some(Command::Version) => {
|
||||
output::print(cli.globals.output_format(), &runtime::version())?;
|
||||
}
|
||||
Some(Command::Metrics(args)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
config.validate()?;
|
||||
print!("{}", runtime::fetch_metrics(&config)?);
|
||||
}
|
||||
Some(Command::Stats(args)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
config.validate()?;
|
||||
output::print(cli.globals.output_format(), &runtime::fetch_stats(&config)?)?;
|
||||
}
|
||||
Some(Command::Completion { shell }) => {
|
||||
let mut command = Cli::command();
|
||||
let name = command.get_name().to_string();
|
||||
clap_complete::generate(*shell, &mut command, name, &mut std::io::stdout());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/init", axum::routing::post(routes::init::handler))
|
||||
.route("/hb", axum::routing::post(routes::heartbeat::handler))
|
||||
.nest_service("/", tower_http::services::ServeDir::new("../frontend"))
|
||||
.layer(tower_http::cors::CorsLayer::permissive())
|
||||
.layer(axum::middleware::from_fn(middleware::log_request))
|
||||
.with_state(state);
|
||||
fn log_file_for_command(cli: &Cli) -> Option<PathBuf> {
|
||||
match &cli.command {
|
||||
None => Config::load(cli.globals.config.as_deref())
|
||||
.ok()
|
||||
.and_then(|config| config.log_file),
|
||||
Some(Command::Run(args)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref()).ok()?;
|
||||
config.apply_run_args(args);
|
||||
config.log_file
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
info!("Server running on :3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
fn init_logging(
|
||||
filter: &str,
|
||||
log_file: Option<PathBuf>,
|
||||
) -> Result<Option<tracing_appender::non_blocking::WorkerGuard>, Box<dyn std::error::Error>> {
|
||||
let env_filter = EnvFilter::try_new(filter)?;
|
||||
if let Some(path) = log_file {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let directory = path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("chronoseal.jsonl");
|
||||
let appender = tracing_appender::rolling::never(directory, file_name);
|
||||
let (writer, guard) = tracing_appender::non_blocking(appender);
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_subscriber::fmt::layer().with_target(false))
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_target(false)
|
||||
.with_writer(writer),
|
||||
)
|
||||
.init();
|
||||
Ok(Some(guard))
|
||||
} else {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_target(false)
|
||||
.init();
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use crate::cli::OutputFormat;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn print<T>(format: OutputFormat, value: &T) -> Result<(), Box<dyn std::error::Error>>
|
||||
where
|
||||
T: Serialize + TextOutput,
|
||||
{
|
||||
match format {
|
||||
OutputFormat::Text => println!("{}", value.to_text()),
|
||||
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
|
||||
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(value)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub trait TextOutput {
|
||||
fn to_text(&self) -> String;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
output::TextOutput,
|
||||
ratelimit::RateLimiter,
|
||||
routes, session,
|
||||
storage::{self, StoreStats},
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthReport {
|
||||
pub status: &'static str,
|
||||
pub bind: String,
|
||||
}
|
||||
|
||||
impl TextOutput for HealthReport {
|
||||
fn to_text(&self) -> String {
|
||||
format!("{}\nbind={}", self.status, self.bind)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusReport {
|
||||
pub running: bool,
|
||||
pub healthy: bool,
|
||||
pub bind: String,
|
||||
pub pid_file: String,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl TextOutput for StatusReport {
|
||||
fn to_text(&self) -> String {
|
||||
let pid = self.pid.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
|
||||
format!(
|
||||
"running={}\nhealthy={}\nbind={}\npid_file={}\npid={}",
|
||||
self.running, self.healthy, self.bind, self.pid_file, pid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VersionReport {
|
||||
pub name: &'static str,
|
||||
pub version: &'static str,
|
||||
pub target: &'static str,
|
||||
}
|
||||
|
||||
impl TextOutput for VersionReport {
|
||||
fn to_text(&self) -> String {
|
||||
format!("{} {}", self.name, self.version)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KeypairReport {
|
||||
pub algorithm: &'static str,
|
||||
pub public_key_hex: String,
|
||||
pub private_key_hex: String,
|
||||
}
|
||||
|
||||
impl TextOutput for KeypairReport {
|
||||
fn to_text(&self) -> String {
|
||||
format!(
|
||||
"algorithm={}\npublic_key_hex={}\nprivate_key_hex={}",
|
||||
self.algorithm, self.public_key_hex, self.private_key_hex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TextOutput for Config {
|
||||
fn to_text(&self) -> String {
|
||||
format!(
|
||||
"bind={}\npid_file={}\ndb_path={}\nfrontend_dir={}\nlog_file={}",
|
||||
self.bind,
|
||||
self.pid_file.display(),
|
||||
self.db_path.display(),
|
||||
self.frontend_dir.display(),
|
||||
self.log_file
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TextOutput for StoreStats {
|
||||
fn to_text(&self) -> String {
|
||||
format!(
|
||||
"sessions={}\nexpired_sessions={}\nmax_chain_length={}",
|
||||
self.sessions, self.expired_sessions, self.max_chain_length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
install_pid_file(&config.pid_file)?;
|
||||
|
||||
let conn = storage::init_db(&config.db_path)?;
|
||||
let state = Arc::new(session::AppState {
|
||||
db: Mutex::new(conn),
|
||||
rate_limiter: Mutex::new(RateLimiter::new(
|
||||
shared::constants::RATE_LIMIT_COUNT,
|
||||
shared::constants::RATE_LIMIT_WINDOW_SECS,
|
||||
)),
|
||||
});
|
||||
|
||||
let bg_state = state.clone();
|
||||
tokio::spawn(async move { crate::cleanup::cleanup_loop(bg_state).await });
|
||||
|
||||
let app = Router::new()
|
||||
.route("/init", axum::routing::post(routes::init::handler))
|
||||
.route("/hb", axum::routing::post(routes::heartbeat::handler))
|
||||
.route("/health", get(health_handler))
|
||||
.route("/metrics", get(metrics_handler))
|
||||
.route("/stats", get(stats_handler))
|
||||
.nest_service("/", tower_http::services::ServeDir::new(&config.frontend_dir))
|
||||
.layer(tower_http::cors::CorsLayer::permissive())
|
||||
.layer(axum::middleware::from_fn(crate::middleware::log_request))
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = config.bind.parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!(bind = %config.bind, "chronoseal daemon started");
|
||||
|
||||
let shutdown = signal_task(config.clone());
|
||||
let result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await;
|
||||
|
||||
remove_pid_file(&config.pid_file);
|
||||
result?;
|
||||
info!("chronoseal daemon stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn probe_health(config: &Config) -> HealthReport {
|
||||
if http_get(&config.bind, "/health").is_ok() {
|
||||
HealthReport {
|
||||
status: "healthy",
|
||||
bind: config.bind.clone(),
|
||||
}
|
||||
} else {
|
||||
HealthReport {
|
||||
status: "unreachable",
|
||||
bind: config.bind.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn probe_status(config: &Config) -> StatusReport {
|
||||
let pid = read_pid(&config.pid_file);
|
||||
let healthy = http_get(&config.bind, "/health").is_ok();
|
||||
StatusReport {
|
||||
running: pid.is_some() || healthy,
|
||||
healthy,
|
||||
bind: config.bind.clone(),
|
||||
pid_file: config.pid_file.display().to_string(),
|
||||
pid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_metrics(config: &Config) -> Result<String, Box<dyn std::error::Error>> {
|
||||
http_get(&config.bind, "/metrics")
|
||||
}
|
||||
|
||||
pub fn fetch_stats(config: &Config) -> Result<StoreStats, Box<dyn std::error::Error>> {
|
||||
let body = http_get(&config.bind, "/stats")?;
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
pub fn generate_keypair() -> KeypairReport {
|
||||
let private_key = rand::random::<[u8; 32]>();
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&private_key);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
KeypairReport {
|
||||
algorithm: "ed25519",
|
||||
public_key_hex: hex::encode(verifying_key.to_bytes()),
|
||||
private_key_hex: hex::encode(private_key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn version() -> VersionReport {
|
||||
VersionReport {
|
||||
name: "chronoseal",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
target: std::env::consts::ARCH,
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_handler() -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "status": "healthy" })))
|
||||
}
|
||||
|
||||
async fn stats_handler(
|
||||
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
|
||||
) -> Result<Json<StoreStats>, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
storage::stats(&db)
|
||||
.map(Json)
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
|
||||
}
|
||||
|
||||
async fn metrics_handler(
|
||||
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
|
||||
) -> Result<String, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
storage::stats(&db)
|
||||
.map(|stats| {
|
||||
format!(
|
||||
"# HELP chronoseal_sessions Active ChronoSeal sessions\n# TYPE chronoseal_sessions gauge\nchronoseal_sessions {}\n# HELP chronoseal_expired_sessions Expired sessions not yet removed\n# TYPE chronoseal_expired_sessions gauge\nchronoseal_expired_sessions {}\n# HELP chronoseal_max_chain_length Maximum heartbeat chain length\n# TYPE chronoseal_max_chain_length gauge\nchronoseal_max_chain_length {}\n",
|
||||
stats.sessions, stats.expired_sessions, stats.max_chain_length
|
||||
)
|
||||
})
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
|
||||
}
|
||||
|
||||
async fn signal_task(config: Config) {
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
|
||||
let shutdown_term = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
sigterm.recv().await;
|
||||
info!("received SIGTERM; shutting down gracefully");
|
||||
shutdown_term.notify_one();
|
||||
});
|
||||
|
||||
let shutdown_int = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
info!("received interrupt; shutting down gracefully");
|
||||
shutdown_int.notify_one();
|
||||
}
|
||||
});
|
||||
|
||||
let hup_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler");
|
||||
while sighup.recv().await.is_some() {
|
||||
match Config::load(None) {
|
||||
Ok(reloaded) => info!(
|
||||
bind = %reloaded.bind,
|
||||
db_path = %reloaded.db_path.display(),
|
||||
"received SIGHUP; configuration reloaded"
|
||||
),
|
||||
Err(err) => warn!(error = %err, "received SIGHUP; configuration reload failed"),
|
||||
}
|
||||
let _ = &hup_config;
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut sigusr1 = signal(SignalKind::user_defined1()).expect("install SIGUSR1 handler");
|
||||
while sigusr1.recv().await.is_some() {
|
||||
info!("received SIGUSR1; stats are available via chronoseal stats or /stats");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
shutdown.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
shutdown.notified().await;
|
||||
}
|
||||
|
||||
fn install_pid_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(err) = fs::create_dir_all(parent) {
|
||||
warn!(path = %parent.display(), error = %err, "could not create PID directory");
|
||||
}
|
||||
}
|
||||
match fs::write(path, std::process::id().to_string()) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
warn!(path = %path.display(), error = %err, "could not write PID file");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_pid_file(path: &Path) {
|
||||
if let Err(err) = fs::remove_file(path) {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
error!(path = %path.display(), error = %err, "could not remove PID file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_pid(path: &Path) -> Option<u32> {
|
||||
fs::read_to_string(path).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes())?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response)?;
|
||||
let (_, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.ok_or("daemon returned an invalid HTTP response")?;
|
||||
Ok(body.to_string())
|
||||
}
|
||||
+41
-3
@@ -1,8 +1,26 @@
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn init_db() -> Result<Connection, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoreStats {
|
||||
pub sessions: u64,
|
||||
pub expired_sessions: u64,
|
||||
pub max_chain_length: u64,
|
||||
}
|
||||
|
||||
pub fn init_db(path: &Path) -> Result<Connection, rusqlite::Error> {
|
||||
if path == Path::new(":memory:") {
|
||||
return init_schema(Connection::open_in_memory()?);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
init_schema(Connection::open(path)?)
|
||||
}
|
||||
|
||||
fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
@@ -18,6 +36,26 @@ pub fn init_db() -> Result<Connection, rusqlite::Error> {
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
|
||||
let now = current_time_ms();
|
||||
let sessions = conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
|
||||
let expired_sessions = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sessions WHERE expires_at < ?1",
|
||||
[now],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let max_chain_length = conn.query_row(
|
||||
"SELECT COALESCE(MAX(chain_length), 0) FROM sessions",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(StoreStats {
|
||||
sessions,
|
||||
expired_sessions,
|
||||
max_chain_length,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_time_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user