Skip to content

Commit f69b4ef

Browse files
Paul Cwolfsoftwaresystemsltd
andcommitted
Merge beta: v25.3.0 brute-force protection + UniFi Integration API + PBS docker restore fixes
Co-Authored-By: CodeWolf <paul@wolf.uk.com> Co-Authored-By: Wolf Software Systems Ltd <paul@wolf.uk.com>
2 parents 5137f3f + 9f2cbe9 commit f69b4ef

8 files changed

Lines changed: 1307 additions & 182 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "25.2.90"
3+
version = "25.3.0"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
10821190
pub 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))

src/backup/mod.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3398,8 +3398,16 @@ fn store_pbs_with_notes_and_log(local_path: &Path, storage: &BackupStorage, file
33983398
// "docker-myapp-2026..." → "myapp", "vm-myvm-2026..." → "myvm"
33993399
let backup_id = extract_backup_id_from_filename(filename);
34003400

3401-
// Determine backup type from filename prefix
3402-
let backup_type = if filename.starts_with("vzdump-lxc-") || filename.starts_with("lxc-") {
3401+
// Determine backup type from filename prefix. Docker archives
3402+
// (`docker-<name>-…`, or a vzdump-style `vzdump-docker-…`) are typed "ct"
3403+
// — the same as the live file-level Docker path (see build_pxar_pairs's
3404+
// Docker arm) — so they list as a Container, not a Host. Without the
3405+
// docker- case they fell into the `else` and were stored as "host", which
3406+
// then made the PBS-list restore refuse them ("host snapshot isn't
3407+
// supported here"). The container name is still parsed correctly by
3408+
// extract_backup_id_from_filename, which already understands `docker-`.
3409+
let backup_type = if filename.starts_with("vzdump-lxc-") || filename.starts_with("lxc-")
3410+
|| filename.starts_with("vzdump-docker-") || filename.starts_with("docker-") {
34033411
"ct"
34043412
} else if filename.starts_with("vm-") || filename.starts_with("vzdump-qemu-") {
34053413
"vm"

0 commit comments

Comments
 (0)