Merge pull request #325 from naturallaw777/copilot/implement-server-side-loopback-solution

feat: server-side loopback overrides + Hub diagnostic fixes for NAT loopback
This commit is contained in:
Sovran Systems
2026-07-15 16:21:10 +00:00
committed by GitHub
4 changed files with 716 additions and 64 deletions
+159 -52
View File
@@ -8,6 +8,7 @@ import contextlib
import glob import glob
import hashlib import hashlib
import hmac import hmac
import ipaddress
import json import json
import logging import logging
import os import os
@@ -80,6 +81,15 @@ DOMAINS_DIR = "/var/lib/domains"
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub" NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh" 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" INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
@@ -964,18 +974,94 @@ def _check_port_status(
return "closed" return "closed"
# Regex for validating domain values written into /etc/hosts. Rejects anything
# containing whitespace, newlines, or characters that could escape a hosts entry.
# NOTE: The equivalent pattern in modules/core/local-domain-loopback.nix (shell
# grep -E) must be kept in sync with this Python regex.
_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 unique IP addresses that *domain* resolves to, or an empty list.
The first element is the address that the system resolver would normally
use for a connection. All elements are checked when determining whether
any address matches the expected public IP or is a loopback address.
"""
try:
results = socket.getaddrinfo(domain, None)
unique_addresses: list[str] = []
for r in results:
addr = r[4][0]
if addr not in unique_addresses:
unique_addresses.append(addr)
return unique_addresses
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: 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: try:
result = subprocess.run( 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, capture_output=True,
text=True, text=True,
timeout=15, timeout=15,
) )
status_code = result.stdout.strip() status_code = result.stdout.strip()
if status_code and status_code.isdigit() and int(status_code) > 0: 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"} return {"reachable": False, "error": result.stderr.strip() or "No response"}
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return {"reachable": False, "error": "timeout"} return {"reachable": False, "error": "timeout"}
@@ -984,25 +1070,28 @@ def _check_domain_reachable(domain: str) -> dict:
def _check_domain_health_fast(domain: str | None, external_ip: str) -> bool: 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: if not domain:
return True return True
resolved_ip: str | None = None addrs = _resolve_all_addresses(domain)
try: if not addrs:
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:
return True 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": if external_ip == "unavailable":
return False 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: def _is_domain_reachable_cached(domain: str) -> bool | None:
@@ -1070,15 +1159,8 @@ def _evaluate_domain_checklist(
"detail": domain, "detail": domain,
}) })
resolved_ip: str | None = None addrs = _resolve_all_addresses(domain)
try: resolved_ip: str | None = addrs[0] if addrs else None
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: if not resolved_ip:
domain_status = { domain_status = {
@@ -1105,7 +1187,31 @@ def _evaluate_domain_checklist(
"has_issues": True, "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 without going through the router. "
"Public DNS cannot be verified from this computer while the override is in place. "
"To check your public DNS from outside, use a tool such as "
"https://dnschecker.org or run: dig @1.1.1.1 " + domain
),
})
elif external_ip == "unavailable":
domain_status = { domain_status = {
"status": "error", "status": "error",
"resolved_ip": resolved_ip, "resolved_ip": resolved_ip,
@@ -1117,7 +1223,7 @@ def _evaluate_domain_checklist(
"status": "warning", "status": "warning",
"detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)", "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 = { domain_status = {
"status": "dns_mismatch", "status": "dns_mismatch",
"resolved_ip": resolved_ip, "resolved_ip": resolved_ip,
@@ -2749,20 +2855,18 @@ async def api_services():
break break
has_domain_issues = False has_domain_issues = False
if needs_domain and domain and enabled: if needs_domain and domain and enabled:
addrs = _resolve_all_addresses(domain)
dns_ok = True dns_ok = True
try: if not addrs:
results = socket.getaddrinfo(domain, None) dns_ok = False
if results: elif all(_is_loopback_address(a) for a in addrs):
resolved_ip = results[0][4][0] # Intentional server-local /etc/hosts override — not a mismatch.
if ( dns_ok = True
elif (
_cached_external_ip != "unavailable" _cached_external_ip != "unavailable"
and resolved_ip != _cached_external_ip and not any(a == _cached_external_ip for a in addrs)
): ):
dns_ok = False dns_ok = False
else:
dns_ok = False
except (socket.gaierror, Exception):
dns_ok = False
if not dns_ok: if not dns_ok:
has_domain_issues = True has_domain_issues = True
@@ -3886,6 +3990,12 @@ async def api_domains_set(req: DomainSetRequest):
except Exception: except Exception:
pass 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} return {"ok": True}
@@ -3933,20 +4043,27 @@ async def api_domains_check(req: DomainCheckRequest):
external_ip = _cached_external_ip external_ip = _cached_external_ip
def check_domain(domain: str) -> dict: def check_domain(domain: str) -> dict:
try: addrs = _resolve_all_addresses(domain)
results = socket.getaddrinfo(domain, None) if not addrs:
if not results:
return { return {
"domain": domain, "status": "unresolvable", "domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip, "resolved_ip": None, "expected_ip": external_ip,
} }
resolved_ip = results[0][4][0] 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": if external_ip == "unavailable":
return { return {
"domain": domain, "status": "error", "domain": domain, "status": "error",
"resolved_ip": resolved_ip, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
} }
if resolved_ip == external_ip: if any(a == external_ip for a in addrs):
return { return {
"domain": domain, "status": "connected", "domain": domain, "status": "connected",
"resolved_ip": resolved_ip, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
@@ -3955,16 +4072,6 @@ async def api_domains_check(req: DomainCheckRequest):
"domain": domain, "status": "dns_mismatch", "domain": domain, "status": "dns_mismatch",
"resolved_ip": resolved_ip, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
} }
except socket.gaierror:
return {
"domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip,
}
except Exception:
return {
"domain": domain, "status": "error",
"resolved_ip": None, "expected_ip": external_ip,
}
check_results = await asyncio.gather(*[ check_results = await asyncio.gather(*[
loop.run_in_executor(None, check_domain, d) for d in req.domains loop.run_in_executor(None, check_domain, d) for d in req.domains
+399
View File
@@ -0,0 +1,399 @@
"""Tests for server-local loopback diagnostics and domain validation.
Covers:
- Domain value validation and injection prevention.
- Loopback address detection (IPv4 and IPv6).
- _resolve_all_addresses returning multiple addresses.
- _check_domain_health_fast with loopback resolution.
- _evaluate_domain_checklist with loopback override — no false dns_mismatch.
- _evaluate_domain_checklist with genuine DNS mismatch — still reports error.
- api_services health stays "healthy" when domain resolves to loopback.
- api_services health stays "needs_attention" when DNS is genuinely wrong.
- api_domains_check returns "local_override" for loopback-resolved domains.
"""
import unittest
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# ---------------------------------------------------------------------------
# Minimal stubs so server.py can be imported without the full FastAPI stack.
# ---------------------------------------------------------------------------
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.JSONResponse = object
responses_module.RedirectResponse = object
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server # noqa: E402
# ===========================================================================
# Domain value validation
# ===========================================================================
class TestValidateDomainValue(unittest.TestCase):
"""_validate_domain_value must reject anything that could corrupt /etc/hosts."""
def _v(self, value: str) -> bool:
return server._validate_domain_value(value)
# -- Valid values --------------------------------------------------------
def test_simple_domain_valid(self):
self.assertTrue(self._v("cloud.example.com"))
def test_subdomain_valid(self):
self.assertTrue(self._v("matrix.home.example.org"))
def test_single_label_with_tld_valid(self):
self.assertTrue(self._v("example.com"))
def test_hyphen_in_domain_valid(self):
self.assertTrue(self._v("my-nextcloud.example.com"))
# -- Injection / malformed values ----------------------------------------
def test_empty_string_invalid(self):
self.assertFalse(self._v(""))
def test_newline_injection_invalid(self):
self.assertFalse(self._v("evil.com\n127.0.0.1 other.host"))
def test_carriage_return_injection_invalid(self):
self.assertFalse(self._v("evil.com\r127.0.0.1 other.host"))
def test_space_injection_invalid(self):
self.assertFalse(self._v("evil.com 127.0.0.1"))
def test_hash_comment_injection_invalid(self):
self.assertFalse(self._v("evil.com# comment"))
def test_bare_hostname_no_dot_invalid(self):
self.assertFalse(self._v("localhost"))
def test_bare_ip_invalid(self):
self.assertFalse(self._v("192.168.1.1"))
def test_too_long_invalid(self):
self.assertFalse(self._v("a" * 254 + ".com"))
def test_leading_dot_invalid(self):
self.assertFalse(self._v(".example.com"))
def test_trailing_dot_invalid(self):
self.assertFalse(self._v("example.com."))
# ===========================================================================
# Loopback address detection
# ===========================================================================
class TestIsLoopbackAddress(unittest.TestCase):
def test_ipv4_loopback(self):
self.assertTrue(server._is_loopback_address("127.0.0.1"))
def test_ipv4_loopback_other(self):
self.assertTrue(server._is_loopback_address("127.0.0.2"))
def test_ipv4_loopback_high(self):
self.assertTrue(server._is_loopback_address("127.255.255.255"))
def test_ipv6_loopback(self):
self.assertTrue(server._is_loopback_address("::1"))
def test_public_ipv4_not_loopback(self):
self.assertFalse(server._is_loopback_address("203.0.113.10"))
def test_private_ipv4_not_loopback(self):
self.assertFalse(server._is_loopback_address("192.168.1.50"))
def test_ipv6_public_not_loopback(self):
self.assertFalse(server._is_loopback_address("2001:db8::1"))
def test_invalid_string_not_loopback(self):
self.assertFalse(server._is_loopback_address("not-an-ip"))
# ===========================================================================
# _check_domain_health_fast
# ===========================================================================
class TestCheckDomainHealthFast(unittest.TestCase):
"""_check_domain_health_fast returns True when there is an issue,
False when everything looks fine."""
def _fast(self, domain, external_ip, resolved_addrs):
with patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs):
return server._check_domain_health_fast(domain, external_ip)
def test_no_domain_no_issue(self):
# None/empty domain: the fast check reports True (handled by checklist).
result = server._check_domain_health_fast(None, "203.0.113.10")
self.assertTrue(result)
def test_empty_domain_no_issue(self):
result = server._check_domain_health_fast("", "203.0.113.10")
self.assertTrue(result)
def test_loopback_ipv4_no_issue(self):
"""Loopback override must not be flagged as a DNS mismatch."""
result = self._fast("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
self.assertFalse(result)
def test_loopback_ipv6_no_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["::1"])
self.assertFalse(result)
def test_matches_external_ip_no_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
self.assertFalse(result)
def test_mismatch_is_an_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
self.assertTrue(result)
def test_unavailable_external_ip_no_issue(self):
result = self._fast("cloud.example.com", "unavailable", ["198.51.100.1"])
self.assertFalse(result)
def test_multiple_addresses_one_matches_no_issue(self):
"""If any resolved address matches external_ip the check should pass."""
result = self._fast(
"cloud.example.com", "203.0.113.10",
["198.51.100.1", "203.0.113.10"],
)
self.assertFalse(result)
# ===========================================================================
# _evaluate_domain_checklist — loopback override path
# ===========================================================================
class TestEvaluateDomainChecklistLoopback(unittest.TestCase):
def _eval(self, domain, external_ip, resolved_addrs, reachable_result=None):
with (
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_check_domain_reachable",
return_value=reachable_result or {"reachable": True, "status_code": 200}),
):
return server._evaluate_domain_checklist(domain, external_ip)
def test_loopback_dns_step_is_ok_not_error(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
dns_step = next(s for s in result["domain_check_steps"] if s["step"] == 2)
self.assertEqual(dns_step["status"], "ok")
self.assertNotIn("mismatch", dns_step.get("detail", "").lower())
def test_loopback_domain_status_is_local_override(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
self.assertEqual(result["domain_status"]["status"], "local_override")
def test_loopback_has_no_issues_when_reachable(self):
result = self._eval(
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
reachable_result={"reachable": True, "status_code": 200},
)
self.assertFalse(result["has_issues"])
def test_loopback_has_issues_when_caddy_unreachable(self):
"""A loopback override with Caddy down should still report an issue."""
result = self._eval(
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
reachable_result={"reachable": False, "error": "connection refused"},
)
self.assertTrue(result["has_issues"])
def test_ipv6_loopback_no_issue(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["::1"])
self.assertEqual(result["domain_status"]["status"], "local_override")
self.assertFalse(result["has_issues"])
def test_genuine_mismatch_still_reports_error(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
self.assertEqual(result["domain_status"]["status"], "dns_mismatch")
self.assertTrue(result["has_issues"])
def test_correct_public_dns_still_reports_ok(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
self.assertEqual(result["domain_status"]["status"], "connected")
self.assertFalse(result["has_issues"])
def test_no_domain_has_issues(self):
result = self._eval(None, "203.0.113.10", [])
self.assertTrue(result["has_issues"])
# ===========================================================================
# api_services — composite health with loopback
# ===========================================================================
class TestApiServicesLoopbackHealth(unittest.IsolatedAsyncioTestCase):
async def _get_health(self, resolved_addrs, cached_reachable):
"""Return the health value for a single domain-requiring service."""
service_cfg = {
"services": [
{"unit": "caddy.service", "icon": "nextcloud", "enabled": True, "type": "system"}
]
}
with (
patch.object(server, "load_config", return_value=service_cfg),
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
patch.object(server.sysctl, "is_active", return_value="active"),
patch.dict(server.SERVICE_DOMAIN_MAP, {"caddy.service": "nextcloud"}, clear=False),
patch("builtins.open", mock_open(read_data="cloud.example.com\n")),
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_is_domain_reachable_cached", return_value=cached_reachable),
patch.object(server, "_get_listening_ports",
return_value={"tcp": {80, 443}, "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports",
return_value={"tcp": set(), "udp": set()}),
patch.object(server, "_cached_external_ip", "203.0.113.10"),
):
results = await server.api_services()
return results[0]["health"]
async def test_loopback_and_reachable_is_healthy(self):
"""Loopback override + Caddy reachable → healthy, not needs_attention."""
health = await self._get_health(["127.0.0.1"], cached_reachable=True)
self.assertEqual(health, "healthy")
async def test_loopback_and_caddy_down_is_needs_attention(self):
"""Loopback override + Caddy unreachable → needs_attention (genuine issue)."""
health = await self._get_health(["127.0.0.1"], cached_reachable=False)
self.assertEqual(health, "needs_attention")
async def test_correct_dns_and_reachable_is_healthy(self):
health = await self._get_health(["203.0.113.10"], cached_reachable=True)
self.assertEqual(health, "healthy")
async def test_dns_mismatch_is_needs_attention(self):
health = await self._get_health(["198.51.100.1"], cached_reachable=True)
self.assertEqual(health, "needs_attention")
# ===========================================================================
# api_domains_check — loopback detection
# ===========================================================================
class TestApiDomainsCheckLoopback(unittest.IsolatedAsyncioTestCase):
async def _check(self, resolved_addrs, external_ip="203.0.113.10"):
with (
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_cached_external_ip", external_ip),
):
result = await server.api_domains_check(
MagicMock(domains=["cloud.example.com"])
)
return result["domains"][0]
async def test_loopback_ipv4_returns_local_override(self):
result = await self._check(["127.0.0.1"])
self.assertEqual(result["status"], "local_override")
async def test_loopback_ipv6_returns_local_override(self):
result = await self._check(["::1"])
self.assertEqual(result["status"], "local_override")
async def test_correct_dns_returns_connected(self):
result = await self._check(["203.0.113.10"])
self.assertEqual(result["status"], "connected")
async def test_mismatch_returns_dns_mismatch(self):
result = await self._check(["198.51.100.1"])
self.assertEqual(result["status"], "dns_mismatch")
async def test_no_resolution_returns_unresolvable(self):
result = await self._check([])
self.assertEqual(result["status"], "unresolvable")
if __name__ == "__main__":
unittest.main()
+145
View File
@@ -0,0 +1,145 @@
{ 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" ];
};
}
+1
View File
@@ -16,6 +16,7 @@
./core/remote-deploy.nix ./core/remote-deploy.nix
./core/no-sleep.nix ./core/no-sleep.nix
./core/cpu-performance.nix ./core/cpu-performance.nix
./core/local-domain-loopback.nix
# ── Always on (no flag) ─────────────────────────────────── # ── Always on (no flag) ───────────────────────────────────
./php.nix ./php.nix