- Replace environment.etc raw script with pkgs.writeShellApplication - Declare runtimeInputs: pkgs.coreutils, pkgs.gawk, pkgs.gnugrep - Use awk -v for safe marker variable passing (no shell interpolation) - Point systemd ExecStart and activation script at lib.getExe hostsUpdateScript - Keep /etc/sovran-hosts-update.sh as a source symlink for operator discoverability - Remove environment.systemPackages reliance - Emit warning (not silently swallow) on activation failure - Add structural regression tests (19 new tests, all passing)
170 lines
7.9 KiB
Nix
170 lines
7.9 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 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" >> "$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"
|
|
'';
|
|
};
|
|
|
|
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" ];
|
|
};
|
|
}
|