{ 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 wrapped Nix-store executable is reused by both the activation hook # and the ``sovran-hosts-update.service`` unit, ensuring a deterministic runtime # PATH in every execution context. let # ── Wrapped Nix-store executable ────────────────────────────────────────── # Built with pkgs.writeShellApplication so that all required runtime tools # (awk, grep, coreutils) are declared explicitly and injected into PATH by # Nix. Both the systemd service and the activation hook reference this same # store-path executable — there is no second raw script body. hostsUpdateScript = pkgs.writeShellApplication { name = "sovran-hosts-update"; # Declare every external command the script calls. These packages are # added to the script's runtime PATH by writeShellApplication; nothing from # the system PATH is relied upon. runtimeInputs = [ pkgs.coreutils # readlink, cp, mv, chmod, mktemp, rm, tr, head pkgs.gawk # awk pkgs.gnugrep # grep ]; text = '' # Regenerate the Sovran-managed loopback block in /etc/hosts. # Safe to run multiple times — idempotent. 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. # awk -v passes marker strings safely without shell interpolation. TMP=$(mktemp "$HOSTS_FILE.XXXXXX") trap 'rm -f "$TMP"' EXIT awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" ' $0 == begin { skip=1; next } $0 == end { 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" printf '%s\n' "# These entries route configured service domains to local Caddy." printf '%s\n' "# They are managed automatically — do not edit this block." printf '%s\n' "$ENTRIES" printf '%s\n' "$END_MARKER" } >> "$TMP" fi # ── Step 5: atomically replace /etc/hosts ───────────────────────────── mv "$TMP" "$HOSTS_FILE" chmod 644 "$HOSTS_FILE" ''; }; in { # ── /etc/sovran-hosts-update.sh — operator discoverability symlink ───────── # Retain the familiar /etc path so administrators can inspect or manually # invoke the helper. The target is the wrapped Nix-store executable, so # there is no second raw script body to keep in sync. environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript; # ── 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; # Point directly at the wrapped Nix-store executable, not the /etc path. ExecStart = lib.getExe hostsUpdateScript; }; }; # ── 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. # The same wrapped Nix-store executable used by the systemd service is # referenced here, guaranteeing identical runtime dependencies in both # execution contexts. system.activationScripts.sovranDomainLoopback = { text = '' if [ -d /var/lib/domains ]; then if ! ${lib.getExe hostsUpdateScript}; then echo "warning: sovran-hosts-update: failed to update /etc/hosts loopback entries" >&2 fi fi ''; deps = [ "etc" "users" ]; }; }