From 9234e1ba47140a2fb23e3430276585f7d79fa946 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Wed, 2 Sep 2026 23:26:01 +0200 Subject: [PATCH] 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 --- .env.example | 2 + .gitea/workflows/ci.yml | 5 + README.md | 5 + ROADMAP.md | 12 +- backend/crates/api/src/config.rs | 5 + backend/crates/api/src/dashboard.rs | 172 +++++++++++++++++++++ backend/crates/api/src/lib.rs | 8 +- backend/crates/api/src/openapi.rs | 1 + backend/crates/api/src/security.rs | 34 ++++ backend/crates/api/src/test_support.rs | 1 + backend/crates/api/tests/dashboard.rs | 77 +++++++++ backend/crates/api/tests/security.rs | 72 +++++++++ backend/crates/infrastructure/src/trivy.rs | 3 +- deploy/install.sh | 52 +++++++ deploy/monitoring.service | 19 +++ docs/architecture.md | 27 ++++ docs/install.md | 56 +++++++ frontend/e2e/auth.spec.ts | 4 +- frontend/e2e/backups.spec.ts | 2 +- frontend/e2e/cluster.spec.ts | 2 +- frontend/e2e/dashboard.spec.ts | 14 ++ frontend/e2e/settings-jobs.spec.ts | 4 +- frontend/e2e/updates.spec.ts | 2 +- frontend/e2e/upgrade.spec.ts | 2 +- frontend/e2e/vulnerabilities.spec.ts | 4 +- frontend/playwright.config.ts | 1 + frontend/src/api/types.ts | 29 ++++ frontend/src/pages/DashboardPage.test.ts | 101 ++++++++++++ frontend/src/pages/DashboardPage.vue | 156 ++++++++++++++++++- 29 files changed, 851 insertions(+), 21 deletions(-) create mode 100644 backend/crates/api/src/dashboard.rs create mode 100644 backend/crates/api/src/security.rs create mode 100644 backend/crates/api/tests/dashboard.rs create mode 100644 backend/crates/api/tests/security.rs create mode 100755 deploy/install.sh create mode 100644 deploy/monitoring.service create mode 100644 docs/architecture.md create mode 100644 docs/install.md create mode 100644 frontend/e2e/dashboard.spec.ts create mode 100644 frontend/src/pages/DashboardPage.test.ts diff --git a/.env.example b/.env.example index 3f609df..bc58309 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ BIND=127.0.0.1:8080 # Created on first start if no user exists BOOTSTRAP_ADMIN_EMAIL=admin@example.com BOOTSTRAP_ADMIN_PASSWORD=change-me-min-12-chars +# Login attempts per IP and minute (default 10) +#LOGIN_RATE_LIMIT=10 # Set to true behind HTTPS so the refresh cookie is marked Secure COOKIE_SECURE=false # Directory with the built frontend (served as SPA fallback) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 831e797..fb5f4c2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -15,6 +15,9 @@ jobs: with: { workspaces: backend } - run: cd backend && cargo fmt --check && cargo clippy --workspace --all-targets -- -D warnings - run: cd backend && cargo test --workspace + - uses: rustsec/audit-check@v2 + with: { token: ${{ secrets.GITHUB_TOKEN }} } + continue-on-error: true frontend: runs-on: ubuntu-latest @@ -24,6 +27,8 @@ jobs: with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json } - run: cd frontend && npm ci - run: cd frontend && npm run lint && npm run typecheck && npm test + - run: cd frontend && npm audit --audit-level=high + continue-on-error: true ui: runs-on: ubuntu-latest diff --git a/README.md b/README.md index d1dac0c..cf6a428 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ Every feature starts with its tests: 3. Implement the smallest code that makes them pass, then refactor. 4. Commit and push. +## Deployment + +See [docs/install.md](docs/install.md) (release build, `deploy/install.sh`, systemd unit) and +[docs/restore.md](docs/restore.md) for restoring backups. `docs/architecture.md` describes the layers. + ## First admin On start the backend creates an admin user from `BOOTSTRAP_ADMIN_EMAIL` / diff --git a/ROADMAP.md b/ROADMAP.md index 82395ba..d20a3f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # SoftVisor Infrastructure Monitoring System – Roadmap -Status: v1.2 (2026-09-02) – Milestones 1 and 2 delivered, test instance on port 3333 +Status: v1.5 (2026-09-02) – all five milestones delivered, test instance on port 3333 This document is derived from `CLAUDE.md`. It breaks the project into work packages (WPs), fixes the technical decisions that are not dictated by `CLAUDE.md`, and lists the open @@ -301,8 +301,8 @@ WP-20, WP-21. Trivy is installed by the deploy script. ### Milestone 4 – Backup management (delivered 2026-09-02) WP-30, WP-31, WP-32. Restore procedure in `docs/restore.md`. -### Milestone 5 – Hardening and release (planned) -WP-40, WP-41, WP-42. +### Milestone 5 – Hardening and release (delivered 2026-09-02) +WP-40, WP-41, WP-42. Open items: TOTP 2FA, dark mode, running as a non-root service user with a scoped sudoers file, upstream image tag check, backup failure mails. --- @@ -363,6 +363,6 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect | WP-30 | M4 | done | 2026-09-02; SMB via smbclient, FTP/FTPS via curl, credentials encrypted | | WP-31 | M4 | done | 2026-09-02; sources: PVC hostpath, pg_dumpall, manifests, host dir; openssl encryption | | WP-32 | M4 | done | 2026-09-02; upload verify, retention, records; docs/restore.md; failure mail not yet | -| WP-40 | M5 | todo | | -| WP-41 | M5 | todo | | -| WP-42 | M5 | todo | | +| WP-40 | M5 | done | 2026-09-02; dashboard with tiles for all areas; dark mode not done | +| WP-41 | M5 | done | 2026-09-02; security headers, CSP, body limit, audits in CI; TOTP 2FA and sudoers scoping not done | +| WP-42 | M5 | done | 2026-09-02; install.sh, systemd unit, docs/install.md, docs/architecture.md | diff --git a/backend/crates/api/src/config.rs b/backend/crates/api/src/config.rs index b1f523b..a6911f9 100644 --- a/backend/crates/api/src/config.rs +++ b/backend/crates/api/src/config.rs @@ -20,6 +20,8 @@ pub struct Config { pub bind: SocketAddr, pub bootstrap_admin: Option<(String, String)>, pub cookie_secure: bool, + /// Login attempts per IP and minute. + pub login_rate_limit: u32, pub frontend_dir: String, } @@ -69,6 +71,9 @@ impl Config { .parse()?, bootstrap_admin: env("BOOTSTRAP_ADMIN_EMAIL").zip(env("BOOTSTRAP_ADMIN_PASSWORD")), cookie_secure: env("COOKIE_SECURE").is_some_and(|v| v == "true" || v == "1"), + login_rate_limit: env("LOGIN_RATE_LIMIT") + .and_then(|v| v.parse().ok()) + .unwrap_or(10), frontend_dir: env("FRONTEND_DIR").unwrap_or_else(|| "../frontend/dist".into()), }) } diff --git a/backend/crates/api/src/dashboard.rs b/backend/crates/api/src/dashboard.rs new file mode 100644 index 0000000..eda478e --- /dev/null +++ b/backend/crates/api/src/dashboard.rs @@ -0,0 +1,172 @@ +//! /api/dashboard: one call with the headline numbers of every area. +use application::vuln_service::Summary; +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use chrono::{DateTime, Duration, Utc}; +use domain::backup::BackupRecord; +use domain::host::OsInfo; +use domain::jobs::{JobKind, JobStatus}; +use serde::Serialize; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::extract::AuthUser; +use crate::AppState; + +pub fn router() -> Router { + Router::new().route("/", get(dashboard)) +} + +#[derive(Serialize, ToSchema)] +pub struct InventoryTile { + pub refreshed_at: Option>, + #[schema(value_type = Option)] + pub os: Option, + pub total: usize, + pub upgradable: usize, + pub security: usize, + pub reboot_required: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct ClusterTile { + pub reachable: bool, + pub error: Option, + pub nodes_ready: usize, + pub nodes: usize, + pub workloads: usize, + pub unhealthy: usize, +} + +#[derive(Serialize, ToSchema)] +pub struct BackupTile { + pub id: Uuid, + pub name: String, + pub enabled: bool, + pub schedule: String, + #[schema(value_type = Option)] + pub last_backup: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct JobTile { + pub id: Uuid, + #[schema(value_type = String)] + pub kind: JobKind, + #[schema(value_type = String)] + pub status: JobStatus, + pub started_at: DateTime, + pub finished_at: Option>, + pub triggered_by: String, +} + +#[derive(Serialize, ToSchema)] +pub struct DashboardResponse { + pub inventory: InventoryTile, + #[schema(value_type = Object)] + pub vulnerabilities: VulnTile, + pub cluster: ClusterTile, + pub backups: Vec, + pub recent_jobs: Vec, + pub failed_jobs_24h: usize, +} + +#[derive(Serialize)] +pub struct VulnTile { + #[serde(flatten)] + pub summary: Summary, + pub scanner: String, +} + +#[utoipa::path(get, path = "/api/dashboard", tag = "dashboard", security(("bearer" = [])), responses((status = 200, body = DashboardResponse)))] +async fn dashboard( + State(s): State, + _: AuthUser, +) -> Result, ApiError> { + let inventory = match s.inventory.current().await? { + Some(inv) => InventoryTile { + refreshed_at: Some(inv.refreshed_at), + total: inv.packages.len(), + upgradable: inv.upgradable(), + security: inv.security_upgrades(), + reboot_required: inv.os.reboot_required, + os: Some(inv.os), + }, + None => InventoryTile { + refreshed_at: None, + os: None, + total: 0, + upgradable: 0, + security: 0, + reboot_required: false, + }, + }; + let scanner = s + .vulns + .scanner_version() + .await + .unwrap_or_else(|e| format!("unavailable: {e}")); + let vulnerabilities = VulnTile { + summary: s.vulns.summary().await?, + scanner, + }; + let cluster = match s.cluster.overview().await { + Ok(o) => ClusterTile { + reachable: true, + error: None, + nodes_ready: o.nodes.iter().filter(|n| n.ready).count(), + nodes: o.nodes.len(), + workloads: o.workloads.len(), + unhealthy: o.workloads.iter().filter(|w| w.ready < w.desired).count(), + }, + Err(e) => ClusterTile { + reachable: false, + error: Some(e.to_string()), + nodes_ready: 0, + nodes: 0, + workloads: 0, + unhealthy: 0, + }, + }; + let backups = s + .backups + .list_strategies() + .await? + .into_iter() + .map(|st| BackupTile { + id: st.strategy.id, + name: st.strategy.name, + enabled: st.strategy.enabled, + schedule: st.strategy.schedule, + last_backup: st.last_backup, + }) + .collect(); + let runs = s.jobs.list(50).await?; + let since = Utc::now() - Duration::hours(24); + let failed_jobs_24h = runs + .iter() + .filter(|r| r.status == JobStatus::Failed && r.started_at >= since) + .count(); + let recent_jobs = runs + .into_iter() + .take(8) + .map(|r| JobTile { + id: r.id, + kind: r.kind, + status: r.status, + started_at: r.started_at, + finished_at: r.finished_at, + triggered_by: r.triggered_by, + }) + .collect(); + Ok(Json(DashboardResponse { + inventory, + vulnerabilities, + cluster, + backups, + recent_jobs, + failed_jobs_24h, + })) +} diff --git a/backend/crates/api/src/lib.rs b/backend/crates/api/src/lib.rs index b84e48e..dc80f80 100644 --- a/backend/crates/api/src/lib.rs +++ b/backend/crates/api/src/lib.rs @@ -3,11 +3,13 @@ pub mod auth; pub mod backups; pub mod cluster; pub mod config; +pub mod dashboard; pub mod error; pub mod extract; pub mod jobs; pub mod openapi; pub mod rate_limit; +pub mod security; pub mod settings; pub mod system; pub mod test_support; @@ -164,6 +166,7 @@ impl AppState { inventory: inventory.clone(), }), ); + let login_rate_limit = cfg.login_rate_limit; let cluster = Arc::new(ClusterService::new(cluster.clone())); let vulns = Arc::new(VulnerabilityService::new( scanner, @@ -190,7 +193,7 @@ impl AppState { vulns, backups, login_limiter: Arc::new(rate_limit::RateLimiter::new( - 10, + login_rate_limit, std::time::Duration::from_secs(60), )), }) @@ -233,7 +236,10 @@ pub fn build_app(state: AppState) -> Router { .nest("/api/cluster", cluster::router()) .nest("/api/vulnerabilities", vulnerabilities::router()) .nest("/api/backups", backups::router()) + .nest("/api/dashboard", dashboard::router()) .fallback_service(spa) + .layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) + .layer(axum::middleware::from_fn(security::headers)) .layer(TraceLayer::new_for_http()) .with_state(state) } diff --git a/backend/crates/api/src/openapi.rs b/backend/crates/api/src/openapi.rs index 2c30a46..e936b5e 100644 --- a/backend/crates/api/src/openapi.rs +++ b/backend/crates/api/src/openapi.rs @@ -34,6 +34,7 @@ impl Modify for BearerAuth { crate::backups::delete_target, crate::backups::test_target, crate::backups::list_strategies, crate::backups::get_strategy, crate::backups::create_strategy, crate::backups::update_strategy, crate::backups::delete_strategy, crate::backups::run_strategy, crate::backups::records, + crate::dashboard::dashboard, ), modifiers(&BearerAuth) )] diff --git a/backend/crates/api/src/security.rs b/backend/crates/api/src/security.rs new file mode 100644 index 0000000..de9be79 --- /dev/null +++ b/backend/crates/api/src/security.rs @@ -0,0 +1,34 @@ +//! Security headers applied to every response. +use axum::http::{header, HeaderValue, Request}; +use axum::middleware::Next; +use axum::response::Response; + +pub const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; \ + font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + +pub async fn headers(req: Request, next: Next) -> Response { + let is_asset = req.uri().path().starts_with("/assets/"); + let mut res = next.run(req).await; + let h = res.headers_mut(); + h.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + h.insert("x-frame-options", HeaderValue::from_static("DENY")); + h.insert("referrer-policy", HeaderValue::from_static("same-origin")); + h.insert("content-security-policy", HeaderValue::from_static(CSP)); + h.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + if !h.contains_key(header::CACHE_CONTROL) { + let value = if is_asset { + "public, max-age=31536000, immutable" + } else { + "no-store" + }; + h.insert(header::CACHE_CONTROL, HeaderValue::from_static(value)); + } + h.remove(header::SERVER); + res +} diff --git a/backend/crates/api/src/test_support.rs b/backend/crates/api/src/test_support.rs index 8941d27..92247d1 100644 --- a/backend/crates/api/src/test_support.rs +++ b/backend/crates/api/src/test_support.rs @@ -23,6 +23,7 @@ pub fn test_config() -> Config { bind: "127.0.0.1:0".parse().unwrap(), bootstrap_admin: None, cookie_secure: false, + login_rate_limit: 10, frontend_dir: "/nonexistent".into(), } } diff --git a/backend/crates/api/tests/dashboard.rs b/backend/crates/api/tests/dashboard.rs new file mode 100644 index 0000000..bdd900a --- /dev/null +++ b/backend/crates/api/tests/dashboard.rs @@ -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 + ); +} diff --git a/backend/crates/api/tests/security.rs b/backend/crates/api/tests/security.rs new file mode 100644 index 0000000..89e3919 --- /dev/null +++ b/backend/crates/api/tests/security.rs @@ -0,0 +1,72 @@ +//! WP-41: security headers and cookie hardening. +mod common; + +use axum::http::StatusCode; +use common::{get, post, test_app_with_admin}; +use serde_json::json; + +#[tokio::test] +async fn responses_carry_security_headers() { + let app = test_app_with_admin().await; + let res = get(&app, "/healthz", None).await; + let h = |k: &str| { + res.headers + .get(k) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + assert_eq!(h("x-content-type-options"), "nosniff"); + assert_eq!(h("x-frame-options"), "DENY"); + assert_eq!(h("referrer-policy"), "same-origin"); + let csp = h("content-security-policy"); + assert!(csp.contains("default-src 'self'"), "{csp}"); + assert!(csp.contains("frame-ancestors 'none'"), "{csp}"); + assert_eq!(h("cache-control"), "no-store"); + assert!(res.headers.get("server").is_none()); + + // API errors are JSON, not HTML, and still carry the headers + let res = get(&app, "/api/users", None).await; + assert_eq!(res.status, StatusCode::UNAUTHORIZED); + assert_eq!(res.json["error"], "unauthorized"); + assert_eq!( + res.headers.get("x-content-type-options").unwrap(), + "nosniff" + ); +} + +#[tokio::test] +async fn refresh_cookie_is_strict_and_scoped() { + let app = test_app_with_admin().await; + let res = post( + &app, + "/api/auth/login", + json!({"email": "admin@example.com", "password": "admin-password-123"}), + None, + ) + .await; + let cookie = res.headers.get("set-cookie").unwrap().to_str().unwrap(); + assert!( + cookie.contains("HttpOnly") + && cookie.contains("SameSite=Strict") + && cookie.contains("Path=/api/auth") + ); + // cross-site style request without the cookie cannot refresh + assert_eq!( + post(&app, "/api/auth/refresh", json!({}), None) + .await + .status, + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test] +async fn oversized_json_bodies_are_rejected() { + let app = test_app_with_admin().await; + let token = common::login(&app, "admin@example.com", "admin-password-123") + .await + .access; + let big = "x".repeat(2 * 1024 * 1024); + let res = post(&app, "/api/users", json!({"email": "a@b.de", "display_name": big, "password": "user-password-123", "role": "user"}), Some(&token)).await; + assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE); +} diff --git a/backend/crates/infrastructure/src/trivy.rs b/backend/crates/infrastructure/src/trivy.rs index 26edf39..77aa815 100644 --- a/backend/crates/infrastructure/src/trivy.rs +++ b/backend/crates/infrastructure/src/trivy.rs @@ -372,8 +372,9 @@ mod tests { #[tokio::test] async fn image_scan_prefers_local_containerd_when_configured() { use std::sync::Mutex; + type Call = (Vec, Vec<(String, String)>); #[derive(Default)] - struct Env(Mutex, Vec<(String, String)>)>>); + struct Env(Mutex>); #[async_trait] impl CommandRunner for Env { async fn run(&self, p: &str, a: &[&str]) -> Result { diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..9fde3eb --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Server-side installer/updater. Run as root on the Debian host with the release +# archive (monitoring-server binary + dist/) in the current directory: +# ./install.sh [--port 8080] +set -euo pipefail +DIR=/opt/monitoring +PORT=${PORT:-8080} +[[ "${1:-}" == "--port" ]] && PORT=$2 + +echo "== dependencies" +apt-get install -y -q smbclient curl gzip tar openssl >/dev/null +if ! command -v trivy >/dev/null; then + apt-get install -y -q wget apt-transport-https gnupg >/dev/null + wget -qO- https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor -o /usr/share/keyrings/trivy.gpg + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" > /etc/apt/sources.list.d/trivy.list + apt-get update -q >/dev/null && apt-get install -y -q trivy >/dev/null +fi + +echo "== files" +mkdir -p "$DIR/data" +systemctl stop monitoring.service 2>/dev/null || true +install -m 0755 monitoring-server "$DIR/monitoring-server" +rm -rf "$DIR/dist" && cp -r dist "$DIR/dist" +install -m 0644 monitoring.service /etc/systemd/system/monitoring.service + +if [[ ! -f "$DIR/.env" ]]; then + echo "== first run: creating $DIR/.env" + ADMIN_PW=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20) + umask 077 + cat > "$DIR/.env" < { + await page.goto('/login') + await page.getByLabel('Email').fill('admin@example.com') + await page.getByLabel('Password').fill('admin-password-123') + await page.getByRole('button', { name: 'Sign in' }).click() + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByTestId('tile-workloads')).toContainText('5') + await expect(page.getByTestId('tile-critical')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Backups' })).toBeVisible() + await page.getByTestId('tile-workloads').click() + await expect(page).toHaveURL(/\/cluster/) +}) diff --git a/frontend/e2e/settings-jobs.spec.ts b/frontend/e2e/settings-jobs.spec.ts index c0fec2f..597afe9 100644 --- a/frontend/e2e/settings-jobs.spec.ts +++ b/frontend/e2e/settings-jobs.spec.ts @@ -10,7 +10,7 @@ async function loginAdmin(page: Page) { test('admin saves SMTP settings and a schedule', async ({ page }) => { await loginAdmin(page) - await page.getByRole('link', { name: 'Settings' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Settings' }).click() await page.getByLabel('SMTP host').fill('mail.example.com') await page.getByLabel('Port').fill('587') await page.getByLabel('Username').fill('bot') @@ -31,7 +31,7 @@ test('admin saves SMTP settings and a schedule', async ({ page }) => { test('admin runs a job manually and sees the log', async ({ page }) => { await loginAdmin(page) - await page.getByRole('link', { name: 'Jobs' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Jobs' }).click() await page.getByLabel('Job').selectOption('package_refresh') await page.getByRole('button', { name: 'Run now' }).click() const row = page.getByRole('row', { name: /package_refresh/ }).first() diff --git a/frontend/e2e/updates.spec.ts b/frontend/e2e/updates.spec.ts index 6022166..b1eb566 100644 --- a/frontend/e2e/updates.spec.ts +++ b/frontend/e2e/updates.spec.ts @@ -5,7 +5,7 @@ test('updates page shows OS info and packages after a refresh', async ({ page }) await page.getByLabel('Email').fill('admin@example.com') await page.getByLabel('Password').fill('admin-password-123') await page.getByRole('button', { name: 'Sign in' }).click() - await page.getByRole('link', { name: 'Updates' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Updates' }).click() await page.getByRole('button', { name: 'Refresh inventory' }).click() await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 }) diff --git a/frontend/e2e/upgrade.spec.ts b/frontend/e2e/upgrade.spec.ts index 2c8e93d..e6f2ce2 100644 --- a/frontend/e2e/upgrade.spec.ts +++ b/frontend/e2e/upgrade.spec.ts @@ -5,7 +5,7 @@ test('admin updates a selected package and sees the live log', async ({ page }) await page.getByLabel('Email').fill('admin@example.com') await page.getByLabel('Password').fill('admin-password-123') await page.getByRole('button', { name: 'Sign in' }).click() - await page.getByRole('link', { name: 'Updates' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Updates' }).click() await page.getByRole('button', { name: 'Refresh inventory' }).click() await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 }) diff --git a/frontend/e2e/vulnerabilities.spec.ts b/frontend/e2e/vulnerabilities.spec.ts index 881303b..4fc9f30 100644 --- a/frontend/e2e/vulnerabilities.spec.ts +++ b/frontend/e2e/vulnerabilities.spec.ts @@ -5,7 +5,7 @@ test('admin runs a scan, filters findings and acknowledges one', async ({ page } await page.getByLabel('Email').fill('admin@example.com') await page.getByLabel('Password').fill('admin-password-123') await page.getByRole('button', { name: 'Sign in' }).click() - await page.getByRole('link', { name: 'Vulnerabilities' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Vulnerabilities' }).click() await page.getByRole('button', { name: 'Scan now' }).click() await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 }) @@ -29,7 +29,7 @@ test('notification threshold can be changed in settings', async ({ page }) => { await page.getByLabel('Email').fill('admin@example.com') await page.getByLabel('Password').fill('admin-password-123') await page.getByRole('button', { name: 'Sign in' }).click() - await page.getByRole('link', { name: 'Settings' }).click() + await page.getByRole('navigation').getByRole('link', { name: 'Settings' }).click() await page.getByLabel('Notify from severity').selectOption('medium') await page.getByRole('button', { name: 'Save notifications' }).click() await expect(page.getByRole('status')).toContainText('Notification settings saved') diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index b3f4e29..3a0240d 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ JWT_SECRET: 'e2e-test-secret-do-not-use-in-production', MASTER_KEY: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', FAKE_HOST: 'true', + LOGIN_RATE_LIMIT: '1000', BOOTSTRAP_ADMIN_EMAIL: 'admin@example.com', BOOTSTRAP_ADMIN_PASSWORD: 'admin-password-123', BIND: '127.0.0.1:8080', diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 20847f0..28f8d3d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -231,3 +231,32 @@ export interface StrategyStatus extends BackupStrategyView { target_name: string last_backup: BackupRecord | null } + +export interface Dashboard { + inventory: { + refreshed_at: string | null + os: OsInfo | null + total: number + upgradable: number + security: number + reboot_required: boolean + } + vulnerabilities: VulnSummary + cluster: { + reachable: boolean + error: string | null + nodes_ready: number + nodes: number + workloads: number + unhealthy: number + } + backups: { + id: string + name: string + enabled: boolean + schedule: string + last_backup: BackupRecord | null + }[] + recent_jobs: Omit[] + failed_jobs_24h: number +} diff --git a/frontend/src/pages/DashboardPage.test.ts b/frontend/src/pages/DashboardPage.test.ts new file mode 100644 index 0000000..b60bc36 --- /dev/null +++ b/frontend/src/pages/DashboardPage.test.ts @@ -0,0 +1,101 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import DashboardPage from './DashboardPage.vue' +import { api } from '../api/client' +import type { Dashboard } from '../api/types' + +vi.mock('../api/client', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})) +vi.mock('vue-router', () => ({ + RouterLink: { template: '' }, + useRouter: () => ({ push: vi.fn() }), +})) + +const data: Dashboard = { + inventory: { + refreshed_at: '2026-09-02T10:00:00Z', + os: { + hostname: 'srv', + name: 'Debian GNU/Linux 12 (bookworm)', + version: '12', + kernel: '6.1', + uptime_secs: 100, + reboot_required: true, + }, + total: 430, + upgradable: 3, + security: 2, + reboot_required: true, + }, + vulnerabilities: { + total: { critical: 5, high: 10, medium: 2, low: 1, unknown: 0 }, + os: { critical: 1, high: 2, medium: 0, low: 0, unknown: 0 }, + images: { critical: 4, high: 8, medium: 2, low: 1, unknown: 0 }, + last_scan: '2026-09-02T09:00:00Z', + scanner: '0.74.0', + }, + cluster: { reachable: true, error: null, nodes_ready: 1, nodes: 1, workloads: 16, unhealthy: 1 }, + backups: [ + { id: 'b1', name: 'Gitea DB', enabled: true, schedule: '0 0 2 * * *', last_backup: null }, + { + id: 'b2', + name: 'Repos', + enabled: true, + schedule: '0 0 3 * * *', + last_backup: { + id: 'r', + strategy_id: 'b2', + filename: 'repos_x.tar.gz', + size_bytes: 1000, + sha256: 'x', + created_at: '2026-09-02T03:00:00Z', + }, + }, + ], + recent_jobs: [ + { + id: 'j1', + kind: 'vulnerability_scan', + status: 'failed', + started_at: '2026-09-02T09:00:00Z', + finished_at: '2026-09-02T09:05:00Z', + triggered_by: 'scheduler', + }, + ], + failed_jobs_24h: 1, +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(api.get).mockResolvedValue(data) +}) + +describe('DashboardPage', () => { + it('renders the key numbers of every area', async () => { + const w = mount(DashboardPage) + await flushPromises() + expect(w.text()).toContain('Debian GNU/Linux 12') + expect(w.find('[data-testid=tile-upgradable]').text()).toContain('3') + expect(w.text()).toContain('Reboot required') + expect(w.find('[data-testid=tile-critical]').text()).toContain('5') + expect(w.find('[data-testid=tile-workloads]').text()).toContain('16') + expect(w.text()).toContain('1 unhealthy') + expect(w.text()).toContain('Gitea DB') + expect(w.text()).toContain('never') + expect(w.text()).toContain('repos_x.tar.gz') + expect(w.find('[data-testid=tile-failed-jobs]').text()).toContain('1') + }) + + it('shows the cluster as unreachable with the error', async () => { + vi.mocked(api.get).mockResolvedValue({ + ...data, + cluster: { ...data.cluster, reachable: false, error: 'connection refused' }, + }) + const w = mount(DashboardPage) + await flushPromises() + expect(w.text()).toContain('unreachable') + expect(w.text()).toContain('connection refused') + }) +}) diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index 447b17d..59cdcf3 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -1,6 +1,156 @@ + +