unresolved root cause
Three Errors, Three Layers, No Confirmed Cause: A WireGuard Reload That Wouldn't Cooperate
TL;DR: Reloading a WireGuard peer without a full restart should be a one-line command. Getting there produced three different, increasingly specific permission errors across three attempts, each individual piece proven to work in isolation, with the actual root cause never fully confirmed. Rather than force a diagnosis the evidence didn't support, the honest call was falling back to a full restart — completely fine here, since nothing was depending on the tunnel staying up yet — and documenting the mystery rather than a guess dressed up as an answer.
Attempt one: sudo on the wrong half
wg syncconf wg0 <(wg-quick strip wg0)
failed with /usr/bin/wg-quick: line 85: /usr/bin/sudo: Permission denied — inside
wg-quick's own script, not from the command as typed. wg-quick detects it isn't
running as root and tries to re-invoke itself via an internal sudo call; that
internal, self-triggered escalation was what failed, not sudo access generally.
The natural next guess — passwordless sudo simply wasn't configured on this host —
was tested directly and ruled out cleanly: sudo -n true; echo $? returned 0.
Whatever was wrong, it wasn't that.
Attempt two: fixing the real gap, hitting a different error
The actual issue with attempt one: sudo was only on the outer command. The
inner wg-quick strip wg0, inside the process substitution, ran as the plain
user — process substitution spawns a genuinely separate subprocess that doesn't
inherit the outer command's escalated privilege. Corrected to put sudo on both
halves:
sudo wg syncconf wg0 <(sudo wg-quick strip wg0)
which produced a different error: fopen: No such file or directory. Progress,
in the sense that the first failure mode was gone — but a new one had replaced it.
Isolating the two halves confirmed both worked completely fine on their own:
sudo cat /etc/wireguard/wg0.conf printed the real, correct file content, and
sudo wg-quick strip wg0 — run plain, no process substitution — printed a
correctly stripped config. Every individual piece was proven functional in
isolation. Only the specific combination failed.
The working theory: process substitution exposes its pipe as a path like
/dev/fd/63, tied to file descriptors open in the unprivileged parent shell.
sudo commonly closes inherited file descriptors above a certain number as a
hardening measure before running the escalated command — which would mean the
exact file descriptor the substitution just created could already be gone by the
time the privileged wg process tried to open it. Consistent with "no such file,"
but never independently confirmed as the actual mechanism.
Attempt three: removing the suspect variable, hitting a third error
To sidestep the file-descriptor theory entirely rather than keep debugging it, the process substitution was replaced with a real temporary file:
umask 077
tmpfile=$(mktemp)
sudo wg-quick strip wg0 > "$tmpfile"
sudo wg syncconf wg0 "$tmpfile"
This produced a third distinct error: fopen: permission denied — no longer
"file doesn't exist," now "file exists, but access is refused." Given sudo cat
had already proven plain root file access works fine on this system, this pointed
toward something more specific than standard Unix permissions — a plausible
candidate being an AppArmor confinement profile on wg itself, restricting which
paths it's allowed to open regardless of Unix ownership. That theory was named,
along with a command to check it (sudo aa-status | grep -i wireguard), but never
actually confirmed one way or the other.
The decision: stop here, and say so plainly
Three attempts, three different specific errors, each one individually plausible, none of them confirmed. At this point, continuing to iterate on increasingly obscure sudo/process-substitution/AppArmor interactions had a real cost with no guaranteed payoff — and, critically, nothing was actually depending on graceful reload working yet. The one peer being tested was the first one; there was no existing connected client whose session needed protecting. Given that, the honest and correct move was falling back to the mechanism already proven to work:
sudo systemctl restart wg-quick@wg0
and writing down the investigation as a real, open question rather than converting an unconfirmed theory into a stated root cause.
What this demonstrates
Not every investigation ends at a name. Three genuinely useful facts got
established along the way — the outer/inner sudo distinction, the fact that every
individual piece works in isolation, and two specific, checkable theories for what
might be happening at the boundary between them — without ever landing on a
single confirmed mechanism. The discipline worth calling out isn't finding the
answer; it's recognizing when the cost of continuing to chase one exceeds the
actual stakes of not having it yet, and choosing a working fallback deliberately
rather than either forcing an unconfirmed theory into a confident-sounding
conclusion, or continuing to burn time on a problem that wasn't blocking anything
real. This is worth revisiting the moment it actually matters — the first time
add-wireguard-peer.yml needs to add a device without disrupting an already-
connected one — rather than solving it preemptively for its own sake.