The hook works when you run it by hand as root and returns Operation not permitted the moment the service starts it. The file did not change between those runs; the environment did. systemd hands the process a smaller world: fewer capabilities, a different /dev, read-only filesystem paths and a task ceiling. Read the failure as a boundary that refused one specific operation, not as a script that broke.

Start From the Operation That Was Refused

A log line saying the unit failed tells you almost nothing. The useful line names the call that was refused: a device node that could not be opened, a directory that could not be written, a namespace that could not be created. Find that call before you touch a single directive, because each one maps to a different restriction. Then read the unit that actually ran, drop-ins included, instead of the file the package shipped.

The query below prints the fragment path, the drop-in directories and the properties that matter for a device or filesystem refusal. Read all of them before deciding which boundary is relevant:

systemctl show "[email protected]" -p FragmentPath -p DropInPaths -p PrivateDevices -p DevicePolicy -p DeviceAllow -p ProtectHome -p ProtectSystem -p TasksMax
journalctl -u "[email protected]" -b --no-pager | grep -iE 'operation not permitted|permission denied|cannot open|failed'

Nothing in that block writes. If the refused call is missing from the journal, raise the client's verbosity for one run instead of guessing at directives. A running process proves the unit started, not that the hook ran with the privileges it needed.

A Namespace Needs Mount Rights a TUN Device Never Uses

Two operations that look alike inside one hook have different permission profiles. Opening /dev/net/tun and creating the interface needs network administration plus access to that node. Creating a named network namespace also needs mount-related work, because the namespace tool mounts a fresh /sys view and binds a state directory into place. The upstream client unit deliberately leaves mount administration out of its bounding set, so the namespace step is refused while the tunnel itself would have worked.

That is why granting the device node does not rescue the script. A device allowance answers one question, whether this process may open this node, and the namespace question is a different one. Match the failing call to its capability before widening anything.

A test in your shell inherits every capability an unrestricted root holds, and the unit inherits what its bounding set kept. The two runs are different experiments even when the binary is identical.

Devices, Protected Paths and Task Ceilings

Once the refused operation is known, three groups of directives explain most failures. Device access is governed by PrivateDevices, which replaces /dev with a minimal set of pseudo-devices, and by DevicePolicy with DeviceAllow, which decide what the cgroup permits. /dev/net/tun is not part of that minimal set, so a unit with PrivateDevices=yes cannot open it whatever the device policy says.

Filesystem protection answers a second question. ProtectHome=yes makes /home, /root and /run/user inaccessible, and ProtectSystem=full makes /usr, /boot and /etc read-only. A hook that writes beside its own configuration file therefore works in your shell and fails under the unit, with the same permission shape as a missing capability. Move the writable input to a path the policy allows, or add a narrow ReadWritePaths= entry.

Resource limits are the third group, and the least obvious. TasksMax= caps how many tasks the unit may create, and a file-descriptor ceiling behaves the same way for sockets and handles. A deployment that grew past the ceiling fails with a resource error rather than a permission error, which is why the exact message still matters.

Move the Namespace Step Out of the Hook

Preparation that needs a privilege the client must not hold belongs in its own unit. A oneshot that runs before the client, keeps its result and is ordered ahead of the connection gives the namespace step what it needs without handing those rights to the tunnel process. The client then references that unit instead of calling the script from its up hook.

The template below is a starting shape, not something to apply unread. It keeps the preparation where the privilege lives and leaves the tunnel unit untouched:

# /etc/systemd/system/[email protected]
[Unit]
Description=Namespace preparation for %i
Before=openvpn-client@%i.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/netns-up.sh %i
CapabilityBoundingSet=CAP_NET_ADMIN CAP_SYS_ADMIN
[Install]
WantedBy=multi-user.target

Add the ordering with a drop-in rather than editing the packaged unit, so an upgrade cannot silently drop your change. The directory is keyed to the instance name, so a second profile needs its own file:

sudo mkdir -p /etc/systemd/system/[email protected]
sudo tee /etc/systemd/system/[email protected]/10-namespace.conf >/dev/null <<'CONF'
[Unit]
[email protected]
[email protected]
CONF
sudo systemctl daemon-reload

Repeat the Same Invocation After the Change

A repair is confirmed only when the same invocation produces a different result. Restart both units, run the operation that failed, and compare its outcome against the journal line you saved before the change. A unit that starts while the hook never runs again is not a repair.

The sequence below keeps the comparison honest, because it checks the refused call rather than the unit's colour. Save its output beside the journal line you captured before the change:

sudo systemctl restart "[email protected]" "[email protected]"
systemctl status "[email protected]" --no-pager
journalctl -u "[email protected]" -b --no-pager | grep -c 'Operation not permitted'
ip -brief addr show tun0

Count the refusals instead of reading the last line, because a hook can fail, retry and succeed, and only the count shows whether the boundary moved. Leave a comment in the drop-in naming the directive you loosened and the reason, since the next upgrade compares your configuration against the packaged default. A unit that only starts once every protection is disabled has not been fixed.

Amanqephe ahlobene