HardStacked
Share:

Attack surface · wg-quick

The service reports active and the tunnel does not exist

Published · HardStacked · 14 min

You set a WireGuard tunnel up as a systemd service, and today the tunnel is gone. systemctl status wg-quick@wg0 answers with a green active (exited). wg show answers with nothing at all — no interface, no peers, no error. They cannot both be right, and it is the green one that is wrong.

Both tools are doing their job. They are answering different questions. systemctl reports on something that happened in the past: a command that exited cleanly, once. wg show reports on the kernel as it is right now. This page walks the gap between those two answers, and then the quieter twin of the same trap: the tunnel you fix today that will not come back at the next boot.

Everything quoted here was read off a Debian 13 (trixie) arm64 machine on 2026-08-16 — the unit file shipped by wireguard-tools 1.0.20210914-3, under systemd 257.13-1~deb13u1. No terminal output has been reconstructed for this page: what gets quoted is files, and you can open the same files on your machine and compare, line for line.

01. The two line verdict

A oneshot service reports the fate of its command, never the state of the object that command was supposed to create. wg-quick up wg0 exited cleanly once, so the unit went active (exited) — and it stays there no matter what happens to the interface afterwards.

systemctl reads systemd’s memory. wg show asks the kernel. When they disagree, the green one is wrong.

02. The fix

These commands are provided as is, without warranty. If your SSH session normally rides inside this tunnel, have a second way in before touching it.

Let the kernel state the problem in its own words first:

bash
$ wg show interfaces

One name per WireGuard interface currently alive in the kernel. If wg0 is not in the list, there is no tunnel, whatever the service says. Bring it back through systemd, so the service state and the kernel end up back in agreement:

bash
$ sudo systemctl restart wg-quick@wg0

Then verify against the kernel, never against the service — section 07 has the exact commands. Re-reading systemctl status proves nothing here: a green Active: line is the symptom this page is about, not the cure.

If the restart fails, the reason is in the journal:

bash
$ journalctl -u wg-quick@wg0

and it is unusually readable, because of four lines at line 30 of /usr/bin/wg-quick:

/usr/bin/wg-quick
cmd() { echo "[#] $*" >&2 "$@" }

Every external command the script runs — every ip, every wg — is echoed before it executes, prefixed [#]. So the journal holds the full recipe in order, and the last [#] line before the failure names the exact step that broke. Error messages come out of die(), five lines further down in the same file.

Two details save time here. Running the script by hand, sudo wg-quick up wg0, puts those same [#] lines straight into your terminal, which is faster to iterate on — but it changes nothing in systemd’s bookkeeping, so finish with the systemctl restart anyway. And skip wg-quick down wg0 while wg show is empty: the script checks the kernel before acting and refuses to take down an interface that is not there. That check is quoted in section 05, and it is this whole page in one line of bash.

03. Why does systemctl say active when wg0 doesn't exist?

Open the unit — systemctl cat wg-quick@wg0, no root needed — and look at two lines, 14 and 15 of the file: Type=oneshot and RemainAfterExit=yes.

For an ordinary simple service, systemd holds a live process. State tracking is easy: the process dies, the unit falls. A oneshot has nothing to hold. The command runs, finishes, and there is no process left — so there is also nothing left whose death could ever flip the unit’s state again.

Without RemainAfterExit, a finished oneshot drops back to inactive (dead), which for a tunnel would be useless: enabled, ran fine, and shows dead. RemainAfterExit=yes is the standard remedy, and it buys the opposite problem: the unit now stays active (exited) forever. That green means exactly one thing — /usr/bin/wg-quick up wg0 exited with code 0, once. Nothing ever refreshes it.

Meanwhile the interface it created lives its own life. wg-quick down wg0 typed by hand tears it down without telling systemd — it is the same command as the unit’s ExecStop=, but nothing routes it through the service manager. ip link delete, a network manager cleaning up, anything that removes the interface: no process dies, no event reaches systemd, the green survives.

One deduction is worth keeping. Had wg-quick up failed, it would have exited non zero and the unit would show failed — a oneshot does report its command’s failure. active (exited) next to an empty wg show therefore means the tunnel did come up, and something removed it afterwards. The status is not a lie about the present. It is an accurate memory of a state that no longer exists.

04. What it looks like when you measure it

The whole unit is 22 lines, 755 bytes, and ships with the package — /usr/lib/systemd/system/wg-quick@.service, quoted in full:

/usr/lib/systemd/system/wg-quick@.service
[Unit] Description=WireGuard via wg-quick(8) for %I After=network-online.target nss-lookup.target Wants=network-online.target nss-lookup.target PartOf=wg-quick.target Documentation=man:wg-quick(8) Documentation=man:wg(8) Documentation=https://www.wireguard.com/ Documentation=https://www.wireguard.com/quickstart/ Documentation=https://git.zx2c4.com/wireguard-tools/about/src/man/wg-quick.8 Documentation=https://git.zx2c4.com/wireguard-tools/about/src/man/wg.8 [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/bin/wg-quick up %i ExecStop=/usr/bin/wg-quick down %i ExecReload=/bin/bash -c 'exec /usr/bin/wg syncconf %i <(exec /usr/bin/wg-quick strip %i)' Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity [Install] WantedBy=multi-user.target

It is a template — the @ in the name. wg-quick@wg0 substitutes wg0 for every %i, so ExecStart becomes /usr/bin/wg-quick up wg0. There is no monitoring directive anywhere in those 22 lines, and there is nowhere to put one: nothing in the [Service] section asks systemd to look at the interface, ever.

What ExecStart launches is a bash script — 389 lines, 13,460 bytes. The moment the interface is actually born is one command inside add_if(), lines 88 to 96:

/usr/bin/wg-quick
add_if() { local ret if ! cmd ip link add "$INTERFACE" type wireguard; then ret=$? [[ -e /sys/module/wireguard ]] || ! command -v "${WG_QUICK_USERSPACE_IMPLEMENTATION:-wireguard-go}" >/dev/null && exit $ret echo "[!] Missing WireGuard kernel module. Falling back to slow userspace implementation." >&2 cmd "${WG_QUICK_USERSPACE_IMPLEMENTATION:-wireguard-go}" "$INTERFACE" fi }

ip link add wg0 type wireguard — that is the object. Everything else in the script decorates it: keys via wg setconf (line 250, set_config()), addresses, routes, DNS. So the chain of custody is: systemd remembers launching a script; the script delegated to ip; ip asked the kernel. The service’s green sits two layers away from the thing it appears to describe, and no layer reports upward once the script has exited.

05. The test that will lie to you

systemctl is-active wg-quick@wg0 performs no network operation of any kind. It asks the systemd process for its recorded state of the unit — a value written when wg-quick up exited, and untouched since. wg show opens a netlink socket and asks the kernel which WireGuard interfaces exist at this instant. One is an archive, the other is a measurement. Nothing in the system ever reconciles them, so they can contradict each other indefinitely — and when they do, only one of them is describing the machine.

The strongest argument for trusting wg show comes from wg-quick itself. Before bringing an interface up, it checks the kernel, line 328:

/usr/bin/wg-quick
cmd_up() { local i [[ -z $(ip link show dev "$INTERFACE" 2>/dev/null) ]] || die "\`$INTERFACE' already exists" [...]

Before taking one down, it checks the kernel again, line 346:

/usr/bin/wg-quick
cmd_down() { [[ " $(wg show interfaces) " == *" $INTERFACE "* ]] || die "\`$INTERFACE' is not a WireGuard interface" [...]

The tool that creates and destroys these interfaces never once asks systemd what it thinks. It asks ip and it asks wg, every time, because that is where the truth is. Copy its habit.

06. The green that does not survive a reboot

There is a second version of this trap, and it costs more, because every check you know passes on the day you make the mistake.

You fix the tunnel with systemctl restart wg-quick@wg0. The service goes active, wg show lists the interface, the handshake is fresh. Weeks later the machine reboots — kernel update, power cut — and the tunnel is simply not there. Nothing failed at boot. Nothing was asked to start.

restart and enable answer two different questions. restart acts on the running system and writes nothing to disk. enable acts on the next boot: it creates a symlink in /etc/systemd/system/multi-user.target.wants/ pointing at the unit file — that directory is named by line 22 of the unit, WantedBy=multi-user.target. At boot, systemd assembles multi-user.target from exactly what that .wants/ directory contains. No link, no start. And no amount of restarting creates the link.

systemctl status does print the answer — the Loaded: line near the top carries enabled or disabled between its semicolons. It sits right above the green Active: and reads like boilerplate, which is why nobody reads it. Ask the question on its own instead:

bash
$ systemctl is-enabled wg-quick@wg0

The answer is one word. If it is disabled, today’s green does not survive a reboot:

bash
$ sudo systemctl enable wg-quick@wg0

Note the instance name, @wg0, not the bare template. The proof it worked is physical: a symlink named wg-quick@wg0.service now exists in /etc/systemd/system/multi-user.target.wants/, pointing at the template unit, and an ls -l in that directory shows it to you. enable creates that link; restart never does. That one filesystem entry is the entire difference between a tunnel and a tunnel until further notice.

One honest caveat. Among the forum reports of this symptom, some describe machines where the service was enabled and the interface still came up wrong at boot — ordering around network-online.target (lines 3 and 4 of the unit) and DNS resolution of the endpoint are the usual suspects, and the unit ships Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity (line 19) precisely to wait out slow DNS. We have not reproduced that case ourselves, so there is no measurement to show you; if is-enabled already answers enabled, your evening starts at journalctl -b -u wg-quick@wg0.

If the service is green, the interface is present, the handshake is fresh, and the machine still comes back broken after a reboot, the problem is no longer the tunnel. It is the platform underneath it.

07. Check that it is actually fixed

Three questions, in order, each aimed at the layer that actually owns the answer.

Does the interface exist? Ask the kernel, through both doors:

bash
$ wg show interfaces
bash
$ ip link show type wireguard

The first prints the names of the WireGuard interfaces the kernel holds right now; the second shows the link objects themselves. wg0 must appear in both. This is the exact point where systemctl disagreed with reality at the top of this page, so these two commands are the test — not the service state.

Is the tunnel actually talking? Push a packet through it — ping a host on the far side — then:

bash
$ sudo wg show wg0 latest-handshakes

One line per peer: its public key and the time of its last completed handshake, as a Unix timestamp. A recent timestamp is the only proof that both ends still agree on their keys. A 0 means that peer has never completed a handshake since the interface came up — an interface can exist, green and configured, and still speak to nobody.

Will it survive a reboot? That question belongs to systemd, and it is the one place where systemctl is the right judge — you are asking about systemd’s own configuration, which it knows perfectly:

bash
$ systemctl is-enabled wg-quick@wg0

Two different questions, two different referees. “Is the tunnel up right now” belongs to the kernel and nothing else. “Will it come back” belongs to systemd and nothing else. The mistake this whole page is built on is asking either one the other’s question.

08. Two more messages, same layer problem

Two errors keep showing up next to this one in search, and both are the same trap at a different depth. One belongs on this page. The other only looks like it does.

"wg-quick: `wg0' already exists" but the tunnel is down

You run wg-quick up wg0, expecting a clean start, and get handed back:

bash
wg-quick: `wg0' already exists

wg show confirms the interface is real. But the service that supposedly owns it, wg-quick@wg0, reads failed or inactive — not the tunnel this page describes, its mirror image. This time the interface survived and the unit's bookkeeping did not.

The message itself is worth trusting, and that is the point worth taking from it. die() is a plain function in /usr/bin/wg-quick, line 35: echo "$PROGRAM: $*" >&2; exit 1. It fires from line 328, inside cmd_up(): [[ -z $(ip link show dev "$INTERFACE" 2>/dev/null) ]] || die "\`$INTERFACE' already exists". Read the order of operations: the script asks ip link show what the kernel holds, right then, and only reaches die if the kernel answers non-empty. Compare that to add_if(), line 90, where the interface actually gets born: if ! cmd ip link add "$INTERFACE" type wireguard; then. One line measures the kernel before doing anything. The other creates the object this whole page is about. already exists comes out of the measuring line — it is a query answered live, not a memory replayed. That makes it the mirror of active (exited): same script, same day, but here the layer checked before speaking instead of recalling what it did once.

What produces this in practice: the tunnel came up fine at some point, then the unit went down hard — an OOM kill, a wg-quick down interrupted mid-script, a crash before ExecStop ran to completion. The interface never got torn down. systemd's record did — and that record is exactly what decides whether restart can fix anything.

systemctl restart wg-quick@wg0 often does not touch the interface at all, because restart only runs ExecStop when systemd's own bookkeeping says the unit is currently active. After a hard crash, that bookkeeping says otherwise. Tested directly on a throwaway systemd --user unit carrying wg-quick's exact two settings, ExecStart and ExecStop each appending one word to a trace file:

disposable test unit, same two settings as wg-quick@.service
Type=oneshot RemainAfterExit=yes ExecStart=sh -c 'echo START>>/tmp/t' ExecStop=sh -c 'echo STOP>>/tmp/t'
restart, from each starting state
restart on inactive -> START restart on active -> STOP START

The control case proves the trace file works and ExecStop does fire when the unit is active. The first case is the crash: with the unit already reading inactive, restart skips straight to ExecStart — no STOP in the trace. Applied to wg-quick, that means wg-quick down never runs, the interface is never touched, and ExecStart walks straight into cmd_up()'s line 328 check, which finds the very interface it is about to try to create. Same accounting gap this whole article opens with: the unit does not tear down what it does not remember creating. Take the interface out by hand instead, then let the unit start clean:

bash
$ sudo ip link del wg0
bash
$ sudo systemctl restart wg-quick@wg0

ip link del wg0 is a direct kernel operation. It does not run cmd_down(), does not care what the config file says, does not ask systemd anything — it just removes the object. Once the kernel has nothing named wg0, line 328's check passes and cmd_up() can proceed.

"handshake for peer … did not complete after 5 seconds"

This one is not a variant of the trap above. It disqualifies it.

bash
Handshake for peer 1 (203.0.113.1:51820) did not complete after 5 seconds, retrying (try 2)

To print this line, wg needs an interface to attach to and a handshake initiation packet to have actually gone out. Both of the things this page is about — a phantom interface, a service lying about the kernel — are already ruled out by the time you see it. The tunnel exists. It is trying. It is failing somewhere past that point: NAT between the peers, a blocked or wrong endpoint port, an MTU that fragments the handshake, a clock or key mismatch, a firewall dropping UDP. Any of those, alone or combined.

We are not going to walk you through which one. That list has real depth — enough for its own dedicated write-up done properly, not a paragraph bolted onto a page about a different failure. What this section can do honestly is tell you where you stand: if wg show interfaces lists your interface and the journal shows retrying handshake attempts, you are one layer above everything covered here. Section 07's three questions still apply as a sequence — go back to is the tunnel actually talking? and start from the peer's endpoint reachability, not from the service state.

The same distance between what a tool reports and what the machine is doing runs through a firewall that reports green while blocking nothing, through a firewall the systemd unit reports as enabled while it is switched off, and through a hardened unit that runs as root and is refused with Permission denied.

Share: