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

@ -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()?,

View File

@ -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")

View File

@ -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<AppState> {
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<String>,
#[schema(value_type = String)]
pub status: JobStatus,
pub started_at: chrono::DateTime<chrono::Utc>,
pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
pub log: String,
pub triggered_by: String,
}
impl From<JobRun> 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<u32>,
}
#[derive(Deserialize, ToSchema)]
pub struct RunRequest {
#[schema(value_type = String, example = "package_refresh")]
pub kind: JobKind,
pub params: Option<String>,
}
#[utoipa::path(get, path = "/api/jobs", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec<JobRunDto>)))]
async fn list(
State(state): State<AppState>,
_: AuthUser,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<JobRunDto>>, 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<String>)))]
async fn kinds(State(state): State<AppState>, _: AuthUser) -> Json<Vec<JobKind>> {
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<AppState>,
_: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<JobRunDto>, 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<AppState>,
AdminUser(admin): AdminUser,
Json(req): Json<RunRequest>,
) -> Result<(StatusCode, Json<JobRunDto>), ApiError> {
let run = state.jobs.start(req.kind, req.params, &admin.email).await?;
Ok((StatusCode::ACCEPTED, Json(run.into())))
}

View File

@ -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<AuthService>,
pub users: Arc<UserService>,
pub settings: Arc<SettingsService>,
pub jobs: Arc<JobRunner>,
pub login_limiter: Arc<rate_limit::RateLimiter>,
}
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<Self> {
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<dyn Mailer>,
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
) -> anyhow::Result<Self> {
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)

View File

@ -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::<std::net::SocketAddr>();

View File

@ -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)
)]

View File

@ -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<AppState> {
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<String>,
pub password_set: bool,
}
#[derive(Serialize, ToSchema)]
pub struct SmtpResponse {
pub configured: bool,
pub smtp: Option<SmtpView>,
}
#[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<String>,
}
#[utoipa::path(get, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), responses((status = 200, body = SmtpResponse)))]
async fn get_smtp(
State(state): State<AppState>,
_: AuthUser,
) -> Result<Json<SmtpResponse>, 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<AppState>,
_: AdminUser,
Json(req): Json<SmtpRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
AdminUser(admin): AdminUser,
Json(req): Json<TestMailRequest>,
) -> Result<StatusCode, ApiError> {
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<String>,
pub default_cron: Option<String>,
}
#[derive(Deserialize, ToSchema)]
pub struct ScheduleRequest {
pub cron: Option<String>,
}
#[utoipa::path(get, path = "/api/settings/schedules", tag = "settings", security(("bearer" = [])), responses((status = 200, body = Vec<ScheduleView>)))]
async fn list_schedules(
State(state): State<AppState>,
_: AuthUser,
) -> Result<Json<Vec<ScheduleView>>, 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<AppState>,
_: AdminUser,
Path(kind): Path<String>,
Json(req): Json<ScheduleRequest>,
) -> Result<StatusCode, ApiError> {
let kind = JobKind::parse(&kind).ok_or(DomainError::NotFound)?;
state.settings.set_schedule(kind, req.cron).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -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<Vec<(Vec<String>, 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, String)> {
SENT.lock().unwrap().clone()
}
pub struct TestJob;
#[async_trait]
impl JobHandler for TestJob {
async fn run(&self, _params: Option<String>, 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)
}