@@ -298,6 +298,8 @@ pub struct AppState {
298298 pub tls_enabled: bool,
299299 /// Login rate limiter (brute-force protection)
300300 pub login_limiter: Arc<crate::auth::LoginRateLimiter>,
301+ /// Brute-force protection — extends the ban engine to container ssh/web logs.
302+ pub bruteforce: Arc<crate::bruteforce::BruteforceState>,
301303 pub scan_detector: Arc<crate::scan_detector::ScanDetector>,
302304 pub diag_control: Arc<crate::diag::Control>,
303305 /// WireGuard bridge configs (cluster → bridge)
@@ -1078,6 +1080,112 @@ pub async fn security_auth_config_set(
10781080 }
10791081}
10801082
1083+ // ---- Brute-force protection (per-node) --------------------------------------
1084+ // These are the node-local endpoints. The fleet_* wrappers below fan them out
1085+ // across the cluster; a peer that receives a propagated call lands here too.
1086+
1087+ /// GET /api/bruteforce/status — this node's watched sources + active bans +
1088+ /// per-source hit counts.
1089+ pub async fn bruteforce_status_get(
1090+ req: HttpRequest,
1091+ state: web::Data<AppState>,
1092+ ) -> HttpResponse {
1093+ if let Err(e) = require_auth(&req, &state) { return e; }
1094+ HttpResponse::Ok().json(state.bruteforce.status_json())
1095+ }
1096+
1097+ /// POST /api/bruteforce/config — set this node's config and (re)apply the
1098+ /// monitors. The fleet wrapper propagates the identical body to peers, so no
1099+ /// self-propagation is done here.
1100+ pub async fn bruteforce_config_set(
1101+ req: HttpRequest,
1102+ state: web::Data<AppState>,
1103+ body: web::Json<crate::bruteforce::BruteforceConfig>,
1104+ ) -> HttpResponse {
1105+ if let Err(e) = require_auth(&req, &state) { return e; }
1106+ match state.bruteforce.set_config_and_apply(body.into_inner()) {
1107+ Ok(saved) => HttpResponse::Ok().json(serde_json::json!({
1108+ "ok": true,
1109+ "config": saved,
1110+ "watched_sources": state.bruteforce.sources.read().unwrap().clone(),
1111+ })),
1112+ Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": e })),
1113+ }
1114+ }
1115+
1116+ /// POST /api/bruteforce/scan — re-run discovery and reattach on this node,
1117+ /// returning the sources now watched (drives the live scan feedback).
1118+ pub async fn bruteforce_scan(
1119+ req: HttpRequest,
1120+ state: web::Data<AppState>,
1121+ ) -> HttpResponse {
1122+ if let Err(e) = require_auth(&req, &state) { return e; }
1123+ let sources = state.bruteforce.rescan();
1124+ HttpResponse::Ok().json(serde_json::json!({
1125+ "ok": true,
1126+ "watched_sources": sources,
1127+ "active_bans": state.bruteforce.status_json().get("active_bans").cloned().unwrap_or(serde_json::Value::Array(vec![])),
1128+ }))
1129+ }
1130+
1131+ // ---- Brute-force protection (fleet) -----------------------------------------
1132+
1133+ /// GET /api/fleet/bruteforce/status — aggregated status across every WolfStack
1134+ /// node in the cluster.
1135+ pub async fn fleet_bruteforce_status(
1136+ req: HttpRequest,
1137+ state: web::Data<AppState>,
1138+ ) -> HttpResponse {
1139+ if let Err(e) = require_auth(&req, &state) { return e; }
1140+ let self_id = state.cluster.self_id.clone();
1141+ let state_for_local = state.clone();
1142+ let local = move || state_for_local.bruteforce.status_json();
1143+ let results = fleet_fanout_get::<serde_json::Value, _>(
1144+ &state, "/api/bruteforce/status", local,
1145+ ).await;
1146+ HttpResponse::Ok().json(serde_json::json!({ "nodes": results, "self_id": self_id }))
1147+ }
1148+
1149+ /// POST /api/fleet/bruteforce/config — push one config to every node and apply.
1150+ pub async fn fleet_bruteforce_config(
1151+ req: HttpRequest,
1152+ state: web::Data<AppState>,
1153+ body: web::Json<crate::bruteforce::BruteforceConfig>,
1154+ ) -> HttpResponse {
1155+ if let Err(e) = require_auth(&req, &state) { return e; }
1156+ let cfg = body.into_inner();
1157+ let payload = serde_json::to_value(&cfg).unwrap_or(serde_json::json!({}));
1158+ let state_for_local = state.clone();
1159+ let cfg_for_local = cfg.clone();
1160+ let local = move || match state_for_local.bruteforce.set_config_and_apply(cfg_for_local) {
1161+ Ok(saved) => serde_json::json!({ "ok": true, "config": saved }),
1162+ Err(e) => serde_json::json!({ "ok": false, "error": e }),
1163+ };
1164+ let results = fleet_fanout_post::<serde_json::Value, _>(
1165+ &state, "/api/bruteforce/config", payload, local,
1166+ ).await;
1167+ HttpResponse::Ok().json(serde_json::json!({ "nodes": results }))
1168+ }
1169+
1170+ /// POST /api/fleet/bruteforce/scan — rescan + reattach on every node, returning
1171+ /// each node's discovered sources for live feedback.
1172+ pub async fn fleet_bruteforce_scan(
1173+ req: HttpRequest,
1174+ state: web::Data<AppState>,
1175+ ) -> HttpResponse {
1176+ if let Err(e) = require_auth(&req, &state) { return e; }
1177+ let self_id = state.cluster.self_id.clone();
1178+ let state_for_local = state.clone();
1179+ let local = move || {
1180+ let sources = state_for_local.bruteforce.rescan();
1181+ serde_json::json!({ "ok": true, "watched_sources": sources })
1182+ };
1183+ let results = fleet_fanout_post::<serde_json::Value, _>(
1184+ &state, "/api/bruteforce/scan", serde_json::json!({}), local,
1185+ ).await;
1186+ HttpResponse::Ok().json(serde_json::json!({ "nodes": results, "self_id": self_id }))
1187+ }
1188+
10811189#[derive(Deserialize)]
10821190pub struct GandalfSetRequest {
10831191 pub enabled: bool,
@@ -17638,9 +17746,24 @@ pub async fn backup_stream(
1763817746
1763917747 // Stream SSE events from the tokio channel
1764017748 let stream = async_stream::stream! {
17641- while let Some(msg) = rx.recv().await {
17642- let event = format!("data: {}\n\n", msg.replace('\n', "\ndata: "));
17643- yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(event));
17749+ loop {
17750+ // Heartbeat: a restore can be silent for minutes (a large
17751+ // `docker load`, a PBS pull), and an idle connection with no bytes
17752+ // gets reaped — by the browser or, more often, a reverse proxy's
17753+ // read timeout — which surfaces in the UI as a bare fetch failure
17754+ // ("Load failed" / "Failed to fetch") with no server error. Emitting
17755+ // an SSE comment every 15s keeps the socket warm. Comment lines
17756+ // (`:`-prefixed) are ignored by the client's `data:` parser.
17757+ match tokio::time::timeout(std::time::Duration::from_secs(15), rx.recv()).await {
17758+ Ok(Some(msg)) => {
17759+ let event = format!("data: {}\n\n", msg.replace('\n', "\ndata: "));
17760+ yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(event));
17761+ }
17762+ Ok(None) => break, // channel closed — restore thread finished
17763+ Err(_) => {
17764+ yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from_static(b": keepalive\n\n"));
17765+ }
17766+ }
1764417767 }
1764517768 };
1764617769
@@ -17708,9 +17831,24 @@ pub async fn backup_restore_stream(
1770817831 });
1770917832
1771017833 let stream = async_stream::stream! {
17711- while let Some(msg) = rx.recv().await {
17712- let event = format!("data: {}\n\n", msg.replace('\n', "\ndata: "));
17713- yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(event));
17834+ loop {
17835+ // Heartbeat: a restore can be silent for minutes (a large
17836+ // `docker load`, a PBS pull), and an idle connection with no bytes
17837+ // gets reaped — by the browser or, more often, a reverse proxy's
17838+ // read timeout — which surfaces in the UI as a bare fetch failure
17839+ // ("Load failed" / "Failed to fetch") with no server error. Emitting
17840+ // an SSE comment every 15s keeps the socket warm. Comment lines
17841+ // (`:`-prefixed) are ignored by the client's `data:` parser.
17842+ match tokio::time::timeout(std::time::Duration::from_secs(15), rx.recv()).await {
17843+ Ok(Some(msg)) => {
17844+ let event = format!("data: {}\n\n", msg.replace('\n', "\ndata: "));
17845+ yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(event));
17846+ }
17847+ Ok(None) => break, // channel closed — restore thread finished
17848+ Err(_) => {
17849+ yield Ok::<_, actix_web::Error>(actix_web::web::Bytes::from_static(b": keepalive\n\n"));
17850+ }
17851+ }
1771417852 }
1771517853 };
1771617854
@@ -40821,6 +40959,13 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
4082140959 .route("/api/security/abuse-report/history", web::get().to(abuse_report_history))
4082240960 .route("/api/security/listening-ports-local", web::get().to(security_listening_ports_local))
4082340961 .route("/api/security/sessions-destroy-all", web::post().to(security_sessions_destroy_all))
40962+ // Brute-force protection (extends the ban engine to container ssh/web)
40963+ .route("/api/bruteforce/status", web::get().to(bruteforce_status_get))
40964+ .route("/api/bruteforce/config", web::post().to(bruteforce_config_set))
40965+ .route("/api/bruteforce/scan", web::post().to(bruteforce_scan))
40966+ .route("/api/fleet/bruteforce/status", web::get().to(fleet_bruteforce_status))
40967+ .route("/api/fleet/bruteforce/config", web::post().to(fleet_bruteforce_config))
40968+ .route("/api/fleet/bruteforce/scan", web::post().to(fleet_bruteforce_scan))
4082440969 // Fleet-wide aggregation and operations
4082540970 .route("/api/fleet/security/lockouts", web::get().to(fleet_security_lockouts))
4082640971 .route("/api/fleet/security/listening-ports", web::get().to(fleet_security_listening_ports))
0 commit comments