WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail
(lettre), persisted job runs with log and status, JobRunner with per-kind
concurrency guard, 6-field cron schedules with defaults, scheduler loop.
Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines.

Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:16:56 +02:00
parent 6113af0a19
commit fa9ac9a6bc
31 changed files with 1632 additions and 58 deletions

View File

@ -0,0 +1,80 @@
//! AES-256-GCM encryption for secrets at rest. Format: base64url(nonce || ciphertext).
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use domain::ports::Cipher;
use domain::DomainError;
pub struct AesGcmCipher(Aes256Gcm);
impl AesGcmCipher {
/// `hex_key` is a 64-character hex string (32 bytes).
pub fn from_hex(hex_key: &str) -> anyhow::Result<Self> {
let bytes = hex_decode(hex_key)?;
anyhow::ensure!(
bytes.len() == 32,
"MASTER_KEY must be 32 bytes (64 hex characters)"
);
Ok(Self(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&bytes))))
}
}
fn hex_decode(s: &str) -> anyhow::Result<Vec<u8>> {
anyhow::ensure!(s.len() % 2 == 0, "odd hex length");
(0..s.len())
.step_by(2)
.map(|i| Ok(u8::from_str_radix(&s[i..i + 2], 16)?))
.collect()
}
impl Cipher for AesGcmCipher {
fn encrypt(&self, plain: &str) -> Result<String, DomainError> {
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ct = self
.0
.encrypt(&nonce, plain.as_bytes())
.map_err(|e| DomainError::Storage(e.to_string()))?;
let mut out = nonce.to_vec();
out.extend(ct);
Ok(URL_SAFE_NO_PAD.encode(out))
}
fn decrypt(&self, cipher_text: &str) -> Result<String, DomainError> {
let bytes = URL_SAFE_NO_PAD
.decode(cipher_text)
.map_err(|e| DomainError::Storage(e.to_string()))?;
if bytes.len() < 12 {
return Err(DomainError::Storage("cipher text too short".into()));
}
let (nonce, ct) = bytes.split_at(12);
let plain = self
.0
.decrypt(Nonce::from_slice(nonce), ct)
.map_err(|_| DomainError::Storage("decryption failed".into()))?;
String::from_utf8(plain).map_err(|e| DomainError::Storage(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
#[test]
fn roundtrip_and_tamper_detection() {
let c = AesGcmCipher::from_hex(KEY).unwrap();
let ct = c.encrypt("hello secret").unwrap();
assert_ne!(
c.encrypt("hello secret").unwrap(),
ct,
"nonce must be random"
);
assert_eq!(c.decrypt(&ct).unwrap(), "hello secret");
let other = AesGcmCipher::from_hex(&KEY.replace('0', "f")).unwrap();
assert!(other.decrypt(&ct).is_err());
assert!(c.decrypt("AAAA").is_err());
assert!(AesGcmCipher::from_hex("abcd").is_err());
}
}

View File

@ -1,10 +1,14 @@
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
pub mod cipher;
pub mod db;
pub mod mail;
pub mod password;
pub mod sqlite;
pub mod token;
pub use cipher::AesGcmCipher;
pub use db::{connect, DbPool};
pub use mail::LettreMailer;
pub use password::Argon2Hasher;
pub use sqlite::{SqliteAuditLog, SqliteRefreshTokens, SqliteUsers};
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
pub use token::JwtIssuer;

View File

@ -0,0 +1,62 @@
use async_trait::async_trait;
use domain::ports::Mailer;
use domain::settings::{SmtpSecurity, SmtpSettings};
use domain::DomainError;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
#[derive(Default)]
pub struct LettreMailer;
#[async_trait]
impl Mailer for LettreMailer {
async fn send(
&self,
smtp: &SmtpSettings,
to: &[String],
subject: &str,
body: &str,
) -> Result<(), DomainError> {
let unavailable = |e: String| DomainError::Unavailable(format!("smtp: {e}"));
let mut msg = Message::builder()
.from(
smtp.from
.parse()
.map_err(|e: lettre::address::AddressError| unavailable(e.to_string()))?,
)
.subject(subject);
for r in to {
msg = msg.to(r
.parse()
.map_err(|e: lettre::address::AddressError| unavailable(e.to_string()))?);
}
let msg = msg
.body(body.to_string())
.map_err(|e| unavailable(e.to_string()))?;
let mut builder = match smtp.security {
SmtpSecurity::None => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
}
SmtpSecurity::StartTls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
.map_err(|e| unavailable(e.to_string()))?
}
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
.map_err(|e| unavailable(e.to_string()))?,
}
.port(smtp.port);
if !smtp.username.is_empty() {
builder = builder.credentials(Credentials::new(
smtp.username.clone(),
smtp.password.clone(),
));
}
builder
.build()
.send(msg)
.await
.map(|_| ())
.map_err(|e| unavailable(e.to_string()))
}
}

View File

@ -280,3 +280,184 @@ mod tests {
assert!(repo.find_by_hash("nope").await.unwrap().is_none());
}
}
pub struct SqliteSettings(pub DbPool);
#[async_trait]
impl domain::ports::SettingsRepository for SqliteSettings {
async fn get(&self, key: &str) -> Result<Option<String>, DomainError> {
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(key)
.fetch_optional(&self.0)
.await
.map_err(storage)
}
async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> {
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.bind(key)
.bind(value)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
}
use domain::jobs::{JobKind, JobRun, JobStatus};
fn job_from_row(r: &SqliteRow) -> JobRun {
JobRun {
id: r.get("id"),
kind: JobKind::parse(r.get::<String, _>("kind").as_str())
.unwrap_or(JobKind::PackageRefresh),
params: r.get("params"),
status: JobStatus::parse(r.get::<String, _>("status").as_str())
.unwrap_or(JobStatus::Failed),
started_at: parse_ts(r.get::<String, _>("started_at").as_str()),
finished_at: r
.get::<Option<String>, _>("finished_at")
.as_deref()
.map(parse_ts),
log: r.get("log"),
triggered_by: r.get("triggered_by"),
}
}
const JOB_COLS: &str = "id, kind, params, status, started_at, finished_at, log, triggered_by";
pub struct SqliteJobRuns(pub DbPool);
#[async_trait]
impl domain::ports::JobRunRepository for SqliteJobRuns {
async fn insert(&self, run: &JobRun) -> Result<(), DomainError> {
sqlx::query(&format!(
"INSERT INTO job_runs ({JOB_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(run.id)
.bind(run.kind.as_str())
.bind(&run.params)
.bind(run.status.as_str())
.bind(run.started_at.to_rfc3339())
.bind(run.finished_at.map(|t| t.to_rfc3339()))
.bind(&run.log)
.bind(&run.triggered_by)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn append_log(&self, id: Uuid, line: &str) -> Result<(), DomainError> {
sqlx::query("UPDATE job_runs SET log = log || ? || char(10) WHERE id = ?")
.bind(line)
.bind(id)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn finish(&self, id: Uuid, status: JobStatus) -> Result<(), DomainError> {
sqlx::query("UPDATE job_runs SET status = ?, finished_at = ? WHERE id = ?")
.bind(status.as_str())
.bind(Utc::now().to_rfc3339())
.bind(id)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn get(&self, id: Uuid) -> Result<Option<JobRun>, DomainError> {
sqlx::query(&format!("SELECT {JOB_COLS} FROM job_runs WHERE id = ?"))
.bind(id)
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(job_from_row))
.map_err(storage)
}
async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError> {
sqlx::query(&format!(
"SELECT {JOB_COLS} FROM job_runs ORDER BY started_at DESC LIMIT ?"
))
.bind(limit)
.fetch_all(&self.0)
.await
.map(|rows| rows.iter().map(job_from_row).collect())
.map_err(storage)
}
async fn find_running(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
sqlx::query(&format!(
"SELECT {JOB_COLS} FROM job_runs WHERE kind = ? AND status = 'running' LIMIT 1"
))
.bind(kind.as_str())
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(job_from_row))
.map_err(storage)
}
async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
sqlx::query(&format!("SELECT {JOB_COLS} FROM job_runs WHERE kind = ? AND status != 'running' ORDER BY started_at DESC LIMIT 1"))
.bind(kind.as_str())
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(job_from_row))
.map_err(storage)
}
}
#[cfg(test)]
mod job_tests {
use super::*;
use domain::ports::{JobRunRepository, SettingsRepository};
#[tokio::test]
async fn settings_upsert() {
let pool = crate::connect("sqlite::memory:").await.unwrap();
let s = SqliteSettings(pool);
assert_eq!(s.get("k").await.unwrap(), None);
s.set("k", "1").await.unwrap();
s.set("k", "2").await.unwrap();
assert_eq!(s.get("k").await.unwrap().as_deref(), Some("2"));
}
#[tokio::test]
async fn job_runs_log_finish_and_queries() {
let pool = crate::connect("sqlite::memory:").await.unwrap();
let repo = SqliteJobRuns(pool);
let run = JobRun {
id: Uuid::new_v4(),
kind: JobKind::PackageRefresh,
params: None,
status: JobStatus::Running,
started_at: Utc::now(),
finished_at: None,
log: String::new(),
triggered_by: "test".into(),
};
repo.insert(&run).await.unwrap();
assert!(repo
.find_running(JobKind::PackageRefresh)
.await
.unwrap()
.is_some());
repo.append_log(run.id, "a").await.unwrap();
repo.append_log(run.id, "b").await.unwrap();
repo.finish(run.id, JobStatus::Success).await.unwrap();
let got = repo.get(run.id).await.unwrap().unwrap();
assert_eq!(got.log, "a\nb\n");
assert_eq!(got.status, JobStatus::Success);
assert!(got.finished_at.is_some());
assert!(repo
.find_running(JobKind::PackageRefresh)
.await
.unwrap()
.is_none());
assert_eq!(
repo.last_finished(JobKind::PackageRefresh)
.await
.unwrap()
.unwrap()
.id,
run.id
);
assert_eq!(repo.list(10).await.unwrap().len(), 1);
}
}