Security hardening: fix all 8 blocking findings for PR #419

Fix 1: Update support.js to collect SSH public key and POST JSON
Fix 2: Legacy njalla.sh migration - parse safely, archive non-executable, replace cron with systemd timer
Fix 3: DDNS SSRF prevention - allowlist only njal.la, reject other hosts, disable curl redirects
Fix 4: Legacy root support-key removal migration (_remove_legacy_root_support_key)
Fix 5: Automatic support-key expiration (expires_at + _expire_support_if_stale)
Fix 6: Move security helpers to security_helpers.py, tests import production code
Fix 7: Real NIP-19/Bech32 npub validation (_bech32_decode + _validate_npub)
Fix 8: Replace journalctl sudo wildcard with restricted sovran-journal-helper.py
Also: Make _write_hub_overrides() atomic with tempfile+os.replace
94 tests passing

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-11 12:07:18 +00:00
committed by GitHub
co-authored by naturallaw777
parent 9b77b04741
commit a111de1ece
8 changed files with 1256 additions and 362 deletions
@@ -0,0 +1,233 @@
"""Sovran Hub — pure security validation helpers.
This module contains the dependency-light security helper functions used by
the Hub server. Keeping them here allows tests to import and exercise the
exact production implementations rather than maintaining separate copies.
All functions in this module depend only on the Python standard library.
"""
from __future__ import annotations
import base64
import ipaddress
import re
import urllib.parse
# ── Nix string escaping ────────────────────────────────────────────────────────
def _nix_escape(value: str) -> str:
"""Escape *value* for use inside a Nix double-quoted string literal.
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
Nix-specific anti-quotation sequences (``${...}``). The returned value is
safe to embed as ``"<returned_value>"`` in generated Nix source.
"""
value = value.replace("\\", "\\\\")
value = value.replace('"', '\\"')
value = value.replace("\n", "\\n")
value = value.replace("\r", "\\r")
value = value.replace("\t", "\\t")
value = value.replace("${", "\\${")
return value
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
def _bech32_polymod(values: list[int]) -> int:
chk = 1
for value in values:
top = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ value
for i in range(5):
if (top >> i) & 1:
chk ^= _BECH32_GENERATOR[i]
return chk
def _bech32_hrp_expand(hrp: str) -> list[int]:
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
values = _bech32_hrp_expand(hrp) + data
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
acc = 0
bits = 0
ret: list[int] = []
for value in data:
acc = (acc << 5) | value
bits += 5
while bits >= 8:
bits -= 8
ret.append((acc >> bits) & 0xFF)
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
return None # invalid padding
return ret
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
Verifies:
- Lowercase-only (mixed case rejected per BIP-173).
- Only valid bech32 charset characters.
- Valid checksum.
- Exactly one separator (``1``).
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
"""
if bech != bech.lower():
return None # mixed case
sep = bech.rfind("1")
if sep < 1 or sep + 7 > len(bech):
return None
hrp = bech[:sep]
data_part = bech[sep + 1:]
if any(c not in _BECH32_CHARSET for c in data_part):
return None
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
return None # bad checksum
converted = _bech32_convertbits_decode(decoded[:-6])
if converted is None:
return None
return hrp, bytes(converted)
def _validate_npub(value: str) -> bool:
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
Checks:
- Lowercase ``npub`` HRP.
- Valid bech32 charset (no uppercase, no invalid chars).
- Valid bech32 checksum.
- Exactly 32 decoded payload bytes (256-bit public key).
- Retains the original regex as a fast pre-filter.
"""
if not NPUB_RE.fullmatch(value):
return False
result = _bech32_decode(value)
if result is None:
return False
hrp, payload = result
return hrp == "npub" and len(payload) == 32
# ── DDNS URL validation ────────────────────────────────────────────────────────
_DDNS_URL_MAX_LEN = 2048
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
# Allowlist: only the official Njal.la provider hostnames are accepted for
# DDNS update URLs. Any other host would allow SSRF against the Hub's
# internal network.
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
def _validate_ddns_url(url: str) -> str:
"""Validate *url* as a safe DDNS update URL and return it normalised.
Rules:
- Must be a valid URL parseable by urllib.parse.
- Scheme must be ``https`` (case-insensitive).
- No userinfo (credentials must not be embedded in the URL).
- No fragment.
- No control characters.
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
- Port must be absent or the default HTTPS port 443.
- No percent-encoded null bytes.
Raises ``ValueError`` with a safe (non-secret) message on failure.
"""
if not url:
raise ValueError("DDNS URL must not be empty")
if len(url) > _DDNS_URL_MAX_LEN:
raise ValueError("DDNS URL exceeds maximum length")
if _DDNS_CONTROL_RE.search(url):
raise ValueError("DDNS URL contains control characters")
try:
parsed = urllib.parse.urlparse(url)
except Exception:
raise ValueError("DDNS URL could not be parsed")
if parsed.scheme.lower() != "https":
raise ValueError("DDNS URL must use the https scheme")
if parsed.username or parsed.password:
raise ValueError("DDNS URL must not contain credentials")
if parsed.fragment:
raise ValueError("DDNS URL must not contain a fragment")
if parsed.port is not None and parsed.port != 443:
raise ValueError("DDNS URL must use the default HTTPS port")
hostname = parsed.hostname or ""
if not hostname:
raise ValueError("DDNS URL must contain a hostname")
# Reject raw IP addresses
try:
ipaddress.ip_address(hostname)
raise ValueError("DDNS URL hostname must not be a raw IP address")
except ValueError as exc:
if "raw IP" in str(exc):
raise
# Allowlist: only Njal.la
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
raise ValueError(
f"DDNS URL hostname is not an allowed Njal.la host "
f"(got {hostname!r})"
)
if "%00" in url.lower():
raise ValueError("DDNS URL must not contain encoded null bytes")
return url
# ── SSH public-key validation ─────────────────────────────────────────────────
_SSH_PUBKEY_ALGORITHMS = frozenset([
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
])
def _validate_ssh_pubkey(key: str) -> str:
"""Validate *key* as a single OpenSSH public key and return it normalised.
Accepts only single-line keys with a supported algorithm, valid base64
payload, and an optional comment. Rejects options, multiple lines,
control characters, and unsupported algorithms.
Raises ``ValueError`` with a safe message on failure.
"""
key = key.strip()
if not key:
raise ValueError("SSH public key must not be empty")
if _DDNS_CONTROL_RE.search(key):
raise ValueError("SSH public key contains control characters")
if "\n" in key or "\r" in key:
raise ValueError("SSH public key must be a single line")
parts = key.split()
if len(parts) < 2:
raise ValueError("SSH public key is malformed")
algo, b64 = parts[0], parts[1]
if algo not in _SSH_PUBKEY_ALGORITHMS:
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
try:
decoded = base64.b64decode(b64, validate=True)
except Exception:
raise ValueError("SSH public key payload is not valid base64")
if len(decoded) < 20:
raise ValueError("SSH public key payload is too short")
return key
+225 -113
View File
@@ -37,6 +37,19 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import load_config, load_versions
from . import systemctl as sysctl
from . import nwc_hub_manager as _nwc_mgr
from .security_helpers import (
_nix_escape,
NPUB_RE,
_validate_npub,
_validate_ddns_url,
_validate_ssh_pubkey,
_DDNS_URL_MAX_LEN,
_DDNS_CONTROL_RE,
_DDNS_ALLOWED_HOSTNAMES,
_SSH_PUBKEY_ALGORITHMS,
_bech32_decode,
_bech32_convertbits_decode,
)
logger = logging.getLogger(__name__)
@@ -84,21 +97,8 @@ NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
NJALLA_DDNS_URLS_FILE = "/var/lib/njalla/ddns_urls.json"
# Nostr npub validation: "npub1" followed by exactly 58 bech32 characters
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
# Accepted SSH public-key algorithms for support sessions
_SSH_PUBKEY_ALGORITHMS = frozenset([
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
])
# DDNS URL: HTTPS only, no credentials, no control chars, max 2048 bytes
_DDNS_URL_MAX_LEN = 2048
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
# Nostr npub validation, SSH pubkey validation, DDNS URL validation, and
# Nix escaping are imported from security_helpers (single source of truth).
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
@@ -159,6 +159,11 @@ SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status"
SUPPORT_KEY_COMMENT = "sovransystemsos-support"
# Maximum duration for a support session in seconds (24 hours).
# After this time the session is automatically expired on startup and on any
# support status/wallet operation.
SUPPORT_SESSION_MAX_SECONDS = 86400 # 24 hours
# Dedicated restricted support user (non-root) for wallet privacy
SUPPORT_USER = "sovran-support"
SUPPORT_USER_HOME = "/var/lib/sovran-support"
@@ -528,97 +533,6 @@ _DICEWARE_WORDS = [
]
def _nix_escape(value: str) -> str:
"""Escape *value* for use inside a Nix double-quoted string literal.
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
Nix-specific anti-quotation sequences (``${...}``). The returned value is
safe to embed as ``"<returned_value>"`` in generated Nix source.
"""
value = value.replace("\\", "\\\\")
value = value.replace('"', '\\"')
value = value.replace("\n", "\\n")
value = value.replace("\r", "\\r")
value = value.replace("\t", "\\t")
value = value.replace("${", "\\${")
return value
def _validate_ddns_url(url: str) -> str:
"""Validate *url* as a safe DDNS update URL and return it normalised.
Rules:
- Must be a valid URL parseable by urllib.parse.
- Scheme must be ``https`` (case-insensitive).
- No userinfo (credentials must not be embedded in the URL).
- No fragment.
- No control characters.
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
- Hostname must be present and not a raw IP address.
Raises ``ValueError`` with a safe (non-secret) message on failure.
"""
if not url:
raise ValueError("DDNS URL must not be empty")
if len(url) > _DDNS_URL_MAX_LEN:
raise ValueError("DDNS URL exceeds maximum length")
if _DDNS_CONTROL_RE.search(url):
raise ValueError("DDNS URL contains control characters")
try:
parsed = urllib.parse.urlparse(url)
except Exception:
raise ValueError("DDNS URL could not be parsed")
if parsed.scheme.lower() != "https":
raise ValueError("DDNS URL must use the https scheme")
if parsed.username or parsed.password:
raise ValueError("DDNS URL must not contain credentials")
if parsed.fragment:
raise ValueError("DDNS URL must not contain a fragment")
hostname = parsed.hostname or ""
if not hostname:
raise ValueError("DDNS URL must contain a hostname")
# Reject raw IP addresses — DDNS providers use hostnames
try:
ipaddress.ip_address(hostname)
raise ValueError("DDNS URL hostname must not be a raw IP address")
except ValueError as exc:
if "raw IP" in str(exc):
raise
return url
def _validate_ssh_pubkey(key: str) -> str:
"""Validate *key* as a single OpenSSH public key and return it normalised.
Accepts only single-line keys with a supported algorithm, valid base64
payload, and an optional comment. Rejects options, multiple lines,
control characters, and unsupported algorithms.
Raises ``ValueError`` with a safe message on failure.
"""
key = key.strip()
if not key:
raise ValueError("SSH public key must not be empty")
if _DDNS_CONTROL_RE.search(key):
raise ValueError("SSH public key contains control characters")
if "\n" in key or "\r" in key:
raise ValueError("SSH public key must be a single line")
parts = key.split()
if len(parts) < 2:
raise ValueError("SSH public key is malformed")
algo, b64 = parts[0], parts[1]
if algo not in _SSH_PUBKEY_ALGORITHMS:
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
# Validate base64 payload
try:
decoded = base64.b64decode(b64, validate=True)
except Exception:
raise ValueError("SSH public key payload is not valid base64")
if len(decoded) < 20:
raise ValueError("SSH public key payload is too short")
return key
def _generate_diceware_password() -> str:
"""Generate a human-readable diceware-style passphrase: word-word-word-N."""
import secrets as _secrets
@@ -1987,8 +1901,20 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
return
content = content[:last_brace] + "\n" + hub_block + content[last_brace:]
with open(CUSTOM_NIX, "w") as f:
f.write(content)
# Atomic write: write to a temp file next to custom.nix then rename so the
# file is never left in a partially-written state if the process is killed.
nix_dir = os.path.dirname(CUSTOM_NIX) or "."
fd, tmp_path = tempfile.mkstemp(dir=nix_dir, prefix=".custom_nix_tmp")
try:
with os.fdopen(fd, "w") as f:
f.write(content)
os.replace(tmp_path, CUSTOM_NIX)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def _migrate_strip_deprecated_features() -> None:
@@ -2055,6 +1981,7 @@ def _is_sshd_feature_enabled() -> bool:
def _is_support_active() -> bool:
"""Check if a per-session support key is currently installed."""
_expire_support_if_stale()
try:
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
return bool(f.read().strip())
@@ -2062,6 +1989,36 @@ def _is_support_active() -> bool:
return False
def _expire_support_if_stale() -> bool:
"""If an active support session has passed its expiry time, disable it.
Returns ``True`` if a session was expired, ``False`` otherwise.
This is called automatically from ``_is_support_active()`` and from
startup, so expiry is enforced even if the user never calls
``/api/support/disable``.
"""
try:
with open(SUPPORT_STATUS_FILE, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return False
expires_at = info.get("expires_at")
if expires_at is None:
# Legacy session without expiry: treat as expired after
# SUPPORT_SESSION_MAX_SECONDS from when it was enabled.
enabled_at = info.get("enabled_at", 0)
if enabled_at and (time.time() - enabled_at) > SUPPORT_SESSION_MAX_SECONDS:
_log_support_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
_disable_support()
return True
return False
if time.time() >= expires_at:
_log_support_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
_disable_support()
return True
return False
def _get_support_session_info() -> dict:
"""Read support session metadata."""
try:
@@ -2212,6 +2169,75 @@ def _get_wallet_unlock_info() -> dict:
return {}
# The exact legacy fleet-wide support key comment used in old deployments.
# This is the only key that the upgrade migration will remove from root's
# authorized_keys. All other keys (admin keys, etc.) are preserved.
_LEGACY_ROOT_SUPPORT_KEY_COMMENT = "sovransystemsos-support"
def _remove_legacy_root_support_key() -> bool:
"""One-time upgrade migration: remove the old fleet-wide support key from root.
Reads ``/root/.ssh/authorized_keys``, removes only lines whose comment
field exactly matches ``_LEGACY_ROOT_SUPPORT_KEY_COMMENT``, and writes the
file back atomically. All other keys and blank/comment lines are
preserved unchanged.
Returns ``True`` if the file was updated, ``False`` if unchanged or absent.
"""
try:
with open(AUTHORIZED_KEYS, "r") as f:
lines = f.readlines()
except FileNotFoundError:
return False
except OSError:
return False
kept: list[str] = []
removed_count = 0
for line in lines:
stripped = line.rstrip("\n")
# A key line has at least 2 whitespace-separated fields; the optional
# third field is the comment. We only remove lines where the comment
# matches exactly — no substring matching.
parts = stripped.split()
if len(parts) >= 3 and parts[2] == _LEGACY_ROOT_SUPPORT_KEY_COMMENT:
removed_count += 1
_log_support_audit(
"LEGACY_ROOT_KEY_REMOVED",
f"removed legacy fleet key with comment={_LEGACY_ROOT_SUPPORT_KEY_COMMENT!r}",
)
else:
kept.append(line)
if removed_count == 0:
return False
# Atomic write: write to tmp then rename
try:
auth_dir = os.path.dirname(AUTHORIZED_KEYS)
fd, tmp = tempfile.mkstemp(dir=auth_dir or ".", prefix=".authorized_keys_tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, AUTHORIZED_KEYS)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
return False
_log_support_audit(
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
f"removed={removed_count} keys_retained={len(kept)}",
)
return True
def _enable_support(pubkey: str) -> bool:
"""Install a per-session SSH public key for the restricted support user.
@@ -2248,6 +2274,7 @@ def _enable_support(pubkey: str) -> bool:
session_info = {
"enabled_at": time.time(),
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
"expires_at": time.time() + SUPPORT_SESSION_MAX_SECONDS,
"use_restricted_user": use_restricted_user,
"wallet_protected": use_restricted_user,
"acl_applied": acl_applied,
@@ -4296,8 +4323,8 @@ async def api_features_toggle(req: FeatureToggleRequest):
if req.feature == "haven":
npub = (req.extra or {}).get("nostr_npub", "").strip()
if npub:
if not NPUB_RE.fullmatch(npub):
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters)")
if not _validate_npub(npub):
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters with valid checksum)")
nostr_npub = npub
elif not nostr_npub:
raise HTTPException(status_code=400, detail="nostr_npub is required for Haven")
@@ -4313,8 +4340,8 @@ async def api_features_toggle(req: FeatureToggleRequest):
# Persist any extra fields (nostr_npub)
new_npub = (req.extra or {}).get("nostr_npub", "").strip()
if new_npub:
if not NPUB_RE.fullmatch(new_npub):
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters)")
if not _validate_npub(new_npub):
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters with valid checksum)")
nostr_npub = new_npub
try:
os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True)
@@ -4437,6 +4464,79 @@ def _validate_safe_name(name: str) -> bool:
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
# Narrow regex matching only the exact curl DDNS pattern written by old Hub
# versions: curl <https://njal.la/...> with optional flags but NO semicolons,
# shell expansions, backticks, or pipe characters. Anything else is rejected.
_LEGACY_NJALLA_CURL_RE = re.compile(
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
r'(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f]|\$\{IP\})+)$'
)
def _migrate_legacy_njalla_script() -> None:
"""Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store.
Reads ``njalla.sh`` without executing or sourcing it. Parses only the
exact narrow curl-pattern lines written by old Hub versions. Any line
that does not match the narrow pattern (including potential injected
commands) is silently discarded — never executed or logged.
URLs extracted from matching lines are validated through
``_validate_ddns_url()`` (HTTPS only, njal.la allowlist) before being
added to ``ddns_urls.json``.
After migration the script is archived with permissions 0o000 so it can
no longer be executed by cron or any other mechanism. If the script does
not exist or the JSON store already has entries, this is a no-op.
"""
try:
with open(NJALLA_SCRIPT, "r") as f:
content = f.read()
except FileNotFoundError:
return
except OSError:
return
existing_urls = _load_ddns_urls()
new_urls: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
# Only match the exact IP-lookup pattern (not a DDNS curl line)
if line.startswith("IP=") or line.startswith("#!/"):
continue
m = _LEGACY_NJALLA_CURL_RE.match(line)
if not m:
# Unrecognised line — discard silently, do NOT log (may contain tokens)
continue
raw_url = m.group(1)
# Replace the bare ${IP} placeholder used in older scripts
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
# Validate without the IP so host/scheme/path checks work; the
# placeholder is restored before storing.
_validate_ddns_url(url_to_validate)
except ValueError:
continue # Silently discard invalid / non-njalla URLs
if raw_url not in existing_urls and raw_url not in new_urls:
new_urls.append(raw_url)
if new_urls:
combined = existing_urls + new_urls
_save_ddns_urls(combined)
_log_support_audit(
"NJALLA_MIGRATION",
f"migrated {len(new_urls)} DDNS URLs from legacy script",
)
# Archive the script: remove executable bit so cron can no longer run it.
try:
os.chmod(NJALLA_SCRIPT, 0o000)
except OSError:
pass
def _ensure_njalla_script() -> None:
"""Create the base njalla.sh (shebang + public-IP lookup) if it is missing.
@@ -4553,7 +4653,7 @@ def _run_njalla_ddns() -> None:
# Replace the placeholder with the validated IP (safe string replacement)
url = raw_url.replace("${IP}", public_ip) if public_ip else raw_url
subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", url],
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
@@ -6168,6 +6268,18 @@ async def _startup_domain_reachability():
_domain_reachability_task = asyncio.create_task(_background_domain_reachability_checker())
@app.on_event("startup")
async def _startup_security_migrations():
"""Run one-time security upgrade migrations on every server start."""
loop = asyncio.get_event_loop()
# Migrate legacy njalla.sh DDNS lines to JSON store and archive the script
await loop.run_in_executor(None, _migrate_legacy_njalla_script)
# Remove the legacy fleet-wide support key from /root/.ssh/authorized_keys
await loop.run_in_executor(None, _remove_legacy_root_support_key)
# Expire any support session that has passed its deadline
await loop.run_in_executor(None, _expire_support_if_stale)
@app.on_event("shutdown")
async def _shutdown_domain_reachability():
"""Stop the background domain reachability checker."""
+44 -3
View File
@@ -110,12 +110,26 @@ function renderSupportInactive() {
'</div>',
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
'<li>Our SSH key is added only to that restricted account</li>',
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
'<li>Wallet files are locked via access controls — not visible to support</li>',
'<li>You control if and when wallet access is granted (time-limited)</li>',
'<li>All session events are logged for your audit</li>',
'<li>Access expires automatically after 24 hours</li>',
'</ol></div>',
'<div class="support-key-section">',
'<label class="support-key-label" for="support-ssh-pubkey">',
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
'</label>',
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
'placeholder="ssh-ed25519 AAAA… support-session" ',
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
'<p class="support-key-hint">',
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
'</p>',
'</div>',
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
'</div>',
].join("");
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
async function enableSupport() {
var btn = document.getElementById("btn-support-enable");
var errEl = document.getElementById("support-key-error");
var textarea = document.getElementById("support-ssh-pubkey");
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
var sshKey = textarea ? textarea.value.trim() : "";
if (!sshKey) {
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
return;
}
// Client-side pre-validation: key must start with a known algorithm prefix
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
if (!hasValidPrefix) {
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
return;
}
if (sshKey.indexOf("\n") !== -1) {
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
return;
}
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
try {
await apiFetch("/api/support/enable", { method: "POST" });
await apiFetch("/api/support/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ssh_public_key: sshKey }),
});
var status = await apiFetch("/api/support/status");
_supportStatus = status;
_supportEnabledAt = status.enabled_at;
renderSupportActive(status);
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
alert("Failed to enable support access. Please try again.");
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
else { alert(detail); }
}
}