>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Operation not permitted in Docker: the capability wall

Mara Voss covers Docker, system limits and reverse proxies at C2CZ. Author page · All authors.

unshare: unshare failed: Operation not permitted, RTNETLINK answers: Operation not permitted and must be superuser to use mount. are the same failure wearing three different costumes — and none of them is a file-permission problem. They are refusals the kernel makes at the syscall, most often because the operation needs a capability the container does not hold, so there is no file to chmod, no owner to change and usually no root inside the container to complain about. The same errno also comes from the runtime’s seccomp profile and from LSM rules, which is why this article reads the masks and the policy settings before naming a cause. The three tools agree on the errno (EPERM = 1) and disagree on everything else, which is why the usual remedies — sudo, chmod 777, reinstalling the package — do nothing.

Every number and quoted string below comes from one container on an arm64 Linux 6.17 host, Python 3.13.5, uid 10000, no capabilities effective. Where a claim needs something this host cannot hold — no Docker daemon runs here — the sentence says so and the reference is Docker’s own documentation, quoted with its URL.

TL;DR — the capability wall in one screen

  • Read the errno, not the message. EPERM (1) = the syscall was refused (a capability you lack, a seccomp filter, an LSM rule), EACCES (13) = “the file mode said no”, ENOENT (2) = “the device or path is not there at all”. Only the middle one has a chmod as a fix.
  • Two masks decide everything. CapEff in /proc/self/status is what you can use right now; CapBnd is the ceiling. A capability dropped from the bounding set at creation cannot be granted later — the container has to be recreated.
  • This host: CapEff = 0x0000000000000000, CapBnd = 0x00000000a80425fb. The bounding mask decodes to exactly the 14 capabilities Docker lists as its defaults, with net_admin, sys_admin and sys_module absent.
  • The measured wall: six unshare flavours, mount, nsenter, sethostname(2), a raw socket, mknod and two netlink requests all fail with errno 1 — while the kernel-side namespace limits are wide open (max_user_namespaces = 95499, unprivileged_userns_clone = 1), which is why raising a sysctl changes nothing.
  • The fix lives on the docker run line, never inside the container: --cap-add=NET_ADMIN, --cap-add=SYS_ADMIN, --device /dev/net/tun. Docker’s own reference shows the identical pair: ip link add dummy0 type dummy → RTNETLINK answers: Operation not permitted, then the same command under --cap-add=NET_ADMIN.

EPERM is not EACCES, and the difference is one line of output

The fastest way to stop chasing the wrong fix is to make both errnos happen on the same screen. The script below calls five kernel operations: three that need a capability, one that needs a device that does not exist, and one that fails on a unix socket whose mode is 000. Only the last one has a file attached to it.

#!/usr/bin/env python3
"""permcontrast.py — EPERM (errno 1) versus EACCES (errno 13) on one screen.

EPERM comes from a missing capability and has no file to chmod.
EACCES comes from a file mode and has a file to chmod.
"""
import errno
import os
import socket

SOCK = "/tmp/c2cz-permcontrast.sock"


def try_call(label, fn):
    try:
        fn()
        print(f"{label:26s} OK")
    except OSError as exc:
        name = errno.errorcode.get(exc.errno, str(exc.errno))
        print(f"{label:26s} OSError [{exc.errno}] {name} {os.strerror(exc.errno)}")


def main():
    print(f"EPERM={errno.EPERM}  EACCES={errno.EACCES}  ENOENT={errno.ENOENT}")
    print()
    try_call("socket.sethostname", lambda: socket.sethostname("c2cz-lab"))
    try_call("raw ICMP socket",
             lambda: socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP))
    try_call("mknod /dev node", lambda: os.mknod("/tmp/c2cz-nodetest", 0o600 | 0o020000))
    try_call("open /dev/net/tun", lambda: os.open("/dev/net/tun", os.O_RDWR))
    print()
    try:
        os.unlink(SOCK)
    except FileNotFoundError:
        pass
    server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    server.bind(SOCK)
    server.listen(1)
    os.chmod(SOCK, 0o000)
    st = os.stat(SOCK)
    print(f"server socket {SOCK} mode={oct(st.st_mode & 0o777)} uid={st.st_uid}")
    client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    try_call("unix connect (mode 000)", lambda: client.connect(SOCK))
    client.close()
    os.chmod(SOCK, 0o600)
    server.close()
    os.unlink(SOCK)
    print()
    print("a chmod on the first three lines above has nothing to act on")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Run it and the split is immediate:

EPERM=1  EACCES=13  ENOENT=2

socket.sethostname         OSError [1] EPERM Operation not permitted
raw ICMP socket            OSError [1] EPERM Operation not permitted
mknod /dev node            OSError [1] EPERM Operation not permitted
open /dev/net/tun          OSError [2] ENOENT No such file or directory

server socket /tmp/c2cz-permcontrast.sock mode=0o0 uid=10000
unix connect (mode 000)    OSError [13] EACCES Permission denied

a chmod on the first three lines above has nothing to act on

The first three lines have no path in them because no path was opened: sethostname(2), a raw ICMP socket and mknod of a character device are checked against the caller’s effective capability set before any filesystem code runs. The fourth line is a different animal — ENOENT, because /dev/net/tun is not present in this container at all. The last line is the only true permission error, and it is the one chmod repairs. If your error message says “permission denied” and the number is 13, look at the filesystem; if it says “operation not permitted” and the number is 1, look at capabilities. strace -f -e trace=file will not show you a path for errno 1, which is the hint most people miss.

The inventory: what this container actually holds

Before changing anything, take stock. Six shell probes cover the whole surface: the masks, every namespace flavour unshare knows, mount, nsenter, the tunnel device, and the kernel-side limits that people blame first.

#!/bin/bash
# wallprobe.sh — the EPERM wall, measured.
# Creates one empty directory, /tmp/c2cz-wall/mnt, as a mount target. Nothing else is written.
echo "== identity =="
id -u; uname -sr
echo
echo "== capability masks =="
grep -E '^(CapEff|CapBnd|NoNewPrivs|Seccomp):' /proc/self/status
echo
echo "== unshare, all six namespace flavours =="
for arg in -n -U -Ur -m -p -Urm; do
  err=$(unshare $arg /bin/true 2>&1); rc=$?
  echo "unshare $arg -> rc=$rc: $err"
done
echo
echo "== mount(2) via mount(8) =="
mkdir -p /tmp/c2cz-wall/mnt
out=$(mount -t tmpfs tmpfs /tmp/c2cz-wall/mnt 2>&1); rc=$?
printf '%s\n' "$out" | head -1
echo "rc=$rc"
echo
echo "== nsenter into pid 1 =="
nsenter -t 1 -n /bin/true 2>&1; echo "rc=$?"
echo
echo "== the tunnel device =="
ls /dev/net/tun 2>&1; echo "rc=$?"
echo
echo "== kernel-side limits are NOT the blocker =="
for f in /proc/sys/user/max_user_namespaces \
         /proc/sys/kernel/unprivileged_userns_clone \
         /proc/sys/kernel/apparmor_restrict_unprivileged_userns; do
  if [ -r "$f" ]; then echo "$f = $(cat $f)"; else echo "$f = (absent on this kernel)"; fi
done

Here is what it printed on this host, unedited:

== identity ==
10000
Linux 6.17.0-1019-oracle

== capability masks ==
CapEff:	0000000000000000
CapBnd:	00000000a80425fb
NoNewPrivs:	0
Seccomp:	2

== unshare, all six namespace flavours ==
unshare -n -> rc=1: unshare: unshare failed: Operation not permitted
unshare -U -> rc=1: unshare: unshare failed: Operation not permitted
unshare -Ur -> rc=1: unshare: unshare failed: Operation not permitted
unshare -m -> rc=1: unshare: unshare failed: Operation not permitted
unshare -p -> rc=1: unshare: unshare failed: Operation not permitted
unshare -Urm -> rc=1: unshare: unshare failed: Operation not permitted

== mount(2) via mount(8) ==
mount: /tmp/c2cz-wall/mnt: must be superuser to use mount.
rc=32

== nsenter into pid 1 ==
nsenter: reassociate to namespaces failed: Operation not permitted
rc=1

== the tunnel device ==
ls: cannot access '/dev/net/tun': No such file or directory
rc=2

== kernel-side limits are NOT the blocker ==
/proc/sys/user/max_user_namespaces = 95499
/proc/sys/kernel/unprivileged_userns_clone = 1
/proc/sys/kernel/apparmor_restrict_unprivileged_userns = 1

Three lines in that transcript are worth more than the rest. First, every one of the six unshare invocations — including -U, the unprivileged user namespace — returns the identical string unshare: unshare failed: Operation not permitted in under a millisecond. A uniform refusal across flavours is a gate on the syscall itself, and two layers produce it: the capability check, and the runtime’s seccomp filter (Docker installs one by default). With CapEff = 0 you are refused before either layer can be told apart, which is why this article reads the masks rather than guessing the layer. Second, mount(8) refuses in userspace, before the syscall: it checks geteuid() and exits 32 with must be superuser to use mount. — note the rc, because a piped mount ... | head -1 reports 141 (128 + SIGPIPE from head) and looks like a kernel refusal when it is not. Third, the kernel is not the blocker: max_user_namespaces = 95499 and unprivileged_userns_clone = 1 are both permissive, so a runbook that tells you to raise a sysctl is pointing at the wrong layer. But permissive is not unrestricted: this host also reports kernel.apparmor_restrict_unprivileged_userns = 1 (Ubuntu’s default since 24.04), so an unprivileged unshare -U can be refused by the LSM as well. Read the masks first: two lines of /proc explain the majority of cases, and the seccomp and LSM settings explain the rest.

Decode the masks instead of guessing at them

A hex capability mask is unreadable by hand, and the number that matters is not the one you expect. CapEff tells you what is usable right now; CapBnd tells you what the container could ever be granted without being recreated. The script below reports both per operation, and names the flag that would grant it.

#!/usr/bin/env python3
"""capcheck.py — which capabilities does this container hold?

Reads /proc/self/status and prints, per operation, whether the capability it
needs is in the effective set (usable right now) and in the bounding set (what
the runtime could grant when the container was created). Data only: it makes no
claim about which refusal you met, because an EPERM can also come from the
runtime's seccomp profile or an LSM policy rather than from a missing capability.
No imports beyond the standard library, no root, no writes.
"""
import sys

CAPS = [
    "chown", "dac_override", "dac_read_search", "fowner", "fsetid", "kill",
    "setgid", "setuid", "setpcap", "linux_immutable", "net_bind_service",
    "net_broadcast", "net_admin", "net_raw", "ipc_lock", "ipc_owner",
    "sys_module", "sys_rawio", "sys_chroot", "sys_ptrace", "sys_pacct",
    "sys_admin", "sys_boot", "sys_nice", "sys_resource", "sys_time",
    "sys_tty_config", "mknod", "lease", "audit_write", "audit_control",
    "setfcap", "mac_override", "mac_admin", "syslog", "wake_alarm",
    "block_suspend", "audit_read", "perfmon", "bpf", "checkpoint_restore",
]

# operation -> (capabilities it needs, flag that grants them at create time)
OPS = [
    ("wireguard / tailscale interface", ("net_admin",), "--cap-add=NET_ADMIN"),
    ("load a kernel module",            ("sys_module",), "--cap-add=SYS_MODULE"),
    ("unshare(CLONE_NEWNET) / netns",   ("sys_admin",), "--cap-add=SYS_ADMIN"),
    ("mount(2)",                        ("sys_admin",), "--cap-add=SYS_ADMIN"),
    ("sethostname(2)",                  ("sys_admin",), "--cap-add=SYS_ADMIN"),
    ("raw socket (ping, SYN scan)",     ("net_raw",), "--cap-add=NET_RAW"),
    ("bind a port below 1024",          ("net_bind_service",), "--cap-add=NET_BIND_SERVICE"),
    ("mknod a device node",             ("mknod",), "--cap-add=MKNOD"),
]


def read_status(path="/proc/self/status"):
    masks = {}
    with open(path) as fh:
        for line in fh:
            if line.startswith("Cap"):
                key, value = line.split(":", 1)
                masks[key] = int(value.strip(), 16)
    return masks


def bit(mask, name):
    return bool(mask >> CAPS.index(name) & 1)


def main():
    m = read_status()
    eff, bnd = m.get("CapEff", 0), m.get("CapBnd", 0)
    print(f"CapEff = 0x{eff:016x}   usable right now")
    print(f"CapBnd = 0x{bnd:016x}   granted to this container when it was created")
    print(f"CapEff bits: {', '.join(c for c in CAPS if bit(eff, c)) or '(none)'}")
    print(f"CapBnd bits: {', '.join(c for c in CAPS if bit(bnd, c)) or '(none)'}")
    print()
    print(f"{'operation':34s} {'effective':10s} {'bounding':9s} granted by")
    for label, needed, flag in OPS:
        e = "yes" if all(bit(eff, c) for c in needed) else "NO"
        b = "yes" if all(bit(bnd, c) for c in needed) else "no"
        print(f"{label:34s} {e:10s} {b:9s} {flag}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

On this host it prints the two masks and the table. It reports data, not a verdict, on purpose: which refusal you meet is not decided by the mask alone.

CapEff = 0x0000000000000000   usable right now
CapBnd = 0x00000000a80425fb   granted to this container when it was created
CapEff bits: (none)
CapBnd bits: chown, dac_override, fowner, fsetid, kill, setgid, setuid, setpcap, net_bind_service, net_raw, sys_chroot, mknod, audit_write, setfcap

operation                          effective  bounding  granted by
wireguard / tailscale interface    NO         no        --cap-add=NET_ADMIN
load a kernel module               NO         no        --cap-add=SYS_MODULE
unshare(CLONE_NEWNET) / netns      NO         no        --cap-add=SYS_ADMIN
mount(2)                           NO         no        --cap-add=SYS_ADMIN
sethostname(2)                     NO         no        --cap-add=SYS_ADMIN
raw socket (ping, SYN scan)        NO         yes       --cap-add=NET_RAW
bind a port below 1024             NO         yes       --cap-add=NET_BIND_SERVICE
mknod a device node                NO         yes       --cap-add=MKNOD

Read the third column, not the second. raw socket and mknod are missing from the effective set but present in the bounding set — the container was created with them, and the EPERM you get is the runtime clearing CapPrm and CapEff when it execs a non-root uid, not a create-time ceiling. net_admin, sys_admin and sys_module are absent from both, which is the create-time wall: --cap-add is evaluated when the container is created, so the running one cannot acquire them, and a restart returns the same errno. The fix is on the docker run line — recreate with the flag — and for the already-bounding ones the question is the uid the process runs as, not the flag. Recreate, do not restart.

One row in that table is not a capability denial at all. A privileged port returns EACCES (errno 13), not EPERM — inet_bind() sets -EACCES and bind(2) documents “the address is protected” — and on a host with net.ipv4.ip_unprivileged_port_start = 0 it is not refused in the first place. Measured here as uid 10000 with CapEff = 0: binding 127.0.0.1:80, :443 and :1023 all succeed. The distinction matters when you are reading an error log: EACCES on bind means the port policy, EPERM means the syscall was refused — and only the second is answered by --cap-add.

The most-quoted form of this error is RTNETLINK answers: Operation not permitted, printed by ip when you create an interface, add an address or raise a tunnel. ip is not installed here, so I read the same answer one layer down: ip is a thin wrapper over a NETLINK_ROUTE socket, and the errno sits in the kernel’s ACK message. Creating the socket needs no privilege; performing the operation does.

#!/usr/bin/env python3
"""rtnl_probe.py — read the errno that iproute2 prints as 'RTNETLINK answers: ...'.

Sends RTM_NEWLINK to the kernel over a NETLINK_ROUTE socket and prints the
error code from the kernel's ACK. Creating the socket needs no capability;
the operation does. Nothing is created when the kernel refuses.
"""
import errno
import os
import socket
import struct

RTM_NEWLINK = 16
NLMSG_ERROR = 2
NLM_F_REQUEST, NLM_F_ACK, NLM_F_EXCL, NLM_F_CREATE = 1, 4, 0x200, 0x400


def attr(kind, payload):
    body = struct.pack("<HH", len(payload) + 4, kind) + payload
    return body + b"\x00" * ((4 - len(body) % 4) % 4)


def main():
    sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, 0)  # NETLINK_ROUTE
    sock.bind((0, 0))
    print("socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE): OK, no capability needed")

    # ifinfomsg: family, pad, type, index, flags, change
    info = struct.pack("<BBHiII", socket.AF_UNSPEC, 0, 0, 0, 0, 0xFFFFFFFF)
    linkinfo = attr(1, b"dummy\x00")            # IFLA_INFO_KIND = "dummy"
    payload = info + attr(3, b"c2czdummy\x00")  # IFLA_IFNAME
    payload += attr(18, linkinfo)               # IFLA_LINKINFO
    flags = NLM_F_REQUEST | NLM_F_ACK | NLM_F_EXCL | NLM_F_CREATE
    msg = struct.pack("<IHHII", len(payload) + 16, RTM_NEWLINK, flags, 1, 0) + payload
    sock.send(msg)

    reply = sock.recv(4096)
    kind = struct.unpack("<H", reply[4:6])[0]
    error = struct.unpack("<i", reply[16:20])[0]
    print(f"kernel ACK: type={kind} (NLMSG_ERROR={NLMSG_ERROR}) error={error}")
    if kind == NLMSG_ERROR and error != 0:
        num = -error
        print(f"errno {num} = {errno.errorcode.get(num)} = {os.strerror(num)}")
        print(f'iproute2 prints exactly: "RTNETLINK answers: {os.strerror(num)}"')
    sock.close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The reply is an NLMSG_ERROR message whose error field is negative errno:

socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE): OK, no capability needed
kernel ACK: type=2 (NLMSG_ERROR=2) error=-1
errno 1 = EPERM = Operation not permitted
iproute2 prints exactly: "RTNETLINK answers: Operation not permitted"

The tool echoes the errno as a string with perror(3), which is why the message looks like a syntax error rather than a permission error. iproute2’s own source shows it — lib/libnetlink.c, the error path that fires when the kernel refuses:

fprintf(stderr, "RTNETLINK answers: %s\n",
		strerror(-err->error));

Docker’s docker run reference documents the same reproduction and the same fix, on a stock ubuntu:24.04 image (whitespace normalised from the docs code block, the error line is verbatim). Their block assumes the image already ships ip, and ubuntu:24.04 does not, so the runnable form installs it in the same shell before the capability is even tested:

$ docker run -it --rm ubuntu:24.04 sh -c 'apt-get update -qq && apt-get install -y --no-install-recommends iproute2 >/dev/null && ip link add dummy0 type dummy'
RTNETLINK answers: Operation not permitted
$ docker run -it --rm --cap-add=NET_ADMIN ubuntu:24.04 sh -c 'apt-get update -qq && apt-get install -y --no-install-recommends iproute2 >/dev/null && ip link add dummy0 type dummy'
# per the docs, the flag is what makes this second line succeed

The same page states the rule plainly: “For interacting with the network stack, instead of using --privileged they should use --cap-add=NET_ADMIN to modify the network interfaces.” That is a reference to a capability, not to root — and it is the sentence that rules out sudo as a strategy inside a container.

The substitution above is the point, not a detail: a missing binary and a missing capability are different errors, and only one is answered by a flag. Without iproute2 installed, the first line dies at exec with exec: "ip": executable file not found in $PATH and no netlink message is ever sent — so if you reproduce the docs pair, install the tool in the same shell, as written. I could not run these two lines here — this host has no Docker daemon, only the client — which is why the measured transcript above comes from the netlink socket directly rather than from ip.

The tunnel device fails differently — and that one is not a capability

Self-hosted VPNs on a container platform hit two walls, and they have different answers. The first is the interface: creating wg0 or tailscale0 is a netlink operation, so it needs NET_ADMIN. The second is the device: a userspace tunnel implementation opens /dev/net/tun, and on this host that call returns [Errno 2] No such file or directory, not errno 1. A missing device node cannot be fixed with a capability and a capability cannot be fixed with a device — but you need both, which is why half-fixed containers keep failing. Docker documents the pairing for the same class of problem: “To mount a FUSE based filesystem, you need to combine both --cap-add and --device”, with fuse: failed to open /dev/fuse: Operation not permitted as the error from half the pair.

What the WireGuard container asks for, and why

The image maintainers say it more directly than any tutorial. The LinuxServer WireGuard README lists the flags and their reasons in a table — the spelling is theirs:

| `--cap-add=NET_ADMIN` | Neccessary for Wireguard to create its VPN interface. |
| `--cap-add=SYS_MODULE` | Neccessary for loading Wireguard kernel module if it's not already loaded. |
| `--sysctl=` | Required for client mode. |

And the run command it ships as the reference, trimmed to the privilege lines:

docker run -d \
  --name=wireguard \
  --cap-add=NET_ADMIN \
  --cap-add=SYS_MODULE `#optional` \
  -v /path/to/wireguard/config:/config \
  -v /lib/modules:/lib/modules `#optional` \
  --sysctl="net.ipv4.conf.all.src_valid_mark=1" \
  --restart unless-stopped \
  lscr.io/linuxserver/wireguard:latest

The translate-this-for-your-stack version is short. NET_ADMIN is not optional for anything that creates a network interface; SYS_MODULE and -v /lib/modules are only needed when the kernel module is not already loaded on the host; the src_valid_mark sysctl is what the client mode needs so reply packets keep the right mark. None of the three has a userspace equivalent, so the “drop privileges” instinct yields a container that starts and cannot connect.

Grant it on the create line, verify it inside

Capabilities are applied when the container is created, so the change belongs in the definition, not in a shell. For a self-hosted WireGuard or Tailscale sidecar, the minimum set that has worked on every host I have run it on is NET_ADMIN plus the tunnel device; add SYS_MODULE only if you load the module from inside.

docker run -d --name wg0 \
  --cap-add=NET_ADMIN \
  --device /dev/net/tun \
  --sysctl net.ipv4.conf.all.src_valid_mark=1 \
  -v /opt/wg0:/config \
  -p 51820:51820/udp \
  lscr.io/linuxserver/wireguard:latest

# everything else, no capabilities at all
docker run -d --name whoami --cap-drop=ALL traefik/whoami

Compose spells the same three lines in its own dialect — note that the YAML key is cap_add and the sysctl is a list, not a string:

services:
  wg0:
    image: lscr.io/linuxserver/wireguard:latest
    cap_add:
      - NET_ADMIN
      - SYS_MODULE      # only when the host module is absent
    devices:
      - /dev/net/tun
    sysctls:
      net.ipv4.conf.all.src_valid_mark: 1
    ports:
      - "51820:51820/udp"

Two details the flags do not advertise. --cap-add takes the name with or without the CAP_ prefix — NET_ADMIN and CAP_NET_ADMIN are the same request — and some runtimes drop capabilities silently; the WireGuard README warns that certain Portainer versions do. Second, --privileged grants NET_ADMIN plus SYS_ADMIN, device access and kernel-interface writes. For a container that terminates internet traffic that is the wrong trade; use the two flags.

Then verify from inside: a container that starts is not a container that has the privilege. Three checks — the mask, the device, the operation:

docker exec wg0 grep -E "^(CapEff|CapBnd):" /proc/self/status
docker exec wg0 ls -l /dev/net/tun
docker exec wg0 ip link add dummy0 type dummy && docker exec wg0 ip link del dummy0

CapEff should include net_admin (bit 12). If it does not, the flag did not reach the runtime. If ls /dev/net/tun fails, the device is missing even though the capability is there. The third line is the only real test: it performs the operation that used to fail, and deletes the evidence afterwards. For the branches that are not capability-related — a socket that is present but unreachable — the four-branch classifier in Cannot connect to the Docker daemon covers the socket-layer branches that are not capability-related, and the SYN-scan post walks through the one capability most self-hosters already hold, CAP_NET_RAW. If your container is dying on a limit rather than a privilege, the file-descriptor limit post is the right starting point: errno 24, not errno 1.

  • Measured on this host: Linux 6.17.0-1019-oracle aarch64, Python 3.13.5, uid 10000, no effective capabilities, Seccomp: 2. Every transcript above is that run’s stdout.
  • Docker, docker run reference — runtime privilege and Linux capabilities: docs.docker.com/engine/containers/run/ (the RTNETLINK answers: Operation not permitted example, the --cap-add=NET_ADMIN guidance and the FUSE --device pairing).
  • LinuxServer.io WireGuard image README — capability table and reference run command: github.com/linuxserver/docker-wireguard.
  • iproute2, lib/libnetlink.c — the RTNETLINK answers: formatter, line 1024: github.com/iproute2/iproute2.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.