Part 1 ended with otbr-agent — the OpenThread Border Router daemon — running as a dedicated system user with three network capabilities and a systemd-analyze security score of 4.1 OK. The service unit carried a hardening block we didn’t explain:
root@iot-gateway:~# systemctl cat otbr-agent | grep -A20 "# Capabilities"
# Capabilities and hardening
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
PrivateDevices=false
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictNamespaces=true
RestrictSUIDSGID=true
LockPersonality=true
This post walks through each directive: what it does, what it restricts, and where the tradeoffs are for a border router specifically. One — PrivateDevices — had to be set to false. The others held.
Setup note: Validated on a Yocto-built image on a Raspberry Pi 5 (systemd 255, Linux 6.18). Kernel version, systemd version, and how your dependencies are built all affect which directives are safe to enable. The drop-in test pattern used throughout — write a
/run/systemd/system/<service>.d/test.conf,daemon-reload, restart, verify — is how to check any directive on your own system before committing it.
What systemd-analyze security actually measures
Before walking the directives, it helps to understand what the tool is actually scoring.
systemd-analyze security <service> evaluates the service unit against a rubric of hardening options. Each missing or weakly-set directive contributes exposure points to the total score. Lower is better:
0.0 → very secure
0.1 – 2.9 → OK
3.0 – 5.9 → medium exposure
6.0 – 8.9 → exposed
9.0+ → UNSAFE
A ✓ means the directive is set and provides the expected restriction. A ✗ means it’s absent or set permissively — but ✗ is a signal, not a verdict. A border router that manages network interfaces will always have PrivateNetwork= marked ✗ because isolating the network namespace would break the thing it exists to do. That’s a deliberate gap, not a missed directive.
The useful question isn’t “how do I get to zero?” It’s “do I understand every ✗ in my output?”
Here’s what it looks like on this gateway, abbreviated:
root@iot-gateway:~# systemd-analyze security otbr-agent.service --no-pager
NAME DESCRIPTION EXPOSURE
✗ PrivateDevices= Service potentially has access to hardware devices 0.2
✓ PrivateTmp= Service has no access to other software's temporary files
✓ ProtectHome= Service has no access to home directories
✓ ProtectKernelLogs= Service cannot read from or write to the kernel log ring buffer
✓ ProtectKernelModules= Service cannot load or read kernel modules
✓ RestrictNamespaces=~user Service cannot create user namespaces
✓ RestrictNamespaces=~net Service cannot create network namespaces
✓ LockPersonality= Service cannot change ABI personality
✓ RestrictSUIDSGID= SUID/SGID file creation by service is restricted
✗ PrivateNetwork= Service has access to the host's network 0.5
✗ SystemCallFilter=~@privileged Service does not filter system calls 0.2
✗ UMask= Files created by service are world-readable by default 0.1
...
→ Overall exposure level for otbr-agent.service: 4.1 OK :-)
Every ✗ is accounted for by the end of this post.
The hardening directives
Walking through each directive above — what it restricts and where the tradeoffs are. The systemd.exec(5) man page has the full semantics; links to the relevant anchors are in each section.
ProtectSystem=full
Remounts /usr, /boot, and /efi read-only inside the service’s mount namespace. The process can’t modify system binaries or bootloader files. strict would make the entire file system hierarchy read-only and would require a complete audit of every path the service writes to at runtime — that audit is deferred. full covers the primary threat surface without that cost.
ProtectHome=true
No access to /home, /root, or /run/user. Standard for any daemon — a border router has no business reading home directories.
PrivateTmp=true
The service gets an isolated /tmp and /var/tmp namespace, separate from the host and all other services. Rules out a class of attacks where one service leaves crafted files in shared temp space for another to consume.
PrivateDevices=false — the forced exception
This is the one directive that had to stay off.
PrivateDevices=true mounts a minimal private /dev that contains only a safe subset of device nodes: null, zero, full, random, urandom, tty. It explicitly excludes real hardware devices — including /dev/net/tun.
OTBR creates the wpan0 Thread interface by opening /dev/net/tun and issuing a TUNSETIFF ioctl. Without the TUN device node, startup fails immediately:
iot-gateway otbr-agent[4235]: [NOTE]-AGENT---: Radio URL: spinel+hdlc+uart:///dev/otbr-rcp?uart-baudrate=460800
iot-gateway otbr-agent[4235]: [C] Platform------: Init() at hdlc_interface.cpp:153: No such file or directory
iot-gateway systemd[1]: otbr-agent.service: Main process exited, code=exited, status=5/NOTINSTALLED
iot-gateway systemd[1]: otbr-agent.service: Failed with result 'exit-code'.
Verified by enabling it via a /run/systemd/system/ drop-in on the device:
mkdir -p /run/systemd/system/otbr-agent.service.d/
cat > /run/systemd/system/otbr-agent.service.d/test.conf << EOF
[Service]
PrivateDevices=true
EOF
systemctl daemon-reload && systemctl restart otbr-agent
The RCP serial device (/dev/otbr-rcp, symlinked to /dev/ttyACM1) also disappears from the private /dev. Both failures happen at the same point.
PrivateDevices=false is the correct setting for this configuration. The TUN device requirement is fundamental to how OTBR creates the wpan0 interface; no alternative was validated against this build.
ProtectKernelTunables=true
Prevents the process from writing to /proc/sys at runtime — no sysctl modifications from within the service. This is safe for OTBR provided IPv6 forwarding is configured at boot, not by the daemon.
On this image, net.ipv6.conf.all.forwarding=1 is set via a sysctl.d fragment applied during early boot by systemd. OTBR never needs to set it at runtime. If your image relies on OTBR to configure forwarding dynamically, this directive needs to come off.
ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectClock
All safe for OTBR without exception:
ProtectKernelModules=true— the service cannot load or unload kernel modulesProtectKernelLogs=true— no access to/proc/kmsgor the kernel log ring bufferProtectControlGroups=true— the cgroup filesystem is read-only; the service can’t modify its own or any other cgroupProtectClock=true— blocksclock_settime,settimeofday, and related calls; NTP is managed bysystemd-timesyncd, not OTBR
RestrictAddressFamilies=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
Any socket() call for a family not in this list is blocked by a seccomp BPF rule that systemd generates and loads before ExecStart runs. The four allowed families come directly from the strace output in Part 1:
| Family | Used for |
|---|---|
AF_UNIX | D-Bus, local IPC, the agent’s own Unix domain socket |
AF_INET / AF_INET6 | REST API, TREL, multicast, ICMPv6 |
AF_NETLINK | Route management via netlink sockets (NETLINK_ROUTE) |
AF_PACKET is notably absent — raw packet socket access at the Ethernet layer is not needed. Excluding it is correct and intentional.
RestrictNamespaces=true
Blocks unshare(), clone() with namespace flags, and setns() for all namespace types. The implementation is seccomp BPF — each namespace type gets its own filter program. This is where the 15 seccomp filters visible in Part 1’s /proc/status output come from:
Seccomp: 2
Seccomp_filters: 15
RestrictNamespaces, RestrictAddressFamilies, LockPersonality, and RestrictSUIDSGID together account for all 15. None of them appear as SystemCallFilter= in systemd-analyze security output — they’re a side effect of sandbox directives, not an explicit syscall allowlist.
LockPersonality=true, RestrictSUIDSGID=true
LockPersonality=true prevents personality() syscall — the process can’t change the kernel execution domain (e.g. switch to Linux32 ABI emulation). RestrictSUIDSGID=true prevents the service from creating setuid/setgid files. Both are standard; neither has OTBR-specific caveats.
NoNewPrivileges=true with ambient capabilities
NoNewPrivileges=true ensures that any binary executed via execve() within the service — including child processes — cannot gain capabilities or privileges beyond what the bounding set allows. Combined with AmbientCapabilities, the interaction is:
- Ambient set grants capabilities at startup without
setcapon the binary - Bounding set caps what can ever be held
NoNewPrivilegespreventsexecvefrom escalating beyond the bounding set
With all three set consistently to the same capability list, this is belt-and-suspenders: the bounding set already prevents escalation, NoNewPrivileges makes that guarantee hold for child processes too. Correct default for any non-root service with ambient capabilities.
StateDirectory= and ReadWritePaths=
StateDirectory=thread
ReadWritePaths=/var/lib/thread
ReadWritePaths=/run
StateDirectory=thread tells systemd to create /var/lib/thread owned by the otbr user before the service starts. OTBR persists Thread network dataset here across reboots.
ProtectSystem=full makes /usr, /boot, and /efi read-only — it does not restrict /run. RuntimeDirectory=otbr creates /run/otbr with correct ownership before the process starts, which is all the service actually needs. ReadWritePaths=/run is redundant here and can be dropped; it’s in the unit as a leftover from earlier iterations when the sandbox boundary wasn’t fully mapped.
The remaining gaps
✗ PrivateNetwork= Service has access to the host's network 0.5
✗ RestrictAddressFamilies=~AF_(INET|INET6) Service may allocate Internet sockets 0.3
✗ SystemCallFilter=~@privileged Service does not filter system calls 0.2
✗ SystemCallFilter=~@clock Service does not filter system calls 0.2
✗ UMask= Files created by service are world-readable by default 0.1
PrivateNetwork= — a border router manages host network interfaces by design. Running in a private network namespace would isolate it from wlan0, wpan0, and the infrastructure network it exists to bridge. This gap cannot be closed.
RestrictAddressFamilies=~AF_(INET|INET6) — AF_INET and AF_INET6 are in the allowed set because OTBR needs them. systemd’s scoring flags any service that can allocate internet sockets as exposed, regardless of whether those sockets are required. The allowed family list is as tight as it can be.
SystemCallFilter= — an explicit syscall allowlist would tighten the remaining attack surface significantly. It’s not set here because building a correct allowlist requires profiling the full runtime syscall set — strace under normal operation, error paths, and restart scenarios. A wrong allowlist causes silent failures that are harder to debug than a missing hardening directive. Deferred to a future iteration.
UMask= — low severity. Files created by the service default to world-readable. Setting UMask=0027 would restrict this. It’s a cosmetic open item; OTBR doesn’t create files that require tighter default permissions.
The score
root@iot-gateway:~# systemd-analyze security otbr-agent.service --no-pager
...
→ Overall exposure level for otbr-agent.service: 4.1 OK :-)
4.1 OK. Every ✗ in the output is accounted for: one is a hard operational requirement (PrivateNetwork), one is a documented tradeoff (SystemCallFilter), two are required socket families that systemd scores as exposed regardless of intent (RestrictAddressFamilies), and one is cosmetic (UMask). None are oversights.
That closes the OTBR service. The pattern — read the directive, test it on hardware, account for every ✗ — is the same work for every other daemon on this gateway. When another service has an interesting story, it’ll make it here.