feat: server-side loopback overrides and Hub diagnostic fixes
- Add modules/core/local-domain-loopback.nix: systemd service and activation script that write configured service domains to a Sovran-managed block in /etc/hosts (127.0.0.1 / ::1) so requests originating on this computer reach Caddy without NAT loopback. - Import local-domain-loopback.nix in modules/modules.nix. - server.py: add _validate_domain_value, _is_loopback_address, _resolve_all_addresses, _trigger_hosts_update helpers. - server.py: update _check_domain_reachable to use --resolve so reachability is checked locally via Caddy, not via NAT loopback. - server.py: update _evaluate_domain_checklist, api_services inline DNS check, and api_domains_check to recognise loopback resolution as an intentional local override rather than a DNS mismatch. - server.py: call _trigger_hosts_update from api_domains_set after saving a service domain so the /etc/hosts entry is applied immediately. - Add app/tests/test_loopback_diagnostics.py with 47 tests covering domain validation, loopback detection, diagnostic checklist logic, composite health, and api_domains_check."
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
{ 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 -euf
|
||||
|
||||
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 ──────────────────
|
||||
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
|
||||
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" ];
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
./core/remote-deploy.nix
|
||||
./core/no-sleep.nix
|
||||
./core/cpu-performance.nix
|
||||
./core/local-domain-loopback.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
|
||||
Reference in New Issue
Block a user