Compare commits
104
Commits
89cfd83b8e
...
v1.0.3
+455
-148
@@ -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"
|
||||
|
||||
@@ -222,28 +232,16 @@ FEATURE_REGISTRY = [
|
||||
"conflicts_with": [],
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
"id": "bip110",
|
||||
"name": "Bitcoin Knots + BIP110",
|
||||
"description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Knots + BIP110 consensus changes. It will disable the currently active alternative.",
|
||||
"category": "bitcoin",
|
||||
"needs_domain": False,
|
||||
"domain_name": None,
|
||||
"needs_ddns": False,
|
||||
"extra_fields": [],
|
||||
"conflicts_with": ["bitcoin-core"],
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
"id": "bitcoin-core",
|
||||
"name": "Bitcoin Core",
|
||||
"description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Core. It will disable the currently active alternative.",
|
||||
"description": "Only one Bitcoin node implementation can be active: Bitcoin Knots + BIP110 (default) or Bitcoin Core. Enabling this replaces Knots + BIP110 with Bitcoin Core. Your timechain data is preserved.",
|
||||
"category": "bitcoin",
|
||||
"needs_domain": False,
|
||||
"domain_name": None,
|
||||
"needs_ddns": False,
|
||||
"extra_fields": [],
|
||||
"conflicts_with": ["bip110"],
|
||||
"conflicts_with": [],
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
@@ -277,22 +275,22 @@ FEATURE_REGISTRY = [
|
||||
},
|
||||
]
|
||||
|
||||
# Feature ids that have been removed/deprecated. The Hub must never write these
|
||||
# back into custom.nix, and should strip any it finds (see startup migration).
|
||||
DEPRECATED_FEATURE_IDS: set[str] = {"bip110"}
|
||||
|
||||
# Map feature IDs to their systemd units in config.json
|
||||
FEATURE_SERVICE_MAP = {
|
||||
"rdp": "gnome-remote-desktop.service",
|
||||
"haven": "haven-relay.service",
|
||||
"element-calling": "livekit.service",
|
||||
"mempool": "mempool.service",
|
||||
"bip110": None,
|
||||
"bitcoin-core": None,
|
||||
"btcpay-web": "btcpayserver.service",
|
||||
"sshd": "sshd.service",
|
||||
}
|
||||
|
||||
# Port requirements for service tiles (keyed by unit name or icon)
|
||||
_PORTS_MATRIX_FEDERATION = [
|
||||
{"port": "8448", "protocol": "TCP", "description": "Matrix server-to-server federation"},
|
||||
]
|
||||
_PORTS_ELEMENT_CALLING = [
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
@@ -305,7 +303,7 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
|
||||
# Infrastructure
|
||||
"caddy.service": [],
|
||||
# Communication
|
||||
"matrix-synapse.service": _PORTS_MATRIX_FEDERATION,
|
||||
"matrix-synapse.service": [],
|
||||
"livekit.service": _PORTS_ELEMENT_CALLING,
|
||||
# Domain-based apps (80/443 handled by end-to-end domain reachability checks)
|
||||
"btcpayserver.service": [],
|
||||
@@ -331,7 +329,6 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
||||
|
||||
# For features that share a unit, disambiguate by icon field
|
||||
FEATURE_ICON_MAP = {
|
||||
"bip110": "bip110",
|
||||
"bitcoin-core": "bitcoin-core",
|
||||
}
|
||||
|
||||
@@ -352,7 +349,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||
"server_plus_desktop": None,
|
||||
"desktop": {"rdp", "sshd"},
|
||||
"node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
||||
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
||||
}
|
||||
|
||||
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||
@@ -977,18 +974,94 @@ 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.
|
||||
# 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:
|
||||
"""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"}
|
||||
@@ -997,25 +1070,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:
|
||||
@@ -1083,15 +1159,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 = {
|
||||
@@ -1118,7 +1187,31 @@ 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 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 = {
|
||||
"status": "error",
|
||||
"resolved_ip": resolved_ip,
|
||||
@@ -1130,7 +1223,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,
|
||||
@@ -1232,7 +1325,7 @@ def _generate_qr_base64(data: str) -> str | None:
|
||||
# ── Update helpers (file-based, no systemctl) ────────────────────
|
||||
|
||||
def _read_update_status() -> str:
|
||||
"""Read the status file. Returns RUNNING, SUCCESS, FAILED, or IDLE."""
|
||||
"""Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE."""
|
||||
try:
|
||||
with open(UPDATE_STATUS, "r") as f:
|
||||
return f.read().strip()
|
||||
@@ -1519,7 +1612,9 @@ def _read_hub_overrides() -> tuple[dict, str | None, str | None, str | None]:
|
||||
r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
||||
section,
|
||||
):
|
||||
features[m.group(1)] = m.group(2) == "true"
|
||||
feat_id = m.group(1)
|
||||
if feat_id not in DEPRECATED_FEATURE_IDS:
|
||||
features[feat_id] = m.group(2) == "true"
|
||||
for m in re.finditer(
|
||||
r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
||||
section,
|
||||
@@ -1552,6 +1647,8 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
"""Write the Hub Managed section inside custom.nix."""
|
||||
lines = []
|
||||
for feat_id, enabled in features.items():
|
||||
if feat_id in DEPRECATED_FEATURE_IDS:
|
||||
continue
|
||||
val = "true" if enabled else "false"
|
||||
if feat_id == "btcpay-web":
|
||||
lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};")
|
||||
@@ -1597,6 +1694,40 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _migrate_strip_deprecated_features() -> None:
|
||||
"""One-time migration: remove deprecated feature lines from the Hub Managed
|
||||
section of custom.nix. Any feature id in DEPRECATED_FEATURE_IDS is dropped
|
||||
while all other Hub-managed settings (other features, nostr_npub, timezone,
|
||||
locale) are preserved byte-for-byte in meaning.
|
||||
|
||||
This is a no-op (and never raises) if CUSTOM_NIX is missing, unreadable, or
|
||||
contains no deprecated lines.
|
||||
"""
|
||||
try:
|
||||
with open(CUSTOM_NIX, "r") as f:
|
||||
content = f.read()
|
||||
except (FileNotFoundError, OSError):
|
||||
return
|
||||
|
||||
# Quick-exit: if none of the deprecated ids appear, nothing to do.
|
||||
hub_begin = content.find(HUB_BEGIN)
|
||||
hub_end = content.find(HUB_END)
|
||||
if hub_begin == -1 or hub_end == -1:
|
||||
return
|
||||
section = content[hub_begin:hub_end]
|
||||
if not any(f"features.{dep_id}" in section for dep_id in DEPRECATED_FEATURE_IDS):
|
||||
return
|
||||
|
||||
try:
|
||||
features, nostr_npub, timezone, locale = _read_hub_overrides()
|
||||
# _read_hub_overrides already excludes DEPRECATED_FEATURE_IDS, so
|
||||
# calling _write_hub_overrides with its output drops the stale lines.
|
||||
_write_hub_overrides(features, nostr_npub, timezone, locale)
|
||||
except Exception:
|
||||
# Never let a migration failure break startup.
|
||||
logger.exception("_migrate_strip_deprecated_features: unexpected error (non-fatal)")
|
||||
|
||||
|
||||
# ── Feature status helpers ─────────────────────────────────────────
|
||||
|
||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||
@@ -1606,7 +1737,7 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||
return False # Default off in Node role; only on via explicit hub toggle
|
||||
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||
if unit is None:
|
||||
return None # bip110, bitcoin-core — can't determine from config
|
||||
return None # bitcoin-core — can't determine from config
|
||||
cfg = load_config()
|
||||
for svc in cfg.get("services", []):
|
||||
if svc.get("unit") == unit:
|
||||
@@ -1925,10 +2056,13 @@ def _verify_support_removed() -> bool:
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"asset_version": ASSET_VERSION,
|
||||
})
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="login.html",
|
||||
context={
|
||||
"asset_version": ASSET_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auto-login")
|
||||
@@ -1993,20 +2127,26 @@ async def api_logout(request: Request):
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"asset_version": ASSET_VERSION,
|
||||
})
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"asset_version": ASSET_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/onboarding", response_class=HTMLResponse)
|
||||
async def onboarding(request: Request):
|
||||
_ensure_onboarding_reopened_for_migration()
|
||||
return templates.TemplateResponse("onboarding.html", {
|
||||
"request": request,
|
||||
"asset_version": ASSET_VERSION,
|
||||
"onboarding_js_hash": _ONBOARDING_JS_HASH,
|
||||
})
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="onboarding.html",
|
||||
context={
|
||||
"asset_version": ASSET_VERSION,
|
||||
"onboarding_js_hash": _ONBOARDING_JS_HASH,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/onboarding/status")
|
||||
@@ -2225,6 +2365,16 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
|
||||
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
||||
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
|
||||
|
||||
# Cache for ``bitcoin-cli getdeploymentinfo`` output (BIP-110 live status)
|
||||
_btc_deployment_cache: tuple[float, dict | None] = (0.0, None)
|
||||
|
||||
# Bitcoin Knots exposes BIP-110 as the `reduced_data` versionbits deployment
|
||||
# (RDTS, bit 4) in getdeploymentinfo. See Knots src/deploymentinfo.cpp,
|
||||
# src/kernel/chainparams.cpp, and doc/bips.md.
|
||||
BIP110_DEPLOYMENT_NAMES = {"reduced_data", "rdts", "bip110", "uasf-bip110"}
|
||||
BIP110_VERSIONBITS_BIT = 4
|
||||
BIP110_SUBVERSION_MARKERS = {"bip110", "uasf-bip110", "reduced_data", "rdts"}
|
||||
|
||||
|
||||
# ── Generic service version detection (NixOS store path) ─────────
|
||||
|
||||
@@ -2339,12 +2489,160 @@ def _get_bitcoin_version_info() -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_bitcoin_deployment_info() -> dict | None:
|
||||
"""Call bitcoin-cli getdeploymentinfo and return parsed JSON, or None on error.
|
||||
|
||||
Results are cached for _BTC_VERSION_CACHE_TTL seconds. Never raises.
|
||||
"""
|
||||
global _btc_deployment_cache
|
||||
now = time.monotonic()
|
||||
cached_at, cached_val = _btc_deployment_cache
|
||||
if now - cached_at < _BTC_VERSION_CACHE_TTL:
|
||||
return cached_val
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getdeploymentinfo"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
_btc_deployment_cache = (now, None)
|
||||
return None
|
||||
info = json.loads(result.stdout)
|
||||
_btc_deployment_cache = (now, info)
|
||||
return info
|
||||
except Exception:
|
||||
_btc_deployment_cache = (now, None)
|
||||
return None
|
||||
|
||||
|
||||
def _get_bip110_status() -> dict:
|
||||
"""Return a dict describing the live BIP-110 deployment/signaling state.
|
||||
|
||||
The returned struct has four stable keys::
|
||||
|
||||
{
|
||||
"supported": bool, # node build is BIP-110-capable
|
||||
"signaling": bool, # node is actively signaling / locked-in / active
|
||||
"state": str, # "active" | "locked_in" | "signaling" |
|
||||
# "not_signaling" | "unsupported" | "unknown"
|
||||
"source": str, # "getdeploymentinfo" | "subversion" | "none"
|
||||
}
|
||||
|
||||
Resolution order (authoritative → fallback → honest unknown):
|
||||
|
||||
1. ``getdeploymentinfo`` (authoritative) — scan ``deployments`` for BIP-110.
|
||||
Bitcoin Knots currently exposes BIP-110 as ``reduced_data`` (RDTS, bit 4;
|
||||
see Knots deploymentinfo.cpp / chainparams.cpp / doc/bips.md), so matching
|
||||
first uses known deployment names, then falls back to versionbits bit 4.
|
||||
|
||||
2. Subversion fallback — if getdeploymentinfo is unavailable or yields no
|
||||
recognisable BIP-110 entry, inspect the ``subversion`` field from
|
||||
``getnetworkinfo``. A case-insensitive match for known BIP-110 markers
|
||||
(including "bip110", "uasf-bip110", "reduced_data", "rdts") is treated as
|
||||
"signaling".
|
||||
|
||||
3. Unknown — if the node is entirely unreachable or neither source is
|
||||
conclusive, return state="unknown", signaling=False, source="none".
|
||||
"""
|
||||
_unknown: dict = {"supported": False, "signaling": False, "state": "unknown", "source": "none"}
|
||||
|
||||
def _deployment_bit(entry: dict) -> int | None:
|
||||
bip9 = entry.get("bip9", {}) or {}
|
||||
bip8 = entry.get("bip8", {}) or {}
|
||||
bit = bip9.get("bit")
|
||||
if bit is None:
|
||||
bit = bip8.get("bit")
|
||||
if bit is None:
|
||||
bit = entry.get("bit")
|
||||
return bit
|
||||
|
||||
# ── 1. getdeploymentinfo (authoritative) ──────────────────────────
|
||||
deploy_info = _get_bitcoin_deployment_info()
|
||||
if deploy_info is not None:
|
||||
deployments = deploy_info.get("deployments", {})
|
||||
if isinstance(deployments, dict):
|
||||
matched_entry: dict | None = None
|
||||
|
||||
# Primary match: known deployment names (case-insensitive exact match)
|
||||
for key, entry in deployments.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
key_lower = key.lower()
|
||||
if key_lower not in BIP110_DEPLOYMENT_NAMES:
|
||||
continue
|
||||
matched_entry = entry
|
||||
break
|
||||
|
||||
# Secondary match: versionbits bit (fallback only)
|
||||
if matched_entry is None:
|
||||
for _, entry in deployments.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _deployment_bit(entry) != BIP110_VERSIONBITS_BIT:
|
||||
continue
|
||||
matched_entry = entry
|
||||
break
|
||||
|
||||
if matched_entry is not None:
|
||||
entry = matched_entry
|
||||
|
||||
# bip9 / bip8 status field
|
||||
bip9 = entry.get("bip9", {}) or {}
|
||||
bip8 = entry.get("bip8", {}) or {}
|
||||
status = (
|
||||
bip9.get("status")
|
||||
or bip8.get("status")
|
||||
or entry.get("status")
|
||||
or ""
|
||||
).lower()
|
||||
active = entry.get("active", False)
|
||||
|
||||
if active or status == "active":
|
||||
return {"supported": True, "signaling": True, "state": "active", "source": "getdeploymentinfo"}
|
||||
if status == "locked_in":
|
||||
return {"supported": True, "signaling": True, "state": "locked_in", "source": "getdeploymentinfo"}
|
||||
if status in ("started", "defined"):
|
||||
# Check whether deployment is currently signaling in this period.
|
||||
stats = bip9.get("statistics") or bip8.get("statistics") or {}
|
||||
# Some Knots outputs expose only ``count`` (not explicit signaling bool),
|
||||
# so treat count>0 as a conservative signaling indicator for this period.
|
||||
count = stats.get("count")
|
||||
signaling = bool(
|
||||
stats.get("signaling")
|
||||
or stats.get("signalling")
|
||||
or (isinstance(count, int) and count > 0)
|
||||
)
|
||||
if signaling:
|
||||
return {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"}
|
||||
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
|
||||
if status == "failed":
|
||||
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
|
||||
# Entry found but status unrecognised — node supports BIP-110 but state unclear
|
||||
return {"supported": True, "signaling": False, "state": "unknown", "source": "getdeploymentinfo"}
|
||||
|
||||
# ── 2. Subversion fallback ─────────────────────────────────────────
|
||||
net_info = _get_bitcoin_version_info()
|
||||
if net_info is not None:
|
||||
subversion = net_info.get("subversion", "") or ""
|
||||
sv_lower = subversion.lower()
|
||||
if any(marker in sv_lower for marker in BIP110_SUBVERSION_MARKERS):
|
||||
return {"supported": True, "signaling": True, "state": "signaling", "source": "subversion"}
|
||||
# Node is reachable via RPC but no BIP-110 marker found anywhere
|
||||
return {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"}
|
||||
|
||||
# ── 3. Node unreachable / RPC not ready ───────────────────────────
|
||||
return _unknown
|
||||
|
||||
|
||||
def _get_bitcoind_version() -> str | None:
|
||||
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
||||
|
||||
Parses the first output line to extract the token after "version ".
|
||||
For example: "Bitcoin Knots daemon version v29.3.knots20260210+bip110-v0.4.1"
|
||||
returns "v29.3.knots20260210+bip110-v0.4.1".
|
||||
For example: "Bitcoin Knots daemon version v29.3.knots20260508"
|
||||
returns "v29.3.knots20260508".
|
||||
|
||||
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
|
||||
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
||||
@@ -2379,26 +2677,13 @@ def _get_bitcoind_version() -> str | None:
|
||||
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
|
||||
"""Format a raw version string from ``bitcoind --version`` for tile display.
|
||||
|
||||
Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown
|
||||
cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1" → "v29.3.knots20260210").
|
||||
For the BIP110 tile (icon == "bip110") a " (bip110 vX.Y.Z)" tag is appended
|
||||
including the patch version.
|
||||
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended,
|
||||
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110
|
||||
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix.
|
||||
"""
|
||||
# Extract the BIP110 patch version before stripping the suffix
|
||||
bip110_ver = ""
|
||||
bip_match = re.search(r"\+bip110-v(\S+)", raw_version)
|
||||
if bip_match:
|
||||
bip110_ver = bip_match.group(1)
|
||||
|
||||
# Strip the +bip110... suffix for the base Knots version
|
||||
display = re.sub(r"\+bip110\S*", "", raw_version)
|
||||
|
||||
# For BIP110 tile, append both the tag and the patch version
|
||||
if icon == "bip110":
|
||||
if bip110_ver:
|
||||
display += f" (bip110 v{bip110_ver})"
|
||||
elif "(bip110)" not in display.lower():
|
||||
display += " (bip110)"
|
||||
display = raw_version
|
||||
if icon == "bip110" and "(bip110)" not in display.lower():
|
||||
display += " (bip110)"
|
||||
return display
|
||||
|
||||
|
||||
@@ -2466,6 +2751,19 @@ async def api_bitcoin_version():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/bitcoin/bip110")
|
||||
async def api_bitcoin_bip110():
|
||||
"""Return live BIP-110 deployment/signaling status from bitcoin-cli.
|
||||
|
||||
Always returns HTTP 200. When bitcoind is unreachable or the node is mid-IBD
|
||||
the response will contain ``state = "unknown"`` so the UI can render a neutral
|
||||
badge rather than an error toast.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
status = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
async def api_services():
|
||||
cfg = load_config()
|
||||
@@ -2557,19 +2855,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:
|
||||
@@ -2646,6 +2942,8 @@ async def api_services():
|
||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||
service_data["bitcoin_version"] = btc_ver # backwards compat
|
||||
service_data["version"] = btc_ver
|
||||
if icon == "bip110":
|
||||
service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return service_data
|
||||
|
||||
results = await asyncio.gather(*[get_status(s) for s in services])
|
||||
@@ -2795,36 +3093,28 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
||||
"status": ps,
|
||||
"description": p.get("description", ""),
|
||||
})
|
||||
extra_ports = port_statuses if unit in ("matrix-synapse.service", "livekit.service") else []
|
||||
extra_ports = port_statuses if unit == "livekit.service" else []
|
||||
|
||||
if needs_domain and unit in ("matrix-synapse.service", "livekit.service"):
|
||||
if needs_domain and unit == "livekit.service":
|
||||
if has_domain_issues:
|
||||
domain_check_steps.append({
|
||||
"step": 4,
|
||||
"label": "Federation Port" if unit == "matrix-synapse.service" else "Additional Ports Required",
|
||||
"label": "Router Setup Needed",
|
||||
"status": "skipped",
|
||||
"detail": "Skipped until Steps 1-3 are complete",
|
||||
"detail": "Finish the domain steps first, then forward the Element Call ports in your router.",
|
||||
})
|
||||
elif unit == "matrix-synapse.service":
|
||||
if extra_ports:
|
||||
matrix_open = extra_ports[0]["status"] != "closed"
|
||||
domain_check_steps.append({
|
||||
"step": 4,
|
||||
"label": "Federation Port",
|
||||
"status": "ok" if matrix_open else "error",
|
||||
"detail": (
|
||||
f"Matrix federation port 8448 (TCP) is {'open' if matrix_open else 'closed'}.\n"
|
||||
f"Matrix federation requires port 8448 (TCP) forwarded to {internal_ip}"
|
||||
),
|
||||
})
|
||||
else:
|
||||
extra_open = all(p["status"] != "closed" for p in extra_ports)
|
||||
# These checks are local-only (listening/firewall state on this computer),
|
||||
# not an outside-in verification of router/NAT forwarding.
|
||||
all_local_ready = all(p["status"] != "closed" for p in extra_ports)
|
||||
domain_check_steps.append({
|
||||
"step": 4,
|
||||
"label": "Additional Ports Required",
|
||||
"status": "ok" if extra_open else "error",
|
||||
"label": "Router Setup Needed" if all_local_ready else "Sovran_SystemsOS Port Setup Needed",
|
||||
"status": "warning" if all_local_ready else "error",
|
||||
"detail": (
|
||||
"Element-Call/LiveKit requires additional forwarded ports for WebRTC and TURN traffic."
|
||||
"Sovran_SystemsOS is ready to use these ports on this computer. Now forward them in your router so Element Call can work from outside your home network."
|
||||
if all_local_ready
|
||||
else "Sovran_SystemsOS is not ready to use all required Element Call ports on this computer yet. Fix the ports marked “Not ready yet” below, then forward them in your router."
|
||||
),
|
||||
})
|
||||
|
||||
@@ -2930,6 +3220,8 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
||||
service_detail["version"] = btc_ver
|
||||
if icon == "bip110":
|
||||
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return service_detail
|
||||
|
||||
|
||||
@@ -3698,6 +3990,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}
|
||||
|
||||
|
||||
@@ -3745,38 +4043,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
|
||||
@@ -4567,17 +4862,21 @@ def _recover_stale_status(status_file: str, log_file: str, unit_name: str) -> bo
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_status = "SUCCESS" if unit_result == "success" else "FAILED"
|
||||
if unit_result == "success":
|
||||
new_status = "REBOOT_REQUIRED" if unit_name == UPDATE_UNIT else "SUCCESS"
|
||||
else:
|
||||
new_status = "FAILED"
|
||||
try:
|
||||
with open(status_file, "w") as f:
|
||||
f.write(new_status)
|
||||
except OSError:
|
||||
pass
|
||||
msg = (
|
||||
"\n[Update completed successfully while the server was restarting.]\n"
|
||||
if new_status == "SUCCESS"
|
||||
else "\n[Update encountered an error. See log above for details.]\n"
|
||||
)
|
||||
if new_status == "REBOOT_REQUIRED":
|
||||
msg = "\n[Update staged successfully while the server was restarting. Reboot required.]\n"
|
||||
elif new_status == "SUCCESS":
|
||||
msg = "\n[Update completed successfully while the server was restarting.]\n"
|
||||
else:
|
||||
msg = "\n[Update encountered an error. See log above for details.]\n"
|
||||
try:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(msg)
|
||||
@@ -4597,6 +4896,14 @@ async def _startup_recover_stale_status():
|
||||
await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup_migrate_deprecated_features():
|
||||
"""Strip deprecated feature lines (e.g. bip110) from the Hub Managed section
|
||||
of custom.nix so they are never re-written and do not cause stale warnings."""
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
|
||||
|
||||
|
||||
async def _background_domain_reachability_checker():
|
||||
"""Periodically curl configured domains and cache reachability results."""
|
||||
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
||||
|
||||
@@ -91,3 +91,30 @@
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── Header reboot button ───────────────────────────────────────── */
|
||||
|
||||
.btn-header-reboot {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(184, 125, 0, 0.35);
|
||||
color: #c98d08;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-header-reboot:hover {
|
||||
border-color: #b87d00;
|
||||
color: #e0a010;
|
||||
background-color: rgba(184, 125, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.btn-header-reboot {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,48 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
background-color: #529E7E;
|
||||
}
|
||||
|
||||
/* Restart = AMBER (manual restart action) */
|
||||
.btn-restart-amber {
|
||||
background-color: #b87d00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-restart-amber:hover:not(:disabled) {
|
||||
background-color: #9a6800;
|
||||
}
|
||||
|
||||
/* Restart conflict warning box */
|
||||
.restart-conflict-box {
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.restart-conflict-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #e69000;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.restart-conflict-desc {
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Reboot error card actions row */
|
||||
.reboot-error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: var(--yellow);
|
||||
color: #0A1A10;
|
||||
|
||||
@@ -155,6 +155,69 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
|
||||
|
||||
.tile-bip110-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tile-bip110-badge--active {
|
||||
background: rgba(109, 191, 139, 0.18);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(109, 191, 139, 0.3);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--locked_in {
|
||||
background: rgba(94, 173, 138, 0.15);
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(94, 173, 138, 0.3);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--signaling {
|
||||
background: rgba(94, 173, 138, 0.12);
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(94, 173, 138, 0.2);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--not_signaling {
|
||||
background: rgba(229, 165, 10, 0.12);
|
||||
color: var(--yellow);
|
||||
border: 1px solid rgba(229, 165, 10, 0.25);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--unsupported {
|
||||
background: rgba(94, 122, 106, 0.12);
|
||||
color: var(--grey);
|
||||
border: 1px solid rgba(94, 122, 106, 0.2);
|
||||
}
|
||||
|
||||
.tile-bip110-badge--unknown {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bip110-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bip110-source-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Service detail modal sections ───────────────────────────────── */
|
||||
|
||||
.svc-detail-section {
|
||||
|
||||
@@ -44,6 +44,28 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
|
||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
||||
|
||||
// Restart confirm dialog
|
||||
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
|
||||
|
||||
// Header Reboot button
|
||||
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
|
||||
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
|
||||
if ($restartConfirmOk.disabled) return;
|
||||
$restartConfirmOk.disabled = true;
|
||||
closeRestartConfirmDialog();
|
||||
doReboot();
|
||||
});
|
||||
|
||||
// Reboot error card buttons
|
||||
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
|
||||
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
|
||||
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
|
||||
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
|
||||
});
|
||||
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
|
||||
|
||||
// ── Upgrade modal functions ───────────────────────────────────────
|
||||
|
||||
function openUpgradeModal() {
|
||||
@@ -54,6 +76,37 @@ function closeUpgradeModal() {
|
||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Restart confirm dialog functions ─────────────────────────────
|
||||
|
||||
var _restartDialogOpener = null;
|
||||
|
||||
function openRestartConfirmDialog() {
|
||||
if (!$restartConfirmModal) return;
|
||||
_restartDialogOpener = document.activeElement;
|
||||
|
||||
// Detect conflicting operations
|
||||
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
|
||||
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
|
||||
|
||||
$restartConfirmModal.classList.add("open");
|
||||
|
||||
// Focus Cancel initially for safety
|
||||
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
|
||||
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
|
||||
}
|
||||
|
||||
function closeRestartConfirmDialog() {
|
||||
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
|
||||
// Re-enable confirm button for next open
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
|
||||
// Return focus to the element that opened the dialog
|
||||
if (_restartDialogOpener && _restartDialogOpener.focus) {
|
||||
try { _restartDialogOpener.focus(); } catch (_) {}
|
||||
_restartDialogOpener = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpgradeToServer() {
|
||||
var confirmBtn = $upgradeConfirmBtn;
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||
|
||||
@@ -73,22 +73,47 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
var introHtml;
|
||||
if (_currentRole === "node") {
|
||||
introHtml =
|
||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
|
||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
|
||||
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
|
||||
'</ol>';
|
||||
} else {
|
||||
introHtml =
|
||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
|
||||
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
|
||||
}
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
'<p><strong>Before continuing:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
|
||||
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>Njal.la will give you a curl command like:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
||||
'<li>Enter the subdomain and paste that curl command below</li>' +
|
||||
introHtml +
|
||||
'<details style="margin-top:10px;">' +
|
||||
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
||||
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
|
||||
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
||||
'(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>)<br>' +
|
||||
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
|
||||
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
|
||||
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>' +
|
||||
'</ol>' +
|
||||
'</details>' +
|
||||
'<details style="margin-top:6px;">' +
|
||||
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
|
||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
||||
'<li>In Njal.la, buy the domain you want.</li>' +
|
||||
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
|
||||
'<li>Copy the curl command Njal.la gives you.</li>' +
|
||||
'</ol>' +
|
||||
'</details>' +
|
||||
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) — and paste its curl command.</p>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
npubField +
|
||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||
@@ -103,7 +128,7 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
if (!subdomain) { alert("Please enter a subdomain."); return; }
|
||||
if (!subdomain) { alert("Please enter a domain."); return; }
|
||||
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||
|
||||
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||
@@ -159,14 +184,14 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
'<p><strong>Troubleshooting steps:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong></li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong>. In Njal.la\'s Name field, note that only the host part is stored (the word before the domain) — not the full domain.</li>' +
|
||||
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' +
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||
'</ol>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
npubField +
|
||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Update</button></div>';
|
||||
@@ -413,16 +438,11 @@ function handleFeatureToggle(feat, newEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
if (conflictNames.length > 0) {
|
||||
var confirmMsg;
|
||||
if (feat.id === "bip110") {
|
||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||
} else if (feat.id === "bitcoin-core") {
|
||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||
} else {
|
||||
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
|
||||
}
|
||||
if (feat.id === "bitcoin-core") {
|
||||
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||
} else if (conflictNames.length > 0) {
|
||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||
} else {
|
||||
proceedAfterConflictCheck();
|
||||
}
|
||||
|
||||
@@ -60,3 +60,17 @@ async function apiFetch(path, options) {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
// ── BIP-110 badge state config ────────────────────────────────────
|
||||
// Shared lookup used by tiles.js and service-detail.js.
|
||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
||||
|
||||
var BIP110_BADGE_CONFIG = {
|
||||
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
|
||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
|
||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
|
||||
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ function onRebuildDone(result) {
|
||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else if (result === "reboot_required") {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — reboot required";
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
|
||||
@@ -145,28 +145,25 @@ function openSecurityModal() {
|
||||
if (rebootBtn) {
|
||||
// Keep button disabled for 5 seconds to prevent accidental clicks
|
||||
var countdown = 5;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
var timer = setInterval(function() {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
|
||||
} else {
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
rebootBtn.addEventListener("click", function() {
|
||||
rebootBtn.disabled = true;
|
||||
rebootBtn.textContent = "Rebooting\u2026";
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
||||
rebootBtn.textContent = "Restarting\u2026";
|
||||
// Hide the security reset overlay so the shared reboot overlay is visible
|
||||
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
|
||||
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
|
||||
doReboot();
|
||||
}, { once: true });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -107,6 +107,21 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Section B2: BIP-110 live status (bip110 tile only)
|
||||
if (icon === 'bip110' && data.bip110) {
|
||||
var bip110 = data.bip110;
|
||||
var bip110State = bip110.state || 'unknown';
|
||||
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
||||
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
||||
'<div class="bip110-status-row">' +
|
||||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
||||
bip110Source +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section C: Domain diagnostics (domain services)
|
||||
if (data.needs_domain) {
|
||||
var steps = data.domain_check_steps || [];
|
||||
@@ -139,20 +154,36 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>';
|
||||
|
||||
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
|
||||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||
var internalIp = trimmedInternalIp || "";
|
||||
var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect";
|
||||
var routerIpHelp = internalIp
|
||||
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
|
||||
: "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule.";
|
||||
var routerNextStep = internalIp
|
||||
? 'Next step: Log in to your router and create forwarding rules for the ports above. Set the destination/internal IP to <strong>' + internalIpHtml + '</strong>.'
|
||||
: 'Next step: Log in to your router and create forwarding rules for the ports above. Use this computer’s internal IP as the destination/internal IP.';
|
||||
var domainConfigured = !!(data.domain && String(data.domain).trim());
|
||||
var extraRows = "";
|
||||
data.extra_ports.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (p.status === "listening") {
|
||||
statusIcon = "✅ Open";
|
||||
if (!effectiveEnabled) {
|
||||
statusIcon = "⚠ Configure Element Call first";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (!domainConfigured) {
|
||||
statusIcon = "⚠ Configure domain first";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "listening") {
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "🟡 Firewall open";
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "❌ Closed";
|
||||
statusIcon = "❌ Not ready yet";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Unknown";
|
||||
statusIcon = "— Could not check";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
extraRows += '<tr>' +
|
||||
@@ -163,11 +194,16 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</tr>';
|
||||
});
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Step 4: Additional Ports</div>' +
|
||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||
'<div class="svc-detail-port-note">Forward these ports in your router to this Sovran_SystemsOS computer.</div>' +
|
||||
'<div class="svc-detail-port-note"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
|
||||
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
||||
'<tbody>' + extraRows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<div class="svc-detail-port-note">The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.</div>' +
|
||||
'<div class="svc-detail-port-note">' + routerNextStep + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||
@@ -176,16 +212,16 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
data.port_statuses.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (p.status === "listening") {
|
||||
statusIcon = "✅ Open";
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "🟡 Firewall open";
|
||||
statusIcon = "✅ Ready";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "🔴 Closed";
|
||||
statusIcon = "❌ Not ready";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Unknown";
|
||||
statusIcon = "— Could not check";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
portTableRows += '<tr>' +
|
||||
@@ -196,9 +232,10 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</tr>';
|
||||
});
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Port Status</div>' +
|
||||
'<div class="svc-detail-section-title">Port Requirements</div>' +
|
||||
'<div class="svc-detail-port-note">This shows whether Sovran_SystemsOS is ready to use this port on this computer. If you need access from outside your home network, forward this port in your router.</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
||||
'<tbody>' + portTableRows + '</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
@@ -242,7 +279,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||
|
||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
|
||||
var addonSectionTitle = (feat.id === "bitcoin-core")
|
||||
? "\u20BF Bitcoin Node Selection"
|
||||
: "\uD83D\uDD27 Addon Feature";
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||
const $rebootErrorCard = document.getElementById("reboot-error-card");
|
||||
const $rebootSubmessage = document.getElementById("reboot-submessage");
|
||||
|
||||
const $credsModal = document.getElementById("creds-modal");
|
||||
const $credsTitle = document.getElementById("creds-modal-title");
|
||||
@@ -101,5 +104,14 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||
|
||||
// Restart confirm dialog
|
||||
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
|
||||
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
|
||||
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
|
||||
const $restartConflictBox = document.getElementById("restart-conflict-box");
|
||||
|
||||
// Header reboot button
|
||||
const $headerRebootBtn = document.getElementById("btn-header-reboot");
|
||||
|
||||
// System status banner
|
||||
// (removed — health is now shown per-tile via the composite health field)
|
||||
@@ -4,6 +4,21 @@
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
var _btcSyncPrev = {};
|
||||
|
||||
// ── BIP-110 badge helper ──────────────────────────────────────────
|
||||
|
||||
function _renderBip110Badge(bip110) {
|
||||
if (!bip110) return '';
|
||||
var state = bip110.state || 'unknown';
|
||||
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
||||
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
||||
}
|
||||
|
||||
function _firstElementFromHtml(html) {
|
||||
var tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.firstElementChild || null;
|
||||
}
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
@@ -165,7 +180,8 @@ function buildTile(svc) {
|
||||
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -265,6 +281,23 @@ function updateTiles(services) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update BIP-110 badge for bip110 tiles
|
||||
if (svc.icon === 'bip110') {
|
||||
var badgeHtml = _renderBip110Badge(svc.bip110);
|
||||
var badgeEl = tile.querySelector(".tile-bip110-badge");
|
||||
if (badgeEl) {
|
||||
// Replace existing badge in-place
|
||||
var newBadge = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
|
||||
} else if (badgeHtml) {
|
||||
// Insert badge after version label (or after tile-name if no version)
|
||||
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
|
||||
if (anchorEl) {
|
||||
var newBadgeEl = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function onUpdateDone(result) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else if (result === "reboot_required") {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — reboot required";
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
@@ -179,23 +179,50 @@ function saveErrorReport() {
|
||||
|
||||
var _rebootStartTime = 0;
|
||||
var _serverWentDown = false;
|
||||
var _rebootFailed = false;
|
||||
|
||||
function _setRebootStatus(msg) {
|
||||
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
||||
}
|
||||
|
||||
function doReboot() {
|
||||
if ($modal) $modal.classList.remove("open");
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
stopRebuildPoll();
|
||||
// Reset overlay to main card
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
|
||||
_setRebootStatus("Sending restart request\u2026");
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
_rebootFailed = false;
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
// Definitive HTTP error — server rejected the request before going down
|
||||
_rebootFailed = true;
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "none";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
|
||||
// Leave overlay visible so the error card is shown
|
||||
}
|
||||
// HTTP 2xx: request accepted, proceed with polling
|
||||
})
|
||||
.catch(function() {
|
||||
// Connection dropped or request aborted — the server is likely already going
|
||||
// down as part of the restart. Treat as success and continue polling.
|
||||
});
|
||||
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
}
|
||||
|
||||
function waitForServerReboot() {
|
||||
if (_rebootFailed) return;
|
||||
// Update status on first check (server hasn't gone down yet)
|
||||
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
||||
|
||||
@@ -205,18 +232,23 @@ function waitForServerReboot() {
|
||||
if (_serverWentDown) {
|
||||
// Server is responding after having been down — reboot is complete.
|
||||
// Any response (even 401/500) means the server process is back.
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
||||
// Server still responding but hasn't gone down yet — keep waiting
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
} else {
|
||||
// Been over 90 seconds and server is responding — just reload
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
clearTimeout(timeoutId);
|
||||
_serverWentDown = true;
|
||||
if (!_serverWentDown) {
|
||||
_serverWentDown = true;
|
||||
_setRebootStatus("The computer is restarting\u2026");
|
||||
}
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,8 +333,6 @@ async function loadStep3() {
|
||||
return;
|
||||
}
|
||||
|
||||
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
|
||||
|
||||
// Build set of enabled service units
|
||||
var enabledUnits = new Set();
|
||||
(_servicesData || []).forEach(function(svc) {
|
||||
@@ -352,25 +350,31 @@ async function loadStep3() {
|
||||
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||
} else {
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '<strong>Before you continue:</strong>'
|
||||
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
|
||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>'
|
||||
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>'
|
||||
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
|
||||
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>'
|
||||
+ '<li>Njal.la will give you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>'
|
||||
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
|
||||
+ '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
|
||||
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
|
||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
||||
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
|
||||
+ '(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>.)<br>'
|
||||
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
|
||||
+ '⚠ Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
|
||||
+ '<li>A Dynamic record has NO IP field. You don't enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
|
||||
+ '</ul>'
|
||||
+ '</li>'
|
||||
+ '<li>Njal.la gives you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>'
|
||||
+ '</ol>'
|
||||
+ '</div>';
|
||||
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.com</code>) and its Njal.la DDNS curl command.</p>';
|
||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
||||
relevantDomains.forEach(function(d) {
|
||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||
html += '<div class="onboarding-domain-group">';
|
||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=' + escHtml(d.name) + '.yourdomain.com&k=abc123&auto"" />';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
|
||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
@@ -512,7 +516,7 @@ async function saveStep3() {
|
||||
async function loadStep4() {
|
||||
var body = document.getElementById("step-4-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||
body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>';
|
||||
|
||||
var networkData = null;
|
||||
|
||||
@@ -523,51 +527,59 @@ async function loadStep4() {
|
||||
return;
|
||||
}
|
||||
|
||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
||||
|
||||
var ip = escHtml(internalIp);
|
||||
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
|
||||
var internalIp = trimmedInternalIp || "";
|
||||
var hasInternalIp = !!internalIp;
|
||||
var ip = escHtml(internalIp || "Could not detect");
|
||||
var routerIpHelp = hasInternalIp
|
||||
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
|
||||
: "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule.";
|
||||
var destinationInstruction = hasInternalIp
|
||||
? 'Set the destination/internal IP to <strong>' + ip + '</strong>'
|
||||
: 'Use this computer’s internal IP as the destination/internal IP';
|
||||
|
||||
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
||||
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||
+ '</p>';
|
||||
|
||||
html += '<div class="onboarding-port-ip">';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
|
||||
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
|
||||
|
||||
// Required ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Router Rules</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward To</th><th>Used For</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP</td></tr>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP / SSL setup</td></tr>';
|
||||
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">SSH Remote Access</td></tr>';
|
||||
html += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Remote SSH access</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Optional ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">These 5 additional port openings are required on top of the 4 required ports above.</div>';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward To</th><th>Used For</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
|
||||
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000–40000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000-40000</td><td class="port-req-proto">TCP & UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '<div style="font-size:0.85em;margin-top:6px;color:var(--color-text-muted,#888);">ℹ The <strong>30000-40000</strong> range is a single forwarding rule — just set its protocol to <strong>both TCP and UDP</strong> (often shown as "Both" or "TCP/UDP" on your router).</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Totals
|
||||
html += '<div class="onboarding-port-totals">';
|
||||
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
|
||||
html += '<strong>Total port openings: 9</strong> (with Element Calling — 4 required + 5 optional)';
|
||||
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
|
||||
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
@@ -582,12 +594,16 @@ async function loadStep4() {
|
||||
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
||||
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
||||
+ '<li>Create a new rule for each port listed above</li>'
|
||||
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
|
||||
+ '<li>' + destinationInstruction + '</li>'
|
||||
+ '<li>Set both internal and external port to the same number</li>'
|
||||
+ '<li>Save and apply changes</li>'
|
||||
+ '</ol>'
|
||||
+ '</details>';
|
||||
|
||||
html += '<div class="onboarding-port-note" style="margin-top:12px;">'
|
||||
+ '<strong>Important:</strong> The Hub can show which ports Sovran_SystemsOS needs, but it cannot fully confirm router forwarding from inside your home network. Full public port verification requires an outside internet check.'
|
||||
+ '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
<div class="header-buttons">
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -61,7 +62,7 @@
|
||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +165,7 @@
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,26 +241,61 @@
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Reboot now
|
||||
I have written down my new password — Restart Entire System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reboot overlay -->
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<div class="reboot-card">
|
||||
<div class="reboot-icon">↻</div>
|
||||
<h2 class="reboot-title">System Rebooting</h2>
|
||||
<!-- Normal restarting card -->
|
||||
<div class="reboot-card" id="reboot-main-card">
|
||||
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||
<p class="reboot-message">
|
||||
Sovran_SystemsOS is now restarting.<br />
|
||||
This page will automatically reconnect once the system is back online.
|
||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||
This page will reconnect automatically when Sovran_SystemsOS is back online.
|
||||
</p>
|
||||
<div class="reboot-dots">
|
||||
<div class="reboot-dots" aria-hidden="true">
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
</div>
|
||||
<p class="reboot-submessage">Stay tuned…</p>
|
||||
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request…</p>
|
||||
</div>
|
||||
<!-- Error card (shown if restart request fails definitively) -->
|
||||
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||
<div class="reboot-icon" aria-hidden="true">⚠</div>
|
||||
<h2 class="reboot-title">Restart could not be started</h2>
|
||||
<p class="reboot-message">
|
||||
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||
</p>
|
||||
<div class="reboot-error-actions">
|
||||
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
|
||||
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Confirm Dialog -->
|
||||
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
|
||||
<p class="restart-conflict-title">The system cannot restart right now.</p>
|
||||
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
|
||||
</div>
|
||||
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
|
||||
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
|
||||
<p class="support-desc">The system usually returns within 1–3 minutes. This page will reconnect automatically.</p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -128,9 +128,8 @@
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
||||
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
|
||||
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
|
||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||
Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
|
||||
Paste the curl command from your Njal.la dashboard for each service.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
@@ -149,14 +148,14 @@
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||
<h2 class="onboarding-step-title">Router Setup</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
|
||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||
Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network.
|
||||
<strong>Ports 80 and 443 are required for HTTPS and SSL certificates.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-4-body">
|
||||
<p class="onboarding-loading">Checking ports…</p>
|
||||
<p class="onboarding-loading">Loading router setup…</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Bip110StatusTests(unittest.TestCase):
|
||||
def _status(self, deploy_info, net_info):
|
||||
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
|
||||
server, "_get_bitcoin_version_info", return_value=net_info
|
||||
):
|
||||
return server._get_bip110_status()
|
||||
|
||||
def test_started_reduced_data_reports_signaling(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"reduced_data": {
|
||||
"type": "bip9",
|
||||
"active": False,
|
||||
"bip9": {
|
||||
"bit": 4,
|
||||
"status": "started",
|
||||
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
|
||||
"signalling": "--#--",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
|
||||
)
|
||||
|
||||
def test_active_reduced_data_reports_active(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "active")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_locked_in_reduced_data_reports_locked_in(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "locked_in")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
|
||||
}
|
||||
}
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
|
||||
)
|
||||
|
||||
def test_node_unreachable_reports_unknown(self):
|
||||
result = self._status(None, None)
|
||||
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HUB_NIX = Path(__file__).resolve().parents[2] / "modules" / "core" / "sovran-hub.nix"
|
||||
|
||||
|
||||
def _section(source: str, start: str, end: str) -> str:
|
||||
start_idx = source.find(start)
|
||||
if start_idx == -1:
|
||||
raise AssertionError(f"Expected section start not found: {start!r}")
|
||||
end_idx = source.find(end, start_idx)
|
||||
if end_idx == -1:
|
||||
raise AssertionError(f"Expected section end not found: {end!r}")
|
||||
return source[start_idx:end_idx]
|
||||
|
||||
|
||||
class HubUpdateBootStagingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = HUB_NIX.read_text()
|
||||
self.update_section = _section(
|
||||
self.source,
|
||||
'update-script = pkgs.writeShellScript "sovran-hub-update.sh" \'\'',
|
||||
"# ── Rebuild wrapper script",
|
||||
)
|
||||
self.rebuild_section = _section(
|
||||
self.source,
|
||||
'rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" \'\'',
|
||||
"# ── Brave launcher wrapper",
|
||||
)
|
||||
|
||||
def test_full_update_uses_boot_not_switch(self):
|
||||
self.assertIn("nixos-rebuild boot --flake /etc/nixos", self.update_section)
|
||||
self.assertNotIn("nixos-rebuild switch --flake /etc/nixos", self.update_section)
|
||||
|
||||
def test_full_update_marks_reboot_required(self):
|
||||
self.assertIn('echo "REBOOT_REQUIRED" > "$STATUS"', self.update_section)
|
||||
|
||||
def test_rebuild_path_keeps_switch_semantics(self):
|
||||
self.assertIn("nixos-rebuild switch --flake /etc/nixos", self.rebuild_section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,122 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix"
|
||||
USERNAME_READ = "USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\""
|
||||
USERNAME_LENGTH_GUARD = "if [ \"''${#USERNAME}\" -gt 32 ]; then"
|
||||
SHORT_PASSWORD_GUARD = 'if [ "\'\'${#PASSWORD}" -lt 8 ]; then'
|
||||
|
||||
|
||||
def _section(source: str, start: str, end: str) -> str:
|
||||
start_idx = source.find(start)
|
||||
if start_idx == -1:
|
||||
raise AssertionError(f"Expected section start not found: {start!r}")
|
||||
end_idx = source.find(end, start_idx)
|
||||
if end_idx == -1:
|
||||
raise AssertionError(f"Expected section end not found: {end!r}")
|
||||
return source[start_idx:end_idx]
|
||||
|
||||
|
||||
class RdpModuleBootSetupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = RDP_NIX.read_text()
|
||||
self.gnome_service = _section(
|
||||
self.source,
|
||||
"systemd.services.gnome-remote-desktop = {",
|
||||
"systemd.tmpfiles.rules = [",
|
||||
)
|
||||
self.setup_service = _section(
|
||||
self.source,
|
||||
"systemd.services.gnome-remote-desktop-setup = {",
|
||||
"};\n}",
|
||||
)
|
||||
|
||||
def test_does_not_redeclare_gnome_remote_desktop_user(self):
|
||||
self.assertNotIn("users.users.gnome-remote-desktop", self.source)
|
||||
self.assertNotIn("createHome = true;", self.source)
|
||||
|
||||
def test_main_service_requires_setup_before_starting(self):
|
||||
self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service)
|
||||
self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
|
||||
self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
|
||||
|
||||
def test_setup_waits_for_configuration_service_and_bounded_timeout(self):
|
||||
self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service)
|
||||
self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service)
|
||||
self.assertIn('"dbus.service"', self.setup_service)
|
||||
self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service)
|
||||
self.assertNotIn("RemainAfterExit", self.setup_service)
|
||||
self.assertIn('TimeoutStartSec = "2min";', self.setup_service)
|
||||
self.assertIn('timeout --kill-after=5s 10s', self.setup_service)
|
||||
self.assertIn('echo "grdctl command timed out: $*" >&2', self.setup_service)
|
||||
self.assertIn('echo "grdctl command failed (exit $rc): $*" >&2', self.setup_service)
|
||||
|
||||
def test_setup_runs_grdctl_directly_as_root(self):
|
||||
# The oneshot service runs as root; grdctl --system is called directly.
|
||||
# GRD 50.x invokes pkexec internally, but the call itself is plain
|
||||
# grdctl --system, not a manual pkexec invocation.
|
||||
self.assertIn('grdctl --system "$@"', self.setup_service)
|
||||
self.assertNotIn("runuser", self.setup_service)
|
||||
self.assertNotIn("sudo", self.setup_service)
|
||||
# No direct Nix-store pkexec invocation (pkgs.polkit}/bin/pkexec).
|
||||
self.assertNotIn("pkgs.polkit}/bin/pkexec", self.setup_service)
|
||||
|
||||
def test_privilege_escalation_packages_absent_from_setup_path(self):
|
||||
self.assertNotIn("pkgs.polkit", self.setup_service)
|
||||
self.assertNotIn("pkgs.util-linux", self.setup_service)
|
||||
|
||||
def test_run_wrappers_bin_prepended_to_path(self):
|
||||
# /run/wrappers/bin must be prepended to PATH before any grdctl_system
|
||||
# invocation so that grdctl --system resolves the NixOS setuid pkexec.
|
||||
path_export = 'export PATH="/run/wrappers/bin:$PATH"'
|
||||
grdctl_marker = "grdctl_system"
|
||||
script = self.setup_service
|
||||
path_idx = script.find(path_export)
|
||||
grdctl_idx = script.find(grdctl_marker)
|
||||
self.assertGreater(path_idx, -1, f"{path_export!r} not found in setup script")
|
||||
self.assertGreater(
|
||||
grdctl_idx, path_idx,
|
||||
"PATH export must appear before the first grdctl_system usage",
|
||||
)
|
||||
|
||||
def test_pkexec_preflight_check(self):
|
||||
# A preflight must confirm /run/wrappers/bin/pkexec is executable
|
||||
# with a clear error message before any GRD configuration changes.
|
||||
self.assertIn("test -x /run/wrappers/bin/pkexec", self.setup_service)
|
||||
self.assertIn(
|
||||
"/run/wrappers/bin/pkexec is absent or not executable",
|
||||
self.setup_service,
|
||||
)
|
||||
|
||||
def test_hub_files_are_the_source_of_truth_for_username_and_password(self):
|
||||
self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service)
|
||||
self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service)
|
||||
self.assertIn(USERNAME_READ, self.setup_service)
|
||||
self.assertIn(USERNAME_LENGTH_GUARD, self.setup_service)
|
||||
self.assertIn('case "$USERNAME" in', self.setup_service)
|
||||
self.assertIn('[A-Za-z_][A-Za-z0-9_-]*)', self.setup_service)
|
||||
self.assertIn("RDP username is too long (''${#USERNAME} characters, maximum 32)", self.setup_service)
|
||||
self.assertIn("RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens", self.setup_service)
|
||||
self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service)
|
||||
self.assertIn("tr -d '\\n'", self.setup_service)
|
||||
self.assertIn('"$PASSWORD_FILE"', self.setup_service)
|
||||
self.assertIn(SHORT_PASSWORD_GUARD, self.setup_service)
|
||||
self.assertIn("RDP password is too short (''${#PASSWORD} characters, minimum 8)", self.setup_service)
|
||||
self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service)
|
||||
self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service)
|
||||
|
||||
def test_secure_permissions_are_enforced_for_state_and_secret_files(self):
|
||||
self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source)
|
||||
self.assertIn("chmod 700", self.setup_service)
|
||||
self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service)
|
||||
self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service)
|
||||
self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service)
|
||||
self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service)
|
||||
self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service)
|
||||
self.assertIn('LOCAL_IP="$(hostname -I | awk \'{print $1}\')"', self.setup_service)
|
||||
self.assertIn('LOCAL_IP="127.0.0.1"', self.setup_service)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,171 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import mock_open, patch
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ServiceDetailRouterWordingTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_livekit_service_detail_includes_internal_ip(self):
|
||||
service_cfg = {
|
||||
"services": [
|
||||
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
|
||||
]
|
||||
}
|
||||
domain_eval = {
|
||||
"domain_status": {"status": "ok"},
|
||||
"domain_reachable": {"reachable": True},
|
||||
"domain_check_steps": [],
|
||||
"has_issues": False,
|
||||
}
|
||||
|
||||
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, {"livekit.service": "element-call"}, clear=False),
|
||||
patch.dict(
|
||||
server.SERVICE_PORT_REQUIREMENTS,
|
||||
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
|
||||
clear=False,
|
||||
),
|
||||
patch("builtins.open", mock_open(read_data="call.example.com\n")),
|
||||
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
|
||||
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
|
||||
patch.object(server, "_save_internal_ip"),
|
||||
patch.object(server, "_get_listening_ports", return_value={"tcp": {7881}, "udp": set()}),
|
||||
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
|
||||
):
|
||||
result = await server.api_service_detail("livekit.service")
|
||||
|
||||
self.assertEqual(result["internal_ip"], "192.168.1.44")
|
||||
self.assertEqual(result["extra_ports"][0]["status"], "listening")
|
||||
self.assertEqual(result["domain_check_steps"][-1]["label"], "Router Setup Needed")
|
||||
|
||||
async def test_livekit_router_step_uses_not_ready_yet_wording(self):
|
||||
service_cfg = {
|
||||
"services": [
|
||||
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
|
||||
]
|
||||
}
|
||||
domain_eval = {
|
||||
"domain_status": {"status": "ok"},
|
||||
"domain_reachable": {"reachable": True},
|
||||
"domain_check_steps": [],
|
||||
"has_issues": False,
|
||||
}
|
||||
|
||||
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, {"livekit.service": "element-call"}, clear=False),
|
||||
patch.dict(
|
||||
server.SERVICE_PORT_REQUIREMENTS,
|
||||
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
|
||||
clear=False,
|
||||
),
|
||||
patch("builtins.open", mock_open(read_data="call.example.com\n")),
|
||||
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
|
||||
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
|
||||
patch.object(server, "_save_internal_ip"),
|
||||
patch.object(server, "_get_listening_ports", return_value={"tcp": set(), "udp": set()}),
|
||||
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
|
||||
):
|
||||
result = await server.api_service_detail("livekit.service")
|
||||
|
||||
self.assertEqual(result["extra_ports"][0]["status"], "closed")
|
||||
self.assertIn("Not ready yet", result["domain_check_steps"][-1]["detail"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style.
|
||||
|
||||
Prior to this fix, the three HTML routes called:
|
||||
templates.TemplateResponse("name.html", {"request": request, ...})
|
||||
which passes the context dict as the second positional argument. With the
|
||||
updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0,
|
||||
FastAPI 0.136.3) that positional argument is the template name, causing Jinja2
|
||||
to receive a dict as a cache key and raise:
|
||||
TypeError: unhashable type: 'dict'
|
||||
|
||||
The fix updates every call to use keyword arguments:
|
||||
templates.TemplateResponse(request=request, name="name.html", context={...})
|
||||
"""
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py"
|
||||
|
||||
|
||||
def _template_response_calls(source: str):
|
||||
"""Return a list of ast.Call nodes that are TemplateResponse calls."""
|
||||
tree = ast.parse(source)
|
||||
calls = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse":
|
||||
calls.append(node)
|
||||
return calls
|
||||
|
||||
|
||||
class TemplateResponseSignatureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = SERVER_PY.read_text()
|
||||
self.calls = _template_response_calls(self.source)
|
||||
|
||||
def test_at_least_one_template_response_call_found(self):
|
||||
self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py")
|
||||
|
||||
def test_no_old_style_positional_dict_context(self):
|
||||
"""No TemplateResponse call should pass a dict literal as its second positional arg.
|
||||
|
||||
The old style was:
|
||||
templates.TemplateResponse("name.html", {"request": request, ...})
|
||||
where args[0] is a string and args[1] is a Dict node. That pattern
|
||||
triggers the Starlette 1.1.0 bug.
|
||||
"""
|
||||
for call in self.calls:
|
||||
positional = call.args
|
||||
if len(positional) >= 2 and isinstance(positional[1], ast.Dict):
|
||||
self.fail(
|
||||
f"Found old-style TemplateResponse call at line {call.lineno}: "
|
||||
"second positional argument is a dict literal. "
|
||||
"Use keyword arguments (request=, name=, context=) instead."
|
||||
)
|
||||
|
||||
def test_request_not_duplicated_in_context(self):
|
||||
"""The 'request' key must not appear inside the context= dict when
|
||||
request= is already passed as a dedicated keyword argument."""
|
||||
for call in self.calls:
|
||||
kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)}
|
||||
|
||||
if "request" not in kw_dict:
|
||||
continue # no request= kwarg, nothing to check
|
||||
|
||||
context_node = kw_dict.get("context")
|
||||
if not isinstance(context_node, ast.Dict):
|
||||
continue
|
||||
|
||||
for key_node in context_node.keys:
|
||||
if isinstance(key_node, ast.Constant) and key_node.value == "request":
|
||||
self.fail(
|
||||
f"TemplateResponse at line {call.lineno} passes 'request' both as "
|
||||
"request= keyword argument and inside the context dict."
|
||||
)
|
||||
|
||||
def test_all_calls_use_keyword_arguments(self):
|
||||
"""Every TemplateResponse call should use keyword arguments for request, name,
|
||||
and context rather than relying on positional ordering."""
|
||||
for call in self.calls:
|
||||
kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)}
|
||||
self.assertIn(
|
||||
"request",
|
||||
kw_args,
|
||||
f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.",
|
||||
)
|
||||
self.assertIn(
|
||||
"name",
|
||||
kw_args,
|
||||
f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
+4
-4
@@ -145,12 +145,12 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave dua bitwarden-desktop
|
||||
synadm brave dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox element-desktop wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||
matrix-synapse age
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
];
|
||||
|
||||
# ── Shell ──────────────────────────────────────────────────
|
||||
|
||||
Generated
+32
-66
@@ -1,33 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"bip110": {
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778967282,
|
||||
"narHash": "sha256-0g9RvVCD6zxY2vy54GhbB1OeeEZdKuxTr9r0whcpRjQ=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"rev": "8d23ed98940d70e42ee870d719677a073a0a5920",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779889285,
|
||||
"narHash": "sha256-5QOMNn/rxJjsy9n2pAG5+AwUXOAPXSzcr62y1tGHXKA=",
|
||||
"lastModified": 1783519926,
|
||||
"narHash": "sha256-2zwAN4lNitHFrHVnRZG3YcvpdtWOoF0cOBstxMeB1KI=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "9a3dd86e11ea5fb17ace9043aa3d0d5ed359a3ca",
|
||||
"rev": "731a1e11c2fefb14f0aa4b1f03cfa85c19c28d71",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -70,11 +52,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -105,7 +87,7 @@
|
||||
"inputs": {
|
||||
"extra-container": "extra-container",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
@@ -126,16 +108,15 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777728799,
|
||||
"narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
|
||||
"lastModified": 1782911660,
|
||||
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
|
||||
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -158,16 +139,16 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1751274312,
|
||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||
"lastModified": 1783856661,
|
||||
"narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||
"rev": "569d578509928497eddc3fdbf94a799027050be4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-24.11",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -189,21 +170,6 @@
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1777728799,
|
||||
"narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
@@ -219,13 +185,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -235,13 +201,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_5": {
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1779259093,
|
||||
"narHash": "sha256-7DKWmH23hL2eYdkxCKeqj2i+yljTKuU+3Nk1UPHOnxc=",
|
||||
"lastModified": 1783791668,
|
||||
"narHash": "sha256-zbcZ1dmBTPfJ7Mlqh/yLEPGpgJnwuv4Xr1xucy2WqMA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d99b013d5d1931ad77fe3912ed218170dec5d9a4",
|
||||
"rev": "716c7a2664ca8325617b8a7fbb609273f2c4cae7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -254,15 +220,15 @@
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_5",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779816597,
|
||||
"narHash": "sha256-Kgod3gZlhSp6WozZ2pFaclXbWpjs6kQLAtldoxb85Lc=",
|
||||
"lastModified": 1783941741,
|
||||
"narHash": "sha256-F+3M1IZrJa920cx2/k2AMKqedEodxLF7COJVkLJwUBo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "297f9341476ba7f821a42d7a2805e206ef8c6ef8",
|
||||
"rev": "e6715f01d9f56f07a27a01386b85ae22b06f0705",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -273,10 +239,9 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"bip110": "bip110",
|
||||
"btc-clients": "btc-clients",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
@@ -298,15 +263,16 @@
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"ref": "future-26.11",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
|
||||
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -56,7 +55,6 @@
|
||||
btc-clients.packages.${pkgs.system}.bisq2
|
||||
btc-clients.packages.${pkgs.system}.sparrow
|
||||
];
|
||||
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ in
|
||||
{
|
||||
imports = [
|
||||
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
|
||||
./branding.nix
|
||||
];
|
||||
|
||||
image.baseName = lib.mkForce "Sovran_SystemsOS";
|
||||
|
||||
+1
-1
@@ -1169,7 +1169,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
btn_box.set_halign(Gtk.Align.CENTER)
|
||||
btn_box.set_margin_bottom(32)
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
|
||||
reboot_btn.add_css_class("suggested-action")
|
||||
reboot_btn.add_css_class("pill")
|
||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.sovran_systemsOS;
|
||||
in
|
||||
{
|
||||
options.sovran_systemsOS.packages.bip110 = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = "BIP110 Bitcoin package";
|
||||
};
|
||||
|
||||
config = lib.mkIf (
|
||||
cfg.features.bip110 &&
|
||||
cfg.packages.bip110 != null
|
||||
) {
|
||||
services.bitcoind.package = lib.mkForce cfg.packages.bip110;
|
||||
|
||||
environment.systemPackages = [
|
||||
cfg.packages.bip110
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
package = config.nix-bitcoin.pkgs.bitcoind-knots;
|
||||
package = pkgs.bitcoind-knots;
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||
txindex = true;
|
||||
tor.proxy = true;
|
||||
|
||||
@@ -16,7 +16,10 @@ let
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
# Only enable Caddy when at least one domain-based service needs it or
|
||||
# the operator has defined custom vhosts. This prevents Caddy from
|
||||
# running on Desktop Only installs that have no web services configured.
|
||||
enable = needsHttpsPorts || extraVhosts != "";
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
};
|
||||
@@ -94,10 +97,10 @@ EOF
|
||||
$MATRIX {
|
||||
reverse_proxy /_matrix/* http://localhost:8008
|
||||
reverse_proxy /_synapse/client/* http://localhost:8008
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
respond \`{"m.server":"$MATRIX:443"}\` 200
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
@@ -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" ];
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
|
||||
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
|
||||
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
|
||||
# Only systems can evaluate without enabling any Bitcoin services.
|
||||
{
|
||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||
}
|
||||
|
||||
# ── Server+Desktop Role (default) ─────────────────────────
|
||||
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
|
||||
@@ -12,19 +19,28 @@
|
||||
(lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||
services.desktopManager.gnome.enable = true;
|
||||
|
||||
# Force all server/node services and features off so they cannot be
|
||||
# accidentally enabled via custom.nix or option defaults on Desktop Only.
|
||||
sovran_systemsOS.services = {
|
||||
synapse = lib.mkDefault false;
|
||||
bitcoin = lib.mkDefault false;
|
||||
vaultwarden = lib.mkDefault false;
|
||||
wordpress = lib.mkDefault false;
|
||||
nextcloud = lib.mkDefault false;
|
||||
synapse = lib.mkForce false;
|
||||
bitcoin = lib.mkForce false;
|
||||
vaultwarden = lib.mkForce false;
|
||||
wordpress = lib.mkForce false;
|
||||
nextcloud = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
sovran_systemsOS.features = {
|
||||
haven = lib.mkForce false;
|
||||
mempool = lib.mkForce false;
|
||||
element-calling = lib.mkForce false;
|
||||
bitcoin-core = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
|
||||
})
|
||||
|
||||
# ── Bitcoin Node Only Role ────────────────────────────────
|
||||
# Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
|
||||
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy
|
||||
(lib.mkIf config.sovran_systemsOS.roles.node {
|
||||
sovran_systemsOS.services = {
|
||||
bitcoin = lib.mkDefault true;
|
||||
@@ -36,7 +52,6 @@
|
||||
|
||||
sovran_systemsOS.features = {
|
||||
mempool = lib.mkDefault true;
|
||||
bip110 = lib.mkDefault true;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
|
||||
+24
-1
@@ -43,12 +43,24 @@
|
||||
# ── Features (default OFF — user can enable in custom.nix) ──
|
||||
features = {
|
||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
|
||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
|
||||
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
|
||||
# default node. This option is retained ONLY so that existing machines with
|
||||
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
|
||||
# custom.nix continue to evaluate. It has no effect and will be removed in a
|
||||
# future release once the Hub has cleaned up old custom.nix files.
|
||||
bip110 = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
internal = true;
|
||||
visible = false;
|
||||
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||
@@ -89,4 +101,15 @@
|
||||
description = "Nostr public key (npub1...) for Haven relay";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
|
||||
warnings = [
|
||||
''
|
||||
sovran_systemsOS.features.bip110 is deprecated and has no effect:
|
||||
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
|
||||
You can safely remove the `sovran_systemsOS.features.bip110` line from
|
||||
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
|
||||
''
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
+10
-29
@@ -29,10 +29,7 @@ let
|
||||
]
|
||||
# ── Bitcoin Base (node implementations) ────────────────────
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
@@ -149,33 +146,16 @@ let
|
||||
echo ""
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "── Step 2/3: nixos-rebuild ──────────────────────────"
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
echo "[OK] switch succeeded"
|
||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this update"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
exit 0
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
RC=1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
BOOT_RC=$?
|
||||
echo "$BOOT_OUT"
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
fi
|
||||
echo ""
|
||||
@@ -191,9 +171,10 @@ let
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Update completed successfully"
|
||||
echo " ✓ Update staged successfully"
|
||||
echo " Reboot required to activate the new system"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Update failed — see errors above"
|
||||
|
||||
@@ -31,7 +31,7 @@ lib.mkIf userExists {
|
||||
};
|
||||
|
||||
systemd.services.factory-ssh-keygen = {
|
||||
description = "Generate factory SSH key for ${userName} if missing";
|
||||
description = "Generate or repair factory SSH key for ${userName}";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "ssh-passphrase-setup.service" ];
|
||||
requires = [ "ssh-passphrase-setup.service" ];
|
||||
@@ -39,14 +39,47 @@ lib.mkIf userExists {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.openssh pkgs.coreutils ];
|
||||
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
|
||||
script = ''
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
set -eu
|
||||
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
lock_file="${keyPath}.lock"
|
||||
|
||||
exec 9>"$lock_file"
|
||||
|
||||
if ! flock -n 9; then
|
||||
echo "Factory SSH key setup is already running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_factory_key() {
|
||||
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
|
||||
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
|
||||
chmod 600 "${keyPath}"
|
||||
chmod 644 "${keyPath}.pub"
|
||||
}
|
||||
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
generate_factory_key
|
||||
elif ! ssh-keygen -y -P "$PASSPHRASE" -f "${keyPath}" >/dev/null 2>&1; then
|
||||
backup_suffix="$(date -u +%Y%m%d_%H%M%S)-$$"
|
||||
backup_path="${keyPath}.bak-$backup_suffix"
|
||||
backup_index=0
|
||||
|
||||
while [ -e "$backup_path" ] || [ -e "$backup_path.pub" ]; do
|
||||
backup_index=$((backup_index + 1))
|
||||
backup_path="${keyPath}.bak-$backup_suffix-$backup_index"
|
||||
done
|
||||
|
||||
echo "Existing factory SSH key does not match current passphrase; backing it up to $backup_path and generating a replacement."
|
||||
mv "${keyPath}" "$backup_path"
|
||||
|
||||
if [ -f "${keyPath}.pub" ]; then
|
||||
mv "${keyPath}.pub" "$backup_path.pub"
|
||||
fi
|
||||
|
||||
generate_factory_key
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
+103
-21
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
||||
};
|
||||
|
||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||
|
||||
@@ -68,35 +68,54 @@ $MATRIX {
|
||||
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
|
||||
respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\`
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
|
||||
}
|
||||
|
||||
$ELEMENT_CALLING {
|
||||
handle /livekit/jwt/sfu/get {
|
||||
# Route all current lk-jwt-service authorization endpoints to port 8073,
|
||||
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
|
||||
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
|
||||
handle @lk_jwt {
|
||||
uri strip_prefix /livekit/jwt
|
||||
reverse_proxy [::1]:8073 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Server {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy localhost:7880
|
||||
reverse_proxy localhost:7880 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
|
||||
####### LIVEKIT RUNTIME CONFIG #######
|
||||
systemd.services.livekit-runtime-config = {
|
||||
description = "Generate LiveKit runtime config from domain files";
|
||||
####### LIVEKIT TURN SETUP (runtime cert + config) #######
|
||||
# Replaces the old dead livekit-runtime-config.service. At runtime this:
|
||||
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
|
||||
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
|
||||
# so LoadCredential can stage them for the (DynamicUser) livekit unit
|
||||
# * detects the primary network interface from the IPv4 default route so
|
||||
# LiveKit only advertises real ICE candidates — not VPN/container/private
|
||||
# addresses from interfaces like Tailscale or Docker bridges
|
||||
# * writes a complete LiveKit config (with turn.domain and interface
|
||||
# substituted) that the overridden ExecStart loads.
|
||||
systemd.services.livekit-turn-setup = {
|
||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
||||
before = [ "livekit.service" ];
|
||||
after = [ "livekit-key-setup.service" ];
|
||||
requiredBy = [ "livekit.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
@@ -106,20 +125,63 @@ EOF
|
||||
unitConfig = {
|
||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
||||
script = ''
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
|
||||
mkdir -p /run/livekit
|
||||
|
||||
cat > /run/livekit/runtime-config.yaml <<EOF
|
||||
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
||||
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
||||
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
||||
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
||||
cp "$CRT" /var/lib/livekit/turn.crt
|
||||
cp "$KEY" /var/lib/livekit/turn.key
|
||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||
|
||||
# Detect the primary network interface from the IPv4 default route.
|
||||
# Restricting LiveKit to this single interface prevents it from
|
||||
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
|
||||
# Docker bridges) that remote peers cannot reach, which causes all
|
||||
# ICE negotiation attempts to fail with responsesReceived: 0.
|
||||
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
|
||||
if [ -z "$IFACE" ]; then
|
||||
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
|
||||
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
|
||||
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
|
||||
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Detected primary network interface: $IFACE"
|
||||
|
||||
# Generate the full LiveKit config the daemon will load. turn.domain and
|
||||
# rtc.interfaces.includes are only known at runtime, so they are
|
||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||
# copies under /run/credentials.
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
port: 7880
|
||||
rtc:
|
||||
use_external_ip: true
|
||||
skip_external_ip_validation: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
room:
|
||||
auto_create: false
|
||||
turn:
|
||||
enabled: true
|
||||
domain: $MATRIX
|
||||
cert_file: /var/lib/livekit/$MATRIX.crt
|
||||
key_file: /var/lib/livekit/$MATRIX.key
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
cert_file: /run/credentials/livekit.service/turn-cert
|
||||
key_file: /run/credentials/livekit.service/turn-key
|
||||
EOF
|
||||
|
||||
chmod 640 /run/livekit/runtime-config.yaml
|
||||
chmod 644 /run/livekit/livekit.yaml
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -130,7 +192,11 @@ EOF
|
||||
keyFile = livekitKeyFile;
|
||||
settings = {
|
||||
rtc.use_external_ip = true;
|
||||
rtc.skip_external_ip_validation = true;
|
||||
rtc.tcp_port = 7881;
|
||||
rtc.udp_port = 7882;
|
||||
rtc.port_range_start = 30000;
|
||||
rtc.port_range_end = 40000;
|
||||
room.auto_create = false;
|
||||
turn = {
|
||||
enabled = true;
|
||||
@@ -140,13 +206,27 @@ EOF
|
||||
};
|
||||
};
|
||||
|
||||
# Override ExecStart to load the runtime-generated config (which carries the
|
||||
# runtime-only turn.domain), mirroring the Caddy ExecStart override pattern in
|
||||
# modules/core/caddy.nix. Deliver the TURN cert/key via LoadCredential so they
|
||||
# are readable under the upstream unit's DynamicUser=true sandbox without
|
||||
# weakening it. Everything else about the standard unit is left intact.
|
||||
systemd.services.livekit.serviceConfig.ExecStart = lib.mkForce [
|
||||
""
|
||||
"${pkgs.livekit}/bin/livekit-server --config /run/credentials/livekit.service/livekit-config --key-file /run/credentials/livekit.service/livekit-secrets"
|
||||
];
|
||||
|
||||
systemd.services.livekit.serviceConfig.LoadCredential = [
|
||||
"livekit-config:/run/livekit/livekit.yaml"
|
||||
"livekit-secrets:${livekitKeyFile}"
|
||||
"turn-cert:/var/lib/livekit/turn.crt"
|
||||
"turn-key:/var/lib/livekit/turn.key"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 30000; to = 40000; }
|
||||
];
|
||||
networking.firewall.allowedTCPPortRanges = [
|
||||
{ from = 30000; to = 40000; }
|
||||
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||
];
|
||||
|
||||
####### JWT SERVICE RUNTIME CONFIG #######
|
||||
@@ -166,11 +246,13 @@ EOF
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
|
||||
mkdir -p /run/lk-jwt-service
|
||||
|
||||
cat > /run/lk-jwt-service/env <<EOF
|
||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
||||
EOF
|
||||
|
||||
chmod 640 /run/lk-jwt-service/env
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -31,7 +32,6 @@
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./bip110.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
|
||||
Executable → Regular
+121
-56
@@ -2,70 +2,104 @@
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
|
||||
users.users.gnome-remote-desktop = {
|
||||
isSystemUser = true;
|
||||
group = "gnome-remote-desktop";
|
||||
home = "/var/lib/gnome-remote-desktop";
|
||||
createHome = true;
|
||||
};
|
||||
users.groups.gnome-remote-desktop = {};
|
||||
|
||||
# Enable the GNOME Remote Desktop service at the system level
|
||||
services.gnome.gnome-remote-desktop.enable = true;
|
||||
|
||||
# Open RDP port in the firewall
|
||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
||||
|
||||
# Ensure the service actually starts and waits for setup to complete
|
||||
# Ensure the service only starts after setup succeeds
|
||||
systemd.services.gnome-remote-desktop = {
|
||||
wantedBy = [ "graphical.target" ];
|
||||
after = [ "gnome-remote-desktop-setup.service" ];
|
||||
wants = [ "gnome-remote-desktop-setup.service" ];
|
||||
requires = [ "gnome-remote-desktop-setup.service" ];
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
];
|
||||
|
||||
systemd.services.gnome-remote-desktop-setup = {
|
||||
description = "Configure GNOME Remote Desktop RDP";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wantedBy = [ "graphical.target" ];
|
||||
before = [ "gnome-remote-desktop.service" ];
|
||||
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"dbus.service"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
wants = [
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
TimeoutStartSec = "2min";
|
||||
};
|
||||
path = [
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.polkit
|
||||
pkgs.openssl
|
||||
pkgs.hostname
|
||||
pkgs.coreutils
|
||||
pkgs.gawk
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.hostname
|
||||
pkgs.openssl
|
||||
pkgs.systemd
|
||||
];
|
||||
script = ''
|
||||
# Ensure directory structure exists
|
||||
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
|
||||
set -euo pipefail
|
||||
|
||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
||||
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
|
||||
# when the caller is root. NixOS exposes the required setuid wrapper at
|
||||
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
|
||||
# must not shadow it. Prepend the wrapper directory so every subsequent
|
||||
# grdctl --system resolves the correct binary.
|
||||
export PATH="/run/wrappers/bin:$PATH"
|
||||
|
||||
STATE_DIR="/var/lib/gnome-remote-desktop"
|
||||
TLS_DIR="$STATE_DIR/tls"
|
||||
USERNAME_FILE="$STATE_DIR/rdp-username"
|
||||
PASSWORD_FILE="$STATE_DIR/rdp-password"
|
||||
CRED_FILE="$STATE_DIR/rdp-credentials"
|
||||
DEFAULT_USERNAME="sovran"
|
||||
|
||||
grdctl_system() {
|
||||
local rc=0
|
||||
|
||||
if timeout --kill-after=5s 10s \
|
||||
grdctl --system "$@"; then
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
||||
echo "grdctl command timed out: $*" >&2
|
||||
fi
|
||||
echo "grdctl command failed (exit $rc): $*" >&2
|
||||
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
|
||||
chmod 700 \
|
||||
"$STATE_DIR" \
|
||||
"$STATE_DIR/.local" \
|
||||
"$STATE_DIR/.local/share" \
|
||||
"$STATE_DIR/.local/share/gnome-remote-desktop" \
|
||||
"$TLS_DIR"
|
||||
|
||||
# Regenerate TLS certificate if missing OR if ownership is wrong
|
||||
# (disable/re-enable cycle can break ownership or grdctl state)
|
||||
NEED_REGEN=0
|
||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||
NEED_REGEN=1
|
||||
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
|
||||
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
|
||||
NEED_REGEN=1
|
||||
fi
|
||||
|
||||
if [ "$NEED_REGEN" = "1" ]; then
|
||||
mkdir -p "$TLS_DIR"
|
||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-sha256 -nodes -days 3650 \
|
||||
@@ -75,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
echo "Generated new RDP TLS certificate"
|
||||
fi
|
||||
|
||||
# Always fix ownership and permissions (handles re-enable after disable)
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||
|
||||
# Configure TLS certificate
|
||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
if [ ! -f "$USERNAME_FILE" ]; then
|
||||
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
|
||||
if [ -z "$USERNAME" ]; then
|
||||
USERNAME="$DEFAULT_USERNAME"
|
||||
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
if [ "''${#USERNAME}" -gt 32 ]; then
|
||||
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$USERNAME" in
|
||||
[A-Za-z_][A-Za-z0-9_-]*)
|
||||
;;
|
||||
*)
|
||||
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
|
||||
chmod 600 "$USERNAME_FILE"
|
||||
|
||||
# Generate password on first boot only
|
||||
PASSWORD=""
|
||||
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
|
||||
PASSWORD=$(openssl rand -base64 16)
|
||||
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
|
||||
else
|
||||
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
openssl rand -base64 16 > "$PASSWORD_FILE"
|
||||
fi
|
||||
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
echo "RDP password file is empty: $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "''${#PASSWORD}" -lt 8 ]; then
|
||||
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
|
||||
chmod 600 "$PASSWORD_FILE"
|
||||
|
||||
LOCAL_IP="$(hostname -I | awk '{print $1}')"
|
||||
if [ -z "$LOCAL_IP" ]; then
|
||||
LOCAL_IP="127.0.0.1"
|
||||
fi
|
||||
|
||||
# Write username to a separate file for the hub
|
||||
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
|
||||
|
||||
# Get current IP address
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
# Always rewrite the credentials file with the current IP
|
||||
cat > "$CRED_FILE" <<EOF
|
||||
========================================
|
||||
GNOME Remote Desktop (RDP) Credentials
|
||||
========================================
|
||||
|
||||
Username: sovran
|
||||
Username: $USERNAME
|
||||
Password: $PASSWORD
|
||||
|
||||
Connect from any RDP client to:
|
||||
@@ -116,11 +170,22 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
========================================
|
||||
EOF
|
||||
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
|
||||
chmod 600 "$CRED_FILE"
|
||||
|
||||
# Enable RDP backend and set credentials
|
||||
grdctl --system rdp enable
|
||||
grdctl --system rdp set-credentials sovran "$PASSWORD"
|
||||
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
|
||||
# before any grdctl --system call. Absence means the system was booted
|
||||
# without security.wrappers or the wrapper directory is not mounted yet.
|
||||
if ! test -x /run/wrappers/bin/pkexec; then
|
||||
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
|
||||
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grdctl_system rdp enable
|
||||
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
|
||||
|
||||
echo "GNOME Remote Desktop RDP configured successfully"
|
||||
'';
|
||||
|
||||
@@ -250,9 +250,6 @@ CREDS
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8448 ];
|
||||
networking.firewall.allowedUDPPorts = [ 8448 ];
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; }
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user