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:
@ -9,6 +9,8 @@ BIND=127.0.0.1:8080
|
|||||||
# Created on first start if no user exists
|
# Created on first start if no user exists
|
||||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=change-me-min-12-chars
|
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
|
# Set to true behind HTTPS so the refresh cookie is marked Secure
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
# Directory with the built frontend (served as SPA fallback)
|
# Directory with the built frontend (served as SPA fallback)
|
||||||
|
|||||||
@ -15,6 +15,9 @@ jobs:
|
|||||||
with: { workspaces: backend }
|
with: { workspaces: backend }
|
||||||
- run: cd backend && cargo fmt --check && cargo clippy --workspace --all-targets -- -D warnings
|
- run: cd backend && cargo fmt --check && cargo clippy --workspace --all-targets -- -D warnings
|
||||||
- run: cd backend && cargo test --workspace
|
- run: cd backend && cargo test --workspace
|
||||||
|
- uses: rustsec/audit-check@v2
|
||||||
|
with: { token: ${{ secrets.GITHUB_TOKEN }} }
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -24,6 +27,8 @@ jobs:
|
|||||||
with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json }
|
with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json }
|
||||||
- run: cd frontend && npm ci
|
- run: cd frontend && npm ci
|
||||||
- run: cd frontend && npm run lint && npm run typecheck && npm test
|
- run: cd frontend && npm run lint && npm run typecheck && npm test
|
||||||
|
- run: cd frontend && npm audit --audit-level=high
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@ -39,6 +39,11 @@ Every feature starts with its tests:
|
|||||||
3. Implement the smallest code that makes them pass, then refactor.
|
3. Implement the smallest code that makes them pass, then refactor.
|
||||||
4. Commit and push.
|
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
|
## First admin
|
||||||
|
|
||||||
On start the backend creates an admin user from `BOOTSTRAP_ADMIN_EMAIL` /
|
On start the backend creates an admin user from `BOOTSTRAP_ADMIN_EMAIL` /
|
||||||
|
|||||||
12
ROADMAP.md
12
ROADMAP.md
@ -1,6 +1,6 @@
|
|||||||
# SoftVisor Infrastructure Monitoring System – Roadmap
|
# 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),
|
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
|
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)
|
### Milestone 4 – Backup management (delivered 2026-09-02)
|
||||||
WP-30, WP-31, WP-32. Restore procedure in `docs/restore.md`.
|
WP-30, WP-31, WP-32. Restore procedure in `docs/restore.md`.
|
||||||
|
|
||||||
### Milestone 5 – Hardening and release (planned)
|
### Milestone 5 – Hardening and release (delivered 2026-09-02)
|
||||||
WP-40, WP-41, WP-42.
|
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-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-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-32 | M4 | done | 2026-09-02; upload verify, retention, records; docs/restore.md; failure mail not yet |
|
||||||
| WP-40 | M5 | todo | |
|
| WP-40 | M5 | done | 2026-09-02; dashboard with tiles for all areas; dark mode not done |
|
||||||
| WP-41 | M5 | todo | |
|
| 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 | todo | |
|
| WP-42 | M5 | done | 2026-09-02; install.sh, systemd unit, docs/install.md, docs/architecture.md |
|
||||||
|
|||||||
@ -20,6 +20,8 @@ pub struct Config {
|
|||||||
pub bind: SocketAddr,
|
pub bind: SocketAddr,
|
||||||
pub bootstrap_admin: Option<(String, String)>,
|
pub bootstrap_admin: Option<(String, String)>,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
|
/// Login attempts per IP and minute.
|
||||||
|
pub login_rate_limit: u32,
|
||||||
pub frontend_dir: String,
|
pub frontend_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,6 +71,9 @@ impl Config {
|
|||||||
.parse()?,
|
.parse()?,
|
||||||
bootstrap_admin: env("BOOTSTRAP_ADMIN_EMAIL").zip(env("BOOTSTRAP_ADMIN_PASSWORD")),
|
bootstrap_admin: env("BOOTSTRAP_ADMIN_EMAIL").zip(env("BOOTSTRAP_ADMIN_PASSWORD")),
|
||||||
cookie_secure: env("COOKIE_SECURE").is_some_and(|v| v == "true" || v == "1"),
|
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()),
|
frontend_dir: env("FRONTEND_DIR").unwrap_or_else(|| "../frontend/dist".into()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
172
backend/crates/api/src/dashboard.rs
Normal file
172
backend/crates/api/src/dashboard.rs
Normal file
@ -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<AppState> {
|
||||||
|
Router::new().route("/", get(dashboard))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct InventoryTile {
|
||||||
|
pub refreshed_at: Option<DateTime<Utc>>,
|
||||||
|
#[schema(value_type = Option<Object>)]
|
||||||
|
pub os: Option<OsInfo>,
|
||||||
|
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<String>,
|
||||||
|
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<Object>)]
|
||||||
|
pub last_backup: Option<BackupRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
pub finished_at: Option<DateTime<Utc>>,
|
||||||
|
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<BackupTile>,
|
||||||
|
pub recent_jobs: Vec<JobTile>,
|
||||||
|
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<AppState>,
|
||||||
|
_: AuthUser,
|
||||||
|
) -> Result<Json<DashboardResponse>, 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -3,11 +3,13 @@ pub mod auth;
|
|||||||
pub mod backups;
|
pub mod backups;
|
||||||
pub mod cluster;
|
pub mod cluster;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod dashboard;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
pub mod security;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod test_support;
|
pub mod test_support;
|
||||||
@ -164,6 +166,7 @@ impl AppState {
|
|||||||
inventory: inventory.clone(),
|
inventory: inventory.clone(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
let login_rate_limit = cfg.login_rate_limit;
|
||||||
let cluster = Arc::new(ClusterService::new(cluster.clone()));
|
let cluster = Arc::new(ClusterService::new(cluster.clone()));
|
||||||
let vulns = Arc::new(VulnerabilityService::new(
|
let vulns = Arc::new(VulnerabilityService::new(
|
||||||
scanner,
|
scanner,
|
||||||
@ -190,7 +193,7 @@ impl AppState {
|
|||||||
vulns,
|
vulns,
|
||||||
backups,
|
backups,
|
||||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||||
10,
|
login_rate_limit,
|
||||||
std::time::Duration::from_secs(60),
|
std::time::Duration::from_secs(60),
|
||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
@ -233,7 +236,10 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.nest("/api/cluster", cluster::router())
|
.nest("/api/cluster", cluster::router())
|
||||||
.nest("/api/vulnerabilities", vulnerabilities::router())
|
.nest("/api/vulnerabilities", vulnerabilities::router())
|
||||||
.nest("/api/backups", backups::router())
|
.nest("/api/backups", backups::router())
|
||||||
|
.nest("/api/dashboard", dashboard::router())
|
||||||
.fallback_service(spa)
|
.fallback_service(spa)
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024))
|
||||||
|
.layer(axum::middleware::from_fn(security::headers))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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::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::create_strategy, crate::backups::update_strategy, crate::backups::delete_strategy,
|
||||||
crate::backups::run_strategy, crate::backups::records,
|
crate::backups::run_strategy, crate::backups::records,
|
||||||
|
crate::dashboard::dashboard,
|
||||||
),
|
),
|
||||||
modifiers(&BearerAuth)
|
modifiers(&BearerAuth)
|
||||||
)]
|
)]
|
||||||
|
|||||||
34
backend/crates/api/src/security.rs
Normal file
34
backend/crates/api/src/security.rs
Normal file
@ -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<axum::body::Body>, 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
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ pub fn test_config() -> Config {
|
|||||||
bind: "127.0.0.1:0".parse().unwrap(),
|
bind: "127.0.0.1:0".parse().unwrap(),
|
||||||
bootstrap_admin: None,
|
bootstrap_admin: None,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
|
login_rate_limit: 10,
|
||||||
frontend_dir: "/nonexistent".into(),
|
frontend_dir: "/nonexistent".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
backend/crates/api/tests/dashboard.rs
Normal file
77
backend/crates/api/tests/dashboard.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
72
backend/crates/api/tests/security.rs
Normal file
72
backend/crates/api/tests/security.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -372,8 +372,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn image_scan_prefers_local_containerd_when_configured() {
|
async fn image_scan_prefers_local_containerd_when_configured() {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
type Call = (Vec<String>, Vec<(String, String)>);
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Env(Mutex<Vec<(Vec<String>, Vec<(String, String)>)>>);
|
struct Env(Mutex<Vec<Call>>);
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl CommandRunner for Env {
|
impl CommandRunner for Env {
|
||||||
async fn run(&self, p: &str, a: &[&str]) -> Result<crate::host::Output, DomainError> {
|
async fn run(&self, p: &str, a: &[&str]) -> Result<crate::host::Output, DomainError> {
|
||||||
|
|||||||
52
deploy/install.sh
Executable file
52
deploy/install.sh
Executable file
@ -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" <<ENV
|
||||||
|
DATABASE_URL=sqlite://$DIR/data/monitoring.db?mode=rwc
|
||||||
|
JWT_SECRET=$(openssl rand -hex 32)
|
||||||
|
MASTER_KEY=$(openssl rand -hex 32)
|
||||||
|
BIND=0.0.0.0:$PORT
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL=admin@softvisor.de
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=$ADMIN_PW
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
FRONTEND_DIR=$DIR/dist
|
||||||
|
WORK_DIR=$DIR/data/work
|
||||||
|
RUST_LOG=info,sqlx=warn
|
||||||
|
ENV
|
||||||
|
echo "Bootstrap admin: admin@softvisor.de / $ADMIN_PW (change it after the first login)"
|
||||||
|
fi
|
||||||
|
grep -q '^MASTER_KEY=' "$DIR/.env" || echo "MASTER_KEY=$(openssl rand -hex 32)" >> "$DIR/.env"
|
||||||
|
|
||||||
|
echo "== service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable -q monitoring.service
|
||||||
|
systemctl restart monitoring.service
|
||||||
|
sleep 2
|
||||||
|
systemctl is-active monitoring.service
|
||||||
|
curl -s "http://127.0.0.1:$PORT/healthz" && echo
|
||||||
19
deploy/monitoring.service
Normal file
19
deploy/monitoring.service
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SoftVisor Infrastructure Monitoring
|
||||||
|
After=network-online.target snap.microk8s.daemon-kubelite.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/opt/monitoring
|
||||||
|
EnvironmentFile=/opt/monitoring/.env
|
||||||
|
ExecStart=/opt/monitoring/monitoring-server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# The service manages apt and the cluster, so it runs as root; keep the rest tight.
|
||||||
|
NoNewPrivileges=false
|
||||||
|
ProtectSystem=false
|
||||||
|
PrivateTmp=true
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
27
docs/architecture.md
Normal file
27
docs/architecture.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Single Rust binary (axum) serving the API and the built Vue SPA; SQLite for state; host tools
|
||||||
|
(`apt-get`, `dpkg-query`, `snap`, `microk8s kubectl`, `trivy`, `smbclient`, `curl`, `tar`,
|
||||||
|
`openssl`) are invoked as subprocesses behind ports so every use case is testable with fakes.
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/crates/
|
||||||
|
domain/ entities, validation, ports (traits) no I/O
|
||||||
|
application/ use cases: auth, users, settings, jobs, depends on domain
|
||||||
|
scheduler, inventory, upgrade, cluster,
|
||||||
|
vulnerabilities, backups
|
||||||
|
infrastructure/ SQLite repos, Argon2/JWT/AES-GCM, lettre, implements the ports
|
||||||
|
Debian inspector/updater, kube-rs gateway,
|
||||||
|
Trivy, smbclient/curl storage, collectors
|
||||||
|
api/ axum routes, auth extractors, OpenAPI, wires everything
|
||||||
|
security headers, config, main
|
||||||
|
frontend/ Vue 3 + TypeScript + Tailwind, Pinia stores, Playwright e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-cutting: a `JobRunner` executes long-running work (refresh, upgrade, scan, backup) as
|
||||||
|
persisted job runs with live logs; a `Scheduler` ticks every 30 s and starts due jobs from cron
|
||||||
|
expressions (settings) and backup strategies. Secrets at rest are AES-256-GCM encrypted with
|
||||||
|
`MASTER_KEY`. Authentication: Argon2id passwords, 15-minute JWT access tokens, rotating refresh
|
||||||
|
tokens in an HttpOnly, SameSite=Strict cookie scoped to `/api/auth`, reuse detection revokes the
|
||||||
|
token family. `FAKE_HOST=true` swaps all host/cluster/scanner/storage adapters for fakes so the
|
||||||
|
app runs on a developer machine and in the UI tests.
|
||||||
56
docs/install.md
Normal file
56
docs/install.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Installation and operation
|
||||||
|
|
||||||
|
## Build a release
|
||||||
|
|
||||||
|
On a machine with Docker (cross-compiles a static x86_64 binary) and Node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm ci && npm run build && cd ..
|
||||||
|
docker run --rm -v "$PWD/backend":/home/rust/src -v cargo-registry-musl:/root/.cargo/registry \
|
||||||
|
messense/rust-musl-cross:x86_64-musl cargo build --release -p api
|
||||||
|
mkdir -p release && cp backend/target/x86_64-unknown-linux-musl/release/monitoring-server deploy/install.sh deploy/monitoring.service release/
|
||||||
|
cp -r frontend/dist release/dist
|
||||||
|
tar -C release -czf monitoring-release.tar.gz .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install or update on the Debian host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp monitoring-release.tar.gz root@server:/tmp/
|
||||||
|
ssh root@server 'mkdir -p /tmp/rel && tar -C /tmp/rel -xzf /tmp/monitoring-release.tar.gz && cd /tmp/rel && ./install.sh --port 8080'
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer installs `smbclient`, `curl`, `openssl` and Trivy, copies the files to
|
||||||
|
`/opt/monitoring`, creates `/opt/monitoring/.env` with random secrets and a bootstrap admin on
|
||||||
|
the first run, and (re)starts the `monitoring.service` systemd unit. Re-running it keeps the
|
||||||
|
existing `.env` and database.
|
||||||
|
|
||||||
|
For the quick test deployment used during development see `deploy/deploy-test.sh`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings live in `/opt/monitoring/.env` (see `.env.example`). Relevant keys:
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `JWT_SECRET` | signs access tokens; changing it logs everyone out |
|
||||||
|
| `MASTER_KEY` | encrypts stored SMTP/SMB/FTP passwords and backup passphrases. **Back it up**: without it stored secrets cannot be read |
|
||||||
|
| `BIND` | listen address, e.g. `0.0.0.0:8080` |
|
||||||
|
| `COOKIE_SECURE` | set `true` behind HTTPS |
|
||||||
|
| `KUBECONFIG` / `KUBECTL` | defaults to the microk8s client config and `microk8s kubectl` |
|
||||||
|
| `CONTAINERD_ADDRESS` | image scans read local images from this socket first |
|
||||||
|
| `LOGIN_RATE_LIMIT` | login attempts per IP and minute |
|
||||||
|
|
||||||
|
Put the app behind a TLS-terminating reverse proxy (e.g. the cluster's ingress or nginx on the host)
|
||||||
|
for production use and set `COOKIE_SECURE=true`.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- Logs: `journalctl -u monitoring.service -f`
|
||||||
|
- Database: SQLite at `/opt/monitoring/data/monitoring.db`. Back it up together with `.env` by
|
||||||
|
creating a backup strategy of type "Directory on the host" for `/opt/monitoring` (the `.env`
|
||||||
|
contains the `MASTER_KEY`, so encrypt that strategy or store it on a trusted target).
|
||||||
|
- Scheduled jobs: package refresh hourly, vulnerability scan daily at 03:00, backups per strategy;
|
||||||
|
all adjustable in Settings. Failed or interrupted runs appear on the Jobs page and on the dashboard.
|
||||||
|
- Restore procedures: `docs/restore.md`.
|
||||||
|
- The service runs as root because it drives `apt-get`, `microk8s kubectl` and reads volume data.
|
||||||
@ -25,7 +25,7 @@ test('admin logs in, manages users, logs out; user has no admin access', async (
|
|||||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
||||||
|
|
||||||
// create a user
|
// create a user
|
||||||
await page.getByRole('link', { name: 'Users' }).click()
|
await page.getByRole('navigation').getByRole('link', { name: 'Users' }).click()
|
||||||
await page.getByRole('button', { name: 'New user' }).click()
|
await page.getByRole('button', { name: 'New user' }).click()
|
||||||
const email = `e2e-${Date.now()}@example.com`
|
const email = `e2e-${Date.now()}@example.com`
|
||||||
await page.getByLabel('Email').fill(email)
|
await page.getByLabel('Email').fill(email)
|
||||||
@ -48,7 +48,7 @@ test('admin logs in, manages users, logs out; user has no admin access', async (
|
|||||||
// the new user can log in but not manage users
|
// the new user can log in but not manage users
|
||||||
await login(page, email, 'user-password-123')
|
await login(page, email, 'user-password-123')
|
||||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: 'Users' })).toHaveCount(0)
|
await expect(page.getByRole('navigation').getByRole('link', { name: 'Users' })).toHaveCount(0)
|
||||||
await page.goto('/users')
|
await page.goto('/users')
|
||||||
await expect(page.getByText('You do not have permission')).toBeVisible()
|
await expect(page.getByText('You do not have permission')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,7 +5,7 @@ test('admin creates a target and a strategy, runs it and sees the backup', async
|
|||||||
await page.getByLabel('Email').fill('admin@example.com')
|
await page.getByLabel('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
await page.getByRole('link', { name: 'Backups' }).click()
|
await page.getByRole('navigation').getByRole('link', { name: 'Backups' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'New target' }).click()
|
await page.getByRole('button', { name: 'New target' }).click()
|
||||||
const name = `NAS ${Date.now()}`
|
const name = `NAS ${Date.now()}`
|
||||||
|
|||||||
@ -7,7 +7,7 @@ test('cluster page shows node, workloads and lets an admin restart a workload',
|
|||||||
await page.getByLabel('Email').fill('admin@example.com')
|
await page.getByLabel('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
await page.getByRole('link', { name: 'Kubernetes' }).click()
|
await page.getByRole('navigation').getByRole('link', { name: 'Kubernetes' }).click()
|
||||||
|
|
||||||
await expect(page.getByTestId('node-version')).toContainText('v1.32')
|
await expect(page.getByTestId('node-version')).toContainText('v1.32')
|
||||||
const row = page.getByRole('row', { name: /^gitea gitea-postgresql statefulset/ })
|
const row = page.getByRole('row', { name: /^gitea gitea-postgresql statefulset/ })
|
||||||
|
|||||||
14
frontend/e2e/dashboard.spec.ts
Normal file
14
frontend/e2e/dashboard.spec.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('dashboard shows the overview tiles after login', async ({ page }) => {
|
||||||
|
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/)
|
||||||
|
})
|
||||||
@ -10,7 +10,7 @@ async function loginAdmin(page: Page) {
|
|||||||
|
|
||||||
test('admin saves SMTP settings and a schedule', async ({ page }) => {
|
test('admin saves SMTP settings and a schedule', async ({ page }) => {
|
||||||
await loginAdmin(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('SMTP host').fill('mail.example.com')
|
||||||
await page.getByLabel('Port').fill('587')
|
await page.getByLabel('Port').fill('587')
|
||||||
await page.getByLabel('Username').fill('bot')
|
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 }) => {
|
test('admin runs a job manually and sees the log', async ({ page }) => {
|
||||||
await loginAdmin(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.getByLabel('Job').selectOption('package_refresh')
|
||||||
await page.getByRole('button', { name: 'Run now' }).click()
|
await page.getByRole('button', { name: 'Run now' }).click()
|
||||||
const row = page.getByRole('row', { name: /package_refresh/ }).first()
|
const row = page.getByRole('row', { name: /package_refresh/ }).first()
|
||||||
|
|||||||
@ -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('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
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 page.getByRole('button', { name: 'Refresh inventory' }).click()
|
||||||
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
||||||
|
|||||||
@ -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('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
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 page.getByRole('button', { name: 'Refresh inventory' }).click()
|
||||||
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
||||||
|
|
||||||
|
|||||||
@ -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('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
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 page.getByRole('button', { name: 'Scan now' }).click()
|
||||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
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('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
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.getByLabel('Notify from severity').selectOption('medium')
|
||||||
await page.getByRole('button', { name: 'Save notifications' }).click()
|
await page.getByRole('button', { name: 'Save notifications' }).click()
|
||||||
await expect(page.getByRole('status')).toContainText('Notification settings saved')
|
await expect(page.getByRole('status')).toContainText('Notification settings saved')
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export default defineConfig({
|
|||||||
JWT_SECRET: 'e2e-test-secret-do-not-use-in-production',
|
JWT_SECRET: 'e2e-test-secret-do-not-use-in-production',
|
||||||
MASTER_KEY: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
|
MASTER_KEY: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
|
||||||
FAKE_HOST: 'true',
|
FAKE_HOST: 'true',
|
||||||
|
LOGIN_RATE_LIMIT: '1000',
|
||||||
BOOTSTRAP_ADMIN_EMAIL: 'admin@example.com',
|
BOOTSTRAP_ADMIN_EMAIL: 'admin@example.com',
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: 'admin-password-123',
|
BOOTSTRAP_ADMIN_PASSWORD: 'admin-password-123',
|
||||||
BIND: '127.0.0.1:8080',
|
BIND: '127.0.0.1:8080',
|
||||||
|
|||||||
@ -231,3 +231,32 @@ export interface StrategyStatus extends BackupStrategyView {
|
|||||||
target_name: string
|
target_name: string
|
||||||
last_backup: BackupRecord | null
|
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<JobRun, 'log'>[]
|
||||||
|
failed_jobs_24h: number
|
||||||
|
}
|
||||||
|
|||||||
101
frontend/src/pages/DashboardPage.test.ts
Normal file
101
frontend/src/pages/DashboardPage.test.ts
Normal file
@ -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: '<a><slot /></a>' },
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,6 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api, ApiError } from '../api/client'
|
||||||
|
import type { Dashboard } from '../api/types'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const d = ref<Dashboard | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
d.value = await api.get<Dashboard>('/api/dashboard')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fmt = (s: string | null | undefined) => (s ? new Date(s).toLocaleString() : 'never')
|
||||||
|
const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-blue-300'
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
<h1 class="text-2xl font-semibold">Dashboard</h1>
|
||||||
<p class="mt-2 text-gray-600">
|
<p v-if="!d" class="mt-2 text-gray-600">Loading overview…</p>
|
||||||
Server overview, update status, vulnerabilities and backups will appear here.
|
|
||||||
</p>
|
<template v-if="d">
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-4">
|
||||||
|
<RouterLink to="/updates" :class="tile" class="md:col-span-2">
|
||||||
|
<div class="text-xs uppercase text-gray-500">Operating system</div>
|
||||||
|
<div class="mt-1 text-lg font-medium">
|
||||||
|
{{ d.inventory.os?.name ?? 'not inventoried yet' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<template v-if="d.inventory.os"
|
||||||
|
>{{ d.inventory.os.hostname }} · kernel {{ d.inventory.os.kernel }} ·
|
||||||
|
{{ d.inventory.total }} packages</template
|
||||||
|
>
|
||||||
|
<template v-else>Run a package refresh on the Updates page.</template>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="d.inventory.reboot_required"
|
||||||
|
class="mt-2 inline-block rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||||
|
>Reboot required</span
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/updates" :class="tile" data-testid="tile-upgradable">
|
||||||
|
<div class="text-xs uppercase text-gray-500">Upgradable packages</div>
|
||||||
|
<div
|
||||||
|
class="mt-1 text-2xl font-semibold"
|
||||||
|
:class="d.inventory.security ? 'text-red-700' : ''"
|
||||||
|
>
|
||||||
|
{{ d.inventory.upgradable }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ d.inventory.security }} security · refreshed {{ fmt(d.inventory.refreshed_at) }}
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/vulnerabilities" :class="tile" data-testid="tile-critical">
|
||||||
|
<div class="text-xs uppercase text-gray-500">Vulnerabilities</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-red-700">
|
||||||
|
{{ d.vulnerabilities.total.critical }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
critical · {{ d.vulnerabilities.total.high }} high · scan
|
||||||
|
{{ fmt(d.vulnerabilities.last_scan) }}
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/cluster" :class="tile" data-testid="tile-workloads">
|
||||||
|
<div class="text-xs uppercase text-gray-500">Kubernetes</div>
|
||||||
|
<template v-if="d.cluster.reachable">
|
||||||
|
<div class="mt-1 text-2xl font-semibold">{{ d.cluster.workloads }}</div>
|
||||||
|
<div class="text-sm" :class="d.cluster.unhealthy ? 'text-red-700' : 'text-gray-600'">
|
||||||
|
workloads · {{ d.cluster.unhealthy }} unhealthy · {{ d.cluster.nodes_ready }}/{{
|
||||||
|
d.cluster.nodes
|
||||||
|
}}
|
||||||
|
nodes ready
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="mt-1 text-lg font-medium text-red-700">unreachable</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ d.cluster.error }}</div>
|
||||||
|
</template>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/jobs" :class="tile" data-testid="tile-failed-jobs">
|
||||||
|
<div class="text-xs uppercase text-gray-500">Failed jobs (24h)</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold" :class="d.failed_jobs_24h ? 'text-red-700' : ''">
|
||||||
|
{{ d.failed_jobs_24h }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ d.recent_jobs.length }} recent runs</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-medium">Backups</h2>
|
||||||
|
<RouterLink to="/backups" class="text-sm text-blue-600 hover:underline"
|
||||||
|
>Manage</RouterLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<table class="mt-3 w-full text-left text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="b in d.backups"
|
||||||
|
:key="b.id"
|
||||||
|
class="border-t border-gray-100"
|
||||||
|
:class="b.enabled ? '' : 'text-gray-400'"
|
||||||
|
>
|
||||||
|
<td class="py-1.5 font-medium">{{ b.name }}</td>
|
||||||
|
<td class="font-mono text-xs">{{ b.schedule }}</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<template v-if="b.last_backup"
|
||||||
|
>{{ b.last_backup.filename }}<br /><span class="text-gray-500">{{
|
||||||
|
fmt(b.last_backup.created_at)
|
||||||
|
}}</span></template
|
||||||
|
>
|
||||||
|
<template v-else>never</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="d.backups.length === 0">
|
||||||
|
<td class="py-3 text-gray-500">No backup strategies configured.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-medium">Recent jobs</h2>
|
||||||
|
<RouterLink to="/jobs" class="text-sm text-blue-600 hover:underline">All jobs</RouterLink>
|
||||||
|
</div>
|
||||||
|
<table class="mt-3 w-full text-left text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="j in d.recent_jobs" :key="j.id" class="border-t border-gray-100">
|
||||||
|
<td class="py-1.5 font-mono text-xs">{{ j.kind }}</td>
|
||||||
|
<td
|
||||||
|
:class="
|
||||||
|
j.status === 'failed'
|
||||||
|
? 'font-medium text-red-700'
|
||||||
|
: j.status === 'running'
|
||||||
|
? 'text-blue-600'
|
||||||
|
: 'text-green-700'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ j.status }}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs text-gray-500">{{ fmt(j.started_at) }} · {{ j.triggered_by }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="d.recent_jobs.length === 0">
|
||||||
|
<td class="py-3 text-gray-500">No jobs have run yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user