//! SQLite stores desired configuration, administrator state and bounded audit history. //! No database call is made from the public HTTP forwarding path. use crate::config::{Config, Envelope}; use anyhow::{ensure, Context, Result}; use serde::Serialize; use sqlx::{ sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, Row, SqliteConnection, SqlitePool, }; use std::{path::Path, time::Duration}; pub const SCHEMA_VERSION: i64 = 7; pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32; pub const AUDIT_CAPACITY: i64 = 10_000; #[derive(Clone)] pub struct Store { pub(crate) pool: SqlitePool, } #[derive(Debug, Clone, Serialize)] pub struct CertificateApiCredential { pub key_id: String, pub comment: String, pub created_at: i64, pub last_used_at: Option, } #[derive(Debug, Serialize)] pub struct AuditEntry { pub id: i64, pub time: i64, pub actor: String, pub action: String, pub detail: String, } pub(crate) async fn audit_tx( connection: &mut SqliteConnection, actor: &str, action: &str, detail: &str, ) -> Result<()> { // Callers only supply allowlisted actions and non-secret, bounded metadata. sqlx::query("INSERT INTO admin_audit(time, actor, action, detail) VALUES(?, ?, ?, ?)") .bind(crate::auth::now()) .bind(actor) .bind(action) .bind(detail) .execute(&mut *connection) .await?; sqlx::query( "DELETE FROM admin_audit WHERE id <= (SELECT COALESCE(MAX(id), 0) - ? FROM admin_audit)", ) .bind(AUDIT_CAPACITY) .execute(&mut *connection) .await?; Ok(()) } impl Store { pub async fn open(path: &Path, initial: &Config) -> Result { let options = SqliteConnectOptions::new() .filename(path) .create_if_missing(true) .journal_mode(SqliteJournalMode::Wal) .synchronous(SqliteSynchronous::Full) .busy_timeout(Duration::from_secs(5)) .foreign_keys(true); let pool = SqlitePoolOptions::new() .max_connections(4) .connect_with(options) .await?; let version: i64 = sqlx::query_scalar("PRAGMA user_version") .fetch_one(&pool) .await?; ensure!( version <= SCHEMA_VERSION, "database schema is newer than this binary; refusing downgrade" ); let mut tx = pool.begin().await?; sqlx::raw_sql(include_str!("../migrations/0002_admin.sql")) .execute(&mut *tx) .await?; sqlx::raw_sql(include_str!("../migrations/0003_certificate_renewals.sql")) .execute(&mut *tx) .await?; sqlx::raw_sql(include_str!("../migrations/0004_admin_preferences.sql")) .execute(&mut *tx) .await?; sqlx::raw_sql(include_str!("../migrations/0005_certificate_api.sql")) .execute(&mut *tx) .await?; sqlx::raw_sql(include_str!("../migrations/0006_certificate_api_credentials.sql")) .execute(&mut *tx) .await?; sqlx::raw_sql(include_str!("../migrations/0007_secret_store.sql")) .execute(&mut *tx) .await?; sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)") .bind(serde_json::to_string(initial)?) .execute(&mut *tx) .await?; sqlx::query("PRAGMA user_version = 7") .execute(&mut *tx) .await?; tx.commit().await?; Ok(Self { pool }) } pub async fn backup_to(&self, path: &Path) -> Result<()> { ensure!(!path.exists(), "backup database destination already exists"); let path_text = path .to_str() .context("backup database path must be valid UTF-8")?; sqlx::query("VACUUM INTO ?") .bind(path_text) .execute(&self.pool) .await .context("could not create consistent SQLite snapshot")?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } let options = SqliteConnectOptions::new() .filename(path) .read_only(true) .busy_timeout(Duration::from_secs(5)); let check_pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await?; let result: String = sqlx::query_scalar("PRAGMA quick_check") .fetch_one(&check_pool) .await?; check_pool.close().await; ensure!(result == "ok", "SQLite snapshot integrity check failed"); Ok(()) } pub async fn load(&self) -> Result { let row = sqlx::query("SELECT revision, document FROM configuration WHERE id = 1") .fetch_one(&self.pool) .await?; let revision: i64 = row.try_get("revision")?; let document: String = row.try_get("document")?; ensure!( document.len() <= crate::config::MAX_CONFIG_BYTES, "stored configuration exceeds size limit" ); Ok(Envelope { revision: revision.try_into()?, config: serde_json::from_str(&document) .context("invalid stored configuration; database was not reset")?, }) } pub async fn replace(&self, expected: u64, config: &Config) -> Result { self.replace_as(expected, config, "local").await } /// Compare-and-swap and audit are one transaction. Publication is handled by App. pub async fn replace_as(&self, expected: u64, config: &Config, actor: &str) -> Result { let expected: i64 = expected.try_into().context("revision overflow")?; ensure!(expected < i64::MAX, "revision overflow"); let mut tx = self.pool.begin().await?; let result = sqlx::query("UPDATE configuration SET revision = revision + 1, document = ? WHERE id = 1 AND revision = ?") .bind(serde_json::to_string(config)?).bind(expected).execute(&mut *tx).await?; if result.rows_affected() == 0 { return Ok(false); } audit_tx( &mut tx, actor, "config.apply", &format!("revision={}", expected + 1), ) .await?; tx.commit().await?; Ok(true) } pub async fn audit(&self, actor: &str, action: &str, detail: &str) -> Result<()> { let mut tx = self.pool.begin().await?; audit_tx(&mut tx, actor, action, detail).await?; tx.commit().await?; Ok(()) } pub async fn load_certificate_renewals( &self, ) -> Result> { let rows = sqlx::query( "SELECT certificate_id, last_checked, next_check, last_renewed, not_after, failures, status, message FROM certificate_renewals ORDER BY certificate_id", ) .fetch_all(&self.pool) .await?; let mut result = std::collections::BTreeMap::new(); for row in rows { let certificate_id: String = row.try_get("certificate_id")?; if !crate::config::valid_id(&certificate_id) { continue; } let failures: i64 = row.try_get("failures")?; result.insert( certificate_id, crate::acme::RenewalState { last_checked: row.try_get("last_checked")?, next_check: row.try_get("next_check")?, last_renewed: row.try_get("last_renewed")?, not_after: row.try_get("not_after")?, failures: failures.max(0).min(u32::MAX as i64) as u32, status: row.try_get("status")?, message: row.try_get("message")?, }, ); } Ok(result) } pub async fn replace_certificate_renewals( &self, values: &std::collections::BTreeMap, ) -> Result<()> { ensure!( values.len() <= 64, "too many certificate renewal observations" ); let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM certificate_renewals") .execute(&mut *tx) .await?; for (certificate_id, state) in values { ensure!( crate::config::valid_id(certificate_id), "invalid certificate renewal id" ); sqlx::query( "INSERT INTO certificate_renewals(certificate_id, last_checked, next_check, last_renewed, not_after, failures, status, message) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(certificate_id) .bind(state.last_checked) .bind(state.next_check) .bind(state.last_renewed) .bind(state.not_after) .bind(i64::from(state.failures)) .bind(&state.status) .bind(&state.message) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(()) } pub async fn certificate_api_credentials(&self) -> Result> { let rows = sqlx::query( "SELECT key_id, comment, created_at, last_used_at FROM certificate_api_credentials ORDER BY key_id", ) .fetch_all(&self.pool) .await?; rows.into_iter() .map(|row| { Ok(CertificateApiCredential { key_id: row.try_get("key_id")?, comment: row.try_get("comment")?, created_at: row.try_get("created_at")?, last_used_at: row.try_get("last_used_at")?, }) }) .collect() } pub async fn upsert_certificate_api_credential( &self, key_id: &str, token_hash: &str, comment: &str, actor: &str, ) -> Result { ensure!( (3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id), "invalid certificate API key id" ); ensure!( token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()), "invalid certificate API token hash" ); ensure!( comment.len() <= 256 && !comment.chars().any(char::is_control), "invalid certificate API comment" ); let now = crate::auth::now(); let mut tx = self.pool.begin().await?; let exists: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?", ) .bind(key_id) .fetch_one(&mut *tx) .await?; if exists == 0 { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials") .fetch_one(&mut *tx) .await?; ensure!( count < CERTIFICATE_API_CREDENTIAL_CAPACITY, "certificate API credential limit reached" ); } sqlx::query( "INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \ ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL", ) .bind(key_id) .bind(token_hash) .bind(comment) .bind(now) .execute(&mut *tx) .await?; audit_tx( &mut tx, actor, "certificate_api.rotate", &format!("key_id={key_id}"), ) .await?; tx.commit().await?; Ok(CertificateApiCredential { key_id: key_id.to_owned(), comment: comment.to_owned(), created_at: now, last_used_at: None, }) } pub async fn revoke_certificate_api_credential(&self, key_id: &str, actor: &str) -> Result { ensure!( (3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id), "invalid certificate API key id" ); let mut tx = self.pool.begin().await?; let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?") .bind(key_id) .execute(&mut *tx) .await?; if result.rows_affected() > 0 { audit_tx( &mut tx, actor, "certificate_api.revoke", &format!("key_id={key_id}"), ) .await?; } tx.commit().await?; Ok(result.rows_affected() > 0) } pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result { let mut tx = self.pool.begin().await?; let result = sqlx::query("DELETE FROM certificate_api_credentials") .execute(&mut *tx) .await?; if result.rows_affected() > 0 { audit_tx( &mut tx, actor, "certificate_api.revoke_all", &format!("count={}", result.rows_affected()), ) .await?; } tx.commit().await?; Ok(result.rows_affected()) } pub async fn verify_certificate_api_credential(&self, key_id: &str, token_hash: &str) -> Result { let row = sqlx::query( "SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?", ) .bind(key_id) .fetch_optional(&self.pool) .await?; let Some(row) = row else { return Ok(false); }; let expected: String = row.try_get("token_hash")?; if !crate::auth::equal_secret(token_hash, &expected) { return Ok(false); } sqlx::query( "UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?", ) .bind(crate::auth::now()) .bind(key_id) .execute(&self.pool) .await?; Ok(true) } pub async fn recent_audit(&self) -> Result> { let rows = sqlx::query( "SELECT id, time, actor, action, detail FROM admin_audit ORDER BY id DESC LIMIT 100", ) .fetch_all(&self.pool) .await?; rows.into_iter() .map(|row| { Ok(AuditEntry { id: row.try_get("id")?, time: row.try_get("time")?, actor: row.try_get("actor")?, action: row.try_get("action")?, detail: row.try_get("detail")?, }) }) .collect() } pub async fn close(&self) { self.pool.close().await; } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn persists_and_rejects_stale_writes() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("test.sqlite3"); let store = Store::open(&file, &Config::default()).await.unwrap(); assert_eq!(store.load().await.unwrap().revision, 0); let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); config.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); config.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); config.transform.preset = Some(crate::transform::Preset::Performance); config.transform.memory_budget_bytes = 512 * 1024 * 1024; config.transform.max_inflight = 64; assert!(store.replace(0, &config).await.unwrap()); assert!(!store.replace(0, &Config::default()).await.unwrap()); assert_eq!(store.recent_audit().await.unwrap().len(), 1); store.close().await; let reopened = Store::open(&file, &Config::default()).await.unwrap(); let data = reopened.load().await.unwrap(); assert_eq!(data.revision, 1); assert_eq!(data.config.vhosts[0].backends[0].id, "demo"); assert_eq!( data.config.cache.memory_preset, Some(crate::cache::MemoryPreset::Recommended) ); assert_eq!( data.config.cache.cold_preset, Some(crate::cache::ColdPreset::Recommended) ); assert_eq!( data.config.transform.preset, Some(crate::transform::Preset::Performance) ); assert_eq!(data.config.transform.memory_budget_bytes, 512 * 1024 * 1024); assert_eq!(data.config.transform.max_inflight, 64); reopened.close().await; } #[tokio::test] async fn certificate_renewal_observations_survive_reopen_in_schema_v3() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("renewals.sqlite3"); let store = Store::open(&file, &Config::default()).await.unwrap(); let mut values = std::collections::BTreeMap::new(); values.insert( "example-cert".to_string(), crate::acme::RenewalState { last_checked: 100, next_check: 200, last_renewed: Some(50), not_after: Some(500), failures: 2, status: "retry".to_string(), message: "temporary CA failure".to_string(), }, ); store.replace_certificate_renewals(&values).await.unwrap(); let version: i64 = sqlx::query_scalar("PRAGMA user_version") .fetch_one(&store.pool) .await .unwrap(); assert_eq!(version, SCHEMA_VERSION); store.close().await; let reopened = Store::open(&file, &Config::default()).await.unwrap(); let saved = reopened.load_certificate_renewals().await.unwrap(); let state = saved.get("example-cert").unwrap(); assert_eq!(state.last_checked, 100); assert_eq!(state.next_check, 200); assert_eq!(state.last_renewed, Some(50)); assert_eq!(state.not_after, Some(500)); assert_eq!(state.failures, 2); assert_eq!(state.status, "retry"); assert_eq!(state.message, "temporary CA failure"); reopened.close().await; } #[tokio::test] async fn certificate_api_credentials_persist_hash_comments_and_individual_usage() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("certificate-api.sqlite3"); let store = Store::open(&file, &Config::default()).await.unwrap(); let token = "b".repeat(64); let hash = crate::auth::secret_hash(&token); let saved = store .upsert_certificate_api_credential( "certbot-main", &hash, "production certbot", "owner", ) .await .unwrap(); assert_eq!(saved.key_id, "certbot-main"); assert_eq!(saved.comment, "production certbot"); assert!(saved.last_used_at.is_none()); let raw: String = sqlx::query_scalar( "SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?", ) .bind("certbot-main") .fetch_one(&store.pool) .await .unwrap(); assert_eq!(raw, hash); assert_ne!(raw, token); let second_hash = crate::auth::secret_hash(&"d".repeat(64)); store .upsert_certificate_api_credential( "deploy-hook", &second_hash, "secondary deploy hook", "owner", ) .await .unwrap(); assert_eq!(store.certificate_api_credentials().await.unwrap().len(), 2); assert!(store .verify_certificate_api_credential("certbot-main", &hash) .await .unwrap()); assert!(!store .verify_certificate_api_credential( "certbot-main", &crate::auth::secret_hash(&"c".repeat(64)), ) .await .unwrap()); let credentials = store.certificate_api_credentials().await.unwrap(); assert!(credentials .iter() .find(|item| item.key_id == "certbot-main") .unwrap() .last_used_at .is_some()); assert!(credentials .iter() .find(|item| item.key_id == "deploy-hook") .unwrap() .last_used_at .is_none()); assert!(store .revoke_certificate_api_credential("certbot-main", "owner") .await .unwrap()); let remaining = store.certificate_api_credentials().await.unwrap(); assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].key_id, "deploy-hook"); assert_eq!(store.revoke_all_certificate_api_credentials("owner").await.unwrap(), 1); assert!(store.certificate_api_credentials().await.unwrap().is_empty()); store.close().await; } #[tokio::test] async fn existing_empty_config_is_not_reseeded() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("test.sqlite3"); let store = Store::open(&file, &Config::default()).await.unwrap(); store.close().await; let demo = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); let store = Store::open(&file, &demo).await.unwrap(); assert!(store.load().await.unwrap().config.vhosts.is_empty()); store.close().await; } #[tokio::test] async fn migration_keeps_v1_config_and_revision() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("old.sqlite3"); let options = SqliteConnectOptions::new() .filename(&path) .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .unwrap(); sqlx::query("CREATE TABLE configuration(id INTEGER PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL)") .execute(&pool).await.unwrap(); let demo = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); sqlx::query("INSERT INTO configuration VALUES(1, 42, ?)") .bind(serde_json::to_string(&demo).unwrap()) .execute(&pool) .await .unwrap(); sqlx::query("PRAGMA user_version = 1") .execute(&pool) .await .unwrap(); pool.close().await; let store = Store::open(&path, &Config::default()).await.unwrap(); let saved = store.load().await.unwrap(); assert_eq!(saved.revision, 42); assert_eq!(saved.config.vhosts[0].backends[0].id, "demo"); assert!(store.admin_record().await.unwrap().is_none()); store.close().await; } }