Security Hardening
Security & Privacy Hardening
Defense-in-depth architecture: hardware token, secure boot, disk encryption, kernel hardening, application sandboxing, network isolation.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Layer 7: Application Sandbox (nixpak + bubblewrap) │
│ Discord, Telegram, Zoom, WeMeet │
├─────────────────────────────────────────────────────────────┤
│ Layer 6: MAC (AppArmor) │
│ killUnconfinedConfinables = true │
├─────────────────────────────────────────────────────────────┤
│ Layer 5: Network (Firewall + Fail2Ban + Tailscale) │
│ LAN-scoped rules, exponential bans, WireGuard mesh │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: Secrets (Agenix) │
│ Per-host scoping, runtime decryption, SSH key identity │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: User Auth (YubiKey PAM U2F + SSH ed25519-sk) │
│ systemd-lock-handler, loginctl unified lock dispatch │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Boot (Lanzaboote Secure Boot + LUKS2 + FIDO2) │
│ Signed kernel, encrypted root, hardware-unlocked LUKS │
├─────────────────────────────────────────────────────────────┤
│ Layer 1: Kernel (sysctl hardening) │
│ kptr_restrict, dmesg_restrict, BPF hardening, ASLR │
├─────────────────────────────────────────────────────────────┤
│ Layer 0: Hardware (YubiKey, USBGuard, TPM2) │
│ Physical token, USB policy, measured boot │
└─────────────────────────────────────────────────────────────┘
Layer 0: Hardware Security
YubiKey (modules/profiles/hardware/yubikey.nix)
Your YubiKey serves multiple roles in the security stack:
| Function | Mechanism | Module |
|---|---|---|
| sudo/login auth | PAM U2F | security.pam.u2f |
| LUKS unlock | FIDO2 | systemd-cryptenroll --fido2-device=auto |
| SSH keys | ed25519-sk (non-exportable) | ssh-keygen -t ed25519-sk |
| Git commit signing | SSH signing | git config gpg.format ssh |
| Auto-lock on removal | udev rule | loginctl lock-session |
Touch requirement: sudo operations require physical touch on the YubiKey, preventing remote attackers from escalating privileges even with SSH access.
Screen Lock Architecture
The screen lock uses a unified loginctl lock-session entry point
backed by systemd-lock-handler. Every lock trigger converges on
lock.target — the locker implementation is a systemd service detail,
not something each call site decides.
any trigger ──→ loginctl lock-session
→ logind signal
→ systemd-lock-handler starts lock.target
├─ (DMS handles locks natively — see below)
├─ diy-lock.service (DIY) hyprlock
└─ x-lock.service (X11) i3lock
DMS does not bind a service to lock.target. Instead, DMS listens for
logind session events natively via its SessionService (onSessionLocked /
onSessionUnlocked in Lock.qml). When loginctl lock-session fires, DMS
detects the signal and raises the Wayland session lock directly — no systemd
service needed. Hotkey binds call dms ipc call lock lock as a fast path.
Only one service is wantedBy lock.target per host — the desktop mode
modules gate them on their enable flags. On DMS hosts, no service binds
to lock.target at all.
| Trigger | Mechanism | File |
|---|---|---|
| Idle timeout | swayidle / hypridle → idle.zsh | swayidle.nix, hypridle.nix |
| Before sleep | swayidle before-sleep → idle.zsh | swayidle.nix |
| YubiKey removal | udev rule → runuser … loginctl lock-session | yubikey.nix |
| Compositor startup | startup hook → loginctl lock-session | niri.nix, hyprland.nix, bspwm.nix |
| Hotkey / rofi | Mod+L bind → loginctl lock-session | binds.conf, binds.kdl, powermenu.zsh |
| CLI / script | hey .lock (wrapper) → loginctl lock-session | bin/lock.zsh |
The idle daemons (swayidle, hypridle) bind to graphical-session.target
generically — no compositor-specific coupling. They detect inactivity and
invoke the lock hook; the hook calls loginctl lock-session, which
dispatches to the appropriate locker via systemd.
On unlock, the flow depends on the locker type:
- DMS: DMS listens for logind unlock signals natively via
onSessionUnlocked()(Lock.qml line 115). The lock screen dismisses itself — no systemd service involvement. External unlocks (YubiKey,loginctl unlock-session) work becauseonSessionLocked()setslockInitiatedLocally = false, soonSessionUnlocked()allows the dismiss. - DIY / X11 (
diy-lock.service/x-lock.service): The locker process blocks until PAM authentication succeeds, then exits with 0.onSuccess=unlock.targettriggers the unlock.
A wtype key event resets the idle timer (PAM/FIDO2 auth bypasses compositor
input).
USBGuard (modules/profiles/hardware/usbguard.nix)
USBGuard blocks unauthorized USB devices by default:
implicitPolicyTarget = "block"; # Deny unknown devices
Status: Rules are placeholder stubs. To populate with real device IDs:
# After plugging in all your trusted devices:
usbguard generate-policy > /etc/usbguard/rules.conf
# Or use the NixOS option:
modules.hardware.usbguard.rules = [
"allow id 046d:c539 # Logitech receiver"
"allow id 1050:0407 # YubiKey"
];
TPM2 (Trusted Platform Module)
TPM2 provides unattended LUKS unlock — the TPM releases the disk encryption key only if the boot chain (firmware → shim → kernel) hasn’t been tampered with. Unlike FIDO2/YubiKey, no physical interaction is needed at boot. The tradeoff: a stolen-but-powered-on machine will auto-unlock.
Hardware requirements: a physical TPM 2.0 chip. Most x86 machines sold
after ~2016 have one. Check with: ls /dev/tpm*. VM TPMs (swtpm) work for
testing but provide no real security — the TPM state is just a file on the
host.
Step 1: Enable the TPM2 profile
Add "tpm2" to the host’s hardware profiles:
modules.profiles.hardware = [
"cpu/amd"
"tpm2" # ← add this
];
This enables security.tpm2 (abrmd, PKCS11, TCTI environment), installs
tpm2-tools, and ensures the TPM2 resource manager runs at boot.
Module: modules/profiles/hardware/tpm2.nix.
Step 2: Verify the TPM is visible
sudo systemctl status tpm2-abrmd # resource manager running
tpm2_getrandom 8 # TPM generates random bytes
ls /dev/tpm0 /dev/tpmrm0 # raw device + kernel RM
Step 3: Choose PCR banks
The TPM measures boot components into Platform Configuration Registers (PCRs). You pick which PCRs to bind the LUKS key to:
| PCR | What it measures | Changes when |
|---|---|---|
| 0 | UEFI firmware | BIOS update, motherboard swap |
| 1 | UEFI configuration | Boot order change, UEFI settings |
| 2 | UEFI drivers / option ROMs | GPU firmware update |
| 4 | Boot manager (shim/systemd-boot) | Bootloader update |
| 7 | Secure Boot state + certificates | SB key rotation, SB toggle |
| 11 | kernel.command_line + UKI sections | Kernel cmdline change |
| 14 | shim’s MokList + vendor certs | MOK enrollment |
Recommended for most deployments: 0+2+7 — covers firmware, boot chain,
and Secure Boot without locking on every kernel update.
Strict (tamper-evident): 0+1+2+3+4+5+7+11 — locks on almost any change,
including kernel updates and boot order changes. Requires re-enrollment
after every nixos-rebuild that touches the kernel. Only useful for
high-security static systems.
Step 4: Enroll the TPM (one-time manual step)
# Check your current LUKS device layout
sudo cryptsetup luksDump /dev/<disk-partition> | head -20
# Enroll TPM2 alongside existing passphrase/key (does NOT remove old slots)
sudo systemd-cryptenroll \
--tpm2-device=auto \
--tpm2-pcrs="0+2+7" \
/dev/<disk-partition>
Example for a typical NVMe setup:
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs="0+2+7" /dev/nvme0n1p2
To require a PIN (TPM + user PIN = defense against physical theft):
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs="0+2+7" \
--tpm2-with-pin=yes /dev/nvme0n1p2
Step 5: Add crypttab options (NixOS config)
In the host’s storage.nix, add to the existing LUKS device:
boot.initrd.luks.devices."root".crypttabExtraOpts = [
"tpm2-device=auto"
"tpm2-measure-pcr=yes"
];
The tpm2-measure-pcr=yes option ensures the initrd measurements extend
PCR 11, which you can use if binding to PCR 11 (kernel cmdline).
Step 6: Rebuild and test
hey sync --host <host>
# On next boot, the disk should auto-unlock without a password.
# If it fails, the boot will fall back to passphrase prompt.
Recovery after firmware update
BIOS updates change PCR 0 — the TPM-unsealed key becomes invalid and boot falls back to passphrase. After booting with the passphrase, re-enroll:
# Remove the old TPM slot and re-enroll
sudo systemd-cryptenroll /dev/<disk-partition> --wipe-slot=tpm2
sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs="0+2+7" /dev/<disk-partition>
Verifying the TPM protects the key
# List enrolled slots; look for the "tpm2" slot
sudo systemd-cryptenroll /dev/<disk-partition>
# Test: clear the TPM and verify the slot can't unlock
sudo tpm2_clear -c p # WARNING: wipes ALL TPM data
# After this, only passphrase/FIDO2 slots will work.
Layer 1: Kernel Hardening (modules/security.nix)
The kernel is hardened via sysctl parameters:
Memory & Address Space
| Parameter | Value | Effect |
|---|---|---|
kernel.kptr_restrict | 2 | Hide kernel pointers from unprivileged users |
kernel.dmesg_restrict | 1 | Restrict dmesg to root only |
vm.mmap_rnd_bits | 32 | ASLR entropy for mmap |
vm.mmap_rnd_compat_bits | 16 | ASLR entropy for compat mode |
kernel.unprivileged_bpf_disabled | 1 | Disable BPF for unprivileged users |
net.core.bpf_jit_harden | 2 | Full BPF JIT hardening |
Network Hardening
| Parameter | Value | Effect |
|---|---|---|
net.ipv4.tcp_syncookies | 1 | SYN flood protection |
net.ipv4.tcp_rfc1337 | 1 | Protect against TIME-WAIT assassination |
net.ipv4.conf.all.rp_filter | 1 | Strict reverse path filtering |
net.ipv4.conf.all.accept_redirects | 0 | Ignore ICMP redirects |
net.ipv6.conf.all.accept_redirects | 0 | Ignore ICMPv6 redirects |
net.ipv4.conf.all.send_redirects | 0 | Don’t send ICMP redirects |
net.ipv4.icmp_echo_ignore_broadcasts | 1 | Ignore broadcast pings |
Blacklisted Kernel Modules
Dangerous/uncommon protocols and known-vulnerable modules are blacklisted:
boot.blacklistedKernelModules = [
"dccp" "sctp" "rds" "rds_tcp" "rds_rdma" "tipc" # rare protocols
"esp4" "esp6" "rxrpc" # Dirty Frag LPE mitigation
];
Vulnerability-driven blacklists are managed separately from static hardening — see docs/vulnerability-response.md for the active list, how to add new entries, and how to verify mitigations on a running host.
Coredumps
Disabled system-wide to prevent information leakage:
systemd.coredump.enable = false;
Performance (not hardening, but co-located)
net.core.default_qdisc = "cake"; # Buffer bloat protection
net.ipv4.tcp_congestion_control = "bbr"; # Google's congestion control
Layer 2: Secure Boot & Disk Encryption
Lanzaboote Secure Boot (modules/profiles/role/workstation.nix)
Lanzaboote signs the kernel and initrd with your own keys, preventing bootkit attacks:
boot.lanzaboote = {
enable = true;
pkiBundle = "/etc/secureboot"; # Keys persisted via impermanence
};
Key management: sbctl is installed for key creation and enrollment:
sbctl create-keys
sbctl enroll-keys --microsoft # Include Microsoft keys for dual-boot
sbctl verify # Check signing status
Active on: bio-smart host (boot = "lanzaboote")
LUKS2 Disk Encryption
Full-disk encryption with multiple unlock methods:
| Host | Encryption | Unlock Method |
|---|---|---|
id3-eniac | LUKS2 (2 partitions) | Keyfile + FIDO2 |
bio-smart | LUKS2 (root) | FIDO2 + password fallback |
sbc-opi5p | None (server) | N/A |
FIDO2 enrollment (one-time setup):
# Add FIDO2 as a LUKS unlock method:
sudo systemd-cryptenroll --fido2-device=auto /dev/nvme0n1p2
# Verify enrolled methods:
sudo systemd-cryptenroll /dev/nvme0n1p2
Agenix-managed keyfiles: LUKS keyfiles are stored as agenix secrets (*.luks-keyfile), decrypted at boot to /run/agenix/.
Layer 3: User Authentication
PAM U2F (modules/profiles/hardware/yubikey.nix)
All interactive authentication requires YubiKey touch:
security.pam.u2f = {
enable = true;
cue = true; # "Please touch the device"
authFile = config.age.secrets.yubikey-auth.path;
};
Protected services: sudo, login, greetd, hyprlock
SSH Authentication (modules/services/net/ssh.nix)
| Setting | Value | Effect |
|---|---|---|
PasswordAuthentication | false | No password login |
KbdInteractiveAuthentication | false | No keyboard-interactive |
PermitRootLogin | prohibit-password | Root only via keys |
X11Forwarding | false | No X11 tunneling |
MaxAuthTries | 3 | Limit attempts |
LoginGraceTime | 30 | 30s timeout |
SSH keys: ed25519-sk (YubiKey-backed, non-exportable) for signing and authentication.
Fail2Ban (modules/services/monitoring/fail2ban.nix)
Exponential ban times with a 7-day cap:
| Offense | Ban Duration |
|---|---|
| 1st | 1 hour |
| 2nd | 2 hours |
| 3rd | 4 hours |
| … | … |
| Max | 168 hours (7 days) |
Tailscale IP ranges are whitelisted to prevent self-lockout.
Vaultwarden has dedicated jails for login and admin endpoints.
Layer 4: Secrets Management (Agenix)
Architecture (modules/agenix.nix)
Agenix decrypts .age secrets at build time using two SSH identity keys:
nix-secrets/secrets/secrets.nix ← Secret definitions (encrypted)
│
▼
agenix decrypts using:
- Host key: /persist/etc/ssh/ssh_host_ed25519_key (per-host, Nix-generated)
- Global key: /persist/etc/ssh/global_ed25519 (optional, shared across hosts)
│
▼
/run/agenix/ ← Runtime (tmpfs, not persisted)
The host key is generated by NixOS and persisted via environment.persistence bind-mount in ssh.nix. The global key is placed directly on the /persist btrfs subvolume (no Nix config needed — files on /persist survive reboots by nature of being on a persistent filesystem). Agenix tries both keys; any missing key is silently skipped.
For the full bootstrap workflow (how keys get onto the machine in the first place), see docs/security.md.
Per-Host Scoping
Secrets can declare which hosts should receive them:
# In nix-secrets/secrets/secrets.nix:
"smtp-password".publicKeys = [id3-eniac bio-smart];
"wireguard-key".publicKeys = [vps-pacman];
# Secrets without 'nodes' are shared across all hosts
The filterByNodes logic in modules/agenix.nix filters out secrets not belonging to the current host, preventing unnecessary decryption.
Registered Secrets
| Secret | Purpose |
|---|---|
user-password | User login password |
root-password | Root password (emergency) |
smtp-password | Email relay auth |
wireguard-*.conf | VPN configs |
*-luks-keyfile | Disk encryption keys |
singbox-*.json | Proxy configs |
syncthing-*.pem | Syncthing certs |
wifi-psk | WiFi passwords |
Layer 5: Network Security
Firewall
Enabled by default with reverse path filtering:
networking.firewall = {
enable = true;
checkReversePath = "loose"; # Needed for Tailscale/WireGuard
};
LAN-scoped rules: Services like Spotify Connect, Steam Remote Play, and printers use the shared lib/firewall.nix helper to accept connections from RFC 1918 private ranges + Tailscale CGNAT, with cleanup on firewall reload:
# lib/firewall.nix — standard trusted source ranges
lanSources = [
"192.168.0.0/16" # RFC 1918 class C — home routers
"10.0.0.0/8" # RFC 1918 class A — corporate
"172.16.0.0/12" # RFC 1918 class B — mid-size, Docker
"100.64.0.0/10" # Tailscale CGNAT
];
# Usage in a service module:
networking.firewall = mkFirewallFor {
tcp = ["57621"];
comment = "spotify-connect";
};
# → generates 4 ACCEPT rules (one per source) + 4 cleanup rules
The helper adds -m comment tags for audit visibility and extraStopCommands to clean up on firewall reload. Services that bind beyond localhost use mkRestrictedPort instead, which inserts ACCEPT rules at the top of the nixos-fw chain followed by a terminal DROP.
Tailscale / WireGuard
| Network | Module | Use Case |
|---|---|---|
Tailscale (ts0) | modules/profiles/network/ts0.nix | Mesh VPN, MagicDNS, zero-config |
WireGuard (wg0) | modules/profiles/network/wg0.nix | Site-to-site VPN, manual config |
Tailscale ranges are whitelisted in fail2ban to prevent self-lockout.
Nginx HTTP Protection (modules/services/web/nginx.nix + lib/nginx.nix)
All HTTP virtual hosts (20+ services) inherit a global protection baseline via genNginxVhostBase in lib/nginx.nix. No per-service configuration needed.
Rate Limiting
Two limit_req_zone pools shared across all vhosts:
| Zone | Rate | Applied to |
|---|---|---|
oauth2_auth | 10 req/s per IP | All OAuth2-protected vhosts (auth_request location) |
general | 50 req/s + 50 burst per IP | All vhosts (server-level, via genNginxVhostBase) |
Connection Limiting
| Mechanism | Limit | Scope |
|---|---|---|
limit_conn_zone $binary_remote_addr zone=perip:10m | 10MB shared zone | All vhosts |
limit_conn perip 20 | 20 concurrent connections per IP | All vhosts (server-level) |
A single IP cannot open more than 20 concurrent HTTP connections to any service. Legitimate browsers use 6-8 connections per domain.
Slow-Loris / Slow-Read Protection
client_header_timeout 10s; # was 60s (nginx default)
client_body_timeout 30s; # was 60s (nginx default)
Attackers sending headers or body 1 byte at a time are disconnected after 10s/30s of inactivity.
AI Scraper / Bot Blocking
A User-Agent map blocks 34 known AI scrapers, data-harvesting bots, and SEO crawlers by returning HTTP 444 (drop connection, no response body):
GPTBot, Claude-Web, ClaudeBot, CCBot, anthropic-ai, Bytespider,
PerplexityBot, cohere, Google-Extended, Applebot-Extended,
AhrefsBot, SemrushBot, MJ12bot, DotBot, PetalBot, BLEXBot, ...
Bots are identified by $http_user_agent pattern matching. The if ($block_bot) { return 444; } directive runs at the server level, before any location processing — zero overhead for legitimate traffic. The list is maintained in commonHttpConfig in modules/services/web/nginx.nix.
Note: This blocks AI crawlers that respect
robots.txtAND those that don’t. It targets the HTTP request itself, not the crawl policy. If you use these bots (e.g., for search indexing), remove the corresponding entry from the map.
fail2ban
Two jails protect the HTTP surface:
| Jail | Triggers on | Threshold | Ban duration |
|---|---|---|---|
nginx-auth | OAuth2 proxy 401 responses | 10/hour | 12h (escalating to 7d) |
kanidm | Kanidm NotAuthenticated errors | 5/30min | 12h (escalating to 7d) |
Tailscale IPs (100.64.0.0/10) are excluded to prevent self-lockout.
Stalwart Email (modules/services/net/stalwart.nix)
When publicEmail.enable = true, the mail server has four-layer abuse protection:
| Layer | Mechanism | Location |
|---|---|---|
| Per-IP connection limits | limit_conn_zone + limit_conn (10/IP) | nginx stream proxy, vps-pacman |
| Per-session rate throttle | session.mail.throttle (50/h), session.rcpt.throttle (100/h) | Stalwart, lab-matrix |
| Relay restrictions | Only authenticated users + Tailscale IPs can relay outbound | Stalwart, lab-matrix |
| Global concurrency cap | server.concurrent-sessions = 200 | Stalwart, lab-matrix |
SMTP AUTH brute-force protection is handled at the nginx connection limit layer rather than via fail2ban, because the TCP stream proxy obscures the real client IP from Stalwart. See email.md for the full security design, tuning guidance, and residual risks.
Proxy (modules/services/net/sing-box.nix)
Sing-box handles traffic routing with protocol-level isolation. Workstation TUN configs should use encrypted DNS, strict routing, and explicit local-network exclusions:
- Remote DoH goes through the proxy path; China/private DNS goes through local DoH.
- TUN uses
auto_route,auto_redirect, andstrict_routeon Linux. - Tailscale, LAN, container bridge, libvirt, Waydroid, and MicroVM private ranges stay local.
- Public internet traffic from browsers, host services, and containers can still route through Sing-box.
- VLESS/REALITY multiplex stays disabled by default to reduce correlation and avoid server-compatibility surprises.
See docs/networking-proxy.md for the detailed Sing-box policy.
Layer 6: Mandatory Access Control (AppArmor)
security.apparmor.enable = true;
security.apparmor.killUnconfinedConfinables = true;
AppArmor confines processes to predefined profiles. killUnconfinedConfinables terminates any process that isn’t confined by an AppArmor profile — a strict policy that ensures all running processes are under MAC control.
Layer 7: Application Sandbox (Nixpak)
What is Nixpak?
Nixpak wraps GUI applications in bubblewrap-based sandboxes using a Flatpak-like model. It provides per-app DBus filtering via xdg-dbus-proxy, GPU passthrough, filesystem isolation, and Flatpak-compatible metadata — all built from native Nix derivations without ostree or runtimes.
Our integration lives in modules/sandbox.nix, which defines three shared nixpak modules (gui-base, network, common) and a mkNixPakApp helper. Per-app configs in the apps attrset import the shared modules and add app-specific bind mounts, sockets, and environment variables.
Sandbox Architecture
┌──────────────────────────┐
│ wrapperScript │
│ HOME=$XDG_FAKE_HOME │
│ XAUTHORITY fallback │
└──────────┬───────────────┘
│ exec
┌──────────▼───────────────┐
│ nixpak launch script │
│ exports BWRAP_EXE, │
│ NIXPAK_APP_EXE, etc. │
└──────────┬───────────────┘
│ exec
┌──────────▼───────────────┐
│ nixpak launcher (Go) │
│ Reads bwrap-args.json │
│ Starts xdg-dbus-proxy │
│ Creates .var dirs │
└──────────┬───────────────┘
│ exec
┌──────────▼───────────────┐
│ bubblewrap (bwrap) │
│ --unshare-user/pid/net │
│ --ro-bind /nix/store │
│ --bind .var/app/<appId> │
│ --setenv HOME ... │
└──────────┬───────────────┘
│
┌──────────▼───────────────┐
│ Sandboxed Application │
└──────────────────────────┘
What the Sandbox Provides
| Protection | Mechanism | Effect |
|---|---|---|
| HOME isolation | Wrapper sets HOME=~/.local/user before launcher | .var dirs and app data jailed under fake home |
| DBus filtering | xdg-dbus-proxy with per-app policies | Only whitelisted services visible |
| Filesystem whitelist | --ro-bind for specific paths | App can’t wander the filesystem |
| /nix/store access | --ro-bind /nix/store | Libraries and assets readable |
| Namespace isolation | --unshare-user/pid/net/uts | Process/network isolation |
| GPU passthrough | gpu.provider = "nixos" | Mesa drivers bundled, /dev/dri exposed |
| Wayland/X11 sockets | Conditional socket bindings | Apps get the display protocol they need |
| PipeWire audio | --ro-bind pipewire socket | Audio/video streams work |
| Child cleanup | --die-with-parent | No orphan processes |
| Theme consistency | XCURSOR_PATH, XDG_DATA_DIRS injected | Cursor and icon themes match the host |
Data Isolation Model
Nixpak apps follow a Flatpak-style data layout under $HOME/.var/app/<appId>/:
~/.local/user/.var/app/<appId>/
├── data/ → bind-mounted to $XDG_DATA_HOME
├── config/ → bind-mounted to $XDG_CONFIG_HOME
└── cache/ → bind-mounted to $XDG_CACHE_HOME
Because our wrapper sets HOME=~/.local/user before the nixpak launcher runs, the entire .var tree lives under the fake home. The configuration bridge (~/.local/user/.config → ~/.config) still applies for host config files exposed via bind.ro.
Shared Nixpak Modules
Three reusable modules in modules/sandbox.nix define the sandbox baseline:
| Module | Purpose |
|---|---|
gui-base | GPU passthrough, cursor/icon themes, locale, fonts, /etc bind mounts |
network | SSL certificates, /etc/resolv.conf, enables networking |
common | DBus policies (30+ SNI slots, portals, MPRIS, notifications), XDG dirs, PipeWire, Wayland |
Per-app configs import these via sharedModules and add app-specific bind.rw, sockets, and env.
Sandboxed Applications
| App | Category | AppId | Display | Why Sandboxed |
|---|---|---|---|---|
| Discord | Messaging | com.discord.Discord | Wayland + PipeWire | Proprietary, telemetry-heavy |
| Telegram | Messaging | org.telegram.desktop | Wayland + PipeWire | Official Telegram client |
| Zoom | Meeting | us.zoom.Zoom | X11 + PipeWire | Proprietary, past security issues |
| WeMeet | Meeting | com.tencent.wemeet | X11 + PipeWire | Tencent, camera/mic access |
Note: QQ (com.qq.QQ) works via nixpak after clearing stale config data. WeChat (com.tencent.WeChat) uses a dedicated bubblewrap wrapper in packages/wechat/ — nixpak’s --unshare-user conflicts with CEF’s internal GPU sandbox, so the custom wrapper provides PID/IPC/cgroup/tmpfs isolation without unsharing the user namespace. Data is stored under $XDG_FAKE_HOME/WeChat_Data/. Both are disabled by default; set modules.desktop.apps.messaging.{qq,wechat}.enable = true to enable.
Non-sandboxed messaging apps (Element, Fractal) are installed directly without wrapping since they’re open source.
How to Sandbox a New App
- Add a nixpak definition in
modules/sandbox.nix:
myapp = mkNixPakApp {
package = pkgs.myapp;
binPath = "bin/myapp"; # binary inside the package
appId = "com.example.MyApp"; # Flatpak-style app ID
extraConfig = {sloth, ...}: {
bubblewrap = {
bind.rw = [
sloth.xdgDocumentsDir
sloth.xdgDownloadDir
];
sockets = {
x11 = false;
wayland = true;
pipewire = true;
};
};
};
};
- Add a toggle in the consuming module (e.g.
modules/desktop/apps/myapp.nix):
options.modules.desktop.apps.myapp = {
enable = mkBoolOpt false;
sandbox = mkBoolOpt true;
};
config = mkIf cfg.enable {
user.packages = [
(if cfg.sandbox
then pkgs.nixpaks.myapp
else wrapFakeHome pkgs.myapp "myapp")
];
};
The overlay at the bottom of modules/sandbox.nix automatically exposes all apps as pkgs.nixpaks.*.
When NOT to Use Nixpak
| Situation | Why Skip |
|---|---|
| Apps needing full filesystem access | --ro-bind whitelist is too restrictive |
| Apps that are DBus services themselves | xdg-dbus-proxy filtering may break them |
| Development tools (compilers, build systems) | Need full /nix/store write access |
| Open source apps with no telemetry | Unnecessary overhead; use wrapFakeHome instead |
| NixOS modules that manage their own binary (Spicetify) | Can’t wrap module-managed binaries |
Verifying the Sandbox
# Check if an app is running inside bwrap:
ps aux | grep bwrap
# Verify HOME isolation:
# Inside the sandbox:
echo $HOME # Should be ~/.local/user, not ~
ls ~/.var/app/ # Should show app data dirs
# Check DBus proxy:
ls /run/user/$(id -u)/nixpak-bus* # nixpak DBus sockets
# Verify process isolation:
# Inside the sandbox:
ls /proc/ # Only sandbox processes visible (unshare-pid)
App-by-App Security Posture
Browsers
| Browser | Sandbox | Extensions | Telemetry |
|---|---|---|---|
| Firefox | wrapFakeHome | uBlock, ClearURLs, LocalCDN, Facebook Container | Disabled (Betterfox) |
| Chromium | mkWrapper (flags only) | — | Ungoogled (no Google services) |
| Qutebrowser | None | Brave adblock | Minimal |
Recommendation: Firefox is the most hardened browser. Use it for untrusted browsing. Chromium for sites requiring Chrome compatibility.
Messaging
| App | Sandbox | Data Access | Network |
|---|---|---|---|
| Discord | nixpak | Fake HOME + .var | Full |
| Telegram | nixpak | Fake HOME + .var | Full |
| (disabled) | — | — | |
| bwrap (dedicated) | $XDG_FAKE_HOME/WeChat_Data/ | Full | |
| Element | None | Full (open source) | Full |
Meeting
| App | Sandbox | Data Access | Network |
|---|---|---|---|
| Zoom | nixpak | Fake HOME + .var | Full |
| WeMeet | nixpak | Fake HOME + .var | Full |
Editors
| Editor | Sandbox | Telemetry | Notes |
|---|---|---|---|
| Neovim | None | None | Open source, local only |
| VS Code | mkWrapper (HOME) | Microsoft (can disable) | Extensions run in-process |
| Cursor | mkWrapper (HOME) | Proprietary AI | Sends code to cloud |
| JetBrains | wrapFakeHome | JetBrains (can disable) | Phoning home for updates |
Security Checklist for New Hosts
When commissioning a new host, verify:
- LUKS2 encryption enabled on root partition
- FIDO2 enrolled:
sudo systemd-cryptenroll --fido2-device=auto /dev/... - YubiKey PAM configured:
modules.profiles.hardware = ["yubikey"] - SSH key-only auth:
modules.services.net.ssh.enable = true - Firewall enabled:
networking.firewall.enable = true - Fail2Ban active:
modules.services.monitoring.fail2ban.enable = true - Agenix secrets scoped: check
nodesattribute insecrets.nix - USBGuard configured: populate device ID rules
- AppArmor active:
security.apparmor.enable = true - Secure boot (workstations):
modules.profiles.boot = "lanzaboote" - Mutable users disabled:
users.mutableUsers = false
Future Improvements
These items are planned but not yet implemented. Ordered by impact/effort ratio.
Fail2Ban Jail Expansion
Current jails: SSH, Forgejo, Vaultwarden. Should be extended to cover:
| Target | Log Source | What it catches |
|---|---|---|
| Nginx auth failures | nginx error log (/oauth2/auth 401) | SSO brute-force |
| Kanidm auth failures | kanidm journal | Identity brute-force |
| Sing-box REALITY scanners | sing-box journal (high-frequency invalid connections) | Already noisy — tune threshold, don’t ban |
Implementation: Add filter definitions to modules/services/monitoring/fail2ban.nix and reference from each service module, or centralize via services.fail2ban.jails in the host config.
Intrusion Detection (aide)
File integrity monitoring to detect trojaned binaries, tampered configs, or unexpected persistent state changes:
services.aide = {
enable = true;
config = ''
/etc p+i+u+g+s+md5
/persist p+i+u+g+md5
/nix/store p+i+u+g
'';
};
Runs nightly via systemd timer. Sends diffs to Gotify or email on change. Minimal overhead (~5MB rules DB). Catches post-intrusion tampering.
Future escalation path: wazuh agent → centralized log analysis + MITRE ATT&CK mapping, or osquery + falco for continuous system introspection and kernel-level anomaly detection.
Service Health Dashboard (gatus)
Declarative endpoint monitoring with config-as-code:
services.gatus = {
enable = true;
settings = {
endpoints = [
{
name = "Forgejo";
url = "https://git.alienzj.org";
interval = "5m";
conditions = ["[STATUS] == 200"];
alerts = [{ type = "gotify"; }];
}
# ... repeat for each service
];
};
};
A single JSON/YAML file defines all health checks. No database, no agents. Exposes a simple status page. Paired with Gotify for push alerts on failure. Fits the declarative NixOS model.
Hardware Monitoring & Alerting
Prevent silent hardware degradation by monitoring temperature, fan, memory, and disk, with alerts routed to Gotify/email/Telegram.
Stack
| Layer | Tool | What it monitors |
|---|---|---|
| Metrics collection | prometheus-node-exporter | CPU temp, fan speed, RAM, disk, network |
| Metrics DB | prometheus or victoria-metrics | Time-series storage |
| Alert rules | prometheus-alertmanager | Threshold-based triggers |
| Notifications | Gotify / email (msmtp) / Telegram bot | Push alerts to operator |
Alert thresholds (lab-matrix)
| Metric | Warning | Critical | Alert action |
|---|---|---|---|
| CPU Tctl (k10temp) | > 75°C sustained 5min | > 90°C | Immediately |
| GPU edge (amdgpu) | > 70°C sustained 5min | > 85°C | Immediately |
| NVMe Composite | > 60°C | > 70°C | Immediately |
| RAM used | > 80% | > 95% | Immediately |
| Disk used (btrfs) | > 80% | > 95% | Immediately |
| System load (15min) | > cores × 2 | > cores × 4 | Immediately |
| Service crash-loop | > 5 restarts/min | > 20 restarts/min | Immediately |
| ZFS/btrfs scrub errors | any | — | Immediately |
Crash-loop detection
Today’s Mealie+Affine incident demonstrated that crash-looping services cause sustained high CPU temperatures without visible load in top. A dedicated alert rule catches this:
# Prometheus alert rule
- alert: ServiceCrashLooping
expr: rate(systemd_unit_state{state="failed"}[5m]) > 0.1
for: 2m
annotations:
summary: "{{ $labels.name }} crash-looping"
Implementation priority
- Quick win:
prometheus-node-exporter+ Gotify — zero-config alerting on CPU temp and fan via a simple shell cron job readingsensorsoutput - Medium: Full Prometheus stack with alertmanager rules for all thresholds above
- Full: VictoriaMetrics (lighter than Prometheus) + Grafana dashboard for historical trends
The quick-win script (run every 60s via systemd timer):
#!/usr/bin/env bash
# /etc/scripts/check-temp.sh — quick thermal watchdog
TEMP=$(sensors -j 2>/dev/null | jq '.["k10temp-pci-00c3"]["Tctl"]["temp1_input"]')
if [ "${TEMP%.*}" -gt 85 ]; then
curl -s "https://push.alienzj.org/message?token=<token>" \
-F "title=CPU HOT" -F "message=lab-matrix Tctl: ${TEMP}°C" -F "priority=5"
fi
Service Network Isolation (4.1)
Currently all 18 lab-matrix services share one network namespace and localhost. A compromised service can reach PostgreSQL, Redis, other services, and internal APIs. Isolation limits lateral movement.
Architecture
Today: Target:
┌──────────────────────┐ ┌──────────────────────────┐
│ lab-matrix (host) │ │ lab-matrix (host) │
│ │ │ │
│ PostgreSQL :5432 │ │ ┌─────────────────┐ │
│ Redis :6379 │ │ │ dmz-net (bridge) │ │
│ Kanidm :8443 │ │ │ nginx :80 │ │
│ Immich :2283 │ │ │ gatus :9091 │ │
│ Vaultwarden :8222 │ │ └─────────────────┘ │
│ Forgejo :3000 │ │ │
│ ...15 more services │ │ ┌─────────────────┐ │
│ │ │ │ app-net (bridge) │ │
│ All share localhost │ │ │ Immich :2283 │ │
└──────────────────────┘ │ │ Forgejo :3000 │ │
│ │ ... │ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ vault-net │ │
│ │ Vaultwarden │ │
│ │ PostgreSQL │ │
│ └─────────────────┘ │
└──────────────────────────┘
Implementation plan (3 phases)
Step 1: Split network zones (podman networks)
# Create isolated bridge networks
podman network create --subnet 10.89.0.0/24 dmz-net # nginx, gatus, gotify
podman network create --subnet 10.89.1.0/24 app-net # Immich, Forgejo, etc.
podman network create --subnet 10.89.2.0/24 vault-net # Vaultwarden, PostgreSQL
Each service runs in its podman container with --network=<zone>. Services in the same zone can reach each other on their container-private IPs. Cross-zone communication goes through the host’s nginx or is explicitly denied.
Step 2: Gradual migration
| Phase | Services to containerize | Rationale |
|---|---|---|
| First | Vaultwarden (+ its PostgreSQL) | Highest value target, smallest surface |
| Second | Immich, Forgejo | Frequent updates, public exposure |
| Third | Paperless, Mealie, Linkwarden, Affine | Medium sensitivity |
| Last | Kanidm, OAuth2-Proxy | Complex OIDC dependencies, do last |
Step 3: Remove host-level services
After all services are containerized, remove NixOS services.<name>.enable entries. The host runs only podman + nginx + tailscale + sing-box. Everything else is a container.
Cross-zone access control
# Podman network policy (iptables/nftables rules)
# vault-net → no outbound (except PostgreSQL port to app-net)
# app-net → no access to vault-net
# dmz-net → can reach app-net:service-ports only
# All zones → can reach host nginx for reverse proxy
Trade-offs
| Pro | Con |
|---|---|
| Service compromise stays in one zone | More complex networking |
| Per-zone resource limits (CPU, RAM) | Podman overhead per container |
| Easier backups (per-container volumes) | Service discovery becomes explicit |
| Can restart zones independently | Debugging cross-zone issues harder |
MicroVM Perimeters for High-Risk Services (4.2)
For the most sensitive services (Vaultwarden, Kanidm), containers may not be enough — a kernel exploit in one container compromises the host. MicroVMs run a separate kernel per VM, providing hardware-level isolation.
When to use MicroVM vs container
| Criterion | Container (podman) | MicroVM (cloud-hypervisor) |
|---|---|---|
| Isolation | Namespace + cgroup | Separate kernel + virtio |
| RAM overhead | ~10MB per container | ~64MB per VM (kernel + initrd) |
| Cold start | <1s | 2-5s (kernel boot + systemd) |
| NixOS-native | Yes (oci-containers) | Yes (microvm.nix) |
| Persistence | Bind mounts | virtio-fs or 9p |
| When to use | Most services | Vaultwarden, Kanidm, SSH jump host |
Implementation (NixOS native)
# hosts/lab-matrix/modules/virt.nix
microvm = {
vaultwarden-vm = {
enable = true;
memory = 512; # MB
cpu = 2;
shares = [ "/persist/vaultwarden-vm/var" ];
modules = [
({ ... }: {
services.vaultwarden.enable = true;
# Vaultwarden runs inside the VM with its own PostgreSQL
# or connects to host PostgreSQL via virtio-vsock
})
];
};
};
MicroVM candidates (priority order)
| Service | Reason | RAM budget |
|---|---|---|
| Vaultwarden | Password vault — highest value target | 512MB |
| Kanidm | Identity provider — compromise = all access | 512MB |
| Forgejo | Code repositories + CI secrets | 1GB |
| SSH jump host | Administrative access gateway | 256MB |
Centralized Log Analysis (4.4)
Today, journald on each host stores logs locally. No aggregation, no alerting on patterns, no retention beyond rotation. For a multi-service homelab, centralized logging enables:
- Correlation: See that a nginx 401 spike correlates with a Kanidm restart
- Retention: Keep service logs beyond journald rotation (1 week → 6 months)
- Alerting: Trigger Gotify alerts on log patterns (e.g., “failed auth from new IP”)
- Compliance: Audit trail for who accessed what and when
Architecture options
| Stack | Components | Complexity | Best for |
|---|---|---|---|
| Loki + Promtail | Grafana Loki (TSDB for logs) + Promtail (agent) | Medium | NixOS-native, pairs with VictoriaMetrics |
| Wazuh | Manager + agents, MITRE ATT&CK rules | High | Security-focused, intrusion detection |
| rsyslog → Loki | Standard syslog forwarding | Low | Simple, but no rich querying |
Recommended: Loki + Promtail (simplest fit for NixOS)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ lab-matrix │ │ vps-pacman │ │ id3-eniac │
│ │ │ │ │ │
│ promtail ────┼────┼── promtail ──┼────┼── promtail ──┼───┐
│ (journald) │ │ (journald) │ │ (journald) │ │
└──────────────┘ └──────────────┘ └──────────────┘ │
│
┌──────▼──────┐
│ lab-matrix │
│ │
│ Loki :3100 │
│ (S3 backend │
│ or local │
│ filesystem) │
└──────┬──────┘
│
┌──────▼──────┐
│ Grafana │
│ (query logs │
│ + metrics │
│ in one UI) │
└──────────────┘
Implementation
# modules/services/monitoring/loki.nix (future module)
services.loki = {
enable = true;
config = {
server.http_listen_port = 3100;
ingester.wal.dir = "/var/lib/loki/wal";
storage_config.filesystem.directory = "/var/lib/loki/data";
schema_config.configs = [{
from = "2025-01-01";
store = "tsdb";
object_store = "filesystem";
schema = "v13";
index = { prefix = "index_"; period = "24h"; };
}];
};
};
services.promtail = {
enable = true;
config = {
server.http_listen_port = 9080;
clients = [{ url = "http://127.0.0.1:3100/loki/api/v1/push"; }];
scrape_configs = [{
job_name = "journal";
journal = { };
relabel_configs = [
{ source_labels = ["__journal__systemd_unit"];
target_label = "unit"; }
];
}];
};
};
Alert rules (LogQL → Gotify)
# Alert on SSH brute force (5+ failed auths in 5min)
- alert: SSHButeForce
expr: |
sum(rate({job="journal", unit="sshd.service"}
|~ "Failed password"[5m])) > 5
annotations:
summary: "SSH brute force on {{ $labels.host }}"
Priority deployment
| Stage | What | Why first |
|---|---|---|
| 1 | Promtail on lab-matrix only | Single host, validates the pipeline |
| 2 | Loki on lab-matrix | Ingest + query on same host |
| 3 | Grafana → Loki + VictoriaMetrics | Unified dashboards (logs + metrics) |
| 4 | Promtail on vps-pacman, id3-eniac | Multi-host log aggregation |
Service data on lab-matrix should be encrypted at rest in backups. Current persistence covers /persist state on the host itself, but off-site backups need separate encryption.
Strategy
Primary: restic + rclone to remote storage (Backblaze B2, S3, or another Tailscale host).
# Initialize a restic repository (one-time)
restic init --repo rclone:b2:alienzj-homelab-backup
# Nightly backup of all persisted service data
restic backup /persist/var/lib \
--exclude '*.tmp' --exclude 'cache' \
--repo rclone:b2:alienzj-homelab-backup \
--password-file /run/agenix/restic-password
# Prune old snapshots (keep 7 daily, 4 weekly, 6 monthly)
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 \
--repo rclone:b2:alienzj-homelab-backup \
--password-file /run/agenix/restic-password
Secondary: btrfs send to an external disk attached to lab-matrix.
Age secrets needed
# Restic repository password (raw)
echo -n 'strong-password' | agenix -e restic-password.age
# Rclone config with cloud credentials (KEY=VALUE)
cat > /tmp/rclone-env.txt << 'SECRET'
B2_ACCOUNT_ID=xxx
B2_ACCOUNT_KEY=xxx
SECRET
agenix -e rclone-env.age < /tmp/rclone-env.txt
What to back up
| Path | Content | Priority |
|---|---|---|
/persist/var/lib/postgresql | All service databases | Critical |
/persist/var/lib/kanidm | Identity provider | Critical |
/persist/var/lib/immich | Photo library | High |
/persist/var/lib/vaultwarden | Password vault | Critical |
/persist/var/lib/forgejo | Git repositories | High |
/persist/var/lib/stalwart-mail | Critical | |
/persist/etc/ssh | Host SSH keys | Critical |
/var/lib/gotify-server | Gotify database | Medium |
/var/lib/private | Various service state | Medium |
Recovery test
Test restoration quarterly by restoring a single service to a temp directory:
restic restore latest --target /tmp/restore-test --include /persist/var/lib/vaultwarden
idm_admin Hardening
idm_admin is a built-in system account in Kanidm — it doesn’t support WebAuthn enrollment via the UI. To enforce YubiKey MFA for admin operations:
- Create a regular person account (e.g.,
admin-alienzj) - Enroll YubiKey via Kanidm web UI (Profile → security key)
- Add to
idm_adminsgroup:kanidm group add-members idm_admins admin-alienzj - Use this account for admin operations instead of
idm_admin - Keep
idm_adminas break-glass recovery (requires root shell on lab-matrix)
Current Attack Surface (vps-pacman)
| Port | Service | Restriction |
|---|---|---|
| 22 | SSH | key-only, AllowUsers: 3 Tailscale IPs |
| 80/443 | Nginx | Public, Cloudflare-proxied |
| 4433, 8443, 9443 | Sing-box VLESS+REALITY | Fingerprint-resistant |
| 22067, 22070 | Tailscale stun/relay | Protocol-standard |
SSH is well-protected. Sing-box ports appear as random HTTPS to scanners — REALITY rejects unauthenticated clients.
Related Documentation
| Doc | Coverage |
|---|---|
docs/security.md | Lanzaboote, DNS, LUKS/FIDO2/TPM2, YubiKey, Agenix, kernel hardening |
docs/vulnerability-response.md | Live vulnerability tracking, module blacklisting, verification |
docs/security-auth-logic.md | Authentication flow and PAM configuration |
docs/sso-identity.md | Kanidm, OAuth2 Proxy, Nginx auth_request |
docs/networking-vpn.md | Tailscale, WireGuard, Headscale, MagicDNS |
docs/networking-proxy.md | Sing-box, SSH tunnels, traffic routing |
docs/storage-disko.md | Disko, Btrfs+LUKS2, impermanence |