Fix all 8 security hardening blockers for PR #423

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-11 15:38:08 +00:00
committed by GitHub
co-authored by naturallaw777
parent 894707a87c
commit 947c04834d
8 changed files with 979 additions and 452 deletions
@@ -188,6 +188,14 @@ def _validate_ddns_url(url: str) -> str:
)
if "%00" in url.lower():
raise ValueError("DDNS URL must not contain encoded null bytes")
# Reject any remaining $ expressions — after ${IP} substitution there
# must be none. Callers that store ${IP} placeholder URLs must substitute
# before calling this function.
if "$" in url:
raise ValueError("DDNS URL must not contain $ expressions")
# Require the exact /update/ path used by Njal.la
if parsed.path != "/update/":
raise ValueError("DDNS URL path must be exactly /update/")
return url
+164 -210
View File
@@ -19,10 +19,12 @@ import shutil
import socket
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from threading import Lock
from fastapi import FastAPI, HTTPException
@@ -37,6 +39,7 @@ 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 . import support_ops as _support_ops
from .security_helpers import (
_nix_escape,
NPUB_RE,
@@ -185,6 +188,11 @@ PROTECTED_WALLET_PATHS: list[str] = [
"/home",
]
# Server-side independent expiry timer for the active support session.
# Scheduled when a session is enabled; cancelled when disabled.
_support_expiry_timer: threading.Timer | None = None
_support_expiry_timer_lock = Lock()
CATEGORY_ORDER = [
("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"),
@@ -1997,26 +2005,13 @@ def _expire_support_if_stale() -> bool:
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
return _support_ops.expire_if_stale(
SUPPORT_STATUS_FILE,
clock_fn=time.time,
disable_fn=_disable_support,
audit_fn=_log_support_audit,
max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
)
def _get_support_session_info() -> dict:
@@ -2169,82 +2164,33 @@ 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"
# The exact base64 blob of the historical fleet-wide root support key is defined
# in support_ops.LEGACY_ROOT_KEY_BLOB and used by _remove_legacy_root_support_key().
def _remove_legacy_root_support_key() -> bool:
"""One-time upgrade migration: remove the old fleet-wide support key from root.
"""One-time upgrade migration: remove the exact historical fleet-wide support key.
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.
Identifies the key by its exact base64 blob, regardless of algorithm prefix
or comment field. All other keys, blank lines, and comment lines are
preserved. The file is written atomically.
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}",
return _support_ops.remove_legacy_root_key(
AUTHORIZED_KEYS,
_support_ops.LEGACY_ROOT_KEY_BLOB,
audit_fn=_log_support_audit,
)
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.
The key is written only to the ``sovran-support`` account's
``authorized_keys``; root's ``authorized_keys`` is never modified.
Applies POSIX ACLs to wallet directories to prevent access by the support
user without explicit user consent.
``authorized_keys`` (atomically); root's ``authorized_keys`` is never
modified. Applies POSIX ACLs to wallet directories to prevent access by
the support user without explicit user consent.
Args:
pubkey: A validated Ed25519/ECDSA OpenSSH public key string (single line).
@@ -2254,12 +2200,28 @@ def _enable_support(pubkey: str) -> bool:
if use_restricted_user:
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
with open(SUPPORT_USER_AUTH_KEYS, "w") as f:
# Atomic write: mkstemp + os.replace
fd, tmp_keys = tempfile.mkstemp(
dir=SUPPORT_USER_SSH_DIR, prefix=".authorized_keys_tmp"
)
try:
with os.fdopen(fd, "w") as f:
f.write(pubkey.strip() + "\n")
os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600)
os.chmod(tmp_keys, 0o600)
try:
pw = pwd.getpwnam(SUPPORT_USER)
os.chown(tmp_keys, pw.pw_uid, pw.pw_gid)
except Exception:
pass
os.replace(tmp_keys, SUPPORT_USER_AUTH_KEYS)
except Exception:
try:
os.unlink(tmp_keys)
except OSError:
pass
raise
try:
pw = pwd.getpwnam(SUPPORT_USER)
os.chown(SUPPORT_USER_AUTH_KEYS, pw.pw_uid, pw.pw_gid)
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
except Exception:
pass
@@ -2271,18 +2233,35 @@ def _enable_support(pubkey: str) -> bool:
acl_applied = _apply_wallet_acls() if use_restricted_user else False
wallet_paths = _get_existing_wallet_paths()
session_id = str(uuid.uuid4())
expires_at = time.time() + SUPPORT_SESSION_MAX_SECONDS
session_info = {
"session_id": session_id,
"enabled_at": time.time(),
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
"expires_at": time.time() + SUPPORT_SESSION_MAX_SECONDS,
"expires_at": expires_at,
"use_restricted_user": use_restricted_user,
"wallet_protected": use_restricted_user,
"acl_applied": acl_applied,
"protected_paths": wallet_paths,
}
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True)
with open(SUPPORT_STATUS_FILE, "w") as f:
# Atomic write of session metadata
status_dir = os.path.dirname(SUPPORT_STATUS_FILE)
os.makedirs(status_dir, exist_ok=True)
fd2, tmp_status = tempfile.mkstemp(dir=status_dir, prefix=".support-session-tmp")
try:
with os.fdopen(fd2, "w") as f:
json.dump(session_info, f)
os.replace(tmp_status, SUPPORT_STATUS_FILE)
except Exception:
try:
os.unlink(tmp_status)
except OSError:
pass
raise
# Schedule server-side independent expiry timer
_schedule_expiry_timer(session_id, expires_at)
_log_support_audit(
"SUPPORT_ENABLED",
@@ -2294,9 +2273,55 @@ def _enable_support(pubkey: str) -> bool:
return False
def _schedule_expiry_timer(session_id: str, expires_at: float) -> None:
"""Schedule a server-side timer to expire the support session at ``expires_at``.
Cancels any previously scheduled timer first. The timer callback compares
the stored session_id and expires_at to prevent a stale timer (for an
older session) from revoking a replacement session.
"""
global _support_expiry_timer
delay = max(0.0, expires_at - time.time())
with _support_expiry_timer_lock:
if _support_expiry_timer is not None:
_support_expiry_timer.cancel()
t = threading.Timer(delay, _auto_expire_support, args=[session_id, expires_at])
t.daemon = True
t.start()
_support_expiry_timer = t
def _cancel_expiry_timer() -> None:
"""Cancel the active server-side support expiry timer if one is running."""
global _support_expiry_timer
with _support_expiry_timer_lock:
if _support_expiry_timer is not None:
_support_expiry_timer.cancel()
_support_expiry_timer = None
def _auto_expire_support(session_id: str, expected_expiry: float) -> None:
"""Timer callback: expire the session only if it still matches session_id / expires_at.
A stale timer for an older session must never revoke a replacement session.
"""
_support_ops.expire_if_stale(
SUPPORT_STATUS_FILE,
clock_fn=time.time,
disable_fn=_disable_support,
audit_fn=_log_support_audit,
session_id=session_id,
expected_expiry=expected_expiry,
max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
)
def _disable_support() -> bool:
"""Remove the per-session support key and revoke all wallet access."""
"""Remove the per-session support key and restore wallet protection."""
try:
# Cancel any pending expiry timer
_cancel_expiry_timer()
# Remove from support user's authorized_keys
try:
os.remove(SUPPORT_USER_AUTH_KEYS)
@@ -2315,8 +2340,8 @@ def _disable_support() -> bool:
except FileNotFoundError:
pass
# Re-apply ACLs to ensure wallet access is revoked
_revoke_wallet_acls()
# Re-apply deny ACLs to restore wallet protection
_apply_wallet_acls()
# Remove session metadata
try:
@@ -4353,11 +4378,10 @@ async def api_features_toggle(req: FeatureToggleRequest):
await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, cur_locale)
# When enabling a feature that relies on dynamic DNS, refresh the Njal.la
# records right away instead of waiting for the 15-minute cron tick.
# records right away instead of waiting for the 15-minute timer tick.
# The newly enabled service needs DNS pointing at this machine as soon as
# the rebuild finishes (cert issuance, reachability).
if req.enabled and feat_meta.get("needs_ddns"):
await loop.run_in_executor(None, _ensure_njalla_script)
await loop.run_in_executor(None, _run_njalla_ddns)
# Clear the old rebuild log so the frontend doesn't pick up stale results
@@ -4464,131 +4488,30 @@ 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\})+)$'
)
# Import the migration regex from support_ops so there is a single canonical
# definition used by both the production server and the test suite.
_LEGACY_NJALLA_CURL_RE = _support_ops._LEGACY_NJALLA_CURL_RE
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``.
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
versions. Delegates to ``support_ops.migrate_legacy_njalla_script`` so
tests can exercise the same code path.
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.
no longer be executed. If persistence fails the script is left untouched.
"""
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",
_support_ops.migrate_legacy_njalla_script(
NJALLA_SCRIPT,
_validate_ddns_url,
_save_ddns_urls,
_load_ddns_urls,
audit_fn=_log_support_audit,
)
# 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.
The Hub appends DDNS curl lines to this script, and those lines use ${IP}.
If the file exists only because of an append (e.g. the web app saved a
domain before the njalla-init systemd unit ran), it would lack the IP
lookup — ${IP} would expand empty during cron runs and the file couldn't
be executed directly. Keep in sync with modules/core/njalla.nix.
"""
njalla_dir = os.path.dirname(NJALLA_SCRIPT)
if njalla_dir:
os.makedirs(njalla_dir, exist_ok=True)
existing = ""
try:
with open(NJALLA_SCRIPT, "r") as f:
existing = f.read()
except OSError:
pass
# Use a unique sentinel instead of substring domain check — avoids
# CodeQL py/incomplete-url-substring-sanitization false positive and
# is more robust than matching "myip.opendns.com" anywhere in file.
if _NJALLA_HEADER_SENTINEL in existing:
return # base header already present
# Backwards compat: old files have the dig line but no sentinel.
# Check for the dig marker without using a domain substring to avoid
# CodeQL py/incomplete-url-substring-sanitization.
if "IP=$(dig" in existing:
# Migrate old file by prepending sentinel for future checks
try:
with open(NJALLA_SCRIPT, "r") as f:
old_content = f.read()
with open(NJALLA_SCRIPT, "w") as f:
f.write(f"{_NJALLA_HEADER_SENTINEL}\n" + old_content)
os.chmod(NJALLA_SCRIPT, 0o755)
except OSError:
pass
return
header = (
"#!/usr/bin/env bash\n"
f"{_NJALLA_HEADER_SENTINEL}\n"
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n\n"
"## Add DDNS entries below — one curl per line\n"
"## Managed via Sovran Hub web interface\n"
)
try:
with open(NJALLA_SCRIPT, "w") as f:
f.write(header + existing)
os.chmod(NJALLA_SCRIPT, 0o755)
except OSError:
pass
def _load_ddns_urls() -> list[str]:
"""Return the list of validated DDNS update URLs from the JSON store."""
@@ -4626,10 +4549,12 @@ def _run_njalla_ddns() -> None:
Resolves the current public IP once, then invokes ``curl`` directly as a
subprocess for each stored DDNS update URL. No shell interpolation is
performed and no user-controlled value is interpreted as shell syntax.
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
substitution; URLs that fail validation are silently skipped.
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
is enabled, so DNS is refreshed right away instead of waiting for the
15-minute cron job (see modules/core/njalla.nix).
15-minute timer tick (see modules/core/njalla.nix).
"""
urls = _load_ddns_urls()
if not urls:
@@ -4648,10 +4573,15 @@ def _run_njalla_ddns() -> None:
except Exception:
public_ip = ""
if not public_ip:
return # skip to avoid sending bare ${IP} to curl
for raw_url in urls:
try:
# Replace the placeholder with the validated IP (safe string replacement)
url = raw_url.replace("${IP}", public_ip) if public_ip else raw_url
url = raw_url.replace("${IP}", public_ip)
# Revalidate after substitution — enforces /update/ path, no $, etc.
_validate_ddns_url(url)
subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False,
@@ -6278,11 +6208,34 @@ async def _startup_security_migrations():
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)
# Reconcile the expiry timer: if a valid session survived startup expiry,
# schedule the server-side timer so expiry occurs even without user activity.
await loop.run_in_executor(None, _reconcile_expiry_timer)
def _reconcile_expiry_timer() -> None:
"""Reschedule the expiry timer from persisted session metadata on startup.
Called after ``_expire_support_if_stale`` so only still-valid sessions are
rescheduled. Cancels any previously running timer first.
"""
try:
with open(SUPPORT_STATUS_FILE, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
_cancel_expiry_timer()
return
session_id = info.get("session_id")
expires_at = info.get("expires_at")
if session_id and expires_at and time.time() < expires_at:
_schedule_expiry_timer(session_id, expires_at)
else:
_cancel_expiry_timer()
@app.on_event("shutdown")
async def _shutdown_domain_reachability():
"""Stop the background domain reachability checker."""
"""Stop the background domain reachability checker and cancel expiry timer."""
global _domain_reachability_task
async with _domain_reachability_task_lock:
task = _domain_reachability_task
@@ -6291,3 +6244,4 @@ async def _shutdown_domain_reachability():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
_cancel_expiry_timer()
+283
View File
@@ -0,0 +1,283 @@
"""Sovran Hub — injectable support session operations.
Functions here handle legacy migration, root-key removal, and support-session
expiry. All filesystem paths, clocks, and callback functions are injectable
so that the test suite can exercise the exact production implementations with
temporary files and mocks rather than maintaining separate copies.
All functions depend only on the Python standard library and the co-located
``security_helpers`` module.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import time as _time_module
from typing import Callable
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
#
# Matches both forms written by old Hub versions:
# curl [flags] https://njal.la/... (unquoted)
# curl [flags] "https://njal.la/..." (quoted — historical form)
#
# Optional flags (in order): --silent, --max-time N, --fail
#
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
# ${...} except the literal ${IP} placeholder, and any extra arguments.
_LEGACY_NJALLA_CURL_RE = re.compile(
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
r'(?:'
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
r')$'
)
# The exact base64 blob of the historical fleet-wide root support key that
# was shipped with old releases of Sovran_SystemsOS and must be removed from
# /root/.ssh/authorized_keys on upgrade.
LEGACY_ROOT_KEY_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
)
def remove_legacy_root_key(
authorized_keys_path: str,
target_blob: str,
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> bool:
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
algorithm prefix or comment field. All other keys, blank lines, and comment
lines are preserved unchanged. The file is written back atomically.
Args:
authorized_keys_path: Path to the authorized_keys file to modify.
target_blob: The exact base64 key blob to remove. Only lines whose
second whitespace-delimited field matches this value are removed;
no substring or comment matching is performed.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. The full key blob is **never** passed to this callback.
Returns:
``True`` if the file was modified (at least one line removed),
``False`` if unchanged or absent.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(authorized_keys_path, "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")
parts = stripped.split()
# Key lines have at least two space-separated fields: algorithm + blob.
# Remove only lines whose blob (parts[1]) matches exactly — no
# substring matching, no comment matching.
if len(parts) >= 2 and parts[1] == target_blob:
removed_count += 1
# Audit without logging the key blob itself.
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
else:
kept.append(line)
if removed_count == 0:
return False
# Atomic write: mkstemp in same directory + os.replace
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, authorized_keys_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
return False
_audit(
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
f"removed={removed_count} keys_retained={len(kept)}",
)
return True
def migrate_legacy_njalla_script(
script_path: str,
validate_fn: Callable[[str], str],
save_fn: Callable[[list[str]], None],
load_fn: Callable[[], list[str]],
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> None:
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
Reads the script **without** executing or sourcing it. Parses only the
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
versions. Any other line is silently discarded — never executed or logged
(it may contain secret tokens).
On successful persistence the script is archived with mode ``0o000`` so
it can no longer be executed. If persistence fails the script is left
**untouched**.
Args:
script_path: Path to the legacy njalla.sh file.
validate_fn: URL validation function; raises ``ValueError`` on invalid
URLs. Callers must substitute the ``${IP}`` placeholder before
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
to the validator.
save_fn: Callable that atomically writes a ``list[str]`` URL list to
the persistent JSON store.
load_fn: Callable that returns the current ``list[str]`` URL list from
the persistent store.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. Token-bearing URLs are **never** passed to this callback.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(script_path, "r") as f:
content = f.read()
except FileNotFoundError:
return
except OSError:
return
existing_urls = load_fn()
new_urls: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or 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
# group(1) = quoted form, group(2) = unquoted form
raw_url = m.group(1) or m.group(2)
# Substitute placeholder so host/scheme/path validation works
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
validate_fn(url_to_validate)
except ValueError:
continue # Silently discard invalid/non-Njal.la 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
try:
save_fn(combined)
except Exception:
# Persistence failed — leave the script untouched, return without
# archiving so the migration can be retried.
return
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
# Archive: remove all permission bits so cron/any mechanism cannot run it
try:
os.chmod(script_path, 0o000)
except OSError:
pass
def expire_if_stale(
status_file: str,
*,
clock_fn: Callable[[], float] | None = None,
disable_fn: Callable[[], bool] | None = None,
audit_fn: Callable[[str, str], None] | None = None,
session_id: str | None = None,
expected_expiry: float | None = None,
max_session_seconds: float = 86400.0,
) -> bool:
"""Expire a support session if its deadline has passed.
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
provided (used by the server-side timer callback), the stored session
metadata is compared field-by-field. A mismatch means a replacement
session has been started after this timer was scheduled; in that case the
function returns ``False`` without touching anything.
Args:
status_file: Path to the JSON session metadata file.
clock_fn: Callable returning current Unix time (default: ``time.time``).
disable_fn: Callable that performs the full disable sequence — removes
the support key, removes wallet-unlock metadata, restores deny
ACLs, clears session metadata, and audits the event. If ``None``,
expiry is detected but no action is taken (useful for tests that
want to inspect detection only).
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging.
session_id: If given, expiry is skipped unless the stored
``session_id`` field matches exactly.
expected_expiry: If given, expiry is skipped unless the stored
``expires_at`` field matches exactly.
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
when ``expires_at`` is absent.
Returns:
``True`` if a session was expired, ``False`` otherwise.
"""
_now = clock_fn if clock_fn is not None else _time_module.time
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
def _disable() -> bool:
return disable_fn() if disable_fn is not None else True
try:
with open(status_file, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return False
# Stale-timer guard
if session_id is not None and info.get("session_id") != session_id:
return False
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
return False
expires_at = info.get("expires_at")
now = _now()
if expires_at is None:
enabled_at = info.get("enabled_at", 0)
if enabled_at and (now - enabled_at) > max_session_seconds:
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
_disable()
return True
return False
if now >= expires_at:
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
_disable()
return True
return False
+2 -3
View File
@@ -193,9 +193,8 @@ backup /etc/nix-bitcoin-secrets/ localhost/
# ── Cron ───────────────────────────────────────────────────
# The legacy njalla.sh root cron job has been replaced by the systemd timer
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Root-shell
# cron execution of njalla.sh is no longer used.
services.cron.enable = false;
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
# retained so that rsnapshot and other module-defined cron jobs continue to run.
# ── Tor ────────────────────────────────────────────────────
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
+42 -13
View File
@@ -6,6 +6,17 @@
"d /var/lib/njalla 0750 root root -"
];
# ── Install the shared validation helper so the DDNS runner can import it ─
# The exact same _validate_ddns_url() function used by the Hub web application
# is installed here as a read-only system file. The DDNS runner imports it
# directly so the two code paths share one validator — no weaker inline copy.
environment.etc."sovran/security_helpers.py" = {
source = ../../app/sovran_systemsos_web/security_helpers.py;
mode = "0444";
user = "root";
group = "root";
};
# ── Safe DDNS update service ─────────────────────────────────────────────
# Reads DDNS update URLs from the JSON store written by the Hub API and
# invokes curl directly — no shell interpolation, no script execution.
@@ -23,6 +34,7 @@
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true;
PrivateTmp = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
@@ -42,15 +54,31 @@
# Install the Python runner script at build time so the service can find it.
# The script is owned by root and not world-writable.
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
# production validator used by the Hub API — before executing any curl call.
# No shell is used; no redirects; no script execution.
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
# the URL is validated after substitution so any remaining $ is rejected.
system.activationScripts.sovran-ddns-update-script = ''
install -d -m 0755 /var/lib/sovran
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
#!/usr/bin/env python3
"""Sovran safe DDNS update runner. Read ddns_urls.json, call curl per URL."""
import ipaddress, json, os, subprocess
"""Sovran safe DDNS update runner.
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
validates each URL using the production _validate_ddns_url() from
/etc/sovran/security_helpers.py, then calls curl per URL.
No shell interpolation. No redirects. No script execution.
"""
import ipaddress, json, os, subprocess, sys
sys.path.insert(0, '/etc/sovran')
try:
from security_helpers import _validate_ddns_url
except ImportError:
sys.exit(0) # validator not available skip silently
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
ALLOWED_HOSTS = frozenset(["njal.la", "www.njal.la"])
try:
with open(URLS_FILE) as f:
@@ -58,7 +86,7 @@ try:
if not isinstance(urls, list):
raise ValueError("not a list")
except Exception:
raise SystemExit(0) # no URLs configured nothing to do
sys.exit(0) # no URLs configured nothing to do
# Resolve current public IP once
public_ip = ""
@@ -68,26 +96,27 @@ try:
capture_output=True, text=True, timeout=10,
)
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
ipaddress.ip_address(raw) # validates
ipaddress.ip_address(raw) # validates raises if not a real IP
public_ip = raw
except Exception:
pass
import urllib.parse
if not public_ip:
sys.exit(0) # no IP resolved skip to avoid sending bare ''${IP}
for raw_url in urls:
try:
url = raw_url.replace("''${IP}", public_ip) if public_ip else raw_url
parsed = urllib.parse.urlparse(url)
if parsed.scheme.lower() != "https":
continue
if (parsed.hostname or "").lower() not in ALLOWED_HOSTS:
continue
# Substitute ''${IP} placeholder then validate through production validator.
# After substitution there must be no $ left; _validate_ddns_url rejects
# any remaining $ expression.
url = raw_url.replace("''${IP}", public_ip)
_validate_ddns_url(url)
subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
except (ValueError, Exception):
pass
PYEOF
chmod 0500 /var/lib/sovran/ddns-update.py
+23 -8
View File
@@ -6,13 +6,14 @@ a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
in tech-support.nix.
Accepted flags:
--unit / -u <name> unit name (letters, digits, @, ., _, - only; .service suffix required)
--unit / -u <name> must be one of the explicitly approved service units
--lines / -n <N> positive integer (max 10000)
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--output / -o <format> short | short-iso | cat | json | verbose
At least one ``--unit`` flag is required; whole-journal queries are rejected.
All other flags, paths, directories, roots, namespaces, and output
destinations are rejected with a non-zero exit code.
"""
@@ -23,9 +24,14 @@ import sys
# ── Allowlists ────────────────────────────────────────────────────────────────
_ALLOWED_UNITS_RE = re.compile(
r'^[a-zA-Z0-9@._\-]+\.(service|socket|timer|target|mount|path|slice|scope)$'
)
# Explicit approved units. Only these four services may be queried through
# the restricted journal helper. Any other unit is rejected.
_APPROVED_UNITS: frozenset[str] = frozenset([
"sovran-hub-web.service",
"caddy.service",
"bitcoind.service",
"lnd.service",
])
_ALLOWED_PRIORITIES = frozenset([
"0", "1", "2", "3", "4", "5", "6", "7",
@@ -50,8 +56,11 @@ def _die(msg: str) -> None:
def _validate_unit(val: str) -> str:
if not _ALLOWED_UNITS_RE.match(val):
_die(f"rejected unit name: {val!r} (only letters/digits/@._- with a known suffix)")
if val not in _APPROVED_UNITS:
_die(
f"rejected unit name: {val!r} "
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
)
return val
@@ -86,6 +95,7 @@ def _validate_output(val: str) -> str:
def main() -> None:
args = sys.argv[1:]
cmd = ["journalctl"]
unit_count = 0
i = 0
while i < len(args):
@@ -96,8 +106,10 @@ def main() -> None:
if i >= len(args):
_die("--unit requires a value")
cmd += ["--unit", _validate_unit(args[i])]
unit_count += 1
elif arg.startswith("--unit="):
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
unit_count += 1
elif arg in ("--lines", "-n"):
i += 1
@@ -149,8 +161,11 @@ def main() -> None:
i += 1
if not cmd[1:]:
_die("at least one flag is required (try --unit <name>)")
if unit_count == 0:
_die(
"at least one --unit flag is required; "
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
)
result = subprocess.run(cmd)
sys.exit(result.returncode)
+2 -2
View File
@@ -63,11 +63,11 @@
{
users = [ "sovran-support" ];
commands = [
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
+452 -213
View File
@@ -1,8 +1,7 @@
"""Security regression tests for Sovran Hub security helpers.
Tests exercise the exact production implementations imported from
``app/sovran_systemsos_web/security_helpers.py`` — no helpers are
redefined here. Every test verifies the deployed code, not a copy.
Tests exercise the exact production implementations — no helpers are
redefined or simulated here. Every test calls the deployed code.
Tests must never:
- reboot, rebuild, or alter real SSH keys
@@ -11,12 +10,13 @@ Tests must never:
"""
import base64
import json
import os
import sys
import tempfile
import unittest
# Add the app package to the path so we can import security_helpers directly
# without the full FastAPI dependency tree.
# Add the app package to the path so we can import without the full FastAPI tree.
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path:
@@ -31,6 +31,7 @@ from sovran_systemsos_web.security_helpers import ( # noqa: E402
_DDNS_ALLOWED_HOSTNAMES,
_bech32_decode,
)
from sovran_systemsos_web import support_ops # noqa: E402
# ---------------------------------------------------------------------------
@@ -47,26 +48,22 @@ class TestNixEscape(unittest.TestCase):
self.assertEqual(_nix_escape("a\\b"), "a\\\\b")
def test_nix_interpolation_escaped(self):
result = _nix_escape("${pkgs.bash}")
self.assertIn("\\${", result)
self.assertFalse(result.startswith("${"))
self.assertEqual(_nix_escape("${evil}"), "\\${evil}")
def test_newline_escaped(self):
result = _nix_escape("foo\nbar")
self.assertNotIn("\n", result)
self.assertIn("\\n", result)
self.assertEqual(_nix_escape("a\nb"), "a\\nb")
def test_carriage_return_escaped(self):
self.assertNotIn("\r", _nix_escape("foo\rbar"))
self.assertEqual(_nix_escape("a\rb"), "a\\rb")
def test_tab_escaped(self):
self.assertNotIn("\t", _nix_escape("foo\tbar"))
self.assertEqual(_nix_escape("a\tb"), "a\\tb")
def test_semicolons_unchanged(self):
self.assertEqual(_nix_escape("a;b"), "a;b")
def test_valid_timezone(self):
self.assertEqual(_nix_escape("Europe/London"), "Europe/London")
self.assertEqual(_nix_escape("America/New_York"), "America/New_York")
def test_injection_payload_quotes_and_interpolation(self):
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
@@ -85,7 +82,7 @@ class TestNixEscape(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestNpubValidationRegex(unittest.TestCase):
"""NPUB_RE must accept valid npub shapes and reject injection payloads."""
"""NPUB_RE must enforce the npub1 + 58 lowercase bech32 shape."""
# 58 bech32 chars after "npub1"
VALID_SHAPE = "npub1" + "q" * 58
@@ -103,7 +100,7 @@ class TestNpubValidationRegex(unittest.TestCase):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
def test_uppercase_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "Q" * 58))
self.assertIsNone(NPUB_RE.fullmatch("NPUB1" + "q" * 58))
def test_injection_quote_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
@@ -113,50 +110,47 @@ class TestNpubValidationRegex(unittest.TestCase):
# ---------------------------------------------------------------------------
# Nostr npub validation — real Bech32 checksum
# Nostr npub validation — full bech32
# ---------------------------------------------------------------------------
class TestNpubBech32Validation(unittest.TestCase):
"""_validate_npub must require a valid Bech32 checksum and 32-byte payload."""
"""_validate_npub must verify the full bech32 checksum and payload length."""
# Known-valid npub (Nostr FAQ test vector — 32 zero bytes)
# npub1 + bech32(hrp="npub", payload=b'\x00'*32)
# The checksum is computed by the library; we hardcode a known-good one.
# To generate: python3 -c "from app.sovran_systemsos_web.security_helpers import *; ..."
# We use _bech32_decode to verify our test vector is valid.
def _make_valid_npub(self) -> str:
"""Build a valid npub from a 32-zero-byte payload using the production Bech32 encoder."""
# Import the production encoder — same module, ensures consistency
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _make_npub(self, payload_bytes: bytes) -> str | None:
"""Build a syntactically valid npub from raw 32-byte payload."""
from sovran_systemsos_web.security_helpers import (
_bech32_polymod, _bech32_hrp_expand, _bech32_create_checksum,
_BECH32_CHARSET,
_bech32_hrp_expand,
_bech32_polymod,
_bech32_create_checksum,
)
def _convertbits_encode(data: bytes) -> list:
acc, bits, ret = 0, 0, []
maxv = (1 << 5) - 1
for v in data:
acc = (acc << 8) | v
bits += 8
while bits >= 5:
bits -= 5
def _convertbits(data, frombits, tobits, pad=True):
acc, bits, ret, maxv = 0, 0, [], (1 << tobits) - 1
for value in data:
acc = ((acc << frombits) | value)
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if bits:
ret.append((acc << (5 - bits)) & maxv)
if pad and bits:
ret.append((acc << (tobits - bits)) & maxv)
return ret
hrp = "npub"
data = _convertbits_encode(b'\x00' * 32)
checksum = _bech32_create_checksum(hrp, data)
combined = data + checksum
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
data5 = _convertbits(list(payload_bytes), 8, 5)
checksum = _bech32_create_checksum(hrp, data5)
full = data5 + checksum
return hrp + "1" + "".join(self.BECH32_CHARSET[d] for d in full)
def test_valid_npub_passes_bech32(self):
npub = self._make_valid_npub()
self.assertTrue(_validate_npub(npub), f"Expected valid npub to pass: {npub}")
npub = self._make_npub(bytes(32))
self.assertIsNotNone(npub)
self.assertTrue(_validate_npub(npub))
def test_bech32_decode_returns_32_bytes(self):
npub = self._make_valid_npub()
npub = self._make_npub(b'\x01' * 32)
result = _bech32_decode(npub)
self.assertIsNotNone(result)
hrp, payload = result
@@ -164,46 +158,32 @@ class TestNpubBech32Validation(unittest.TestCase):
self.assertEqual(len(payload), 32)
def test_corrupted_checksum_rejected(self):
npub = self._make_valid_npub()
# Flip the last character
last = npub[-1]
replacement = "q" if last != "q" else "p"
corrupted = npub[:-1] + replacement
npub = self._make_npub(bytes(32))
# Flip last character in the data part
corrupted = npub[:-1] + ("q" if npub[-1] != "q" else "p")
self.assertFalse(_validate_npub(corrupted))
def test_mixed_case_rejected(self):
npub = self._make_valid_npub()
self.assertFalse(_validate_npub(npub.upper()))
self.assertFalse(_validate_npub(npub.capitalize()))
npub = self._make_npub(bytes(32))
mixed = npub[:10].upper() + npub[10:]
self.assertFalse(_validate_npub(mixed))
def test_wrong_hrp_rejected(self):
# lnurl1 with 32-byte payload would have wrong HRP
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
def test_synthetic_all_q_rejected_by_checksum(self):
# "npub1" + "q"*58 passes the regex but likely fails the checksum
synthetic = "npub1" + "q" * 58
# The all-q string almost certainly has an invalid checksum
result = _bech32_decode(synthetic)
if result is not None:
hrp, payload = result
# If it somehow decodes, payload must be 32 bytes to be valid
if hrp == "npub" and len(payload) == 32:
self.assertTrue(_validate_npub(synthetic))
else:
self.assertFalse(_validate_npub(synthetic))
else:
self.assertFalse(_validate_npub(synthetic))
self.assertFalse(_validate_npub("npub1" + "q" * 58))
# ---------------------------------------------------------------------------
# DDNS URL validation — SSRF prevention
# DDNS URL validation
# ---------------------------------------------------------------------------
class TestDdnsUrlValidation(unittest.TestCase):
"""_validate_ddns_url must prevent SSRF and injection payloads."""
"""_validate_ddns_url must enforce all security constraints."""
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"
# VALID_URL has no ${IP}: callers must substitute before validation.
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=1.2.3.4"
def test_valid_njalla_url_accepted(self):
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
@@ -214,11 +194,11 @@ class TestDdnsUrlValidation(unittest.TestCase):
def test_http_scheme_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("http://njal.la/update/?h=test&k=TOKEN")
_validate_ddns_url("http://njal.la/update/?k=TOKEN")
def test_ftp_scheme_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("ftp://njal.la/update/?h=test&k=TOKEN")
_validate_ddns_url("ftp://njal.la/update/?k=TOKEN")
def test_credentials_in_url_rejected(self):
with self.assertRaises(ValueError):
@@ -226,7 +206,7 @@ class TestDdnsUrlValidation(unittest.TestCase):
def test_fragment_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN#fragment")
_validate_ddns_url("https://njal.la/update/?k=TOKEN#frag")
def test_raw_ip_host_rejected(self):
with self.assertRaises(ValueError):
@@ -238,17 +218,15 @@ class TestDdnsUrlValidation(unittest.TestCase):
def test_control_character_newline_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN\nmalicious")
_validate_ddns_url("https://njal.la/update/?k=TOKEN\n")
def test_control_character_null_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN\x00evil")
_validate_ddns_url("https://njal.la/update/?k=\x00TOKEN")
def test_percent_encoded_null_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00evil")
# ── SSRF allowlist tests ────────────────────────────────────────────────
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00")
def test_localhost_rejected(self):
with self.assertRaises(ValueError):
@@ -259,29 +237,44 @@ class TestDdnsUrlValidation(unittest.TestCase):
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
def test_arbitrary_public_hostname_rejected(self):
"""Any hostname that is not njal.la must be rejected."""
with self.assertRaises(ValueError):
_validate_ddns_url("https://evil.example.com/update/?k=TOKEN")
_validate_ddns_url("https://example.com/update/?k=TOKEN")
def test_attacker_host_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://attacker.invalid/update/?k=TOKEN")
_validate_ddns_url("https://attacker.njal.la/update/?k=TOKEN")
def test_metadata_endpoint_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://169.254.169.254/latest/meta-data/")
_validate_ddns_url("https://169.254.169.254/update/?k=TOKEN")
def test_empty_url_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("")
def test_too_long_rejected(self):
long_url = "https://njal.la/update/?" + "k=" + "x" * 3000
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/?" + "x" * 2050)
_validate_ddns_url(long_url)
def test_allowed_hostnames_set(self):
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertNotIn("attacker.njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertNotIn("localhost", _DDNS_ALLOWED_HOSTNAMES)
def test_dollar_expression_rejected(self):
"""$ in a validated URL is rejected; callers must substitute ${IP} first."""
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?h=test&k=TOKEN&a=${IP}")
def test_wrong_path_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/api/?k=TOKEN")
def test_exact_update_path_accepted(self):
url = "https://njal.la/update/?h=host.example.com&k=TOKEN"
self.assertEqual(_validate_ddns_url(url), url)
# ---------------------------------------------------------------------------
@@ -289,31 +282,31 @@ class TestDdnsUrlValidation(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestSshPubkeyValidation(unittest.TestCase):
"""_validate_ssh_pubkey must accept valid keys and reject injections."""
"""_validate_ssh_pubkey must accept only valid single-line OpenSSH public keys."""
_PAYLOAD = base64.b64encode(b"\x00" * 64).decode()
VALID_KEY = f"ssh-ed25519 {_PAYLOAD} user@host"
VALID_ED25519 = (
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host"
)
def test_valid_ed25519_accepted(self):
self.assertEqual(_validate_ssh_pubkey(self.VALID_KEY), self.VALID_KEY)
result = _validate_ssh_pubkey(self.VALID_ED25519)
self.assertEqual(result, self.VALID_ED25519)
def test_unsupported_algorithm_rsa_rejected(self):
payload = base64.b64encode(b"\x00" * 40).decode()
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-rsa {payload} user@host")
_validate_ssh_pubkey("ssh-rsa AAAAB3NzaC1yc2EAAAA user@host")
def test_dss_algorithm_rejected(self):
payload = base64.b64encode(b"\x00" * 40).decode()
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-dss {payload} user@host")
_validate_ssh_pubkey("ssh-dss AAAAB3NzaC1kc3MAAA user@host")
def test_multiline_injection_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"{self.VALID_KEY}\nssh-ed25519 AAAA second-key")
_validate_ssh_pubkey(self.VALID_ED25519 + "\necho pwned")
def test_options_prefix_not_accepted(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f'command="evil" {self.VALID_KEY}')
_validate_ssh_pubkey('command="ls" ' + self.VALID_ED25519)
def test_empty_key_rejected(self):
with self.assertRaises(ValueError):
@@ -321,16 +314,16 @@ class TestSshPubkeyValidation(unittest.TestCase):
def test_control_character_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-ed25519 {self._PAYLOAD}\x00 user@host")
_validate_ssh_pubkey("ssh-ed25519 AAAA\x00 user@host")
def test_malformed_base64_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-ed25519 NOT!VALID!BASE64 user@host")
_validate_ssh_pubkey("ssh-ed25519 not-valid-base64!!! user@host")
def test_too_short_payload_rejected(self):
short = base64.b64encode(b"\x00" * 5).decode()
short_b64 = base64.b64encode(b"\x00" * 10).decode()
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-ed25519 {short} user@host")
_validate_ssh_pubkey(f"ssh-ed25519 {short_b64} user@host")
def test_missing_key_body_rejected(self):
with self.assertRaises(ValueError):
@@ -338,7 +331,7 @@ class TestSshPubkeyValidation(unittest.TestCase):
# ---------------------------------------------------------------------------
# Auth-exempt path enforcement
# Auth-exempt paths
# ---------------------------------------------------------------------------
class TestAuthExemptPaths(unittest.TestCase):
@@ -404,13 +397,19 @@ class TestTechSupportSudoRules(unittest.TestCase):
content = self._get_nix_content()
self.assertIn("sovran-journal-helper", content)
def test_sovran_hub_web_service_referenced(self):
"""tech-support.nix must reference sovran-hub-web.service, not the nonexistent sovran-hub.service."""
content = self._get_nix_content()
self.assertIn("sovran-hub-web.service", content)
self.assertNotIn('"sovran-hub.service"', content)
# ---------------------------------------------------------------------------
# Journal helper validation
# Journal helper — unit allowlist validation
# ---------------------------------------------------------------------------
class TestJournalHelper(unittest.TestCase):
"""The restricted journal helper must reject dangerous flags."""
"""The restricted journal helper must enforce the explicit unit allowlist."""
def _run_helper(self, args):
"""Run the helper script and return (returncode, stderr)."""
@@ -422,15 +421,34 @@ class TestJournalHelper(unittest.TestCase):
)
return result.returncode, result.stderr
def test_valid_unit_flag_accepted(self):
# The helper will fail to actually run journalctl (not installed),
# but it must not reject the flag itself before calling journalctl.
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
# If journalctl is not installed, rc != 0 but stderr from helper is about journalctl
# If journalctl IS installed, it runs successfully (rc=0 or journalctl error)
# What we check is that the helper itself did NOT print "rejected"
# ── Allowlisted units ──
def test_sovran_hub_web_accepted(self):
rc, stderr = self._run_helper(["--unit", "sovran-hub-web.service"])
self.assertNotIn("rejected", stderr)
self.assertNotIn("sovran-journal-helper: rejected", stderr)
def test_caddy_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service"])
self.assertNotIn("rejected", stderr)
def test_bitcoind_accepted(self):
rc, stderr = self._run_helper(["--unit", "bitcoind.service"])
self.assertNotIn("rejected", stderr)
def test_lnd_accepted(self):
rc, stderr = self._run_helper(["--unit", "lnd.service"])
self.assertNotIn("rejected", stderr)
# ── Rejected units ──
def test_unapproved_service_rejected(self):
rc, stderr = self._run_helper(["--unit", "sshd.service"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_old_sovran_hub_service_rejected(self):
"""The old nonexistent sovran-hub.service must now be rejected."""
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_directory_flag_rejected(self):
rc, stderr = self._run_helper(["--directory", "/var/log"])
@@ -448,8 +466,16 @@ class TestJournalHelper(unittest.TestCase):
self.assertIn("rejected", stderr)
def test_no_args_rejected(self):
"""Whole-journal queries (no --unit) must be rejected."""
rc, stderr = self._run_helper([])
self.assertNotEqual(rc, 0)
# Should mention --unit requirement
self.assertIn("unit", stderr.lower())
def test_lines_only_no_unit_rejected(self):
"""--lines without --unit is a whole-journal query and must be rejected."""
rc, stderr = self._run_helper(["--lines", "50"])
self.assertNotEqual(rc, 0)
def test_lines_flag_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
@@ -483,7 +509,6 @@ class TestJournalHelper(unittest.TestCase):
self.assertIn("rejected", stderr)
def test_invalid_unit_name_rejected(self):
# Unit names with directory traversal or invalid chars
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
@@ -495,188 +520,402 @@ class TestJournalHelper(unittest.TestCase):
# ---------------------------------------------------------------------------
# Legacy njalla migration safety
# Legacy Njalla migration — production-backed tests
# ---------------------------------------------------------------------------
class TestNjallaLegacyMigration(unittest.TestCase):
"""_migrate_legacy_njalla_script must not execute or preserve malicious content."""
"""migrate_legacy_njalla_script must not execute or preserve malicious content.
def _run_migration(self, script_content: str) -> list[str]:
"""Run the migration against a temp file and return extracted URLs."""
import json
import tempfile
import sys
All tests call the exact production implementation from support_ops with
temporary files; no logic is duplicated here.
"""
# We can't import server.py but we can replicate the migration logic
# using security_helpers for validation.
import re
from sovran_systemsos_web.security_helpers import _validate_ddns_url
def _run_migration(self, script_content: str) -> tuple[list[str], bool]:
"""Run the production migration against temp files, return (urls, script_archived)."""
captured_urls: list[str] = []
saved = [False]
LEGACY_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 _load():
return []
def _save(urls):
captured_urls.extend(urls)
saved[0] = True
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "njalla.sh")
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
support_ops.migrate_legacy_njalla_script(
script_path,
_validate_ddns_url,
_save,
_load,
)
extracted: list[str] = []
for raw_line in script_content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
continue
m = LEGACY_CURL_RE.match(line)
if not m:
continue
raw_url = m.group(1)
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
_validate_ddns_url(url_to_validate)
extracted.append(raw_url)
except ValueError:
pass
return extracted
archived = oct(os.stat(script_path).st_mode)[-3:] == "000"
return captured_urls, archived
def test_valid_curl_line_extracted(self):
def test_valid_unquoted_curl_line_extracted(self):
script = (
"#!/usr/bin/env bash\n"
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n"
"curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n"
)
urls = self._run_migration(script)
urls, archived = self._run_migration(script)
self.assertEqual(len(urls), 1)
self.assertIn("njal.la", urls[0])
self.assertTrue(archived, "script should be archived after successful migration")
def test_valid_quoted_curl_line_extracted(self):
"""Historical quoted form: curl \"https://njal.la/...\" must be parsed."""
script = (
"#!/usr/bin/env bash\n"
'curl "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"\n'
)
urls, archived = self._run_migration(script)
self.assertEqual(len(urls), 1, "quoted URL must be extracted")
self.assertIn("njal.la", urls[0])
def test_command_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
urls = self._run_migration(script)
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_backtick_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
urls = self._run_migration(script)
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_pipe_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
urls = self._run_migration(script)
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_dollar_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=$(evil_command)\n"
urls = self._run_migration(script)
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_non_njalla_url_not_extracted(self):
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
urls = self._run_migration(script)
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_http_url_not_extracted(self):
script = "curl http://njal.la/update/?k=TOKEN\n"
urls = self._run_migration(script)
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_semicolons_in_url_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN;echo evil\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_newline_injection_not_extracted(self):
script = 'curl "https://njal.la/update/?k=TOKEN\necho evil"\n'
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_malformed_quotes_not_extracted(self):
"""Half-open quote must not match."""
script = 'curl "https://njal.la/update/?k=TOKEN\n'
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_failed_persistence_leaves_script_untouched(self):
"""If save_fn raises, the script must NOT be archived."""
def _fail_save(urls):
raise OSError("disk full")
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "njalla.sh")
script_content = (
"#!/usr/bin/env bash\n"
"curl https://njal.la/update/?h=test&k=TOKEN\n"
)
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
support_ops.migrate_legacy_njalla_script(
script_path,
_validate_ddns_url,
_fail_save,
lambda: [],
)
# Script must still be executable (not archived)
mode = oct(os.stat(script_path).st_mode)[-3:]
self.assertNotEqual(mode, "000", "script must not be archived when persistence fails")
# ---------------------------------------------------------------------------
# Legacy root key removal
# Legacy root key removal — production-backed tests
# ---------------------------------------------------------------------------
class TestLegacyRootKeyRemoval(unittest.TestCase):
"""_remove_legacy_root_support_key must remove only the legacy key."""
"""remove_legacy_root_key must remove only the exact historical key blob.
def _simulate_removal(self, lines: list[str]) -> list[str]:
"""Simulate the key-removal logic without touching real files."""
COMMENT = "sovransystemsos-support"
kept = []
for line in lines:
stripped = line.rstrip("\n")
parts = stripped.split()
if len(parts) >= 3 and parts[2] == COMMENT:
pass # remove
else:
kept.append(line)
return kept
All tests call the exact production implementation from support_ops with
temporary files; no simulation is used.
"""
def test_legacy_key_removed(self):
lines = [
"ssh-ed25519 AAAA admin@host\n",
"ssh-ed25519 BBBB sovransystemsos-support\n",
"ssh-ed25519 CCCC another@host\n",
]
result = self._simulate_removal(lines)
self.assertEqual(len(result), 2)
contents = "".join(result)
self.assertNotIn("sovransystemsos-support", contents)
self.assertIn("admin@host", contents)
self.assertIn("another@host", contents)
TARGET_BLOB = support_ops.LEGACY_ROOT_KEY_BLOB
def _do_removal(self, file_content: str) -> tuple[bool, str]:
"""Run production key removal, return (changed, result_content)."""
with tempfile.TemporaryDirectory() as tmpdir:
auth_keys = os.path.join(tmpdir, "authorized_keys")
with open(auth_keys, "w") as f:
f.write(file_content)
changed = support_ops.remove_legacy_root_key(auth_keys, self.TARGET_BLOB)
with open(auth_keys) as f:
result = f.read()
return changed, result
def test_exact_historical_key_removed(self):
"""The exact historical key must be removed regardless of comment."""
lines = (
"ssh-ed25519 AAAA admin@host\n"
f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
"ssh-ed25519 CCCC another@host\n"
)
changed, result = self._do_removal(lines)
self.assertTrue(changed)
self.assertNotIn(self.TARGET_BLOB, result)
self.assertIn("admin@host", result)
self.assertIn("another@host", result)
def test_same_comment_different_blob_preserved(self):
"""A key with 'free@nixos' comment but different blob must NOT be removed."""
lines = (
"ssh-ed25519 DIFFERENTBLOB free@nixos\n"
)
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertIn("DIFFERENTBLOB", result)
def test_legacy_key_with_different_comment_removed(self):
"""The exact blob with any comment (or no comment) must be removed."""
lines = f"ssh-ed25519 {self.TARGET_BLOB} some-other-comment\n"
changed, result = self._do_removal(lines)
self.assertTrue(changed)
self.assertNotIn(self.TARGET_BLOB, result)
def test_unrelated_keys_preserved(self):
lines = [
"ssh-ed25519 AAAA admin@host\n",
"ssh-ed25519 CCCC another@host\n",
]
result = self._simulate_removal(lines)
lines = "ssh-ed25519 AAAA admin@host\nssh-ed25519 CCCC another@host\n"
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertEqual(result, lines)
def test_empty_file_unchanged(self):
self.assertEqual(self._simulate_removal([]), [])
changed, result = self._do_removal("")
self.assertFalse(changed)
self.assertEqual(result, "")
def test_comment_line_preserved(self):
lines = [
"# authorized keys\n",
"ssh-ed25519 AAAA admin@host\n",
]
result = self._simulate_removal(lines)
lines = "# authorized keys\nssh-ed25519 AAAA admin@host\n"
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertEqual(result, lines)
def test_multiple_legacy_keys_all_removed(self):
lines = [
"ssh-ed25519 AAAA sovransystemsos-support\n",
"ssh-ed25519 BBBB sovransystemsos-support\n",
"ssh-ed25519 CCCC admin@host\n",
]
result = self._simulate_removal(lines)
self.assertEqual(len(result), 1)
self.assertIn("admin@host", "".join(result))
def test_missing_file_returns_false(self):
result = support_ops.remove_legacy_root_key("/nonexistent/path", self.TARGET_BLOB)
self.assertFalse(result)
def test_audit_callback_called(self):
events = []
lines = f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
with tempfile.TemporaryDirectory() as tmpdir:
auth_keys = os.path.join(tmpdir, "authorized_keys")
with open(auth_keys, "w") as f:
f.write(lines)
support_ops.remove_legacy_root_key(
auth_keys, self.TARGET_BLOB,
audit_fn=lambda event, details="": events.append(event),
)
self.assertIn("LEGACY_ROOT_KEY_REMOVED", events)
# ---------------------------------------------------------------------------
# Support session expiration
# Support session expiry — production-backed tests
# ---------------------------------------------------------------------------
class TestSupportSessionExpiration(unittest.TestCase):
"""Support session expiry logic must respect expires_at."""
"""expire_if_stale must enforce expiry and the session_id guard.
def _is_expired(self, session_info: dict) -> bool:
"""Replicate the expiry check from _expire_support_if_stale."""
import time
expires_at = session_info.get("expires_at")
if expires_at is None:
enabled_at = session_info.get("enabled_at", 0)
return bool(enabled_at and (time.time() - enabled_at) > 86400)
return time.time() >= expires_at
All tests call the exact production implementation from support_ops with
temporary files and injectable clock/disable functions.
"""
def _write_session(self, tmpdir, **fields) -> str:
status_file = os.path.join(tmpdir, "support-session-status")
with open(status_file, "w") as f:
json.dump(fields, f)
return status_file
def test_future_expiry_not_expired(self):
import time
info = {"expires_at": time.time() + 3600}
self.assertFalse(self._is_expired(info))
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() + 3600)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_past_expiry_expired(self):
import time
info = {"expires_at": time.time() - 1}
self.assertTrue(self._is_expired(info))
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_no_expiry_recent_session_not_expired(self):
import time
info = {"enabled_at": time.time() - 100}
self.assertFalse(self._is_expired(info))
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, enabled_at=time.time() - 100)
result = support_ops.expire_if_stale(sf, clock_fn=time.time)
self.assertFalse(result)
def test_no_expiry_old_session_expired(self):
import time
info = {"enabled_at": time.time() - 86401}
self.assertTrue(self._is_expired(info))
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, enabled_at=time.time() - 86401)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_session_id_guard_matching_expires(self):
"""Timer with matching session_id must expire the session."""
import time
sid = "test-session-id"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=sid,
expected_expiry=exp,
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_stale_timer_does_not_revoke_replacement_session(self):
"""A timer for an old session must not revoke a newer replacement session."""
import time
old_sid = "old-session"
new_sid = "new-session"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
# Current session has the NEW session id
sf = self._write_session(tmpdir, session_id=new_sid, expires_at=exp)
disabled = [False]
# Timer fires with OLD session id
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=old_sid, # stale — doesn't match stored new_sid
expected_expiry=exp,
)
self.assertFalse(result, "stale timer must not revoke replacement session")
self.assertFalse(disabled[0])
def test_stale_expiry_mismatch_does_not_revoke(self):
"""A timer with mismatched expected_expiry must not revoke."""
import time
sid = "same-sid"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp + 999)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=sid,
expected_expiry=exp, # differs from stored exp+999
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_startup_reconciliation_with_live_session(self):
"""On startup, a live session must not be expired."""
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() + 3600, session_id="live")
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_audit_callback_receives_support_expired_event(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
events = []
support_ops.expire_if_stale(
sf,
clock_fn=time.time,
audit_fn=lambda event, details="": events.append(event),
)
self.assertIn("SUPPORT_EXPIRED", events)
def test_zero_enabled_at_not_expired(self):
info = {"enabled_at": 0}
self.assertFalse(self._is_expired(info))
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, enabled_at=0)
result = support_ops.expire_if_stale(sf)
self.assertFalse(result)
# ---------------------------------------------------------------------------
# Cron composition
# ---------------------------------------------------------------------------
class TestCronComposition(unittest.TestCase):
"""configuration.nix must not disable cron (rsnapshot and other module jobs depend on it)."""
def _get_config_content(self):
path = os.path.join(_REPO_ROOT, "configuration.nix")
with open(path) as f:
return f.read()
def test_cron_not_disabled(self):
"""services.cron.enable = false must not appear in configuration.nix."""
self.assertNotIn("services.cron.enable = false", self._get_config_content())
def test_sovran_ddns_update_timer_in_njalla(self):
"""The periodic Njalla updater must be the systemd timer, not a cron job."""
path = os.path.join(_REPO_ROOT, "modules", "core", "njalla.nix")
with open(path) as f:
content = f.read()
self.assertIn("sovran-ddns-update", content)
self.assertNotIn("services.cron", content)
if __name__ == "__main__":