Contents

systemd hardening: taking a Python service from 9.6 to 1.7

TL;DR

systemd-analyze security is an underused tool. It scans your unit files and computes an exposure score from 0.0 (UNSAFE) to 10.0 (PERFECT). Custom Python services often score around 9.6 by default — that’s bad.

I took my hugo-mcp service (FastAPI exposing 7 MCP tools) from 9.6 → 1.7 without breaking a single feature. Here are the directives that actually matter, and the ones that are traps.

Initial score

Diagram Diagram
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 9.6 UNSAFE 😨

9.6 out of 10. The service runs root-equivalent, can setuid(), can allocate RWX memory, sees all system processes, can write everywhere, can make arbitrary syscalls.

For an MCP that edits Markdown content and triggers a Hugo build, that’s massive overkill.

The directives that actually matter

1. System isolation

[Service]
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid

Impact: the service only sees /proc/<its-pid>/, can’t touch the kernel, cgroups, or system clock.

2. Linux capabilities

CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=true

The service runs with zero capabilities. No CAP_NET_BIND_SERVICE, no CAP_SYS_ADMIN. If you listen on a port < 1024, use socket activation or setcap on the binary — not an ambient capability.

3. Filesystem write whitelist

ReadWritePaths=/home/jm/hugo-mcp /home/jm/hugo-site
ReadOnlyPaths=/etc
PrivateMounts=true

The service can write to ONLY 2 specific folders. Any other open(O_WRONLY) returns EROFS. Even if someone finds an RCE in FastAPI, the attacker can’t modify /etc/passwd or /usr/local/bin/.

4. Network egress whitelist

IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
IPAddressAllow=192.168.122.1/32
# Cloudflare API (cache purge)
IPAddressAllow=104.16.0.0/12
IPAddressAllow=2606:4700::/32

The most powerful and trickiest directive. The service can only reach loopback, libvirt bridge, and Cloudflare ranges.

Trap #1: if you forget external ranges your service calls (Cloudflare API, GitHub, your log broker), you’ll get silent timeouts that are hard to debug. I spent ~2h figuring out why purge_cache was timing out at 5s when manual SSH worked: IPAddressDeny=any was active without Cloudflare whitelisting.

Trap #2: IPAddressAllow only supports IPv4/IPv6 ranges, not hostnames. So if Cloudflare changes its ranges, you have to update the unit file.

5. Syscall filter

SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native

@system-service is a predefined set of “reasonable” syscalls for a service. Blocks anything niche (mount, swapon, reboot…).

6. Bonus

LockPersonality=true              # blocks setpersonality()
RestrictRealtime=true             # no realtime scheduling
RestrictSUIDSGID=true             # no setuid file creation
RemoveIPC=true                    # IPC cleanup on stop
RestrictNamespaces=true           # no unshare(CLONE_NEWNS)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true       # blocks mmap PROT_WRITE|PROT_EXEC
UMask=0077                        # files created without group/world

MemoryDenyWriteExecute=true is interesting: it blocks RWX mmap, which breaks most JIT-style shellcodes. For a pure Python service (no JIT), zero functional impact.

The UMask=0077 trap

This directive creates ALL files as 0600 (read/write only for the user). Innocent-looking.

Except my hugo-mcp service writes to public/ (Hugo build folder), and nginx (which serves these files) runs as a different user. Result:

$ ls -la public/
-rw------- 1 hugo-mcp hugo-mcp  4823 May  9 14:23 index.html

nginx can no longer read index.html. The site returns 403 Forbidden on everything after each rebuild.

Fix: add chmod -R o+rX public/ in deploy.sh after each build:

hugo --minify
chmod -R o+rX public/   # ← essential after UMask=0077
rsync -a public/ /var/www/hugo/

Final score

$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 1.7 EXPOSED 😱

From 9.6 UNSAFE to 1.7 EXPOSED. The “EXPOSED” label still sounds threatening, but systemd-analyze just considers any service that listens on a port to be exposed by nature. Going lower means socket activation + non-privileged User= + full namespace isolation, which no longer makes sense for an active network service.

What systemd-analyze doesn’t tell you

The systemd score only covers kernel isolation. It says nothing about:

  • Application security (auth, input validation, rate limiting…)
  • Secrets in /etc/secrets/ (readable by root via another path)
  • Logs that might leak tokens
  • Python deps (CVE in a cached lib)

It’s just one layer in a defense-in-depth strategy. But it’s a cheap layer that hardens the default posture significantly.

Recipe applicable to any Python service

If you have a Python systemd service, here’s a drop-in you can adapt in 5 minutes:

sudo systemctl edit my-service
[Service]
# Isolation
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectProc=invisible
ProcSubset=pid

# Capabilities
CapabilityBoundingSet=
AmbientCapabilities=

# Filesystem
ReadWritePaths=/home/user/data
PrivateMounts=true

# Network
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
# Add the external ranges you need

# Syscalls
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

# Bonus
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true
UMask=0077

Then:

sudo systemctl daemon-reload
sudo systemctl restart my-service
sudo systemd-analyze security my-service

If the service refuses to start, check journalctl -u my-service -n 50 — usually it’s IPAddressDeny=any blocking a forgotten external API call.

Conclusion

systemd hardening is an underrated ops discipline. 30 minutes of investment to go from 9.6 to 1.7 on a service. Almost always worth it.

For the ongoing MCP security sprint, this is just the foundation. Still to come: application-level rate limiting, structured JSON audit logs, strict Pydantic validation, token rotation, internal TLS. But none of those layers would make sense on a 9.6 UNSAFE service.

You harden the OS first. The code comes after.