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:
copilot-swe-agent[bot]
2026-07-15 15:24:16 +00:00
committed by GitHub
parent 6f908513e3
commit 2cb0c734d8
4 changed files with 705 additions and 64 deletions
+162 -64
View File
@@ -8,6 +8,7 @@ import contextlib
import glob
import hashlib
import hmac
import ipaddress
import json
import logging
import os
@@ -80,6 +81,15 @@ DOMAINS_DIR = "/var/lib/domains"
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
# Domain keys that produce a public HTTPS virtual host via Caddy
_SERVICE_DOMAIN_KEYS = frozenset([
"matrix", "wordpress", "nextcloud", "btcpayserver",
"vaultwarden", "haven", "element-calling",
])
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
@@ -964,18 +974,87 @@ def _check_port_status(
return "closed"
# Regex for validating domain values written into /etc/hosts. Rejects anything
# containing whitespace, newlines, or characters that could escape a hosts entry.
_SAFE_DOMAIN_RE = re.compile(
r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
)
def _validate_domain_value(domain: str) -> bool:
"""Return True if *domain* is a valid hostname safe to write into /etc/hosts.
Rejects values containing whitespace, newlines, or other characters that
could inject additional entries or corrupt the hosts file.
"""
if not domain or len(domain) > 253:
return False
# Guard against newline / whitespace injection before regex check.
if any(c in domain for c in ('\n', '\r', ' ', '\t', '#')):
return False
return bool(_SAFE_DOMAIN_RE.match(domain))
def _is_loopback_address(ip: str) -> bool:
"""Return True if *ip* is a loopback address (127.0.0.0/8 or ::1)."""
try:
return ipaddress.ip_address(ip).is_loopback
except ValueError:
return False
def _resolve_all_addresses(domain: str) -> list[str]:
"""Return all IP addresses that *domain* resolves to, or an empty list."""
try:
results = socket.getaddrinfo(domain, None)
seen: list[str] = []
for r in results:
addr = r[4][0]
if addr not in seen:
seen.append(addr)
return seen
except Exception:
return []
def _trigger_hosts_update() -> None:
"""Start the sovran-hosts-update systemd service (best-effort, no-op if unavailable)."""
try:
subprocess.run(
["systemctl", "start", SOVRAN_HOSTS_SERVICE],
timeout=30,
check=False,
capture_output=True,
)
except Exception:
pass
def _check_domain_reachable(domain: str) -> dict:
"""Curl the domain to verify end-to-end HTTPS reachability."""
"""Check HTTPS reachability for *domain* via local Caddy (loopback).
Using ``--resolve`` ensures the request reaches Caddy on this computer
without depending on router NAT loopback or the public DNS result.
A successful local check is sufficient to confirm that Caddy and the
virtual-host configuration are working correctly.
"""
try:
result = subprocess.run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", f"https://{domain}"],
[
"curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", "10",
"--resolve", f"{domain}:443:127.0.0.1",
"--resolve", f"{domain}:80:127.0.0.1",
f"https://{domain}",
],
capture_output=True,
text=True,
timeout=15,
)
status_code = result.stdout.strip()
if status_code and status_code.isdigit() and int(status_code) > 0:
return {"reachable": True, "status_code": int(status_code)}
return {"reachable": True, "status_code": int(status_code), "via_loopback": True}
return {"reachable": False, "error": result.stderr.strip() or "No response"}
except subprocess.TimeoutExpired:
return {"reachable": False, "error": "timeout"}
@@ -984,25 +1063,28 @@ def _check_domain_reachable(domain: str) -> dict:
def _check_domain_health_fast(domain: str | None, external_ip: str) -> bool:
"""Fast domain issue check for tile health (no curl/subprocess calls)."""
"""Fast domain issue check for tile health (no curl/subprocess calls).
Returns ``True`` when a domain issue is detected that warrants
``needs_attention``, ``False`` otherwise.
Loopback resolution is treated as an intentional server-local override,
not a DNS mismatch.
"""
if not domain:
return True
resolved_ip: str | None = None
try:
results = socket.getaddrinfo(domain, None)
if results:
resolved_ip = results[0][4][0]
except socket.gaierror:
resolved_ip = None
except Exception:
resolved_ip = None
if not resolved_ip:
addrs = _resolve_all_addresses(domain)
if not addrs:
return True
# If every resolved address is loopback the intentional /etc/hosts
# override is in place — this is healthy, not a mismatch.
if all(_is_loopback_address(a) for a in addrs):
return False
if external_ip == "unavailable":
return False
return resolved_ip != external_ip
return not any(a == external_ip for a in addrs)
def _is_domain_reachable_cached(domain: str) -> bool | None:
@@ -1070,15 +1152,8 @@ def _evaluate_domain_checklist(
"detail": domain,
})
resolved_ip: str | None = None
try:
results = socket.getaddrinfo(domain, None)
if results:
resolved_ip = results[0][4][0]
except socket.gaierror:
resolved_ip = None
except Exception:
resolved_ip = None
addrs = _resolve_all_addresses(domain)
resolved_ip: str | None = addrs[0] if addrs else None
if not resolved_ip:
domain_status = {
@@ -1105,7 +1180,29 @@ def _evaluate_domain_checklist(
"has_issues": True,
}
if external_ip == "unavailable":
# Detect intentional server-local loopback override from /etc/hosts.
# When all addresses are loopback the public DNS is not checked via the
# system resolver (which would always return the override). We proceed
# to the reachability check so Caddy health can still be verified.
loopback_override = all(_is_loopback_address(a) for a in addrs)
if loopback_override:
domain_status = {
"status": "local_override",
"resolved_ip": resolved_ip,
"expected_ip": external_ip,
}
steps.append({
"step": 2,
"label": "DNS / Local Override",
"status": "ok",
"detail": (
"Server-local loopback override is active — this computer routes the domain "
"directly to Caddy. Public DNS verification is skipped on this computer; "
"confirm your DNS provider points the domain to your external IP separately."
),
})
elif external_ip == "unavailable":
domain_status = {
"status": "error",
"resolved_ip": resolved_ip,
@@ -1117,7 +1214,7 @@ def _evaluate_domain_checklist(
"status": "warning",
"detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)",
})
elif resolved_ip != external_ip:
elif not any(a == external_ip for a in addrs):
domain_status = {
"status": "dns_mismatch",
"resolved_ip": resolved_ip,
@@ -2749,19 +2846,17 @@ async def api_services():
break
has_domain_issues = False
if needs_domain and domain and enabled:
addrs = _resolve_all_addresses(domain)
dns_ok = True
try:
results = socket.getaddrinfo(domain, None)
if results:
resolved_ip = results[0][4][0]
if (
_cached_external_ip != "unavailable"
and resolved_ip != _cached_external_ip
):
dns_ok = False
else:
dns_ok = False
except (socket.gaierror, Exception):
if not addrs:
dns_ok = False
elif all(_is_loopback_address(a) for a in addrs):
# Intentional server-local /etc/hosts override — not a mismatch.
dns_ok = True
elif (
_cached_external_ip != "unavailable"
and not any(a == _cached_external_ip for a in addrs)
):
dns_ok = False
if not dns_ok:
@@ -3886,6 +3981,12 @@ async def api_domains_set(req: DomainSetRequest):
except Exception:
pass
# Regenerate the server-local /etc/hosts loopback entries so the newly
# saved domain is immediately reachable on this computer without NAT
# loopback support on the router.
if req.domain_name in _SERVICE_DOMAIN_KEYS:
_trigger_hosts_update()
return {"ok": True}
@@ -3933,38 +4034,35 @@ async def api_domains_check(req: DomainCheckRequest):
external_ip = _cached_external_ip
def check_domain(domain: str) -> dict:
try:
results = socket.getaddrinfo(domain, None)
if not results:
return {
"domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip,
}
resolved_ip = results[0][4][0]
if external_ip == "unavailable":
return {
"domain": domain, "status": "error",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
if resolved_ip == external_ip:
return {
"domain": domain, "status": "connected",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
return {
"domain": domain, "status": "dns_mismatch",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
except socket.gaierror:
addrs = _resolve_all_addresses(domain)
if not addrs:
return {
"domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip,
}
except Exception:
resolved_ip = addrs[0]
# Server-local /etc/hosts loopback override — report as such rather
# than as a DNS mismatch. Public DNS cannot be verified from this
# computer when the override is active.
if all(_is_loopback_address(a) for a in addrs):
return {
"domain": domain, "status": "local_override",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
if external_ip == "unavailable":
return {
"domain": domain, "status": "error",
"resolved_ip": None, "expected_ip": external_ip,
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
if any(a == external_ip for a in addrs):
return {
"domain": domain, "status": "connected",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
return {
"domain": domain, "status": "dns_mismatch",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
check_results = await asyncio.gather(*[
loop.run_in_executor(None, check_domain, d) for d in req.domains