From fa9ac9a6bcf0dc303da6388aabd5f1ec336691ec Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Wed, 2 Sep 2026 22:16:56 +0200 Subject: [PATCH] WP-02: encrypted settings, SMTP mail, job runner and cron scheduler 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 --- .env.example | 4 + ROADMAP.md | 2 +- backend/Cargo.lock | 254 +++++++++++++++++- backend/crates/api/Cargo.toml | 1 + backend/crates/api/src/config.rs | 8 + backend/crates/api/src/error.rs | 2 + backend/crates/api/src/jobs.rs | 103 +++++++ backend/crates/api/src/lib.rs | 52 +++- backend/crates/api/src/main.rs | 3 +- backend/crates/api/src/openapi.rs | 2 + backend/crates/api/src/settings.rs | 178 ++++++++++++ backend/crates/api/src/test_support.rs | 55 +++- backend/crates/application/src/jobs.rs | 102 ++++++- backend/crates/application/src/scheduler.rs | 76 +++++- .../application/src/settings_service.rs | 81 ++++-- backend/crates/infrastructure/Cargo.toml | 3 + .../migrations/0002_settings_and_jobs.sql | 16 ++ backend/crates/infrastructure/src/cipher.rs | 80 ++++++ backend/crates/infrastructure/src/lib.rs | 6 +- backend/crates/infrastructure/src/mail.rs | 62 +++++ backend/crates/infrastructure/src/sqlite.rs | 181 +++++++++++++ deploy/deploy-test.sh | 4 + frontend/playwright.config.ts | 2 + frontend/src/api/client.ts | 1 + frontend/src/api/types.ts | 41 +++ frontend/src/components/AppShell.vue | 1 + frontend/src/components/SmtpForm.vue | 104 +++++++ frontend/src/pages/JobsPage.test.ts | 2 +- frontend/src/pages/JobsPage.vue | 114 ++++++++ frontend/src/pages/SettingsPage.vue | 145 ++++++++++ frontend/src/router.ts | 5 +- 31 files changed, 1632 insertions(+), 58 deletions(-) create mode 100644 backend/crates/api/src/jobs.rs create mode 100644 backend/crates/api/src/settings.rs create mode 100644 backend/crates/infrastructure/migrations/0002_settings_and_jobs.sql create mode 100644 backend/crates/infrastructure/src/cipher.rs create mode 100644 backend/crates/infrastructure/src/mail.rs create mode 100644 frontend/src/components/SmtpForm.vue create mode 100644 frontend/src/pages/JobsPage.vue create mode 100644 frontend/src/pages/SettingsPage.vue diff --git a/.env.example b/.env.example index 3ce9b9d..1698050 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Backend configuration (copy to backend/.env for local development) DATABASE_URL=sqlite://data/monitoring.db?mode=rwc JWT_SECRET=change-me-to-a-long-random-string +# 64 hex characters (openssl rand -hex 32); encrypts stored secrets such as SMTP passwords +MASTER_KEY=change-me-64-hex-characters +# true on dev machines without apt/kubectl: uses fake host adapters with sample data +FAKE_HOST=false BIND=127.0.0.1:8080 # Created on first start if no user exists BOOTSTRAP_ADMIN_EMAIL=admin@example.com diff --git a/ROADMAP.md b/ROADMAP.md index 4510282..eac12b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -354,7 +354,7 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect |----|-----------|--------|-------| | WP-00 | M1 | done | 2026-09-02 | | WP-01 | M1 | done | 2026-09-02 | -| WP-02 | M1 (shell) / M2 (rest) | shell done | shell, toasts, placeholders 2026-09-02; secrets/scheduler/SMTP in M2 | +| WP-02 | M1 (shell) / M2 (rest) | done | shell 2026-09-02; encrypted settings, SMTP, job runner, scheduler 2026-09-02 | | WP-10 | M2 | todo | | | WP-11 | M2 | todo | | | WP-12 | M2 | todo | | diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d81d17b..80f5d6a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -38,6 +73,7 @@ version = "0.1.0" dependencies = [ "anyhow", "application", + "async-trait", "axum", "chrono", "domain", @@ -61,7 +97,7 @@ name = "application" version = "0.1.0" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chrono", "cron", "domain", @@ -186,6 +222,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -267,6 +309,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -336,9 +388,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "der" version = "0.7.10" @@ -405,6 +467,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "equivalent" version = "1.0.2" @@ -442,6 +520,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -579,6 +663,16 @@ dependencies = [ "r-efi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -644,6 +738,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.5.0" @@ -874,18 +979,30 @@ dependencies = [ name = "infrastructure" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "argon2", "async-trait", + "base64 0.22.1", "chrono", "domain", "jsonwebtoken", + "lettre", "serde", "sqlx", "tokio", "uuid", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -909,7 +1026,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -927,6 +1044,34 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.189" @@ -1041,6 +1186,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1117,6 +1271,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking" version = "2.2.1" @@ -1163,7 +1323,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1221,6 +1381,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1263,6 +1435,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "6.0.0" @@ -1380,6 +1558,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1596,7 +1809,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -1671,7 +1884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "bytes", @@ -1715,7 +1928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -1947,6 +2160,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -2126,6 +2349,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2262,6 +2495,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/backend/crates/api/Cargo.toml b/backend/crates/api/Cargo.toml index e9ab5be..467b082 100644 --- a/backend/crates/api/Cargo.toml +++ b/backend/crates/api/Cargo.toml @@ -13,6 +13,7 @@ domain.workspace = true application.workspace = true infrastructure.workspace = true anyhow.workspace = true +async-trait.workspace = true axum.workspace = true chrono.workspace = true dotenvy.workspace = true diff --git a/backend/crates/api/src/config.rs b/backend/crates/api/src/config.rs index 0298c25..e24dd63 100644 --- a/backend/crates/api/src/config.rs +++ b/backend/crates/api/src/config.rs @@ -5,6 +5,10 @@ use std::net::SocketAddr; pub struct Config { pub database_url: String, pub jwt_secret: String, + /// 64 hex chars; encrypts secrets at rest. + pub master_key: String, + /// Use fake host adapters (dev machines without apt/kubectl). + pub fake_host: bool, pub bind: SocketAddr, pub bootstrap_admin: Option<(String, String)>, pub cookie_secure: bool, @@ -20,10 +24,14 @@ impl Config { jwt_secret.len() >= 32, "JWT_SECRET must be at least 32 characters" ); + let master_key = env("MASTER_KEY") + .ok_or_else(|| anyhow::anyhow!("MASTER_KEY is required (64 hex characters)"))?; Ok(Self { database_url: env("DATABASE_URL") .unwrap_or_else(|| "sqlite://data/monitoring.db?mode=rwc".into()), jwt_secret, + master_key, + fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"), bind: env("BIND") .unwrap_or_else(|| "127.0.0.1:8080".into()) .parse()?, diff --git a/backend/crates/api/src/error.rs b/backend/crates/api/src/error.rs index d08077b..6f5c4bd 100644 --- a/backend/crates/api/src/error.rs +++ b/backend/crates/api/src/error.rs @@ -24,6 +24,8 @@ impl IntoResponse for ApiError { InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"), InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"), Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"), + Conflict(_) => (StatusCode::CONFLICT, "conflict"), + Unavailable(_) => (StatusCode::BAD_GATEWAY, "unavailable"), Storage(msg) => { tracing::error!("storage error: {msg}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal") diff --git a/backend/crates/api/src/jobs.rs b/backend/crates/api/src/jobs.rs new file mode 100644 index 0000000..36e9f56 --- /dev/null +++ b/backend/crates/api/src/jobs.rs @@ -0,0 +1,103 @@ +//! /api/jobs: run history and manual job runs. +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use domain::jobs::{JobKind, JobRun, JobStatus}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extract::{AdminUser, AuthUser}; +use crate::AppState; + +pub fn router() -> Router { + Router::new() + .route("/", get(list)) + .route("/kinds", get(kinds)) + .route("/run", post(run)) + .route("/{id}", get(get_one)) +} + +#[derive(Serialize, ToSchema)] +pub struct JobRunDto { + pub id: Uuid, + #[schema(value_type = String)] + pub kind: JobKind, + pub params: Option, + #[schema(value_type = String)] + pub status: JobStatus, + pub started_at: chrono::DateTime, + pub finished_at: Option>, + pub log: String, + pub triggered_by: String, +} + +impl From for JobRunDto { + fn from(r: JobRun) -> Self { + Self { + id: r.id, + kind: r.kind, + params: r.params, + status: r.status, + started_at: r.started_at, + finished_at: r.finished_at, + log: r.log, + triggered_by: r.triggered_by, + } + } +} + +#[derive(Deserialize)] +pub struct ListQuery { + pub limit: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct RunRequest { + #[schema(value_type = String, example = "package_refresh")] + pub kind: JobKind, + pub params: Option, +} + +#[utoipa::path(get, path = "/api/jobs", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec)))] +async fn list( + State(state): State, + _: AuthUser, + Query(q): Query, +) -> Result>, ApiError> { + Ok(Json( + state + .jobs + .list(q.limit.unwrap_or(50).min(500)) + .await? + .into_iter() + .map(Into::into) + .collect(), + )) +} + +#[utoipa::path(get, path = "/api/jobs/kinds", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec)))] +async fn kinds(State(state): State, _: AuthUser) -> Json> { + Json(state.jobs.kinds()) +} + +#[utoipa::path(get, path = "/api/jobs/{id}", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = JobRunDto), (status = 404)))] +async fn get_one( + State(state): State, + _: AuthUser, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(state.jobs.get(id).await?.into())) +} + +#[utoipa::path(post, path = "/api/jobs/run", tag = "jobs", security(("bearer" = [])), request_body = RunRequest, responses((status = 202, body = JobRunDto), (status = 404), (status = 409)))] +async fn run( + State(state): State, + AdminUser(admin): AdminUser, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + let run = state.jobs.start(req.kind, req.params, &admin.email).await?; + Ok((StatusCode::ACCEPTED, Json(run.into()))) +} diff --git a/backend/crates/api/src/lib.rs b/backend/crates/api/src/lib.rs index 6eea443..7eea322 100644 --- a/backend/crates/api/src/lib.rs +++ b/backend/crates/api/src/lib.rs @@ -3,17 +3,22 @@ pub mod auth; pub mod config; pub mod error; pub mod extract; +pub mod jobs; pub mod openapi; pub mod rate_limit; +pub mod settings; pub mod test_support; pub mod users; use std::sync::Arc; -use application::{AuthService, UserService}; +use application::scheduler::Scheduler; +use application::{AuthService, JobRunner, SettingsService, UserService}; use axum::{routing::get, Json, Router}; +use domain::ports::Mailer; use infrastructure::{ - Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers, + AesGcmCipher, Argon2Hasher, DbPool, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteJobRuns, + SqliteRefreshTokens, SqliteSettings, SqliteUsers, }; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; @@ -25,30 +30,63 @@ pub struct AppState { pub cfg: Config, pub auth: Arc, pub users: Arc, + pub settings: Arc, + pub jobs: Arc, pub login_limiter: Arc, } impl AppState { /// Wire the services on top of a connected database. - pub fn new(cfg: Config, pool: DbPool) -> Self { + pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result { + let fake = cfg.fake_host; + Self::with_adapters(cfg, pool, Arc::new(LettreMailer), move |r| { + if fake { + test_support::register_test_jobs(r) + } else { + r + } + }) + } + + /// Wiring with replaceable adapters (used by tests and the fake-host mode). + pub fn with_adapters( + cfg: Config, + pool: DbPool, + mailer: Arc, + register_jobs: impl FnOnce(JobRunner) -> JobRunner, + ) -> anyhow::Result { let users = Arc::new(SqliteUsers(pool.clone())); let hasher = Arc::new(Argon2Hasher); let auth = AuthService::new( users.clone(), Arc::new(SqliteRefreshTokens(pool.clone())), - Arc::new(SqliteAuditLog(pool)), + Arc::new(SqliteAuditLog(pool.clone())), hasher.clone(), Arc::new(JwtIssuer::new(&cfg.jwt_secret)), ); - Self { + let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?); + let settings = Arc::new(SettingsService::new( + Arc::new(SqliteSettings(pool.clone())), + cipher, + mailer, + )); + let jobs = Arc::new(register_jobs(JobRunner::new(Arc::new(SqliteJobRuns(pool))))); + Ok(Self { cfg, auth: Arc::new(auth), users: Arc::new(UserService::new(users, hasher)), + settings, + jobs, login_limiter: Arc::new(rate_limit::RateLimiter::new( 10, std::time::Duration::from_secs(60), )), - } + }) + } + + /// Spawn the cron scheduler on the current runtime. + pub fn start_scheduler(&self) { + tokio::spawn(Scheduler::new(self.jobs.clone(), self.settings.clone()).run()); } pub async fn bootstrap(&self) -> anyhow::Result<()> { @@ -69,6 +107,8 @@ pub fn build_app(state: AppState) -> Router { .route("/api/openapi.json", get(openapi::spec)) .nest("/api/auth", auth::router()) .nest("/api/users", users::router()) + .nest("/api/settings", settings::router()) + .nest("/api/jobs", jobs::router()) .fallback_service(spa) .layer(TraceLayer::new_for_http()) .with_state(state) diff --git a/backend/crates/api/src/main.rs b/backend/crates/api/src/main.rs index 0b12a0d..f706ed9 100644 --- a/backend/crates/api/src/main.rs +++ b/backend/crates/api/src/main.rs @@ -17,8 +17,9 @@ async fn main() -> anyhow::Result<()> { std::fs::create_dir_all(dir)?; } let pool = infrastructure::connect(&cfg.database_url).await?; - let state = AppState::new(cfg.clone(), pool); + let state = AppState::new(cfg.clone(), pool)?; state.bootstrap().await?; + state.start_scheduler(); let listener = tokio::net::TcpListener::bind(cfg.bind).await?; tracing::info!("listening on http://{}", cfg.bind); let app = build_app(state).into_make_service_with_connect_info::(); diff --git a/backend/crates/api/src/openapi.rs b/backend/crates/api/src/openapi.rs index 0669be3..e96055a 100644 --- a/backend/crates/api/src/openapi.rs +++ b/backend/crates/api/src/openapi.rs @@ -24,6 +24,8 @@ impl Modify for BearerAuth { paths( crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me, crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password, + crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule, + crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run, ), modifiers(&BearerAuth) )] diff --git a/backend/crates/api/src/settings.rs b/backend/crates/api/src/settings.rs new file mode 100644 index 0000000..d5fdf8a --- /dev/null +++ b/backend/crates/api/src/settings.rs @@ -0,0 +1,178 @@ +//! /api/settings: SMTP configuration and job schedules. +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use domain::jobs::JobKind; +use domain::settings::{SmtpSecurity, SmtpSettings}; +use domain::DomainError; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::error::ApiError; +use crate::extract::{AdminUser, AuthUser}; +use crate::AppState; + +pub fn router() -> Router { + Router::new() + .route("/smtp", get(get_smtp).put(put_smtp)) + .route("/smtp/test", post(test_smtp)) + .route("/schedules", get(list_schedules)) + .route("/schedules/{kind}", put(put_schedule)) +} + +#[derive(Serialize, ToSchema)] +pub struct SmtpView { + pub host: String, + pub port: u16, + #[schema(value_type = String, example = "starttls")] + pub security: SmtpSecurity, + pub username: String, + pub from: String, + pub notify_to: Vec, + pub password_set: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct SmtpResponse { + pub configured: bool, + pub smtp: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct SmtpRequest { + pub host: String, + pub port: u16, + #[schema(value_type = String, example = "starttls")] + pub security: SmtpSecurity, + #[serde(default)] + pub username: String, + /// Empty keeps the currently stored password. + #[serde(default)] + pub password: String, + pub from: String, + #[serde(default)] + pub notify_to: Vec, +} + +#[utoipa::path(get, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), responses((status = 200, body = SmtpResponse)))] +async fn get_smtp( + State(state): State, + _: AuthUser, +) -> Result, ApiError> { + let smtp = state.settings.smtp().await?.map(|s| SmtpView { + host: s.host, + port: s.port, + security: s.security, + username: s.username, + from: s.from, + notify_to: s.notify_to, + password_set: !s.password.is_empty(), + }); + Ok(Json(SmtpResponse { + configured: smtp.is_some(), + smtp, + })) +} + +#[utoipa::path(put, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), request_body = SmtpRequest, responses((status = 204), (status = 422)))] +async fn put_smtp( + State(state): State, + _: AdminUser, + Json(req): Json, +) -> Result { + let password = if req.password.is_empty() { + state + .settings + .smtp() + .await? + .map(|s| s.password) + .unwrap_or_default() + } else { + req.password + }; + state + .settings + .set_smtp(SmtpSettings { + host: req.host.trim().into(), + port: req.port, + security: req.security, + username: req.username, + password, + from: req.from.trim().into(), + notify_to: req + .notify_to + .into_iter() + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()) + .collect(), + }) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize, ToSchema)] +pub struct TestMailRequest { + pub to: String, +} + +#[utoipa::path(post, path = "/api/settings/smtp/test", tag = "settings", security(("bearer" = [])), request_body = TestMailRequest, responses((status = 204), (status = 422), (status = 502)))] +async fn test_smtp( + State(state): State, + AdminUser(admin): AdminUser, + Json(req): Json, +) -> Result { + let body = format!( + "This is a test mail from SoftVisor Monitoring, requested by {}.", + admin.email + ); + state + .settings + .send_mail(Some(vec![req.to]), "SoftVisor Monitoring test mail", &body) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Serialize, ToSchema)] +pub struct ScheduleView { + #[schema(value_type = String, example = "package_refresh")] + pub kind: JobKind, + pub cron: Option, + pub default_cron: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct ScheduleRequest { + pub cron: Option, +} + +#[utoipa::path(get, path = "/api/settings/schedules", tag = "settings", security(("bearer" = [])), responses((status = 200, body = Vec)))] +async fn list_schedules( + State(state): State, + _: AuthUser, +) -> Result>, ApiError> { + let mut out = Vec::new(); + for kind in JobKind::ALL + .into_iter() + .filter(|k| k.default_schedule().is_some()) + { + out.push(ScheduleView { + kind, + cron: state.settings.schedule(kind).await?, + default_cron: kind.default_schedule().map(String::from), + }); + } + Ok(Json(out)) +} + +#[utoipa::path(put, path = "/api/settings/schedules/{kind}", tag = "settings", security(("bearer" = [])), request_body = ScheduleRequest, responses((status = 204), (status = 404), (status = 422)))] +async fn put_schedule( + State(state): State, + _: AdminUser, + Path(kind): Path, + Json(req): Json, +) -> Result { + let kind = JobKind::parse(&kind).ok_or(DomainError::NotFound)?; + state.settings.set_schedule(kind, req.cron).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/crates/api/src/test_support.rs b/backend/crates/api/src/test_support.rs index 597aaea..08c1fff 100644 --- a/backend/crates/api/src/test_support.rs +++ b/backend/crates/api/src/test_support.rs @@ -1,11 +1,22 @@ //! Helpers to build a fully wired app on an in-memory database for integration tests. -use crate::{build_app, AppState, Config}; +use std::sync::{Arc, Mutex}; + +use application::{JobHandler, JobLog, JobRunner}; +use async_trait::async_trait; use axum::Router; +use domain::jobs::JobKind; +use domain::ports::Mailer; +use domain::settings::SmtpSettings; +use domain::DomainError; + +use crate::{build_app, AppState, Config}; pub fn test_config() -> Config { Config { database_url: "sqlite::memory:".into(), jwt_secret: "test-secret-test-secret-test-secret-1234".into(), + master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(), + fake_host: true, bind: "127.0.0.1:0".parse().unwrap(), bootstrap_admin: None, cookie_secure: false, @@ -13,6 +24,45 @@ pub fn test_config() -> Config { } } +/// Mails "sent" by all test apps of this process. +static SENT: Mutex, String, String)>> = Mutex::new(Vec::new()); + +pub struct RecordingMailer; + +#[async_trait] +impl Mailer for RecordingMailer { + async fn send( + &self, + _: &SmtpSettings, + to: &[String], + subject: &str, + body: &str, + ) -> Result<(), DomainError> { + SENT.lock() + .unwrap() + .push((to.to_vec(), subject.into(), body.into())); + Ok(()) + } +} + +pub fn sent_mails(_app: &Router) -> Vec<(Vec, String, String)> { + SENT.lock().unwrap().clone() +} + +pub struct TestJob; + +#[async_trait] +impl JobHandler for TestJob { + async fn run(&self, _params: Option, log: &dyn JobLog) -> Result<(), String> { + log.line("test handler ran").await; + Ok(()) + } +} + +pub fn register_test_jobs(runner: JobRunner) -> JobRunner { + runner.register(JobKind::PackageRefresh, Arc::new(TestJob)) +} + pub async fn build_test_app() -> Router { build_test_app_with(test_config()).await } @@ -29,7 +79,8 @@ async fn build_test_app_with(cfg: Config) -> Router { let pool = infrastructure::connect(&cfg.database_url) .await .expect("db"); - let state = AppState::new(cfg, pool); + let state = AppState::with_adapters(cfg, pool, Arc::new(RecordingMailer), register_test_jobs) + .expect("state"); state.bootstrap().await.expect("bootstrap"); build_app(state) } diff --git a/backend/crates/application/src/jobs.rs b/backend/crates/application/src/jobs.rs index 54ec8dc..e5a0bc1 100644 --- a/backend/crates/application/src/jobs.rs +++ b/backend/crates/application/src/jobs.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; -use domain::jobs::{JobKind, JobRun}; +use chrono::Utc; +use domain::jobs::{JobKind, JobRun, JobStatus}; use domain::ports::JobRunRepository; use domain::DomainError; use uuid::Uuid; @@ -24,6 +25,24 @@ pub struct JobRunner { handlers: HashMap>, } +struct RepoLog { + runs: Arc, + id: Uuid, +} + +#[async_trait] +impl JobLog for RepoLog { + async fn line(&self, text: &str) { + if let Err(e) = self.runs.append_log(self.id, text).await { + tracing_line(&format!("failed to append job log: {e}")); + } + } +} + +fn tracing_line(msg: &str) { + eprintln!("{msg}"); +} + impl JobRunner { pub fn new(runs: Arc) -> Self { Self { @@ -43,31 +62,88 @@ impl JobRunner { k } + async fn begin( + &self, + kind: JobKind, + params: Option, + triggered_by: &str, + ) -> Result<(JobRun, Arc), DomainError> { + let handler = self + .handlers + .get(&kind) + .cloned() + .ok_or(DomainError::NotFound)?; + if self.runs.find_running(kind).await?.is_some() { + return Err(DomainError::Conflict(format!( + "{} is already running", + kind.as_str() + ))); + } + let run = JobRun { + id: Uuid::new_v4(), + kind, + params, + status: JobStatus::Running, + started_at: Utc::now(), + finished_at: None, + log: String::new(), + triggered_by: triggered_by.into(), + }; + self.runs.insert(&run).await?; + Ok((run, handler)) + } + + async fn execute(runs: Arc, handler: Arc, run: &JobRun) { + let log = RepoLog { + runs: runs.clone(), + id: run.id, + }; + let status = match handler.run(run.params.clone(), &log).await { + Ok(()) => JobStatus::Success, + Err(e) => { + log.line(&format!("ERROR: {e}")).await; + JobStatus::Failed + } + }; + if let Err(e) = runs.finish(run.id, status).await { + tracing_line(&format!("failed to finish job: {e}")); + } + } + /// Start a job in the background. Fails with `Conflict` if the kind is already running. pub async fn start( &self, - _kind: JobKind, - _params: Option, - _triggered_by: &str, + kind: JobKind, + params: Option, + triggered_by: &str, ) -> Result { - todo!() + let (run, handler) = self.begin(kind, params, triggered_by).await?; + let (runs, run_clone) = (self.runs.clone(), run.clone()); + tokio::spawn(async move { Self::execute(runs, handler, &run_clone).await }); + Ok(run) } /// Run a job and wait for it to finish (used by tests and the scheduler). pub async fn run_and_wait( &self, - _kind: JobKind, - _params: Option, - _triggered_by: &str, + kind: JobKind, + params: Option, + triggered_by: &str, ) -> Result { - todo!() + let (run, handler) = self.begin(kind, params, triggered_by).await?; + Self::execute(self.runs.clone(), handler, &run).await; + self.get(run.id).await } - pub async fn get(&self, _id: Uuid) -> Result { - todo!() + pub async fn get(&self, id: Uuid) -> Result { + self.runs.get(id).await?.ok_or(DomainError::NotFound) } - pub async fn list(&self, _limit: u32) -> Result, DomainError> { - todo!() + pub async fn list(&self, limit: u32) -> Result, DomainError> { + self.runs.list(limit).await + } + + pub async fn last_finished(&self, kind: JobKind) -> Result, DomainError> { + self.runs.last_finished(kind).await } } diff --git a/backend/crates/application/src/scheduler.rs b/backend/crates/application/src/scheduler.rs index 24d0bfe..88816d4 100644 --- a/backend/crates/application/src/scheduler.rs +++ b/backend/crates/application/src/scheduler.rs @@ -1,30 +1,84 @@ -//! Cron scheduler: decides which scheduled job kinds are due. +//! Cron scheduler: decides which scheduled job kinds are due and runs them. +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + use chrono::{DateTime, Utc}; +use cron::Schedule; use domain::jobs::JobKind; use domain::DomainError; +use crate::{JobRunner, SettingsService}; + /// Validate a 6-field cron expression (seconds first). -pub fn validate_cron(_expr: &str) -> Result<(), DomainError> { - todo!() +pub fn validate_cron(expr: &str) -> Result<(), DomainError> { + Schedule::from_str(expr) + .map(|_| ()) + .map_err(|e| DomainError::Validation(format!("invalid cron expression: {e}"))) } /// Next fire time strictly after `after`. -pub fn next_fire(_expr: &str, _after: DateTime) -> Option> { - todo!() +pub fn next_fire(expr: &str, after: DateTime) -> Option> { + Schedule::from_str(expr).ok()?.after(&after).next() } /// A job is due if a fire time exists in `(last_run, now]`. With no last run, it is due /// if a fire time falls within the last `grace` seconds, so a fresh start does not /// immediately run every job. pub fn is_due( - _expr: &str, - _last_run: Option>, - _now: DateTime, - _grace_secs: i64, + expr: &str, + last_run: Option>, + now: DateTime, + grace_secs: i64, ) -> bool { - todo!() + let since = last_run.unwrap_or(now - chrono::Duration::seconds(grace_secs)); + next_fire(expr, since).is_some_and(|t| t <= now) } pub struct Scheduler { - pub kinds: Vec, + runner: Arc, + settings: Arc, + tick: Duration, +} + +impl Scheduler { + pub fn new(runner: Arc, settings: Arc) -> Self { + Self { + runner, + settings, + tick: Duration::from_secs(30), + } + } + + /// One pass: start every registered kind that is due. Returns the kinds started. + pub async fn tick_once(&self, now: DateTime) -> Vec { + let mut started = Vec::new(); + for kind in self.runner.kinds() { + let Ok(Some(cron)) = self.settings.schedule(kind).await else { + continue; + }; + let last = self + .runner + .last_finished(kind) + .await + .ok() + .flatten() + .map(|r| r.started_at); + if is_due(&cron, last, now, self.tick.as_secs() as i64 * 2) { + if self.runner.start(kind, None, "scheduler").await.is_ok() { + started.push(kind); + } + } + } + started + } + + /// Run forever; meant to be spawned on the runtime. + pub async fn run(self) { + let mut interval = tokio::time::interval(self.tick); + loop { + interval.tick().await; + self.tick_once(Utc::now()).await; + } + } } diff --git a/backend/crates/application/src/settings_service.rs b/backend/crates/application/src/settings_service.rs index a9c94d6..a112347 100644 --- a/backend/crates/application/src/settings_service.rs +++ b/backend/crates/application/src/settings_service.rs @@ -2,9 +2,11 @@ use std::sync::Arc; use domain::jobs::JobKind; use domain::ports::{Cipher, Mailer, SettingsRepository}; -use domain::settings::SmtpSettings; +use domain::settings::{SmtpSettings, KEY_SMTP, SECRET_KEYS}; use domain::DomainError; +use crate::scheduler::validate_cron; + pub struct SettingsService { repo: Arc, cipher: Arc, @@ -17,7 +19,6 @@ impl SettingsService { cipher: Arc, mailer: Arc, ) -> Self { - let _ = (&repo, &cipher, &mailer); Self { repo, cipher, @@ -25,34 +26,84 @@ impl SettingsService { } } - pub async fn smtp(&self) -> Result, DomainError> { - todo!() + async fn get(&self, key: &str) -> Result, DomainError> { + let Some(raw) = self.repo.get(key).await? else { + return Ok(None); + }; + if SECRET_KEYS.contains(&key) { + self.cipher.decrypt(&raw).map(Some) + } else { + Ok(Some(raw)) + } } - pub async fn set_smtp(&self, _smtp: SmtpSettings) -> Result<(), DomainError> { - todo!() + async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> { + let stored = if SECRET_KEYS.contains(&key) { + self.cipher.encrypt(value)? + } else { + value.to_string() + }; + self.repo.set(key, &stored).await + } + + pub async fn smtp(&self) -> Result, DomainError> { + match self.get(KEY_SMTP).await? { + Some(json) => serde_json::from_str(&json) + .map(Some) + .map_err(|e| DomainError::Storage(e.to_string())), + None => Ok(None), + } + } + + pub async fn set_smtp(&self, smtp: SmtpSettings) -> Result<(), DomainError> { + smtp.validate()?; + let json = serde_json::to_string(&smtp).map_err(|e| DomainError::Storage(e.to_string()))?; + self.set(KEY_SMTP, &json).await } /// Send a mail to the configured recipients (or `to` if given) using the stored SMTP settings. pub async fn send_mail( &self, - _to: Option>, - _subject: &str, - _body: &str, + to: Option>, + subject: &str, + body: &str, ) -> Result<(), DomainError> { - todo!() + let smtp = self + .smtp() + .await? + .ok_or_else(|| DomainError::Validation("smtp is not configured".into()))?; + let to = to.unwrap_or_else(|| smtp.notify_to.clone()); + if to.is_empty() { + return Err(DomainError::Validation("no recipients configured".into())); + } + self.mailer.send(&smtp, &to, subject, body).await } /// Cron expression (6 fields, seconds first) for a scheduled job kind, or None if disabled. - pub async fn schedule(&self, _kind: JobKind) -> Result, DomainError> { - todo!() + pub async fn schedule(&self, kind: JobKind) -> Result, DomainError> { + match self.get(&schedule_key(kind)).await? { + Some(v) if v.is_empty() => Ok(None), + Some(v) => Ok(Some(v)), + None => Ok(kind.default_schedule().map(String::from)), + } } pub async fn set_schedule( &self, - _kind: JobKind, - _cron: Option, + kind: JobKind, + cron: Option, ) -> Result<(), DomainError> { - todo!() + let value = match cron.map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) { + Some(c) => { + validate_cron(&c)?; + c + } + None => String::new(), + }; + self.set(&schedule_key(kind), &value).await } } + +fn schedule_key(kind: JobKind) -> String { + format!("schedule.{}", kind.as_str()) +} diff --git a/backend/crates/infrastructure/Cargo.toml b/backend/crates/infrastructure/Cargo.toml index 685f2cb..5a67677 100644 --- a/backend/crates/infrastructure/Cargo.toml +++ b/backend/crates/infrastructure/Cargo.toml @@ -7,10 +7,13 @@ license.workspace = true [dependencies] domain.workspace = true anyhow.workspace = true +aes-gcm = "0.10" argon2.workspace = true +base64.workspace = true async-trait.workspace = true chrono.workspace = true jsonwebtoken.workspace = true +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] } serde.workspace = true sqlx.workspace = true uuid.workspace = true diff --git a/backend/crates/infrastructure/migrations/0002_settings_and_jobs.sql b/backend/crates/infrastructure/migrations/0002_settings_and_jobs.sql new file mode 100644 index 0000000..4093ff5 --- /dev/null +++ b/backend/crates/infrastructure/migrations/0002_settings_and_jobs.sql @@ -0,0 +1,16 @@ +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE job_runs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + params TEXT, + status TEXT NOT NULL CHECK (status IN ('running', 'success', 'failed')), + started_at TEXT NOT NULL, + finished_at TEXT, + log TEXT NOT NULL DEFAULT '', + triggered_by TEXT NOT NULL +); +CREATE INDEX job_runs_kind_started ON job_runs(kind, started_at DESC); diff --git a/backend/crates/infrastructure/src/cipher.rs b/backend/crates/infrastructure/src/cipher.rs new file mode 100644 index 0000000..91ebde5 --- /dev/null +++ b/backend/crates/infrastructure/src/cipher.rs @@ -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 { + 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::::from_slice(&bytes)))) + } +} + +fn hex_decode(s: &str) -> anyhow::Result> { + 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 { + 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 { + 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()); + } +} diff --git a/backend/crates/infrastructure/src/lib.rs b/backend/crates/infrastructure/src/lib.rs index ec9270f..b63d66a 100644 --- a/backend/crates/infrastructure/src/lib.rs +++ b/backend/crates/infrastructure/src/lib.rs @@ -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; diff --git a/backend/crates/infrastructure/src/mail.rs b/backend/crates/infrastructure/src/mail.rs new file mode 100644 index 0000000..299b6b8 --- /dev/null +++ b/backend/crates/infrastructure/src/mail.rs @@ -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::::builder_dangerous(&smtp.host) + } + SmtpSecurity::StartTls => { + AsyncSmtpTransport::::starttls_relay(&smtp.host) + .map_err(|e| unavailable(e.to_string()))? + } + SmtpSecurity::Tls => AsyncSmtpTransport::::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())) + } +} diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs index d9921bb..08091a1 100644 --- a/backend/crates/infrastructure/src/sqlite.rs +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -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, 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::("kind").as_str()) + .unwrap_or(JobKind::PackageRefresh), + params: r.get("params"), + status: JobStatus::parse(r.get::("status").as_str()) + .unwrap_or(JobStatus::Failed), + started_at: parse_ts(r.get::("started_at").as_str()), + finished_at: r + .get::, _>("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, 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, 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, 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, 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); + } +} diff --git a/deploy/deploy-test.sh b/deploy/deploy-test.sh index 687c453..5f9aab4 100755 --- a/deploy/deploy-test.sh +++ b/deploy/deploy-test.sh @@ -27,6 +27,7 @@ if ! ssh "$HOST" "test -f $DIR/.env"; then ssh "$HOST" "umask 077; cat > $DIR/.env" <(path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), + put: (path: string, body?: unknown) => request('PUT', path, body), } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 2a962eb..3e16ae2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -26,3 +26,44 @@ export interface UpdateUserPayload { role?: Role is_active?: boolean } + +export type SmtpSecurity = 'none' | 'starttls' | 'tls' + +export interface SmtpView { + host: string + port: number + security: SmtpSecurity + username: string + from: string + notify_to: string[] + password_set: boolean +} + +export interface SmtpPayload { + host: string + port: number + security: SmtpSecurity + username: string + password: string + from: string + notify_to: string[] +} + +export interface ScheduleView { + kind: string + cron: string | null + default_cron: string | null +} + +export type JobStatus = 'running' | 'success' | 'failed' + +export interface JobRun { + id: string + kind: string + params?: string | null + status: JobStatus + started_at: string + finished_at: string | null + log: string + triggered_by: string +} diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 069eed8..91c1eaf 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -9,6 +9,7 @@ const nav = [ { to: '/updates', label: 'Updates' }, { to: '/vulnerabilities', label: 'Vulnerabilities' }, { to: '/backups', label: 'Backups' }, + { to: '/jobs', label: 'Jobs' }, { to: '/users', label: 'Users', admin: true }, { to: '/settings', label: 'Settings' }, ] diff --git a/frontend/src/components/SmtpForm.vue b/frontend/src/components/SmtpForm.vue new file mode 100644 index 0000000..513917f --- /dev/null +++ b/frontend/src/components/SmtpForm.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/pages/JobsPage.test.ts b/frontend/src/pages/JobsPage.test.ts index 8343766..7d3fc1c 100644 --- a/frontend/src/pages/JobsPage.test.ts +++ b/frontend/src/pages/JobsPage.test.ts @@ -5,7 +5,7 @@ import { useAuthStore } from '../stores/auth' import { api } from '../api/client' vi.mock('../api/client', () => ({ - api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }, + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn() }, ApiError: class extends Error {}, })) diff --git a/frontend/src/pages/JobsPage.vue b/frontend/src/pages/JobsPage.vue new file mode 100644 index 0000000..520c72b --- /dev/null +++ b/frontend/src/pages/JobsPage.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue new file mode 100644 index 0000000..07e6cb8 --- /dev/null +++ b/frontend/src/pages/SettingsPage.vue @@ -0,0 +1,145 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index fd2fad8..ef349ad 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -6,6 +6,8 @@ import LoginPage from './pages/LoginPage.vue' import DashboardPage from './pages/DashboardPage.vue' import UsersPage from './pages/UsersPage.vue' import PlaceholderPage from './pages/PlaceholderPage.vue' +import SettingsPage from './pages/SettingsPage.vue' +import JobsPage from './pages/JobsPage.vue' export const router = createRouter({ history: createWebHistory(), @@ -24,7 +26,8 @@ export const router = createRouter({ }, { path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } }, { path: 'users', component: UsersPage, meta: { admin: true } }, - { path: 'settings', component: PlaceholderPage, props: { title: 'Settings' } }, + { path: 'jobs', component: JobsPage }, + { path: 'settings', component: SettingsPage }, ], }, ],