Files
Sovran_SystemsOS/modules/core/local-domain-loopback.nix
T
copilot-swe-agent[bot]andGitHub de9b069a88 fix: address code review feedback
- Use set -eu (not set -euf) in sovran-hosts-update.sh
- Add sync note for domain validation regex between shell and Python
- Rename `seen` to `unique_addresses` in _resolve_all_addresses
- Improve loopback override UI message with external DNS check guidance
- Add docstring note about first-address display in _resolve_all_addresses"
2026-07-15 15:26:43 +00:00

146 lines
6.4 KiB
Nix

{ 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" ];
};
}