16 Commits
Author SHA1 Message Date
naturallaw777 139849acb6 Merge pull request 'removed bitwardend desktop' (#10) from staging-dev into stable
Reviewed-on: #10
2026-06-02 16:10:05 -05:00
naturallaw777 5bac6e1cd0 removed bitwardend desktop 2026-06-02 16:02:04 -05:00
naturallaw777 89cfd83b8e Merge pull request 'removed element desktop' (#9) from staging-dev into stable
Reviewed-on: #9
2026-06-02 15:51:23 -05:00
naturallaw777 04e1dbf50f removed element desktop 2026-06-02 15:45:40 -05:00
naturallaw777 8213f363c4 Merge pull request 'Nixpkgs Update' (#8) from staging-dev into stable
Reviewed-on: #8
2026-06-02 11:32:45 -05:00
naturallaw777 aad1e3decc overall nixpkgs update 2026-06-02 11:27:41 -05:00
naturallaw777 2614c93638 Merge pull request 'Update README.md' (#7) from staging-dev into stable
Reviewed-on: #7
2026-05-23 15:48:56 -05:00
naturallaw777 9531a2efdd Update README.md 2026-05-23 15:33:42 -05:00
naturallaw777 24be048f98 Update README.md 2026-05-23 14:22:34 -05:00
naturallaw777 467990b527 Update README.md 2026-05-23 14:20:01 -05:00
naturallaw777 426451b9b8 Merge pull request 'update readme' (#6) from staging-dev into stable
Reviewed-on: #6
2026-05-23 14:18:58 -05:00
naturallaw777 20ccde21fe Merge pull request 'Fixed outdated port config for livekit' (#5) from staging-dev into stable
Reviewed-on: #5
2026-05-21 22:48:24 -05:00
naturallaw777 a7931e674a Merge pull request 'overall nixpkgs update' (#4) from staging-dev into stable
Reviewed-on: #4
2026-05-21 17:28:37 -05:00
naturallaw777 51609a1217 Merge pull request 'Delete docs directory' (#3) from staging-dev into stable
Reviewed-on: #3
2026-05-21 16:52:57 -05:00
naturallaw777 629f5f76c2 Merge pull request 'Update to stable branch' (#2) from staging-dev into stable
Reviewed-on: #2
2026-05-21 09:46:44 -05:00
naturallaw777 b6e9dea268 Merge pull request 'initial retooling' (#1) from staging-dev into stable
Reviewed-on: #1
2026-05-21 08:10:11 -05:00
41 changed files with 591 additions and 3458 deletions
+105 -999
View File
File diff suppressed because it is too large Load Diff
+134 -441
View File
@@ -8,7 +8,6 @@ import contextlib
import glob import glob
import hashlib import hashlib
import hmac import hmac
import ipaddress
import json import json
import logging import logging
import os import os
@@ -81,15 +80,6 @@ DOMAINS_DIR = "/var/lib/domains"
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub" NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh" NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
# Domain keys that produce a public HTTPS virtual host via Caddy
_SERVICE_DOMAIN_KEYS = frozenset([
"matrix", "wordpress", "nextcloud", "btcpayserver",
"vaultwarden", "haven", "element-calling",
])
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
@@ -233,15 +223,27 @@ FEATURE_REGISTRY = [
"port_requirements": [], "port_requirements": [],
}, },
{ {
"id": "bitcoin-core", "id": "bip110",
"name": "Bitcoin Core", "name": "Bitcoin Knots + BIP110",
"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.", "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", "category": "bitcoin",
"needs_domain": False, "needs_domain": False,
"domain_name": None, "domain_name": None,
"needs_ddns": False, "needs_ddns": False,
"extra_fields": [], "extra_fields": [],
"conflicts_with": [], "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.",
"category": "bitcoin",
"needs_domain": False,
"domain_name": None,
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": ["bip110"],
"port_requirements": [], "port_requirements": [],
}, },
{ {
@@ -275,22 +277,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 # Map feature IDs to their systemd units in config.json
FEATURE_SERVICE_MAP = { FEATURE_SERVICE_MAP = {
"rdp": "gnome-remote-desktop.service", "rdp": "gnome-remote-desktop.service",
"haven": "haven-relay.service", "haven": "haven-relay.service",
"element-calling": "livekit.service", "element-calling": "livekit.service",
"mempool": "mempool.service", "mempool": "mempool.service",
"bip110": None,
"bitcoin-core": None, "bitcoin-core": None,
"btcpay-web": "btcpayserver.service", "btcpay-web": "btcpayserver.service",
"sshd": "sshd.service", "sshd": "sshd.service",
} }
# Port requirements for service tiles (keyed by unit name or icon) # 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 = [ _PORTS_ELEMENT_CALLING = [
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"}, {"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
@@ -303,7 +305,7 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
# Infrastructure # Infrastructure
"caddy.service": [], "caddy.service": [],
# Communication # Communication
"matrix-synapse.service": [], "matrix-synapse.service": _PORTS_MATRIX_FEDERATION,
"livekit.service": _PORTS_ELEMENT_CALLING, "livekit.service": _PORTS_ELEMENT_CALLING,
# Domain-based apps (80/443 handled by end-to-end domain reachability checks) # Domain-based apps (80/443 handled by end-to-end domain reachability checks)
"btcpayserver.service": [], "btcpayserver.service": [],
@@ -329,6 +331,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
# For features that share a unit, disambiguate by icon field # For features that share a unit, disambiguate by icon field
FEATURE_ICON_MAP = { FEATURE_ICON_MAP = {
"bip110": "bip110",
"bitcoin-core": "bitcoin-core", "bitcoin-core": "bitcoin-core",
} }
@@ -349,7 +352,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
ROLE_FEATURES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = {
"server_plus_desktop": None, "server_plus_desktop": None,
"desktop": {"rdp", "sshd"}, "desktop": {"rdp", "sshd"},
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"}, "node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
} }
SERVICE_DESCRIPTIONS: dict[str, str] = { SERVICE_DESCRIPTIONS: dict[str, str] = {
@@ -974,94 +977,18 @@ def _check_port_status(
return "closed" return "closed"
# Regex for validating domain values written into /etc/hosts. Rejects anything
# containing whitespace, newlines, or characters that could escape a hosts entry.
# NOTE: The equivalent pattern in modules/core/local-domain-loopback.nix (shell
# grep -E) must be kept in sync with this Python regex.
_SAFE_DOMAIN_RE = re.compile(
r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
)
def _validate_domain_value(domain: str) -> bool:
"""Return True if *domain* is a valid hostname safe to write into /etc/hosts.
Rejects values containing whitespace, newlines, or other characters that
could inject additional entries or corrupt the hosts file.
"""
if not domain or len(domain) > 253:
return False
# Guard against newline / whitespace injection before regex check.
if any(c in domain for c in ('\n', '\r', ' ', '\t', '#')):
return False
return bool(_SAFE_DOMAIN_RE.match(domain))
def _is_loopback_address(ip: str) -> bool:
"""Return True if *ip* is a loopback address (127.0.0.0/8 or ::1)."""
try:
return ipaddress.ip_address(ip).is_loopback
except ValueError:
return False
def _resolve_all_addresses(domain: str) -> list[str]:
"""Return all unique IP addresses that *domain* resolves to, or an empty list.
The first element is the address that the system resolver would normally
use for a connection. All elements are checked when determining whether
any address matches the expected public IP or is a loopback address.
"""
try:
results = socket.getaddrinfo(domain, None)
unique_addresses: list[str] = []
for r in results:
addr = r[4][0]
if addr not in unique_addresses:
unique_addresses.append(addr)
return unique_addresses
except Exception:
return []
def _trigger_hosts_update() -> None:
"""Start the sovran-hosts-update systemd service (best-effort, no-op if unavailable)."""
try:
subprocess.run(
["systemctl", "start", SOVRAN_HOSTS_SERVICE],
timeout=30,
check=False,
capture_output=True,
)
except Exception:
pass
def _check_domain_reachable(domain: str) -> dict: def _check_domain_reachable(domain: str) -> dict:
"""Check HTTPS reachability for *domain* via local Caddy (loopback). """Curl the domain to verify end-to-end HTTPS reachability."""
Using ``--resolve`` ensures the request reaches Caddy on this computer
without depending on router NAT loopback or the public DNS result.
A successful local check is sufficient to confirm that Caddy and the
virtual-host configuration are working correctly.
"""
try: try:
result = subprocess.run( result = subprocess.run(
[ ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "10", f"https://{domain}"],
"curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", "10",
"--resolve", f"{domain}:443:127.0.0.1",
"--resolve", f"{domain}:80:127.0.0.1",
f"https://{domain}",
],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=15, timeout=15,
) )
status_code = result.stdout.strip() status_code = result.stdout.strip()
if status_code and status_code.isdigit() and int(status_code) > 0: if status_code and status_code.isdigit() and int(status_code) > 0:
return {"reachable": True, "status_code": int(status_code), "via_loopback": True} return {"reachable": True, "status_code": int(status_code)}
return {"reachable": False, "error": result.stderr.strip() or "No response"} return {"reachable": False, "error": result.stderr.strip() or "No response"}
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return {"reachable": False, "error": "timeout"} return {"reachable": False, "error": "timeout"}
@@ -1070,28 +997,25 @@ def _check_domain_reachable(domain: str) -> dict:
def _check_domain_health_fast(domain: str | None, external_ip: str) -> bool: def _check_domain_health_fast(domain: str | None, external_ip: str) -> bool:
"""Fast domain issue check for tile health (no curl/subprocess calls). """Fast domain issue check for tile health (no curl/subprocess calls)."""
Returns ``True`` when a domain issue is detected that warrants
``needs_attention``, ``False`` otherwise.
Loopback resolution is treated as an intentional server-local override,
not a DNS mismatch.
"""
if not domain: if not domain:
return True return True
addrs = _resolve_all_addresses(domain) resolved_ip: str | None = None
if not addrs: 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:
return True return True
# If every resolved address is loopback the intentional /etc/hosts
# override is in place — this is healthy, not a mismatch.
if all(_is_loopback_address(a) for a in addrs):
return False
if external_ip == "unavailable": if external_ip == "unavailable":
return False return False
return not any(a == external_ip for a in addrs) return resolved_ip != external_ip
def _is_domain_reachable_cached(domain: str) -> bool | None: def _is_domain_reachable_cached(domain: str) -> bool | None:
@@ -1159,8 +1083,15 @@ def _evaluate_domain_checklist(
"detail": domain, "detail": domain,
}) })
addrs = _resolve_all_addresses(domain) resolved_ip: str | None = None
resolved_ip: str | None = addrs[0] if addrs else 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: if not resolved_ip:
domain_status = { domain_status = {
@@ -1187,31 +1118,7 @@ def _evaluate_domain_checklist(
"has_issues": True, "has_issues": True,
} }
# Detect intentional server-local loopback override from /etc/hosts. if external_ip == "unavailable":
# When all addresses are loopback the public DNS is not checked via the
# system resolver (which would always return the override). We proceed
# to the reachability check so Caddy health can still be verified.
loopback_override = all(_is_loopback_address(a) for a in addrs)
if loopback_override:
domain_status = {
"status": "local_override",
"resolved_ip": resolved_ip,
"expected_ip": external_ip,
}
steps.append({
"step": 2,
"label": "DNS / Local Override",
"status": "ok",
"detail": (
"Server-local loopback override is active — this computer routes the domain "
"directly to Caddy without going through the router. "
"Public DNS cannot be verified from this computer while the override is in place. "
"To check your public DNS from outside, use a tool such as "
"https://dnschecker.org or run: dig @1.1.1.1 " + domain
),
})
elif external_ip == "unavailable":
domain_status = { domain_status = {
"status": "error", "status": "error",
"resolved_ip": resolved_ip, "resolved_ip": resolved_ip,
@@ -1223,7 +1130,7 @@ def _evaluate_domain_checklist(
"status": "warning", "status": "warning",
"detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)", "detail": f"Resolves to {resolved_ip} (external IP unavailable for comparison)",
}) })
elif not any(a == external_ip for a in addrs): elif resolved_ip != external_ip:
domain_status = { domain_status = {
"status": "dns_mismatch", "status": "dns_mismatch",
"resolved_ip": resolved_ip, "resolved_ip": resolved_ip,
@@ -1325,7 +1232,7 @@ def _generate_qr_base64(data: str) -> str | None:
# ── Update helpers (file-based, no systemctl) ──────────────────── # ── Update helpers (file-based, no systemctl) ────────────────────
def _read_update_status() -> str: def _read_update_status() -> str:
"""Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE.""" """Read the status file. Returns RUNNING, SUCCESS, FAILED, or IDLE."""
try: try:
with open(UPDATE_STATUS, "r") as f: with open(UPDATE_STATUS, "r") as f:
return f.read().strip() return f.read().strip()
@@ -1612,9 +1519,7 @@ 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*;', r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
section, section,
): ):
feat_id = m.group(1) features[m.group(1)] = m.group(2) == "true"
if feat_id not in DEPRECATED_FEATURE_IDS:
features[feat_id] = m.group(2) == "true"
for m in re.finditer( for m in re.finditer(
r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
section, section,
@@ -1647,8 +1552,6 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
"""Write the Hub Managed section inside custom.nix.""" """Write the Hub Managed section inside custom.nix."""
lines = [] lines = []
for feat_id, enabled in features.items(): for feat_id, enabled in features.items():
if feat_id in DEPRECATED_FEATURE_IDS:
continue
val = "true" if enabled else "false" val = "true" if enabled else "false"
if feat_id == "btcpay-web": if feat_id == "btcpay-web":
lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};") lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};")
@@ -1694,40 +1597,6 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
f.write(content) 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 ───────────────────────────────────────── # ── Feature status helpers ─────────────────────────────────────────
def _is_feature_enabled_in_config(feature_id: str) -> bool | None: def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
@@ -1737,7 +1606,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 return False # Default off in Node role; only on via explicit hub toggle
unit = FEATURE_SERVICE_MAP.get(feature_id) unit = FEATURE_SERVICE_MAP.get(feature_id)
if unit is None: if unit is None:
return None # bitcoin-core — can't determine from config return None # bip110, bitcoin-core — can't determine from config
cfg = load_config() cfg = load_config()
for svc in cfg.get("services", []): for svc in cfg.get("services", []):
if svc.get("unit") == unit: if svc.get("unit") == unit:
@@ -2056,13 +1925,10 @@ def _verify_support_removed() -> bool:
@app.get("/login", response_class=HTMLResponse) @app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
return templates.TemplateResponse( return templates.TemplateResponse("login.html", {
request=request, "request": request,
name="login.html",
context={
"asset_version": ASSET_VERSION, "asset_version": ASSET_VERSION,
}, })
)
@app.get("/auto-login") @app.get("/auto-login")
@@ -2127,26 +1993,20 @@ async def api_logout(request: Request):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index(request: Request):
return templates.TemplateResponse( return templates.TemplateResponse("index.html", {
request=request, "request": request,
name="index.html",
context={
"asset_version": ASSET_VERSION, "asset_version": ASSET_VERSION,
}, })
)
@app.get("/onboarding", response_class=HTMLResponse) @app.get("/onboarding", response_class=HTMLResponse)
async def onboarding(request: Request): async def onboarding(request: Request):
_ensure_onboarding_reopened_for_migration() _ensure_onboarding_reopened_for_migration()
return templates.TemplateResponse( return templates.TemplateResponse("onboarding.html", {
request=request, "request": request,
name="onboarding.html",
context={
"asset_version": ASSET_VERSION, "asset_version": ASSET_VERSION,
"onboarding_js_hash": _ONBOARDING_JS_HASH, "onboarding_js_hash": _ONBOARDING_JS_HASH,
}, })
)
@app.get("/api/onboarding/status") @app.get("/api/onboarding/status")
@@ -2365,16 +2225,6 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
# Cache for ``bitcoind --version`` output (available even before RPC is ready) # Cache for ``bitcoind --version`` output (available even before RPC is ready)
_btcd_version_cache: tuple[float, str | None] = (0.0, None) _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) ───────── # ── Generic service version detection (NixOS store path) ─────────
@@ -2489,160 +2339,12 @@ def _get_bitcoin_version_info() -> dict | None:
return 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: def _get_bitcoind_version() -> str | None:
"""Run ``bitcoind --version`` and return the raw version string, or None on error. """Run ``bitcoind --version`` and return the raw version string, or None on error.
Parses the first output line to extract the token after "version ". Parses the first output line to extract the token after "version ".
For example: "Bitcoin Knots daemon version v29.3.knots20260508" For example: "Bitcoin Knots daemon version v29.3.knots20260210+bip110-v0.4.1"
returns "v29.3.knots20260508". returns "v29.3.knots20260210+bip110-v0.4.1".
Works regardless of whether the RPC server is ready (IBD, warmup, etc.). Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL). Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
@@ -2677,12 +2379,25 @@ def _get_bitcoind_version() -> str | None:
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str: def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
"""Format a raw version string from ``bitcoind --version`` for tile display. """Format a raw version string from ``bitcoind --version`` for tile display.
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended, Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110 cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1""v29.3.knots20260210").
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix. For the BIP110 tile (icon == "bip110") a " (bip110 vX.Y.Z)" tag is appended
including the patch version.
""" """
display = raw_version # Extract the BIP110 patch version before stripping the suffix
if icon == "bip110" and "(bip110)" not in display.lower(): 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 += " (bip110)"
return display return display
@@ -2751,19 +2466,6 @@ 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") @app.get("/api/services")
async def api_services(): async def api_services():
cfg = load_config() cfg = load_config()
@@ -2855,18 +2557,20 @@ async def api_services():
break break
has_domain_issues = False has_domain_issues = False
if needs_domain and domain and enabled: if needs_domain and domain and enabled:
addrs = _resolve_all_addresses(domain)
dns_ok = True dns_ok = True
if not addrs: try:
dns_ok = False results = socket.getaddrinfo(domain, None)
elif all(_is_loopback_address(a) for a in addrs): if results:
# Intentional server-local /etc/hosts override — not a mismatch. resolved_ip = results[0][4][0]
dns_ok = True if (
elif (
_cached_external_ip != "unavailable" _cached_external_ip != "unavailable"
and not any(a == _cached_external_ip for a in addrs) and resolved_ip != _cached_external_ip
): ):
dns_ok = False dns_ok = False
else:
dns_ok = False
except (socket.gaierror, Exception):
dns_ok = False
if not dns_ok: if not dns_ok:
has_domain_issues = True has_domain_issues = True
@@ -2942,8 +2646,6 @@ async def api_services():
btc_ver = _format_bitcoin_version(raw_ver, icon=icon) btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
service_data["bitcoin_version"] = btc_ver # backwards compat service_data["bitcoin_version"] = btc_ver # backwards compat
service_data["version"] = btc_ver service_data["version"] = btc_ver
if icon == "bip110":
service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
return service_data return service_data
results = await asyncio.gather(*[get_status(s) for s in services]) results = await asyncio.gather(*[get_status(s) for s in services])
@@ -3093,28 +2795,36 @@ async def api_service_detail(unit: str, icon: str | None = None):
"status": ps, "status": ps,
"description": p.get("description", ""), "description": p.get("description", ""),
}) })
extra_ports = port_statuses if unit == "livekit.service" else [] extra_ports = port_statuses if unit in ("matrix-synapse.service", "livekit.service") else []
if needs_domain and unit == "livekit.service": if needs_domain and unit in ("matrix-synapse.service", "livekit.service"):
if has_domain_issues: if has_domain_issues:
domain_check_steps.append({ domain_check_steps.append({
"step": 4, "step": 4,
"label": "Router Setup Needed", "label": "Federation Port" if unit == "matrix-synapse.service" else "Additional Ports Required",
"status": "skipped", "status": "skipped",
"detail": "Finish the domain steps first, then forward the Element Call ports in your router.", "detail": "Skipped until Steps 1-3 are complete",
}) })
else: elif unit == "matrix-synapse.service":
# These checks are local-only (listening/firewall state on this computer), if extra_ports:
# not an outside-in verification of router/NAT forwarding. matrix_open = extra_ports[0]["status"] != "closed"
all_local_ready = all(p["status"] != "closed" for p in extra_ports)
domain_check_steps.append({ domain_check_steps.append({
"step": 4, "step": 4,
"label": "Router Setup Needed" if all_local_ready else "Sovran_SystemsOS Port Setup Needed", "label": "Federation Port",
"status": "warning" if all_local_ready else "error", "status": "ok" if matrix_open else "error",
"detail": ( "detail": (
"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." f"Matrix federation port 8448 (TCP) is {'open' if matrix_open else 'closed'}.\n"
if all_local_ready f"Matrix federation requires port 8448 (TCP) forwarded to {internal_ip}"
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." ),
})
else:
extra_open = 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",
"detail": (
"Element-Call/LiveKit requires additional forwarded ports for WebRTC and TURN traffic."
), ),
}) })
@@ -3220,8 +2930,6 @@ async def api_service_detail(unit: str, icon: str | None = None):
btc_ver = _format_bitcoin_version(raw_ver, icon=icon) btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["bitcoin_version"] = btc_ver # backwards compat
service_detail["version"] = btc_ver service_detail["version"] = btc_ver
if icon == "bip110":
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
return service_detail return service_detail
@@ -3990,12 +3698,6 @@ async def api_domains_set(req: DomainSetRequest):
except Exception: except Exception:
pass pass
# Regenerate the server-local /etc/hosts loopback entries so the newly
# saved domain is immediately reachable on this computer without NAT
# loopback support on the router.
if req.domain_name in _SERVICE_DOMAIN_KEYS:
_trigger_hosts_update()
return {"ok": True} return {"ok": True}
@@ -4043,27 +3745,20 @@ async def api_domains_check(req: DomainCheckRequest):
external_ip = _cached_external_ip external_ip = _cached_external_ip
def check_domain(domain: str) -> dict: def check_domain(domain: str) -> dict:
addrs = _resolve_all_addresses(domain) try:
if not addrs: results = socket.getaddrinfo(domain, None)
if not results:
return { return {
"domain": domain, "status": "unresolvable", "domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip, "resolved_ip": None, "expected_ip": external_ip,
} }
resolved_ip = addrs[0] resolved_ip = results[0][4][0]
# Server-local /etc/hosts loopback override — report as such rather
# than as a DNS mismatch. Public DNS cannot be verified from this
# computer when the override is active.
if all(_is_loopback_address(a) for a in addrs):
return {
"domain": domain, "status": "local_override",
"resolved_ip": resolved_ip, "expected_ip": external_ip,
}
if external_ip == "unavailable": if external_ip == "unavailable":
return { return {
"domain": domain, "status": "error", "domain": domain, "status": "error",
"resolved_ip": resolved_ip, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
} }
if any(a == external_ip for a in addrs): if resolved_ip == external_ip:
return { return {
"domain": domain, "status": "connected", "domain": domain, "status": "connected",
"resolved_ip": resolved_ip, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
@@ -4072,6 +3767,16 @@ async def api_domains_check(req: DomainCheckRequest):
"domain": domain, "status": "dns_mismatch", "domain": domain, "status": "dns_mismatch",
"resolved_ip": resolved_ip, "expected_ip": external_ip, "resolved_ip": resolved_ip, "expected_ip": external_ip,
} }
except socket.gaierror:
return {
"domain": domain, "status": "unresolvable",
"resolved_ip": None, "expected_ip": external_ip,
}
except Exception:
return {
"domain": domain, "status": "error",
"resolved_ip": None, "expected_ip": external_ip,
}
check_results = await asyncio.gather(*[ check_results = await asyncio.gather(*[
loop.run_in_executor(None, check_domain, d) for d in req.domains loop.run_in_executor(None, check_domain, d) for d in req.domains
@@ -4862,21 +4567,17 @@ def _recover_stale_status(status_file: str, log_file: str, unit_name: str) -> bo
except Exception: except Exception:
pass pass
if unit_result == "success": new_status = "SUCCESS" if unit_result == "success" else "FAILED"
new_status = "REBOOT_REQUIRED" if unit_name == UPDATE_UNIT else "SUCCESS"
else:
new_status = "FAILED"
try: try:
with open(status_file, "w") as f: with open(status_file, "w") as f:
f.write(new_status) f.write(new_status)
except OSError: except OSError:
pass pass
if new_status == "REBOOT_REQUIRED": msg = (
msg = "\n[Update staged successfully while the server was restarting. Reboot required.]\n" "\n[Update completed successfully while the server was restarting.]\n"
elif new_status == "SUCCESS": if new_status == "SUCCESS"
msg = "\n[Update completed successfully while the server was restarting.]\n" else "\n[Update encountered an error. See log above for details.]\n"
else: )
msg = "\n[Update encountered an error. See log above for details.]\n"
try: try:
with open(log_file, "a") as f: with open(log_file, "a") as f:
f.write(msg) f.write(msg)
@@ -4896,14 +4597,6 @@ async def _startup_recover_stale_status():
await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT) 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(): async def _background_domain_reachability_checker():
"""Periodically curl configured domains and cache reachability results.""" """Periodically curl configured domains and cache reachability results."""
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY) await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
@@ -91,30 +91,3 @@
border-color: var(--accent-color); border-color: var(--accent-color);
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,48 +102,6 @@ button.btn-reboot:hover:not(:disabled) {
background-color: #529E7E; 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 { .btn-save {
background-color: var(--yellow); background-color: var(--yellow);
color: #0A1A10; color: #0A1A10;
@@ -155,69 +155,6 @@
white-space: nowrap; 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 ───────────────────────────────── */ /* ── Service detail modal sections ───────────────────────────────── */
.svc-detail-section { .svc-detail-section {
@@ -44,28 +44,6 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal); if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) 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 ─────────────────────────────────────── // ── Upgrade modal functions ───────────────────────────────────────
function openUpgradeModal() { function openUpgradeModal() {
@@ -76,37 +54,6 @@ function closeUpgradeModal() {
if ($upgradeModal) $upgradeModal.classList.remove("open"); 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() { async function doUpgradeToServer() {
var confirmBtn = $upgradeConfirmBtn; var confirmBtn = $upgradeConfirmBtn;
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; } if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
+23 -43
View File
@@ -73,47 +73,22 @@ 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>'; 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 introHtml; var externalIp = _cachedExternalIp || "your external IP";
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 = $domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' + '<div class="domain-setup-intro">' +
introHtml + '<p><strong>Before continuing:</strong></p>' +
'<details style="margin-top:10px;">' + '<ol>' +
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' + '<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>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' + '<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 Njal.la, open a domain you own and click &quot;Add record&quot;.</li>' + '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
'<li>Set record type to <strong>Dynamic</strong>.</li>' + '<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>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' + '<li>Njal.la will give you a curl command like:<br>' +
'(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: &nbsp;<code>call</code>)<br>' + '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto&quot;</code></li>' +
'&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>' + '<li>Enter the subdomain and paste that curl command below</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 &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>' +
'</ol>' + '</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>' +
'<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-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-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></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 &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField + 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 &amp; Enable</button></div>'; '<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 &amp; Enable</button></div>';
@@ -128,7 +103,7 @@ function openDomainSetupModal(feat, onSaved) {
ddnsUrl = ddnsUrl.trim(); ddnsUrl = ddnsUrl.trim();
npub = npub.trim(); npub = npub.trim();
if (!subdomain) { alert("Please enter a domain."); return; } if (!subdomain) { alert("Please enter a subdomain."); return; }
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
var saveBtn = document.getElementById("domain-setup-save-btn"); var saveBtn = document.getElementById("domain-setup-save-btn");
@@ -184,14 +159,14 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<p><strong>Troubleshooting steps:</strong></p>' + '<p><strong>Troubleshooting steps:</strong></p>' +
'<ol>' + '<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>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>. 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>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong></li>' +
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' + '<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>' + '<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 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>' + '<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'</ol>' + '</ol>' +
'</div>' + '</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-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-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></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 &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField + 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 &amp; Update</button></div>'; '<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 &amp; Update</button></div>';
@@ -438,11 +413,16 @@ function handleFeatureToggle(feat, newEnabled) {
}); });
} }
if (feat.id === "bitcoin-core") { if (conflictNames.length > 0) {
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?"; 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?";
}
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck); openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else { } else {
proceedAfterConflictCheck(); proceedAfterConflictCheck();
} }
@@ -60,17 +60,3 @@ async function apiFetch(path, options) {
} }
return res.json(); 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 // Auto-reload the page after a short delay so tiles and toggles reflect the new state
setTimeout(function() { window.location.reload(); }, 1200); setTimeout(function() { window.location.reload(); }, 1200);
} else if (result === "reboot_required") { } else if (result === "reboot_required") {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required"; if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — reboot required";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex"; if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
} else { } else {
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong"; if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
+11 -8
View File
@@ -145,25 +145,28 @@ function openSecurityModal() {
if (rebootBtn) { if (rebootBtn) {
// Keep button disabled for 5 seconds to prevent accidental clicks // Keep button disabled for 5 seconds to prevent accidental clicks
var countdown = 5; var countdown = 5;
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")"; rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
var timer = setInterval(function() { var timer = setInterval(function() {
countdown--; countdown--;
if (countdown <= 0) { if (countdown <= 0) {
clearInterval(timer); clearInterval(timer);
rebootBtn.disabled = false; rebootBtn.disabled = false;
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System"; rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
} else { } else {
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")"; rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
} }
}, 1000); }, 1000);
rebootBtn.addEventListener("click", function() { rebootBtn.addEventListener("click", function() {
rebootBtn.disabled = true; rebootBtn.disabled = true;
rebootBtn.textContent = "Restarting\u2026"; rebootBtn.textContent = "Rebooting\u2026";
// Hide the security reset overlay so the shared reboot overlay is visible if ($rebootOverlay) $rebootOverlay.classList.add("visible");
var $secResetOverlay2 = document.getElementById("security-reset-overlay"); _rebootStartTime = Date.now();
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible"); _serverWentDown = false;
doReboot(); 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() {});
}, { once: true }); }, { once: true });
} }
} catch (err) { } catch (err) {
@@ -107,21 +107,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' + '</div>' +
'</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) // Section C: Domain diagnostics (domain services)
if (data.needs_domain) { if (data.needs_domain) {
var steps = data.domain_check_steps || []; var steps = data.domain_check_steps || [];
@@ -154,36 +139,20 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>'; '</div>';
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) { 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 computers 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 computers internal IP as the destination/internal IP.';
var domainConfigured = !!(data.domain && String(data.domain).trim());
var extraRows = ""; var extraRows = "";
data.extra_ports.forEach(function(p) { data.extra_ports.forEach(function(p) {
var statusIcon, statusClass2; var statusIcon, statusClass2;
if (!effectiveEnabled) { if (p.status === "listening") {
statusIcon = "⚠ Configure Element Call first"; statusIcon = "✅ Open";
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"; statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") { } else if (p.status === "firewall_open") {
statusIcon = "✅ Ready"; statusIcon = "🟡 Firewall open";
statusClass2 = "port-status-open"; statusClass2 = "port-status-open";
} else if (p.status === "closed") { } else if (p.status === "closed") {
statusIcon = "❌ Not ready yet"; statusIcon = "❌ Closed";
statusClass2 = "port-status-closed"; statusClass2 = "port-status-closed";
} else { } else {
statusIcon = "— Could not check"; statusIcon = "— Unknown";
statusClass2 = "port-status-unknown"; statusClass2 = "port-status-unknown";
} }
extraRows += '<tr>' + extraRows += '<tr>' +
@@ -194,16 +163,11 @@ async function openServiceDetailModal(unit, name, icon) {
'</tr>'; '</tr>';
}); });
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' + '<div class="svc-detail-section-title">Step 4: Additional Ports</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">' + '<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' + '<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
'<tbody>' + extraRows + '</tbody>' + '<tbody>' + extraRows + '</tbody>' +
'</table>' + '</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>'; '</div>';
} }
} else if (data.port_statuses && data.port_statuses.length > 0) { } else if (data.port_statuses && data.port_statuses.length > 0) {
@@ -212,16 +176,16 @@ async function openServiceDetailModal(unit, name, icon) {
data.port_statuses.forEach(function(p) { data.port_statuses.forEach(function(p) {
var statusIcon, statusClass2; var statusIcon, statusClass2;
if (p.status === "listening") { if (p.status === "listening") {
statusIcon = "✅ Ready"; statusIcon = "✅ Open";
statusClass2 = "port-status-listening"; statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") { } else if (p.status === "firewall_open") {
statusIcon = "✅ Ready"; statusIcon = "🟡 Firewall open";
statusClass2 = "port-status-open"; statusClass2 = "port-status-open";
} else if (p.status === "closed") { } else if (p.status === "closed") {
statusIcon = "❌ Not ready"; statusIcon = "🔴 Closed";
statusClass2 = "port-status-closed"; statusClass2 = "port-status-closed";
} else { } else {
statusIcon = "— Could not check"; statusIcon = "— Unknown";
statusClass2 = "port-status-unknown"; statusClass2 = "port-status-unknown";
} }
portTableRows += '<tr>' + portTableRows += '<tr>' +
@@ -232,10 +196,9 @@ async function openServiceDetailModal(unit, name, icon) {
'</tr>'; '</tr>';
}); });
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Port Requirements</div>' + '<div class="svc-detail-section-title">Port Status</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">' + '<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' + '<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
'<tbody>' + portTableRows + '</tbody>' + '<tbody>' + portTableRows + '</tbody>' +
'</table>' + '</table>' +
'</div>'; '</div>';
@@ -279,7 +242,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features // Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bitcoin-core") var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
? "\u20BF Bitcoin Node Selection" ? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature"; : "\uD83D\uDD27 Addon Feature";
@@ -49,9 +49,6 @@ const $btnSave = document.getElementById("btn-save-report");
const $btnCloseModal = document.getElementById("btn-close-modal"); const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay"); 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 $credsModal = document.getElementById("creds-modal");
const $credsTitle = document.getElementById("creds-modal-title"); const $credsTitle = document.getElementById("creds-modal-title");
@@ -104,14 +101,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn"); const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
const $upgradeCloseBtn = document.getElementById("upgrade-close-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 // System status banner
// (removed — health is now shown per-tile via the composite health field) // (removed — health is now shown per-tile via the composite health field)
+1 -34
View File
@@ -4,21 +4,6 @@
// Keyed by tileId: { progress: float, timestamp: ms } // Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {}; 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 ───────────────────────────────────────── // ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) { function buildTiles(services, categoryLabels) {
@@ -180,8 +165,7 @@ function buildTile(svc) {
var ver = svc.version || svc.bitcoin_version || ''; var ver = svc.version || svc.bitcoin_version || '';
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : ''; var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</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 + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></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 + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer"; tile.style.cursor = "pointer";
tile.addEventListener("click", function() { tile.addEventListener("click", function() {
@@ -281,23 +265,6 @@ 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);
}
}
}
} }
} }
} }
+2 -34
View File
@@ -154,7 +154,7 @@ function onUpdateDone(result) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete"; if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
if ($btnReboot) $btnReboot.style.display = "inline-flex"; if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else if (result === "reboot_required") { } else if (result === "reboot_required") {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required"; if ($modalStatus) $modalStatus.textContent = "✓ Update complete — reboot required";
if ($btnReboot) $btnReboot.style.display = "inline-flex"; if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else { } else {
if ($modalStatus) $modalStatus.textContent = "✗ Update failed"; if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
@@ -179,50 +179,23 @@ function saveErrorReport() {
var _rebootStartTime = 0; var _rebootStartTime = 0;
var _serverWentDown = false; var _serverWentDown = false;
var _rebootFailed = false;
function _setRebootStatus(msg) {
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
}
function doReboot() { function doReboot() {
if ($modal) $modal.classList.remove("open"); if ($modal) $modal.classList.remove("open");
if ($rebuildModal) $rebuildModal.classList.remove("open"); if ($rebuildModal) $rebuildModal.classList.remove("open");
stopUpdatePoll(); stopUpdatePoll();
stopRebuildPoll(); 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"); if ($rebootOverlay) $rebootOverlay.classList.add("visible");
_rebootStartTime = Date.now(); _rebootStartTime = Date.now();
_serverWentDown = false; _serverWentDown = false;
_rebootFailed = false;
var rebootCtrl = new AbortController(); var rebootCtrl = new AbortController();
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT); setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }) fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
.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 // Wait before the first check — NixOS shutdown after an update can take 20-40s
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY); setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
} }
function waitForServerReboot() { 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 controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT); var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
@@ -232,23 +205,18 @@ function waitForServerReboot() {
if (_serverWentDown) { if (_serverWentDown) {
// Server is responding after having been down — reboot is complete. // Server is responding after having been down — reboot is complete.
// Any response (even 401/500) means the server process is back. // Any response (even 401/500) means the server process is back.
_setRebootStatus("System is back online. Reconnecting\u2026");
window.location.reload(); window.location.reload();
} else if ((Date.now() - _rebootStartTime) < 90000) { } else if ((Date.now() - _rebootStartTime) < 90000) {
// Server still responding but hasn't gone down yet — keep waiting // Server still responding but hasn't gone down yet — keep waiting
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
} else { } else {
// Been over 90 seconds and server is responding — just reload // Been over 90 seconds and server is responding — just reload
_setRebootStatus("System is back online. Reconnecting\u2026");
window.location.reload(); window.location.reload();
} }
}) })
.catch(function() { .catch(function() {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!_serverWentDown) {
_serverWentDown = true; _serverWentDown = true;
_setRebootStatus("The computer is restarting\u2026");
}
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}); });
} }
+29 -45
View File
@@ -333,6 +333,8 @@ async function loadStep3() {
return; return;
} }
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
// Build set of enabled service units // Build set of enabled service units
var enabledUnits = new Set(); var enabledUnits = new Set();
(_servicesData || []).forEach(function(svc) { (_servicesData || []).forEach(function(svc) {
@@ -350,31 +352,25 @@ async function loadStep3() {
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>'; html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else { } else {
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>' + '<strong>Before you continue:</strong>'
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">' + '<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>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>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>For each service, add a <strong>Dynamic</strong> record in Njal.la:' + '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">' + '<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>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' + '<li>Njal.la will give you a curl command like:<br>'
+ '(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: <code>call</code>.)<br>' + '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto"</code></li>'
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>' + '<li>Enter the subdomain and paste that curl command below for each service</li>'
+ '&#9888; 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&apos;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 &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>'
+ '</ol>' + '</ol>'
+ '</div>'; + '</div>';
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>'; 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>';
relevantDomains.forEach(function(d) { relevantDomains.forEach(function(d) {
var currentVal = (_domainsData && _domainsData[d.name]) || ""; var currentVal = (_domainsData && _domainsData[d.name]) || "";
html += '<div class="onboarding-domain-group">'; html += '<div class="onboarding-domain-group">';
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>'; 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 += '<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 += '<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 &quot;https://njal.la/update/?h=...&amp;k=...&amp;auto&quot;" />'; html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl &quot;https://njal.la/update/?h=' + escHtml(d.name) + '.yourdomain.com&amp;k=abc123&amp;auto&quot;" />';
html += '<p class="onboarding-hint" style="margin-top:4px;"> Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>'; 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 += '<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>'; html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
@@ -516,7 +512,7 @@ async function saveStep3() {
async function loadStep4() { async function loadStep4() {
var body = document.getElementById("step-4-body"); var body = document.getElementById("step-4-body");
if (!body) return; if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>'; body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
var networkData = null; var networkData = null;
@@ -527,59 +523,51 @@ async function loadStep4() {
return; return;
} }
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : ""; var internalIp = (networkData && networkData.internal_ip) || "unknown";
var internalIp = trimmedInternalIp || "";
var hasInternalIp = !!internalIp; var ip = escHtml(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 computers 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 computers internal IP as the destination/internal IP';
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">' 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>' + '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
+ '</p>'; + '</p>';
html += '<div class="onboarding-port-ip">'; html += '<div class="onboarding-port-ip">';
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>'; html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
html += ' <span class="port-req-internal-ip">' + ip + '</span>'; html += ' <span class="port-req-internal-ip">' + ip + '</span>';
html += '</div>'; html += '</div>';
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
// Required ports table // Required ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">'; 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 Router Rules</div>'; html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
html += '<table class="onboarding-port-table">'; html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>'; html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>';
html += '<tbody>'; 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 / SSL setup</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</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">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">Remote SSH access</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 += '</tbody></table>'; html += '</tbody></table>';
html += '</div>'; html += '</div>';
// Optional ports table // Optional ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">'; html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>'; 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);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</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 += '<table class="onboarding-port-table">'; html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>'; html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>';
html += '<tbody>'; 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">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">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">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">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 &amp; 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">3000040000</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 += '</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>'; html += '</div>';
// Totals // Totals
html += '<div class="onboarding-port-totals">'; html += '<div class="onboarding-port-totals">';
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>'; html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)'; html += '<strong>Total port openings: 9</strong> (with Element Calling4 required + 5 optional)';
html += '</div>'; html += '</div>';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
@@ -594,16 +582,12 @@ 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>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>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>Create a new rule for each port listed above</li>'
+ '<li>' + destinationInstruction + '</li>' + '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
+ '<li>Set both internal and external port to the same number</li>' + '<li>Set both internal and external port to the same number</li>'
+ '<li>Save and apply changes</li>' + '<li>Save and apply changes</li>'
+ '</ol>' + '</ol>'
+ '</details>'; + '</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; body.innerHTML = html;
} }
+10 -46
View File
@@ -24,7 +24,6 @@
<span class="title">Sovran_SystemsOS Hub</span> <span class="title">Sovran_SystemsOS Hub</span>
<div class="header-buttons"> <div class="header-buttons">
<span class="role-badge" id="role-badge">Loading…</span> <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> <button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
</div> </div>
</header> </header>
@@ -62,7 +61,7 @@
<div class="modal-log" id="modal-log" aria-live="polite"></div> <div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button> <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">Restart Entire System</button> <button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button> <button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div> </div>
</div> </div>
@@ -165,7 +164,7 @@
<div class="modal-log" id="rebuild-log" aria-live="polite"></div> <div class="modal-log" id="rebuild-log" aria-live="polite"></div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button> <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">Restart Entire System</button> <button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button> <button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
</div> </div>
</div> </div>
@@ -241,61 +240,26 @@
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>. You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
</p> </p>
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled> <button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
I have written down my new password — Restart Entire System I have written down my new password — Reboot now
</button> </button>
</div> </div>
</div> </div>
<!-- Reboot overlay --> <!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay"> <div class="reboot-overlay" id="reboot-overlay">
<!-- Normal restarting card --> <div class="reboot-card">
<div class="reboot-card" id="reboot-main-card"> <div class="reboot-icon"></div>
<div class="reboot-icon" aria-hidden="true"></div> <h2 class="reboot-title">System Rebooting</h2>
<h2 class="reboot-title">Restarting Entire System</h2>
<p class="reboot-message"> <p class="reboot-message">
The entire computer is restarting, including the desktop and all hosted services.<br /> Sovran_SystemsOS is now restarting.<br />
This page will reconnect automatically when Sovran_SystemsOS is back online. This page will automatically reconnect once the system is back online.
</p> </p>
<div class="reboot-dots" aria-hidden="true"> <div class="reboot-dots">
<span class="reboot-dot"></span> <span class="reboot-dot"></span>
<span class="reboot-dot"></span> <span class="reboot-dot"></span>
<span class="reboot-dot"></span> <span class="reboot-dot"></span>
</div> </div>
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request</p> <p class="reboot-submessage">Stay tuned</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 13 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>
</div> </div>
@@ -128,8 +128,9 @@
<h2 class="onboarding-step-title">Domain Configuration</h2> <h2 class="onboarding-step-title">Domain Configuration</h2>
<p class="onboarding-step-desc"> <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. 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.
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. 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.
Paste the curl command from your Njal.la dashboard for each service. 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.
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-3-body"> <div class="onboarding-card" id="step-3-body">
@@ -148,14 +149,14 @@
<div class="onboarding-panel" id="step-4" style="display:none"> <div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-step-header"> <div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span> <span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Router Setup</h2> <h2 class="onboarding-step-title">Port Forwarding Check</h2>
<p class="onboarding-step-desc"> <p class="onboarding-step-desc">
Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network. 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 are required for HTTPS and SSL certificates.</strong> <strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-4-body"> <div class="onboarding-card" id="step-4-body">
<p class="onboarding-loading">Loading router setup</p> <p class="onboarding-loading">Checking ports</p>
</div> </div>
<div class="onboarding-footer"> <div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button> <button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
-166
View File
@@ -1,166 +0,0 @@
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()
-44
View File
@@ -1,44 +0,0 @@
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()
-399
View File
@@ -1,399 +0,0 @@
"""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()
-122
View File
@@ -1,122 +0,0 @@
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()
@@ -1,171 +0,0 @@
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()
@@ -1,99 +0,0 @@
"""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.

Before

Width:  |  Height:  |  Size: 442 KiB

+3 -4
View File
@@ -145,12 +145,11 @@
ranger fastfetch gedit openssl pwgen ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US hunspell hunspellDicts.en_US
synadm brave dua synadm brave dua gparted pv unzip parted screen zenity
gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit lk-jwt-service livekit-libwebrtc livekit-cli livekit
matrix-synapse age onlyoffice-desktopeditors matrix-synapse age
]; ];
# ── Shell ────────────────────────────────────────────────── # ── Shell ──────────────────────────────────────────────────
Generated
+63 -28
View File
@@ -1,15 +1,33 @@
{ {
"nodes": { "nodes": {
"btc-clients": { "bip110": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1783519926, "lastModified": 1778967282,
"narHash": "sha256-2zwAN4lNitHFrHVnRZG3YcvpdtWOoF0cOBstxMeB1KI=", "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": 1780397635,
"narHash": "sha256-6WH7LKD6i91VLWoz4mEpoULtqVinCEZxG7ZjJPMSi3k=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "731a1e11c2fefb14f0aa4b1f03cfa85c19c28d71", "rev": "feacd7684dc6bfcd49c57764944a2049bbd71924",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -52,11 +70,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1782949081, "lastModified": 1778716662,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -87,7 +105,7 @@
"inputs": { "inputs": {
"extra-container": "extra-container", "extra-container": "extra-container",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_3",
"nixpkgs-25_05": "nixpkgs-25_05", "nixpkgs-25_05": "nixpkgs-25_05",
"nixpkgs-unstable": "nixpkgs-unstable" "nixpkgs-unstable": "nixpkgs-unstable"
}, },
@@ -108,15 +126,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1782911660, "lastModified": 1777728799,
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=", "narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "cf720c15e108d432d29041cc5a185630809acefb", "rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "master",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -139,16 +158,16 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1783856661, "lastModified": 1751274312,
"narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=", "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "569d578509928497eddc3fdbf94a799027050be4", "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "nixos-26.05", "ref": "nixos-24.11",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -170,6 +189,21 @@
} }
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": {
"lastModified": 1780218263,
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1778737229, "lastModified": 1778737229,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=", "narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
@@ -185,13 +219,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_3": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1783776592, "lastModified": 1780243769,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -201,13 +235,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_4": { "nixpkgs_5": {
"locked": { "locked": {
"lastModified": 1783791668, "lastModified": 1780030872,
"narHash": "sha256-zbcZ1dmBTPfJ7Mlqh/yLEPGpgJnwuv4Xr1xucy2WqMA=", "narHash": "sha256-u6WU/yd/o8iYQrHX3RAwO1hYa3LkoSL+WNQD0rJfJZQ=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "716c7a2664ca8325617b8a7fbb609273f2c4cae7", "rev": "e9a7635a57597d9754eccebdfc7045e6c8600e6b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -220,15 +254,15 @@
"nixvim": { "nixvim": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_4", "nixpkgs": "nixpkgs_5",
"systems": "systems_2" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1783941741, "lastModified": 1780235872,
"narHash": "sha256-F+3M1IZrJa920cx2/k2AMKqedEodxLF7COJVkLJwUBo=", "narHash": "sha256-/TTVFhJQ5StCOrRFO+5NfSkYPSbAAekwymovcQzxNgE=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "e6715f01d9f56f07a27a01386b85ae22b06f0705", "rev": "e5c7b40dc569f5c97ba2182d409f0fb54c02d7c1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -239,9 +273,10 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"bip110": "bip110",
"btc-clients": "btc-clients", "btc-clients": "btc-clients",
"nix-bitcoin": "nix-bitcoin", "nix-bitcoin": "nix-bitcoin",
"nixpkgs": "nixpkgs_3", "nixpkgs": "nixpkgs_4",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixvim": "nixvim" "nixvim": "nixvim"
} }
+4 -2
View File
@@ -6,10 +6,11 @@
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release"; nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
nixvim.url = "github:nix-community/nixvim"; nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05"; nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
}; };
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }: outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
let let
overlay-stable = final: prev: { overlay-stable = final: prev: {
@@ -55,6 +56,7 @@
btc-clients.packages.${pkgs.system}.bisq2 btc-clients.packages.${pkgs.system}.bisq2
btc-clients.packages.${pkgs.system}.sparrow btc-clients.packages.${pkgs.system}.sparrow
]; ];
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
}; };
}; };
}; };
+1
View File
@@ -16,6 +16,7 @@ in
{ {
imports = [ imports = [
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix" "${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
./branding.nix
]; ];
image.baseName = lib.mkForce "Sovran_SystemsOS"; image.baseName = lib.mkForce "Sovran_SystemsOS";
+1 -1
View File
@@ -1169,7 +1169,7 @@ class InstallerWindow(Adw.ApplicationWindow):
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
btn_box.set_halign(Gtk.Align.CENTER) btn_box.set_halign(Gtk.Align.CENTER)
btn_box.set_margin_bottom(32) btn_box.set_margin_bottom(32)
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System") reboot_btn = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
reboot_btn.add_css_class("suggested-action") reboot_btn.add_css_class("suggested-action")
reboot_btn.add_css_class("pill") reboot_btn.add_css_class("pill")
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"])) reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
+23
View File
@@ -0,0 +1,23 @@
{ 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
];
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = { services.bitcoind = {
enable = true; enable = true;
package = pkgs.bitcoind-knots; package = config.nix-bitcoin.pkgs.bitcoind-knots;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true; txindex = true;
tor.proxy = true; tor.proxy = true;
+4 -7
View File
@@ -16,10 +16,7 @@ let
in in
{ {
services.caddy = { services.caddy = {
# Only enable Caddy when at least one domain-based service needs it or enable = true;
# 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"; user = "caddy";
group = "root"; group = "root";
}; };
@@ -97,10 +94,10 @@ EOF
$MATRIX { $MATRIX {
reverse_proxy /_matrix/* http://localhost:8008 reverse_proxy /_matrix/* http://localhost:8008
reverse_proxy /_synapse/client/* http://localhost:8008 reverse_proxy /_synapse/client/* http://localhost:8008
handle /.well-known/matrix/server {
header Content-Type application/json
respond \`{"m.server":"$MATRIX:443"}\` 200
} }
$MATRIX:8448 {
reverse_proxy http://localhost:8008
} }
EOF EOF
fi fi
-145
View File
@@ -1,145 +0,0 @@
{ 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" ];
};
}
+8 -23
View File
@@ -3,13 +3,6 @@
{ {
config = lib.mkMerge [ 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) ───────────────────────── # ── Server+Desktop Role (default) ─────────────────────────
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop { (lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
sovran_systemsOS.web.btcpayserver = lib.mkDefault true; sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
@@ -19,28 +12,19 @@
(lib.mkIf config.sovran_systemsOS.roles.desktop { (lib.mkIf config.sovran_systemsOS.roles.desktop {
services.desktopManager.gnome.enable = true; 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 = { sovran_systemsOS.services = {
synapse = lib.mkForce false; synapse = lib.mkDefault false;
bitcoin = lib.mkForce false; bitcoin = lib.mkDefault false;
vaultwarden = lib.mkForce false; vaultwarden = lib.mkDefault false;
wordpress = lib.mkForce false; wordpress = lib.mkDefault false;
nextcloud = lib.mkForce false; nextcloud = lib.mkDefault false;
}; };
sovran_systemsOS.features = { sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
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 Node Only Role ────────────────────────────────
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy # Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
(lib.mkIf config.sovran_systemsOS.roles.node { (lib.mkIf config.sovran_systemsOS.roles.node {
sovran_systemsOS.services = { sovran_systemsOS.services = {
bitcoin = lib.mkDefault true; bitcoin = lib.mkDefault true;
@@ -52,6 +36,7 @@
sovran_systemsOS.features = { sovran_systemsOS.features = {
mempool = lib.mkDefault true; mempool = lib.mkDefault true;
bip110 = lib.mkDefault true;
}; };
sovran_systemsOS.web.btcpayserver = lib.mkDefault false; sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
+1 -24
View File
@@ -43,24 +43,12 @@
# ── Features (default OFF — user can enable in custom.nix) ── # ── Features (default OFF — user can enable in custom.nix) ──
features = { features = {
haven = lib.mkEnableOption "Haven NOSTR relay"; haven = lib.mkEnableOption "Haven NOSTR relay";
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling"; element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core"; bitcoin-core = lib.mkEnableOption "Bitcoin Core";
rdp = lib.mkEnableOption "Gnome Remote Desktop"; rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access"; 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) ────────────────── # ── Web exposure (controls Caddy vhosts) ──────────────────
@@ -101,15 +89,4 @@
description = "Nostr public key (npub1...) for Haven relay"; 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.
''
];
};
} }
+29 -10
View File
@@ -29,7 +29,10 @@ let
] ]
# ── Bitcoin Base (node implementations) ──────────────────── # ── Bitcoin Base (node implementations) ────────────────────
++ lib.optionals cfg.services.bitcoin [ ++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ { 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 = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } { 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 = [ { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
@@ -146,16 +149,33 @@ let
echo "" echo ""
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo " Step 2/3: nixos-rebuild boot (stage next reboot) " echo " Step 2/3: nixos-rebuild "
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \ SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true 2>&1)
BOOT_RC=$? SWITCH_RC=$?
echo "$BOOT_OUT" echo "$SWITCH_OUT"
if [ "$BOOT_RC" -ne 0 ]; then if [ "$SWITCH_RC" -eq 0 ]; then
echo "[ERROR] nixos-rebuild boot failed" 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"
RC=1 RC=1
fi fi
echo "" echo ""
@@ -171,10 +191,9 @@ let
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo "" echo ""
echo " Update staged successfully" echo " Update completed successfully"
echo " Reboot required to activate the new system"
echo "" echo ""
echo "REBOOT_REQUIRED" > "$STATUS" echo "SUCCESS" > "$STATUS"
else else
echo "" echo ""
echo " Update failed see errors above" echo " Update failed see errors above"
+3 -36
View File
@@ -31,7 +31,7 @@ lib.mkIf userExists {
}; };
systemd.services.factory-ssh-keygen = { systemd.services.factory-ssh-keygen = {
description = "Generate or repair factory SSH key for ${userName}"; description = "Generate factory SSH key for ${userName} if missing";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "ssh-passphrase-setup.service" ]; after = [ "ssh-passphrase-setup.service" ];
requires = [ "ssh-passphrase-setup.service" ]; requires = [ "ssh-passphrase-setup.service" ];
@@ -39,47 +39,14 @@ lib.mkIf userExists {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
}; };
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ]; path = [ pkgs.openssh pkgs.coreutils ];
script = '' script = ''
set -eu if [ ! -f "${keyPath}" ]; then
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase) 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}" ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
chown ${userName}:users "${keyPath}" "${keyPath}.pub" chown ${userName}:users "${keyPath}" "${keyPath}.pub"
chmod 600 "${keyPath}" chmod 600 "${keyPath}"
chmod 644 "${keyPath}.pub" 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 fi
''; '';
}; };
+21 -103
View File
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
}; };
####### ENSURE SERVICES START AFTER KEY EXISTS ####### ####### ENSURE SERVICES START AFTER KEY EXISTS #######
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ]; systemd.services.livekit.after = [ "livekit-key-setup.service" ];
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ]; systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
@@ -68,54 +68,35 @@ $MATRIX {
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" 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" 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" }] }\` 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" }] }\`
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\` }
$MATRIX:8448 {
reverse_proxy http://localhost:8008
} }
$ELEMENT_CALLING { $ELEMENT_CALLING {
# Route all current lk-jwt-service authorization endpoints to port 8073, handle /livekit/jwt/sfu/get {
# 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 uri strip_prefix /livekit/jwt
reverse_proxy [::1]:8073 { reverse_proxy [::1]:8073 {
header_up Host {host} header_up Host {host}
header_up X-Forwarded-Server {host} header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
} }
} }
handle { 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 EOF
''; '';
}; };
####### LIVEKIT TURN SETUP (runtime cert + config) ####### ####### LIVEKIT RUNTIME CONFIG #######
# Replaces the old dead livekit-runtime-config.service. At runtime this: systemd.services.livekit-runtime-config = {
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded) description = "Generate LiveKit runtime config from domain files";
# * 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" ]; before = [ "livekit.service" ];
after = [ "livekit-key-setup.service" ];
requiredBy = [ "livekit.service" ]; requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
@@ -125,63 +106,20 @@ EOF
unitConfig = { unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling"; ConditionPathExists = "/var/lib/domains/element-calling";
}; };
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ]; path = [ pkgs.coreutils ];
script = '' script = ''
MATRIX=$(cat /var/lib/domains/matrix) MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/livekit mkdir -p /run/livekit
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir. cat > /run/livekit/runtime-config.yaml <<EOF
# 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: turn:
enabled: true
domain: $MATRIX domain: $MATRIX
tls_port: 5349 cert_file: /var/lib/livekit/$MATRIX.crt
udp_port: 3478 key_file: /var/lib/livekit/$MATRIX.key
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
EOF EOF
chmod 644 /run/livekit/livekit.yaml chmod 640 /run/livekit/runtime-config.yaml
''; '';
}; };
@@ -192,11 +130,7 @@ EOF
keyFile = livekitKeyFile; keyFile = livekitKeyFile;
settings = { settings = {
rtc.use_external_ip = true; rtc.use_external_ip = true;
rtc.skip_external_ip_validation = true;
rtc.tcp_port = 7881;
rtc.udp_port = 7882; rtc.udp_port = 7882;
rtc.port_range_start = 30000;
rtc.port_range_end = 40000;
room.auto_create = false; room.auto_create = false;
turn = { turn = {
enabled = true; enabled = true;
@@ -206,27 +140,13 @@ 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.allowedTCPPorts = [ 5349 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ]; networking.firewall.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [ networking.firewall.allowedUDPPortRanges = [
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range { from = 30000; to = 40000; }
];
networking.firewall.allowedTCPPortRanges = [
{ from = 30000; to = 40000; }
]; ];
####### JWT SERVICE RUNTIME CONFIG ####### ####### JWT SERVICE RUNTIME CONFIG #######
@@ -246,13 +166,11 @@ EOF
path = [ pkgs.coreutils ]; path = [ pkgs.coreutils ];
script = '' script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling) ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/lk-jwt-service mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
EOF EOF
chmod 640 /run/lk-jwt-service/env chmod 640 /run/lk-jwt-service/env
+1 -1
View File
@@ -16,7 +16,6 @@
./core/remote-deploy.nix ./core/remote-deploy.nix
./core/no-sleep.nix ./core/no-sleep.nix
./core/cpu-performance.nix ./core/cpu-performance.nix
./core/local-domain-loopback.nix
# ── Always on (no flag) ─────────────────────────────────── # ── Always on (no flag) ───────────────────────────────────
./php.nix ./php.nix
@@ -32,6 +31,7 @@
# ── Features (default OFF — enable in custom.nix) ───────── # ── Features (default OFF — enable in custom.nix) ─────────
./haven.nix ./haven.nix
./bip110.nix
./element-calling.nix ./element-calling.nix
./mempool.nix ./mempool.nix
./bitcoin-core.nix ./bitcoin-core.nix
Regular → Executable
+55 -120
View File
@@ -2,104 +2,70 @@
lib.mkIf config.sovran_systemsOS.features.rdp { 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 # Enable the GNOME Remote Desktop service at the system level
services.gnome.gnome-remote-desktop.enable = true; services.gnome.gnome-remote-desktop.enable = true;
# Open RDP port in the firewall # Open RDP port in the firewall
networking.firewall.allowedTCPPorts = [ 3389 ]; networking.firewall.allowedTCPPorts = [ 3389 ];
# Ensure the service only starts after setup succeeds # Ensure the service actually starts and waits for setup to complete
systemd.services.gnome-remote-desktop = { systemd.services.gnome-remote-desktop = {
wantedBy = [ "graphical.target" ]; wantedBy = [ "graphical.target" ];
after = [ "gnome-remote-desktop-setup.service" ]; after = [ "gnome-remote-desktop-setup.service" ];
requires = [ "gnome-remote-desktop-setup.service" ]; wants = [ "gnome-remote-desktop-setup.service" ];
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop 0750 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 0750 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/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
]; ];
systemd.services.gnome-remote-desktop-setup = { systemd.services.gnome-remote-desktop-setup = {
description = "Configure GNOME Remote Desktop RDP"; description = "Configure GNOME Remote Desktop RDP";
wantedBy = [ "graphical.target" ]; wantedBy = [ "multi-user.target" ];
before = [ "gnome-remote-desktop.service" ]; before = [ "gnome-remote-desktop.service" ];
after = [ after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
"dbus.service" wants = [ "network-online.target" ];
"systemd-tmpfiles-setup.service"
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
wants = [
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
TimeoutStartSec = "2min"; RemainAfterExit = true;
}; };
path = [ path = [
pkgs.coreutils
pkgs.gawk
pkgs.gnome-remote-desktop pkgs.gnome-remote-desktop
pkgs.hostname pkgs.polkit
pkgs.openssl pkgs.openssl
pkgs.systemd pkgs.hostname
pkgs.gawk
]; ];
script = '' script = ''
set -euo pipefail # 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
# GRD 50.x invokes pkexec internally for every grdctl --system call, even TLS_DIR="/var/lib/gnome-remote-desktop/tls"
# when the caller is root. NixOS exposes the required setuid wrapper at CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
# /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 NEED_REGEN=0
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
NEED_REGEN=1 NEED_REGEN=1
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
NEED_REGEN=1 NEED_REGEN=1
fi fi
if [ "$NEED_REGEN" = "1" ]; then if [ "$NEED_REGEN" = "1" ]; then
mkdir -p "$TLS_DIR"
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt" rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-sha256 -nodes -days 3650 \ -sha256 -nodes -days 3650 \
@@ -109,59 +75,39 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
echo "Generated new RDP TLS certificate" echo "Generated new RDP TLS certificate"
fi fi
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt" # Always fix ownership and permissions (handles re-enable after disable)
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
chmod 600 "$TLS_DIR/rdp-tls.key" chmod 600 "$TLS_DIR/rdp-tls.key"
chmod 644 "$TLS_DIR/rdp-tls.crt" chmod 644 "$TLS_DIR/rdp-tls.crt"
if [ ! -f "$USERNAME_FILE" ]; then # Configure TLS certificate
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE" grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
fi grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
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"
if [ ! -f "$PASSWORD_FILE" ]; then # Generate password on first boot only
openssl rand -base64 16 > "$PASSWORD_FILE" PASSWORD=""
fi if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")" PASSWORD=$(openssl rand -base64 16)
if [ -z "$PASSWORD" ]; then echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
echo "RDP password file is empty: $PASSWORD_FILE" >&2 chmod 600 /var/lib/gnome-remote-desktop/rdp-password
exit 1 else
fi PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
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 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 cat > "$CRED_FILE" <<EOF
======================================== ========================================
GNOME Remote Desktop (RDP) Credentials GNOME Remote Desktop (RDP) Credentials
======================================== ========================================
Username: $USERNAME Username: sovran
Password: $PASSWORD Password: $PASSWORD
Connect from any RDP client to: Connect from any RDP client to:
@@ -170,22 +116,11 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
======================================== ========================================
EOF EOF
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
chmod 600 "$CRED_FILE" chmod 600 "$CRED_FILE"
# Preflight: the NixOS setuid pkexec wrapper must be present and executable # Enable RDP backend and set credentials
# before any grdctl --system call. Absence means the system was booted grdctl --system rdp enable
# without security.wrappers or the wrapper directory is not mounted yet. grdctl --system rdp set-credentials sovran "$PASSWORD"
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" echo "GNOME Remote Desktop RDP configured successfully"
''; '';
+3
View File
@@ -250,6 +250,9 @@ CREDS
''; '';
}; };
networking.firewall.allowedTCPPorts = [ 8448 ];
networking.firewall.allowedUDPPorts = [ 8448 ];
sovran_systemsOS.domainRequirements = [ sovran_systemsOS.domainRequirements = [
{ name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; } { name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; }
]; ];