Files
oikos/infrastructure/vps-hardening.md
dtoro 8ef17dba3d sops-encrypt TURN password + Authentik client secret; homelab render-vps-configs
The IONOS netbird VPS held two credentials in plaintext that were the last
holdouts from the homelab's sops+age secrets pattern:
  - /root/turn-pass.txt  (coturn long-term-credential password)
  - PKCEAuthorizationFlow.ProviderConfig.ClientSecret inline in
    /opt/management.json (Authentik OIDC client secret)

This commit moves both into sops-encrypted YAML in the repo and adds a render
command that recreates the VPS config files from templates + decrypted secrets:

* secrets/turn-shared-secret.yaml      — encrypted `password: <coturn pwd>`
* secrets/netbird-authentik-oidc.yaml  — encrypted `client_secret: <...>`
  Both recipients = hubris + apps + republic-laptop (same 3 as hello.yaml).
* vps/turnserver.conf.tmpl + vps/management.json.tmpl — templates with
  {{TURN_PASSWORD}} + {{AUTHENTIK_CLIENT_SECRET}} placeholders.
* bin/homelab new subcommand `render-vps-configs`:
  - Decrypts both secrets locally (works on any recipient).
  - Substitutes placeholders into templates.
  - Diffs against current VPS state via ssh, prompts, applies atomically
    (write `.new` then mv), restarts coturn + netbird-mgmt.
  - --dry-run + -y flags. Hops through hubris when not running on hubris
    itself, since VPS sshd is locked to hubris's pubkey.
* inventory.yaml adds the VPS as `kind: external` (new kind; reserved for
  ssh-managed hosts that aren't homelab clients themselves — no age key,
  no /opt/homelab-context). hosts/netbird-vps.yaml regenerated.
* SHARED_SECRETS list includes both new secrets so re-keys on enrollment
  changes pick them up automatically.

After this lands + the 5-min sync propagates to hubris, run from hubris PVE
shell (or any client; hubris just skips the extra ssh hop):

  homelab render-vps-configs --dry-run    # see plan, no changes
  homelab render-vps-configs -y           # apply + restart services

Once verified working, the plaintext `/root/turn-pass.txt` should be deleted
on the VPS (the rendered /etc/turnserver.conf no longer needs it as a
reference).
2026-05-21 22:21:06 +02:00

6.1 KiB

VPS hardening — 82.165.190.79 / 100.122.165.149

IONOS VPS that runs the Netbird control plane and the public ingress traefik. Hardened 2026-04-23 from its stock-Plesk state.

At a glance

  • Hostname: inspiring-ramanujan.82-165-190-79.plesk.page
  • OS: Debian 13
  • Mesh: netbird 100.122.165.149 (peer of the lab mesh; routes 192.168.8.0/24 via hubris).
  • Public: 82.165.190.79 (ens6).
  • Public DNS: IONOS wildcard *.hubris.network → 82.165.190.79.
  • Docker stack at /opt/docker-compose.yml: traefik (TLS/ACME) + dashboard + mgmt + signal + relay + proxy — netbird-mgmt 0.71.3 vanilla deploy since 2026-05-21 (see mesh.md changelog).
  • Host services (outside docker): coturn (TURN-TCP on :3478, long-term creds rendered into /etc/turnserver.conf by homelab render-vps-configs from sops-encrypted secrets/turn-shared-secret.yaml).
  • Config rendering: /etc/turnserver.conf + /opt/management.json are generated from templates in vps/*.tmpl on this repo by homelab render-vps-configs. Secret placeholders ({{TURN_PASSWORD}}, {{AUTHENTIK_CLIENT_SECRET}}) are substituted from sops-encrypted secrets decrypted on hubris and pushed over ssh. Do not hand-edit those two files on the VPS — the next render will overwrite them.

SSH

  • Key-only (PasswordAuthentication no, PermitRootLogin prohibit-password) via drop-in at /etc/ssh/sshd_config.d/10-hubris-hardening.conf. Original config backed up at /etc/ssh/sshd_config.bak.<ts>.
  • Mesh-only: public :22 is dropped by the nftables firewall. SSH reaches the VPS only over wt0. ListenAddress itself is still 0.0.0.0 — gating is firewall-layer.
  • Authorized root keys: PVE (root@hubris), Mac Mini (d.toro.v@pm.me). Add a new device with ssh-copy-id root@100.122.165.149 from a mesh peer before disabling its access paths.

Firewall — nftables (inet hubris-fw)

Config at /etc/nftables.conf, service enabled.

  • Public iface ens6. Wireguard iface wt0.
  • INPUT on ens6 allow-list: DHCP (67→68), rate-limited ICMP/ICMPv6, TCP 3478 (coturn TURN-TCP, added 2026-05-21). Everything else drops.
  • wt0 fully accepted in INPUT. lo accepted.
  • IONOS upstream firewall also gates inbound traffic before it reaches ens6. Open ports today: TCP 80/443 (traefik), UDP 51820 (netbird-proxy), TCP 3478 (coturn, added 2026-05-21). UDP 3478 is dropped by IONOS upstream regardless of local nftables. See mesh.md ICE/STUN for the STUN/TURN port matrix.
  • FORWARD chain at priority filter-10 (runs before Docker's FORWARD) hosts the fail2ban ban enforcement — see below.
  • Set banned4 (typed ipv4_addr, flag timeout) holds fail2ban's drops.
  • Coexists with Docker's ip nat / ip filter tables (iptables-nft compat). Do NOT flush ruleset in this config — it'll wipe Docker's state too.

fail2ban

  • Jail traefik-4xx tails /var/log/traefik/access.log (bind-mounted from container). Filter at /etc/fail2ban/filter.d/traefik-4xx.conf matches 401/403/404/429 from blog-public@file or artifacto-public@file routers only — netbird-grpc traffic isn't considered.
  • Tunables: findtime=600, maxretry=30, bantime=3600.
  • Action at /etc/fail2ban/action.d/nft-hubris.conf adds/removes elements from inet hubris-fw banned4 with per-element timeout.

CRITICAL invariant — wireguard / fail2ban

Bans must never affect wt0 or wireguard UDP. The FORWARD chain explicitly accepts the following before the ban check:

  • udp 51820 (wireguard)
  • udp 3478 (STUN)
  • ct state established,related

The INPUT ban rule is scoped to iifname "ens6".

Violating this takes the mesh down for every home device (they share one public IP) and the only recovery is IONOS console → nft flush set inet hubris-fw banned4.

Traefik access log

  • Written to /var/log/traefik/access.log on the host via a bind mount added to /opt/docker-compose.yml (traefik volumes include /var/log/traefik:/logs) plus --accesslog.filepath=/logs/access.log.
  • CLF format. Real client IP arrives correctly because docker userland-proxy is off — see public ingress.

Plesk / mail / FTP / Dr.Web

Stopped and disabled (not uninstalled). All of: dovecot, dovecot.socket, postfix, postfix@-, pc-remote, xinetd, plesk-task-manager, plesk-web-socket, sw-cp-server, sw-engine, plesk-repaird, plesk-repaird.socket, drwebd.

psa.service is masked (was a one-shot boot bootstrap). /etc/cron.d/plesk-backup-manager-task renamed to .disabled.

Reverse: systemctl unmask psa; systemctl enable --now <svc>.

Auto-patching

  • unattended-upgrades enabled (stock).
  • Drop-in at /etc/apt/apt.conf.d/52hubris-reboot.conf sets auto-reboot at 04:00 UTC when /var/run/reboot-required is set.
  • Runs inside the stock apt-daily-upgrade.timer.

Recovery paths

Ordered by preference:

  1. SSH via mesh — primary. Any mesh peer with an authorized key.
  2. IONOS web console (my.ionos.com → VPS → Console) — uses the system password, not SSH keys. Bypasses any firewall misconfig.
  3. IONOS rescue mode — boot rescue, mount rootfs, edit /etc/nftables.conf or /etc/ssh/sshd_config.d/10-hubris-hardening.conf to a known-good state, reboot.

Changelog

2026-05-21 — netbird stack migrated combined → vanilla; coturn added

Replaced the netbirdio/netbird-server combined image with the canonical mgmt + signal + relay + dashboard containers (0.71.3). Added host-side coturn for external TURN, with nftables rule iifname "ens6" tcp dport 3478 accept and an IONOS upstream firewall exception. Authentik on LXC 124 now provides OIDC for the netbird dashboard. Full context in mesh.md changelog.

2026-04-28 — wiki entry created

Initial documentation.

2026-04-23 — hardened

nftables firewall, mesh-only SSH, fail2ban traefik jail, Plesk disabled, auto-reboot 04:00 UTC, wireguard/fail2ban invariant established.