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

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

View 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,
}))
}

View File

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

View File

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

View 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
}

View File

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