{ config, pkgs, lib, ... }: # ── Server-local domain loopback overrides ──────────────────────────────────── # # Some routers (especially newer ISP-provided devices) do not support NAT # loopback (hairpin NAT). When a request originates on this computer and # targets a public domain name that resolves to the router's WAN address, the # router may refuse to loop the connection back in — causing Nextcloud, WordPress # background jobs, and other server-side callbacks to fail even when the service # is fully operational from the internet. # # This module installs a one-shot systemd service, # ``sovran-hosts-update.service``, that reads the configured service domains # from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a # domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated # Sovran-managed block in ``/etc/hosts``. # # With those entries in place: # • Requests originating on this computer resolve the public domain name to # 127.0.0.1, reach Caddy directly, and never touch the router. # • Caddy still receives the correct public hostname via TLS SNI so virtual- # host routing and certificate validation continue to work. # • The Sovran Hub can verify Caddy reachability locally without needing NAT # loopback. # # Limitation: this does not help other devices on your home network (phones, # laptops). Those devices resolve domains via the router's DNS and still depend # on NAT loopback (or require manual router DNS overrides). For now, only # server-originated requests benefit from this override. # # On NixOS, /etc/hosts is normally a symlink into the Nix store and is # regenerated by the system activation script. The ``system.activationScripts`` # hook below converts it to a writable file each time the system is activated # (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block. # The same script is also run by the ``sovran-hosts-update.service`` unit so # that the Hub can trigger it immediately after saving a domain without # requiring a full rebuild. { # ── Helper script (stored in the Nix store, never reads /var/lib at eval) ── environment.systemPackages = [ pkgs.coreutils ]; environment.etc."sovran-hosts-update.sh" = { mode = "0755"; text = '' #!/bin/sh # Regenerate the Sovran-managed loopback block in /etc/hosts. # Safe to run multiple times — idempotent. set -eu DOMAINS_DIR="/var/lib/domains" HOSTS_FILE="/etc/hosts" BEGIN_MARKER="# Sovran managed begin — server-local loopback overrides" END_MARKER="# Sovran managed end" # ── Step 1: ensure /etc/hosts is a regular writable file ────────────── # On NixOS /etc/hosts starts as a symlink to the Nix store. We replace # it with a copy so we can append our block without touching the store. if [ -L "$HOSTS_FILE" ]; then TARGET=$(readlink -f "$HOSTS_FILE") cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp" mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE" chmod 644 "$HOSTS_FILE" fi # ── Step 2: remove any existing Sovran block ────────────────────────── # Use a temp file so the operation is atomic. TMP=$(mktemp "$HOSTS_FILE.XXXXXX") trap 'rm -f "$TMP"' EXIT awk " /^$BEGIN_MARKER\$/ { skip=1; next } /^$END_MARKER\$/ { skip=0; next } !skip " "$HOSTS_FILE" > "$TMP" # ── Step 3: collect valid configured service domains ────────────────── # NOTE: The hostname validation regex below must stay in sync with # _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py. ENTRIES="" for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do FILE="$DOMAINS_DIR/$KEY" [ -f "$FILE" ] || continue # Read the domain value (strip all whitespace, limit to 253 chars) DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253) [ -z "$DOMAIN" ] && continue # Validate: must match a reasonable hostname pattern (no injection) if ! printf '%s' "$DOMAIN" | grep -qE \ '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2 continue fi ENTRIES="$ENTRIES 127.0.0.1 $DOMAIN ::1 $DOMAIN" done # ── Step 4: append the Sovran block if there are any entries ────────── if [ -n "$ENTRIES" ]; then printf '\n%s\n' "$BEGIN_MARKER" >> "$TMP" printf '%s\n' "# These entries route configured service domains to local Caddy." >> "$TMP" printf '%s\n' "# They are managed automatically — do not edit this block." >> "$TMP" printf '%s\n' "$ENTRIES" >> "$TMP" printf '%s\n' "$END_MARKER" >> "$TMP" fi # ── Step 5: atomically replace /etc/hosts ───────────────────────────── mv "$TMP" "$HOSTS_FILE" chmod 644 "$HOSTS_FILE" ''; }; # ── Systemd service ──────────────────────────────────────────────────────── systemd.services.sovran-hosts-update = { description = "Update /etc/hosts with Sovran server-local loopback overrides"; documentation = [ "https://github.com/naturallaw777/sovran-systems" ]; # Run before Caddy so loopback entries are ready when it starts. before = [ "caddy.service" "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "/etc/sovran-hosts-update.sh"; }; }; # ── Activation script (runs after every nixos-rebuild switch) ───────────── # This ensures the loopback block survives rebuilds that restore the /etc/hosts # symlink. The "users" and "etc" scripts must complete first. system.activationScripts.sovranDomainLoopback = { text = '' if [ -x /etc/sovran-hosts-update.sh ] && [ -d /var/lib/domains ]; then /etc/sovran-hosts-update.sh || true fi ''; deps = [ "etc" "users" ]; }; }