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:
@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)]
|
||||
|
||||
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(),
|
||||
bootstrap_admin: None,
|
||||
cookie_secure: false,
|
||||
login_rate_limit: 10,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user