Skip to content

Commit 6d9003a

Browse files
author
Paul C
committed
v24.7.32: dnsmasq zombie leak + wolfnet config save + VM network save
Three Sponsor-reported fixes ship together. * KO4BSR 2026-05-28: 1300+ defunct dnsmasq processes parented to wolfstack on his node. Root cause: `Command::new("dnsmasq").spawn()` in both `ensure_lxcbr0_services` (containers) and the per-TAP WolfNet DHCP setup (vms) launched dnsmasq and dropped the Child handle without ever calling `.wait()`. dnsmasq daemonizes by double- fork, so its initial process exits the moment the daemon is forked — and that initial process becomes a zombie under wolfstack because nothing reaps it. Switched both call sites to `.status()` so the parent dnsmasq is reaped synchronously after its quick daemonize fork. Restarting wolfstack clears the existing pile. * Klas (Sponsor) 2026-05-28 #1: editing the listen port on one node wiped `/etc/wolfnet/config.toml` and wolfnet then exited on every start. WolfStack-side `save_wolfnet_config` and the four other paths that rewrote the file now route through one helper that refuses empty/malformed payloads, snapshots the existing file to `config.toml.bak`, and atomic-renames a `.tmp` into place. wolfnet 0.5.25 (shipped separately) adds the matching self-heal on the load side: if config.toml is missing or empty but .bak exists, it gets restored before parsing. * Klas (Sponsor) 2026-05-28 #2: "trying to change a network setting on a vm and an unable to click save button; a little later it says settings saved but settings have not been changed" on a Proxmox node. Two issues: - The Save/Cancel footer in the VM-settings modal scrolled with the body and could end up below the viewport on small screens. Made it position:sticky to the bottom of the modal-body so it is always reachable. - On the Proxmox update path, `qm set --net0`, `qm set --net1`, and the WolfNet bridge reconcile all logged a warning and returned Ok(()) on failure — so the handler answered 200 to the API and the UI showed a success toast while PVE had rejected the change. All three now propagate the error so the frontend surfaces what PVE actually said. `qm set --delete net1` keeps treating "net1 not in config" as a no-op since that's the desired state.
1 parent 91645a0 commit 6d9003a

5 files changed

Lines changed: 173 additions & 33 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 = "24.7.31"
3+
version = "24.7.32"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/containers/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1897,6 +1897,14 @@ fn ensure_lxcbr0_services(_used_lxc_net: bool) {
18971897

18981898
if !dns_in_use && !dnsmasq_running {
18991899
let _ = std::fs::create_dir_all("/run/lxc");
1900+
// `.status()`, not `.spawn()`: dnsmasq daemonizes by double-fork,
1901+
// and its initial process exits the moment it has forked the
1902+
// daemon. If we `.spawn()` and drop the Child handle we never
1903+
// reap that initial process — it stays as a `<defunct>` zombie
1904+
// parented to wolfstack. KO4BSR 2026-05-28: 1300+ defunct
1905+
// dnsmasq under wolfstack on a node where this reconcile fires
1906+
// every minute. `.status()` blocks just long enough for the
1907+
// daemonize fork to complete (typically <100ms) and reaps it.
19001908
let _ = Command::new("dnsmasq")
19011909
.args([
19021910
"--strict-order",
@@ -1912,7 +1920,7 @@ fn ensure_lxcbr0_services(_used_lxc_net: bool) {
19121920
"--interface=lxcbr0",
19131921
"--conf-file=", // avoid reading /etc/dnsmasq.conf
19141922
])
1915-
.spawn();
1923+
.status();
19161924
}
19171925

19181926
// Forwarding sysctl on the bridge — separate from global ip_forward

src/networking/mod.rs

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -789,11 +789,81 @@ pub fn get_wolfnet_local_info() -> Option<serde_json::Value> {
789789
}))
790790
}
791791

792+
/// Atomic + backup write of `/etc/wolfnet/config.toml`. Every code
793+
/// path that updates the WolfNet config goes through here.
794+
///
795+
/// Three guarantees on top of a plain `fs::write`:
796+
/// 1. **No empty replaces.** If the caller hands us a blank or
797+
/// whitespace-only payload we refuse the write outright. A
798+
/// truncated payload that survives serialisation but is missing
799+
/// the [network]/[security] sections also fails the load-check
800+
/// below, so the original file stays intact. klasSponsor
801+
/// 2026-05-28 reported `config.toml` being wiped after a port
802+
/// edit on one node — wolfnet then exited on next start because
803+
/// it had no config to load. This check makes that class of
804+
/// regression impossible regardless of which call site is at
805+
/// fault.
806+
/// 2. **`config.toml.bak` snapshot before every replace.** Always
807+
/// written when the on-disk file is non-empty. Manual recovery
808+
/// becomes `cp config.toml.bak config.toml`, and the wolfnet
809+
/// daemon also picks it up automatically (see wolfnet's
810+
/// `Config::load_from_file`).
811+
/// 3. **Atomic rename**, not in-place truncate. A crash partway
812+
/// through the write can no longer leave the live config
813+
/// truncated/empty — the previous file remains visible until
814+
/// the rename completes.
815+
fn write_wolfnet_config_atomic(content: &str) -> Result<(), String> {
816+
const PATH: &str = "/etc/wolfnet/config.toml";
817+
const TMP: &str = "/etc/wolfnet/config.toml.tmp";
818+
const BAK: &str = "/etc/wolfnet/config.toml.bak";
819+
820+
if content.trim().is_empty() {
821+
return Err(
822+
"Refusing to write empty WolfNet config (would brick the daemon). \
823+
Existing config left untouched.".to_string(),
824+
);
825+
}
826+
827+
// Sanity-check: a real wolfnet config always carries at least
828+
// [network] and [security] sections. Anything missing both is a
829+
// tell-tale of a serialisation bug upstream — fail fast rather
830+
// than overwrite a working file with garbage.
831+
if !content.contains("[network]") || !content.contains("[security]") {
832+
return Err(
833+
"Refusing to write WolfNet config missing [network]/[security] sections. \
834+
Existing config left untouched.".to_string(),
835+
);
836+
}
837+
838+
// Make sure /etc/wolfnet exists — write to .tmp first.
839+
if let Some(parent) = std::path::Path::new(PATH).parent() {
840+
let _ = std::fs::create_dir_all(parent);
841+
}
842+
std::fs::write(TMP, content)
843+
.map_err(|e| format!("Failed to stage WolfNet config at {}: {}", TMP, e))?;
844+
845+
// Snapshot the previous good config to .bak — best-effort: an
846+
// absent .bak isn't fatal, but we want a recovery copy after
847+
// every successful replace.
848+
if std::path::Path::new(PATH).exists() {
849+
let _ = std::fs::copy(PATH, BAK);
850+
}
851+
852+
// Atomic rename — POSIX guarantees this is either fully visible
853+
// or fully not.
854+
std::fs::rename(TMP, PATH).map_err(|e| {
855+
format!(
856+
"Failed to install new {} (staged copy left at {}): {}",
857+
PATH, TMP, e
858+
)
859+
})?;
860+
861+
Ok(())
862+
}
863+
792864
/// Save the raw WolfNet config file
793865
pub fn save_wolfnet_config(content: &str) -> Result<String, String> {
794-
std::fs::write("/etc/wolfnet/config.toml", content)
795-
.map_err(|e| format!("Failed to write WolfNet config: {}", e))?;
796-
866+
write_wolfnet_config_atomic(content)?;
797867
Ok("Configuration saved".to_string())
798868
}
799869

@@ -1054,8 +1124,7 @@ pub fn add_wolfnet_peer(name: &str, endpoint: PeerEndpoint, ip: &str, public_key
10541124
// whether we cleared an endpoint (see above).
10551125
let output = toml::to_string_pretty(&doc)
10561126
.map_err(|e| format!("Failed to serialize config: {}", e))?;
1057-
std::fs::write(config_path, &output)
1058-
.map_err(|e| format!("Failed to write config: {}", e))?;
1127+
write_wolfnet_config_atomic(&output)?;
10591128
if cleared_endpoint {
10601129
restart_wolfnet();
10611130
} else {
@@ -1103,8 +1172,7 @@ pub fn add_wolfnet_peer(name: &str, endpoint: PeerEndpoint, ip: &str, public_key
11031172
// Write back
11041173
let output = toml::to_string_pretty(&doc)
11051174
.map_err(|e| format!("Failed to serialize config: {}", e))?;
1106-
std::fs::write(config_path, &output)
1107-
.map_err(|e| format!("Failed to write config: {}", e))?;
1175+
write_wolfnet_config_atomic(&output)?;
11081176

11091177
// Apply config: try SIGHUP hot-reload, fall back to restart for older wolfnet
11101178
reload_or_restart_wolfnet();
@@ -1394,8 +1462,7 @@ pub fn reconcile_wolfnet_peers_batch(
13941462
// Write the whole updated config once.
13951463
let output = toml::to_string_pretty(&doc)
13961464
.map_err(|e| format!("Failed to serialize config: {}", e))?;
1397-
std::fs::write(config_path, &output)
1398-
.map_err(|e| format!("Failed to write config: {}", e))?;
1465+
write_wolfnet_config_atomic(&output)?;
13991466

14001467
// ONE reload/restart at the end. If any peer was cleared and we're
14011468
// running against a pre-0.5.22 wolfnet whose SIGHUP handler doesn't
@@ -1478,8 +1545,7 @@ pub fn remove_wolfnet_peer(name: &str) -> Result<String, String> {
14781545
}
14791546

14801547
let new_content = result_lines.join("\n");
1481-
std::fs::write(config_path, &new_content)
1482-
.map_err(|e| format!("Failed to write config: {}", e))?;
1548+
write_wolfnet_config_atomic(&new_content)?;
14831549

14841550

14851551

src/vms/manager.rs

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,8 +1484,20 @@ impl VmManager {
14841484
let out = Command::new("qm").args(["set", &vmid_str, "--net0", &val]).output()
14851485
.map_err(|e| format!("qm set --net0 failed: {}", e))?;
14861486
if !out.status.success() {
1487+
// Propagate the PVE error instead of warning-and-
1488+
// succeeding. klasSponsor 2026-05-28 reported
1489+
// "settings saved but settings have not been
1490+
// changed" — the silent warn here is why: qm
1491+
// rejected the change (e.g., bridge missing on the
1492+
// host, running-VM restriction, perms) and the
1493+
// handler still returned 200, so the UI showed
1494+
// success while PVE kept the old config.
14871495
let stderr = String::from_utf8_lossy(&out.stderr);
1488-
warn!("qm set --net0 for VMID {} failed: {}", vmid, stderr.trim());
1496+
return Err(format!(
1497+
"qm set --net0 for VMID {} failed: {}",
1498+
vmid,
1499+
stderr.trim()
1500+
));
14891501
}
14901502
}
14911503
}
@@ -1541,21 +1553,58 @@ impl VmManager {
15411553
if !wip.is_empty() {
15421554
self.ensure_dnsmasq_installed();
15431555
let bridge = Self::wn_bridge_name(&vmid_str);
1544-
if let Err(e) = self.setup_wolfnet_bridge(&bridge, wip) {
1545-
warn!("WolfNet bridge reconcile (qm) for VMID {} failed: {}", vmid, e);
1546-
} else {
1547-
let _ = Command::new("qm").args([
1548-
"set", &vmid_str, "--net1",
1549-
&format!("virtio,bridge={}", bridge)
1550-
]).output();
1556+
// Bridge setup or `qm set --net1` failures used
1557+
// to be swallowed silently — the UI showed
1558+
// "settings saved" with no actual NIC change.
1559+
// Surface both so the operator sees the real
1560+
// reason (klasSponsor 2026-05-28).
1561+
self.setup_wolfnet_bridge(&bridge, wip).map_err(|e| {
1562+
format!(
1563+
"WolfNet bridge reconcile (qm) for VMID {} failed: {}",
1564+
vmid, e
1565+
)
1566+
})?;
1567+
let out = Command::new("qm").args([
1568+
"set", &vmid_str, "--net1",
1569+
&format!("virtio,bridge={}", bridge)
1570+
]).output()
1571+
.map_err(|e| format!("qm set --net1 failed: {}", e))?;
1572+
if !out.status.success() {
1573+
let stderr = String::from_utf8_lossy(&out.stderr);
1574+
return Err(format!(
1575+
"qm set --net1 for VMID {} failed: {}",
1576+
vmid,
1577+
stderr.trim()
1578+
));
15511579
}
15521580
}
15531581
}
15541582
} else {
15551583
// Mode is explicitly non-wolfnet — drop net1 if present.
1556-
// qm errors when net1 doesn't exist; the result is
1557-
// ignored on purpose (the desired state IS "no net1").
1558-
let _ = Command::new("qm").args(["set", &vmid_str, "--delete", "net1"]).output();
1584+
// qm errors when net1 doesn't exist; that specific case
1585+
// is the desired state and stays ignored, but any other
1586+
// failure (e.g., permissions) needs to surface so the
1587+
// UI doesn't report a false success.
1588+
let out = Command::new("qm").args(["set", &vmid_str, "--delete", "net1"]).output();
1589+
if let Ok(o) = &out {
1590+
if !o.status.success() {
1591+
let stderr = String::from_utf8_lossy(&o.stderr);
1592+
let stderr_trim = stderr.trim();
1593+
// PVE phrases the "net1 not in config" rejection
1594+
// as `unable to find net1 in config`; treat that
1595+
// as a no-op since the desired state is already
1596+
// met.
1597+
let is_already_gone = stderr_trim.contains("net1")
1598+
&& (stderr_trim.contains("not in config")
1599+
|| stderr_trim.contains("does not exist"));
1600+
if !is_already_gone {
1601+
return Err(format!(
1602+
"qm set --delete net1 for VMID {} failed: {}",
1603+
vmid, stderr_trim
1604+
));
1605+
}
1606+
}
1607+
}
15591608
let bridge = Self::wn_bridge_name(&vmid_str);
15601609
self.cleanup_wolfnet_bridge(&bridge, wolfnet_ip.as_deref());
15611610
}
@@ -3014,6 +3063,15 @@ impl VmManager {
30143063
let lease_file = format!("/run/dnsmasq-{}.leases", tap);
30153064
let _ = std::fs::remove_file(&lease_file);
30163065
let dns_server = "8.8.8.8";
3066+
// `.status()`, not `.spawn()`: dnsmasq daemonizes by double-
3067+
// fork, so the immediate child we launched exits the moment
3068+
// the daemon is forked. If we `.spawn()` and never `.wait()`
3069+
// on the Child, that initial process becomes a `<defunct>`
3070+
// zombie parented to wolfstack — one per TAP setup, forever.
3071+
// KO4BSR 2026-05-28 saw 1300+ accumulate under wolfstack.
3072+
// `.status()` blocks for the ~100ms it takes dnsmasq to
3073+
// fork-and-exit, reaps the parent, and the daemonized child
3074+
// gets reparented to init as normal.
30173075
let dnsmasq_result = Command::new("dnsmasq")
30183076
.args([
30193077
&format!("--interface={}", tap),
@@ -3040,14 +3098,15 @@ impl VmManager {
30403098
])
30413099
.stdout(std::process::Stdio::null())
30423100
.stderr(std::process::Stdio::null())
3043-
.spawn();
3101+
.status();
30443102

30453103
match dnsmasq_result {
3046-
Ok(_child) => {
3047-
// `Command::spawn()` returns Ok the moment fork+exec
3048-
// succeeds — but dnsmasq can still abort a moment
3049-
// later if its bind() fails (`Address already in
3050-
// use`, missing perms, kernel misconfig, etc.).
3104+
Ok(_status) => {
3105+
// `.status()` returns once the parent dnsmasq has
3106+
// exited the daemonize fork — but dnsmasq can still
3107+
// abort a moment later if its bind() fails (`Address
3108+
// already in use`, missing perms, kernel misconfig,
3109+
// etc.).
30513110
// Verify the daemon actually stayed up and the pid
30523111
// file points at a live process bound to OUR tap.
30533112
// If it didn't, log loudly so the predictive

web/js/app.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26225,9 +26225,16 @@ async function showVmSettings(name) {
2622526225
</div>
2622626226
</div>
2622726227

26228-
<!-- Footer (always visible) -->
26229-
<div style="display:flex; gap:8px; margin-top:16px; padding-top:12px; border-top:1px solid var(--border);">
26230-
<button class="btn btn-primary" onclick="saveVmSettings('${vm.name}')">Save</button>
26228+
<!-- Footer pinned to the bottom of the scrollable modal-body.
26229+
Was a plain inline div that scrolled with the rest of the
26230+
content — on smaller viewports (or with the Boot Options +
26231+
passthrough lists expanded) the Save button could end up
26232+
below the visible area, and klasSponsor 2026-05-28 hit
26233+
exactly that: "unable to click save button" while changing
26234+
a VM's network setting. Sticky positioning keeps Save/Cancel
26235+
reachable no matter where the user has scrolled to. -->
26236+
<div style="display:flex; gap:8px; padding:12px 24px; margin:16px -24px -24px -24px; border-top:1px solid var(--border); position:sticky; bottom:-24px; background:var(--bg-card); z-index:5;">
26237+
<button class="btn btn-primary" onclick="saveVmSettings('${escapeAttr(vm.name)}')">Save</button>
2623126238
<button class="btn" onclick="closeContainerDetail()">Cancel</button>
2623226239
</div>
2623326240
`;

0 commit comments

Comments
 (0)