The connection hooks on a VPN daemon run as the daemon, and usually that means they run as root. The moment one of those hooks writes into somebody's home directory or restarts a desktop helper, you have handed a network event the ability to act as an arbitrary local user. The usual shortcut, reading a username out of an environment variable and passing it along, does not fix that. It relabels the action without changing which account executes it. What works is a three-part handoff where the privileged step says what happened and a separate unit does the acting under the right identity.

A username in the environment is not a credential switch

Unix decides what a process may touch from its effective user ID, not from the strings in its environment. Setting USER=alice or exporting LOGNAME changes what careless software prints in its output; it changes nothing about file ownership checks. A hook running as root with that variable set still opens Alice's files as root.

The names are also not the same name. The remote access login, the local Unix account and the certificate common name are three identifiers that often coincide and occasionally do not. A user who authenticates as a.smith may have no local account at all, or may map to asmith through a directory service. Any design that assumes one string serves all three roles will break on the first contractor.

So the first question is not "which user is connected" but "which local account is allowed to perform this action". Answer that from your own account database, not from whatever the client sent:

id -u alice
getent passwd alice | cut -d: -f1,3,6
stat -c '%U:%G %a %n' /home/alice/.local/state/vpn-status.json

The third command is the one people skip. If the target file is owned by root because the previous hook created it, a correctly configured user action will fail with a permission error that looks like a systemd problem and is not one.

The daemon stays where it is

Changing the VPN service's own User= to Alice is the wrong lever. It alters how the daemon binds interfaces, reads keys, writes its status file and manages routes, and it moves all of that risk into a unit that was working. The daemon needs its privileges; the follow-up action does not.

Look at what the unit is actually doing before touching it. Two commands tell you the execution identity and the exact command lines in force, including anything dropped in by an override:

systemctl show -p User -p Group -p ExecStart openvpn-server@server
systemctl cat openvpn-server@server

On a default package install the daemon is root and the --up script inherits that. Confirm it rather than assuming, because a hardened distribution may have moved it to a dedicated account, and a hand-written drop-in may run the hook through a wrapper with different rules.

Three named parts, one narrow trigger

The arrangement that survives maintenance has an event, a trigger and an action. The daemon reports that a named session came up. A trigger accepts that small fact and asks for one fixed action. The action runs under the local account that owns the files it touches. Nothing in the chain accepts an arbitrary command path.

The action itself is an ordinary service unit, and the identity is one directive inside it:

[Service]
Type=oneshot
User=alice
Group=alice
ExecStart=/usr/local/bin/refresh-vpn-status --slot %i

Note that User=alice applies to the whole unit, and systemd documents one exception worth knowing: a command prefixed with + runs with full privileges regardless. That prefix is a legitimate tool for a single privileged step and a convenient way to undo the separation you just built, so treat any + in that unit as something to justify in review.

Keep the trigger's input minimal and non-secret. The session identifier, the mapped account and a timestamp are usually all that is needed. Never pass a shell fragment, an executable path or a client-supplied environment block across the boundary, because the receiving side has no way to tell a legitimate value from an injected one.

Verify the identity that actually ran

Configuration review is not evidence. After an approved test connection, confirm the UID that touched the file and the UID the daemon is still using:

stat -c '%U %G %y %n' /home/alice/.local/state/vpn-status.json
ps -o user=,pid=,cmd= -p "$(pgrep -o -f 'openvpn --daemon')"
systemd-run --uid=alice --pipe /usr/local/bin/refresh-vpn-status --dry-run

The first line should show Alice and a timestamp inside your test window. The second should still show root or the packaged service account, which proves the daemon did not move. The third is the cheapest way to test the action's permissions without waiting for a real reconnect.

If the file's owner is Alice but its group or mode changed between runs, look for a UMask= or a directory permission difference rather than a logic error. If the timestamp did not move at all, the trigger never fired, and the useful next step is journalctl -u for the action unit rather than another client reconnect.

What a connection event still does not prove

An established tunnel is one fact. It does not mean the desktop session is unlocked, the session bus is reachable, or the private resource the action wants is available yet. A oneshot unit under Alice's UID has her identity and none of her session, so anything requiring a graphical bus has to go through the supported user-session mechanism and define what happens when she is logged out.

Reconnects also repeat. A laptop that sleeps on a train can produce a dozen connection events in an hour, and a hook that appends a line each time turns a status file into a log nobody can read. Make the action idempotent, or let it replace a single record keyed by session.

Finally, bound the blast radius in the unit itself: a read-only filesystem view apart from the state directory, no new privileges, and an explicit timeout. The separation between daemon and user action is worth having on its own, but it is worth much more when the action cannot do anything except the one thing you approved.

ဆက်စပ်ဆောင်းပါးများ