WP-40/41/42: dashboard, security hardening, deployment and operations docs

Dashboard endpoint and page aggregating inventory, vulnerabilities, cluster
health, backups and recent jobs. Security headers (CSP, nosniff, DENY,
referrer policy), 1 MB body limit, configurable login rate limit, audit
steps in CI. Installer script, systemd unit, install/architecture docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:26:01 +02:00
parent a89395ae18
commit 9234e1ba47
29 changed files with 851 additions and 21 deletions

View File

@ -0,0 +1,77 @@
//! WP-40: GET /api/dashboard aggregates the state of all areas.
mod common;
use axum::http::StatusCode;
use common::{get, post, test_app_with_admin};
use serde_json::json;
const ADMIN: &str = "admin@example.com";
const PW: &str = "admin-password-123";
async fn wait(app: &axum::Router, token: &str, id: &str) {
for _ in 0..100 {
let r = get(app, &format!("/api/jobs/{id}"), Some(token)).await;
if r.json["status"] != "running" {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
}
#[tokio::test]
async fn dashboard_reflects_inventory_vulnerabilities_cluster_backups_and_jobs() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
let d = get(&app, "/api/dashboard", Some(&token)).await;
assert_eq!(d.status, StatusCode::OK, "{}", d.json);
assert!(d.json["inventory"]["refreshed_at"].is_null());
assert_eq!(d.json["vulnerabilities"]["total"]["critical"], 0);
assert_eq!(d.json["cluster"]["reachable"], true);
assert_eq!(d.json["cluster"]["workloads"], 5);
assert_eq!(d.json["cluster"]["unhealthy"], 0);
assert_eq!(d.json["backups"].as_array().unwrap().len(), 0);
assert_eq!(d.json["recent_jobs"].as_array().unwrap().len(), 0);
for kind in ["package_refresh", "vulnerability_scan"] {
let run = post(&app, "/api/jobs/run", json!({"kind": kind}), Some(&token)).await;
wait(&app, &token, run.json["id"].as_str().unwrap()).await;
}
let t = post(&app, "/api/backups/targets", json!({"name": "NAS", "kind": "smb", "host": "nas", "share": "b", "username": "u", "password": "p"}), Some(&token)).await;
let s = post(&app, "/api/backups/strategies", json!({"name": "DB", "source": {"type": "host_path", "path": "/tmp"}, "schedule": "0 0 2 * * *", "target_id": t.json["id"], "retention": 2}), Some(&token)).await;
assert_eq!(s.status, StatusCode::CREATED, "{}", s.json);
let d = get(&app, "/api/dashboard", Some(&token)).await;
assert!(d.json["inventory"]["refreshed_at"].is_string());
assert_eq!(
d.json["inventory"]["os"]["name"],
"Debian GNU/Linux 12 (bookworm)"
);
assert_eq!(d.json["inventory"]["upgradable"], 3);
assert_eq!(d.json["inventory"]["security"], 2);
assert_eq!(d.json["inventory"]["reboot_required"], true);
assert!(
d.json["vulnerabilities"]["total"]["critical"]
.as_u64()
.unwrap()
>= 2
);
assert!(d.json["vulnerabilities"]["last_scan"].is_string());
let b = &d.json["backups"][0];
assert_eq!(b["name"], "DB");
assert!(b["last_backup"].is_null());
assert_eq!(b["enabled"], true);
let jobs = d.json["recent_jobs"].as_array().unwrap();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0]["kind"], "vulnerability_scan", "newest first");
assert!(
jobs[0].get("log").is_none(),
"no logs in the dashboard payload"
);
assert_eq!(d.json["failed_jobs_24h"], 0);
assert_eq!(
get(&app, "/api/dashboard", None).await.status,
StatusCode::UNAUTHORIZED
);
}