use std::path::{Path, PathBuf}; use anyhow::Context; use serde::Deserialize; pub const DEFAULT_LISTEN: &str = "0.0.0.0:7000"; pub const DEFAULT_DATA_DIR: &str = "data"; pub const DEFAULT_TLS_CERT: &str = "data/server-cert.der"; pub const DEFAULT_TLS_KEY: &str = "data/server-key.der"; pub const DEFAULT_STORE_BACKEND: &str = "file"; pub const DEFAULT_DB_PATH: &str = "data/qpq.db"; #[derive(Debug, Default, Deserialize)] pub struct FileConfig { pub listen: Option, pub data_dir: Option, pub tls_cert: Option, pub tls_key: Option, pub auth_token: Option, pub allow_insecure_auth: Option, /// When true, enqueue does not require an identity-bound session: only a valid token is required. /// The server does not associate the request with a specific sender (Sealed Sender). #[serde(default)] pub sealed_sender: Option, pub store_backend: Option, pub db_path: Option, pub db_key: Option, /// Metrics HTTP listen address (e.g. "0.0.0.0:9090"). If set, /metrics is served there. pub metrics_listen: Option, /// When true and metrics_listen is set, start the metrics server. #[serde(default)] pub metrics_enabled: Option, pub federation: Option, /// Directory containing plugin `.so` / `.dylib` files to load at startup. pub plugin_dir: Option, /// When true, audit logs hash identity key prefixes and omit payload sizes. #[serde(default)] pub redact_logs: Option, /// WebSocket JSON-RPC bridge listen address (e.g. "0.0.0.0:9000"). pub ws_listen: Option, /// WebTransport (HTTP/3) listen address for browser clients (e.g. "0.0.0.0:7443"). pub webtransport_listen: Option, /// Graceful shutdown drain timeout in seconds. pub drain_timeout_secs: Option, /// Default per-RPC timeout in seconds. pub rpc_timeout_secs: Option, /// Storage/database operation timeout in seconds. pub storage_timeout_secs: Option, } #[derive(Debug)] pub struct EffectiveConfig { pub listen: String, pub data_dir: String, pub tls_cert: PathBuf, pub tls_key: PathBuf, pub auth_token: Option, pub allow_insecure_auth: bool, /// When true, enqueue does not require identity; valid token only (Sealed Sender). pub sealed_sender: bool, pub store_backend: String, pub db_path: PathBuf, pub db_key: String, /// If Some(addr), metrics server listens here (e.g. "0.0.0.0:9090"). pub metrics_listen: Option, /// Start metrics server only when true and metrics_listen is set. pub metrics_enabled: bool, pub federation: Option, /// Directory to scan for plugin `.so` / `.dylib` files at startup. None = no plugins. pub plugin_dir: Option, /// When true, audit logs hash identity key prefixes and omit payload sizes. pub redact_logs: bool, /// WebSocket JSON-RPC bridge listen address. If set, the bridge is started. pub ws_listen: Option, /// WebTransport (HTTP/3) listen address. If set, the WebTransport endpoint is started. pub webtransport_listen: Option, /// Graceful shutdown drain timeout in seconds. pub drain_timeout_secs: u64, /// Default per-RPC timeout in seconds. pub rpc_timeout_secs: u64, /// Storage/database operation timeout in seconds. pub storage_timeout_secs: u64, } pub const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 30; pub const DEFAULT_RPC_TIMEOUT_SECS: u64 = 30; pub const DEFAULT_STORAGE_TIMEOUT_SECS: u64 = 10; #[derive(Debug, Default, Deserialize)] pub struct FederationFileConfig { pub enabled: Option, pub domain: Option, pub listen: Option, pub federation_cert: Option, pub federation_key: Option, pub federation_ca: Option, #[serde(default)] pub peers: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct FederationPeerConfig { pub domain: String, pub address: String, } #[derive(Debug)] #[allow(dead_code)] // federation not yet wired up pub struct EffectiveFederationConfig { pub enabled: bool, pub domain: String, pub listen: String, pub federation_cert: PathBuf, pub federation_key: PathBuf, pub federation_ca: PathBuf, pub peers: Vec, } pub fn load_config(path: Option<&Path>) -> anyhow::Result { let path = match path { Some(p) => PathBuf::from(p), None => PathBuf::from("qpq-server.toml"), }; if !path.exists() { return Ok(FileConfig::default()); } let contents = std::fs::read_to_string(&path).with_context(|| format!("read config file {path:?}"))?; let cfg: FileConfig = toml::from_str(&contents).with_context(|| format!("parse config file {path:?}"))?; Ok(cfg) } pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig { let listen = if args.listen == DEFAULT_LISTEN { file.listen .clone() .unwrap_or_else(|| DEFAULT_LISTEN.to_string()) } else { args.listen.clone() }; let data_dir = if args.data_dir == DEFAULT_DATA_DIR { file.data_dir .clone() .unwrap_or_else(|| DEFAULT_DATA_DIR.to_string()) } else { args.data_dir.clone() }; let tls_cert = if args.tls_cert == Path::new(DEFAULT_TLS_CERT) { file.tls_cert .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_CERT)) } else { args.tls_cert.clone() }; let tls_key = if args.tls_key == Path::new(DEFAULT_TLS_KEY) { file.tls_key .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_TLS_KEY)) } else { args.tls_key.clone() }; let auth_token = if args.auth_token.is_some() { args.auth_token.clone() } else { file.auth_token.clone() }; let allow_insecure_auth = if args.allow_insecure_auth { true } else { file.allow_insecure_auth.unwrap_or(false) }; let sealed_sender = args.sealed_sender || file.sealed_sender.unwrap_or(false); let store_backend = if args.store_backend == DEFAULT_STORE_BACKEND { file.store_backend .clone() .unwrap_or_else(|| DEFAULT_STORE_BACKEND.to_string()) } else { args.store_backend.clone() }; let db_path = if args.db_path == Path::new(DEFAULT_DB_PATH) { file.db_path .clone() .unwrap_or_else(|| PathBuf::from(DEFAULT_DB_PATH)) } else { args.db_path.clone() }; let db_key = if args.db_key.is_empty() { file.db_key.clone().unwrap_or_else(|| args.db_key.clone()) } else { args.db_key.clone() }; let metrics_listen = args .metrics_listen .clone() .or_else(|| file.metrics_listen.clone()); let metrics_enabled = args .metrics_enabled .or(file.metrics_enabled) .unwrap_or(metrics_listen.is_some()); let federation = { let file_fed = file.federation.as_ref(); let enabled = args.federation_enabled || file_fed.and_then(|f| f.enabled).unwrap_or(false); if enabled { let domain = args.federation_domain.clone() .or_else(|| file_fed.and_then(|f| f.domain.clone())) .unwrap_or_default(); let listen_fed = args.federation_listen.clone() .or_else(|| file_fed.and_then(|f| f.listen.clone())) .unwrap_or_else(|| "0.0.0.0:7001".to_string()); let federation_cert = file_fed.and_then(|f| f.federation_cert.clone()) .unwrap_or_else(|| PathBuf::from("data/federation-cert.der")); let federation_key = file_fed.and_then(|f| f.federation_key.clone()) .unwrap_or_else(|| PathBuf::from("data/federation-key.der")); let federation_ca = file_fed.and_then(|f| f.federation_ca.clone()) .unwrap_or_else(|| PathBuf::from("data/federation-ca.der")); let peers = file_fed .map(|f| f.peers.clone()) .unwrap_or_default(); Some(EffectiveFederationConfig { enabled, domain, listen: listen_fed, federation_cert, federation_key, federation_ca, peers, }) } else { None } }; let plugin_dir = args.plugin_dir.clone().or_else(|| file.plugin_dir.clone()); let redact_logs = args.redact_logs || file.redact_logs.unwrap_or(false); let ws_listen = args .ws_listen .clone() .or_else(|| file.ws_listen.clone()); let webtransport_listen = args .webtransport_listen .clone() .or_else(|| file.webtransport_listen.clone()); let drain_timeout_secs = if args.drain_timeout == DEFAULT_DRAIN_TIMEOUT_SECS { file.drain_timeout_secs.unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS) } else { args.drain_timeout }; let rpc_timeout_secs = if args.rpc_timeout == DEFAULT_RPC_TIMEOUT_SECS { file.rpc_timeout_secs.unwrap_or(DEFAULT_RPC_TIMEOUT_SECS) } else { args.rpc_timeout }; let storage_timeout_secs = if args.storage_timeout == DEFAULT_STORAGE_TIMEOUT_SECS { file.storage_timeout_secs.unwrap_or(DEFAULT_STORAGE_TIMEOUT_SECS) } else { args.storage_timeout }; EffectiveConfig { listen, data_dir, tls_cert, tls_key, auth_token, allow_insecure_auth, sealed_sender, store_backend, db_path, db_key, metrics_listen, metrics_enabled, federation, plugin_dir, redact_logs, ws_listen, webtransport_listen, drain_timeout_secs, rpc_timeout_secs, storage_timeout_secs, } } pub fn validate_production_config(effective: &EffectiveConfig) -> anyhow::Result<()> { if effective.allow_insecure_auth { anyhow::bail!("production forbids --allow-insecure-auth"); } let token = effective .auth_token .as_deref() .filter(|s| !s.is_empty()) .ok_or_else(|| { anyhow::anyhow!("production requires QPQ_AUTH_TOKEN (non-empty)") })?; if token == "devtoken" { anyhow::bail!( "production forbids auth_token 'devtoken'; set a strong QPQ_AUTH_TOKEN" ); } if token.len() < 16 { anyhow::bail!( "production requires QPQ_AUTH_TOKEN of at least 16 characters (got {})", token.len() ); } if effective.store_backend == "sql" && effective.db_key.is_empty() { anyhow::bail!("production with store_backend=sql requires non-empty QPQ_DB_KEY"); } if effective.store_backend == "sql" { let db_dir = effective .db_path .parent() .unwrap_or_else(|| Path::new(".")); // Verify the directory exists and is writable by creating+removing a probe file. let probe = db_dir.join(".qpq-write-probe"); std::fs::write(&probe, b"probe") .with_context(|| format!("DB path parent {:?} is not writable", db_dir))?; let _ = std::fs::remove_file(&probe); } if effective.store_backend != "sql" { tracing::warn!( "production is using file-backed storage; \ consider store_backend=sql with QPQ_DB_KEY for encryption at rest" ); } if !effective.tls_cert.exists() || !effective.tls_key.exists() { anyhow::bail!( "production requires existing TLS cert and key (no auto-generation); provide QPQ_TLS_CERT and QPQ_TLS_KEY" ); } Ok(()) }