Files
Magent/scripts/container_smoke.py
T

341 lines
18 KiB
Python

"""Disposable-image checks, streamed into the container by ci_container_smoke.sh.
Uses only Python's standard library. All credentials and configuration below
are synthetic and the caller disables network egress and background workers.
This checks script/asset delivery and CSP compatibility, not browser execution.
"""
from html.parser import HTMLParser
import hashlib
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import sqlite3
import subprocess
import sys
from urllib import error, parse, request
ORIGIN = "https://magent-ci.example.test"
FRONTEND = "http://127.0.0.1:3000"
SETUP_TOKEN = "ci-only-setup-token-with-at-least-32-characters"
ADMIN_USERNAME = "container-smoke-admin"
ADMIN_PASSWORD = "Container-smoke-owner-password-123456789!"
INTEGRATION_SECRET = "synthetic-container-smoke-integration-key"
LOGIN_MESSAGE = "Welcome to an independent Magent installation"
BACKUP_PASSPHRASE = "Synthetic container backup passphrase only"
CACHE_FIXTURE = Path("/app/data/artwork/tmdb/w342/container-smoke.jpg")
CACHE_CONTENT = b"synthetic artwork cache fixture"
def managed_installation() -> bool:
return os.environ.get("MAGENT_MANAGED_SECRETS") in {"true", "auto"} and not os.environ.get("JWT_SECRET")
def check(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def http(
path: str,
*,
expected: int = 200,
method: str = "GET",
payload: dict | None = None,
form: dict | None = None,
raw: bytes | None = None,
headers: dict | None = None,
base: str = FRONTEND,
) -> tuple[bytes, object]:
outgoing_headers = {"Origin": ORIGIN, **(headers or {})}
check(sum(value is not None for value in (payload, form, raw)) <= 1,
"HTTP body must use only one encoding")
data = raw
if payload is not None:
data = json.dumps(payload).encode()
outgoing_headers["Content-Type"] = "application/json"
elif form is not None:
data = parse.urlencode(form).encode()
outgoing_headers["Content-Type"] = "application/x-www-form-urlencoded"
probe = request.Request(base + path, data=data, headers=outgoing_headers, method=method)
try:
response = request.urlopen(probe, timeout=30)
except error.HTTPError as exc:
response = exc
with response:
check(response.status == expected, f"{method} {path}: expected {expected}, got {response.status}")
return response.read(), response.headers
def api(path: str, **kwargs) -> dict:
body, _ = http("/api" + path, **kwargs)
return json.loads(body)
class PageAssets(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.scripts: list[dict] = []
self.assets: set[str] = set()
def handle_starttag(self, tag: str, attributes: list) -> None:
values = dict(attributes)
if tag == "script":
self.scripts.append(values)
if values.get("src"):
self.assets.add(values["src"])
if tag == "link" and values.get("href", "").startswith("/_next/static/"):
self.assets.add(values["href"])
def check_page(path: str, asset_cache: set[str]) -> str:
body, headers = http(path)
check("text/html" in headers.get("Content-Type", ""), f"{path} is not HTML")
policy = headers.get("Content-Security-Policy", "")
match = re.search(r"script-src [^;]*'nonce-([^']+)'", policy)
check(match is not None, f"{path} missing script nonce policy")
nonce = match.group(1)
check("'strict-dynamic'" in policy, f"{path} lost strict-dynamic")
check("'unsafe-eval'" not in policy, f"{path} enables development eval")
check(headers.get("X-Content-Type-Options") == "nosniff", "Missing nosniff header")
check(headers.get("X-Frame-Options") == "DENY", "Missing anti-framing header")
check(headers.get("X-Powered-By") is None, "Frontend exposes its framework")
parsed = PageAssets()
parsed.feed(body.decode())
executable_scripts = [
script for script in parsed.scripts
if script.get("type", "").lower() in ("", "module", "text/javascript", "application/javascript")
]
check(bool(executable_scripts), f"{path} contains no frontend bootstrap scripts")
for script in executable_scripts:
check(script.get("nonce") == nonce, f"{path} contains a script blocked by its CSP nonce")
check(any(asset.startswith("/_next/static/") and ".js" in asset for asset in parsed.assets),
f"{path} contains no static JavaScript assets")
for asset in sorted(parsed.assets - asset_cache):
check(asset.startswith("/_next/static/"), f"Unexpected external executable asset on {path}")
content, asset_headers = http(asset)
check(bool(content), f"Empty static asset: {asset}")
check("text/html" not in asset_headers.get("Content-Type", ""), f"Asset returned HTML: {asset}")
asset_cache.add(asset)
return nonce
def check_packaging() -> None:
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
check(Path("/app/frontend/server.js").is_file(), "Missing standalone frontend server")
check(shutil.which("node") == "/usr/local/bin/node", "Node is not the standalone runtime binary")
check(shutil.which("supervisord") == "/usr/local/bin/supervisord", "Missing Python supervisor")
check(shutil.which("curl") is not None, "curl compatibility for existing healthchecks was removed")
for executable in ("npm", "npx", "yarn", "pnpm", "pip", "pip3", "gcc", "g++", "make", "git", "gpg"):
check(shutil.which(executable) is None, f"Unnecessary runtime development tool: {executable}")
for forbidden in (
"/app/.git", "/app/tests", "/app/app/tests", "/app/backend/tests",
"/app/frontend/app", "/app/frontend/tsconfig.json", "/app/frontend/proxy.ts",
"/app/frontend/node_modules/typescript", "/app/frontend/node_modules/eslint",
"/app/frontend/node_modules/vitest", "/app/frontend/node_modules/@playwright",
"/app/frontend/node_modules/@biomejs",
"/app/frontend/node_modules/@next/swc-linux-x64-gnu",
"/app/frontend/node_modules/@next/swc-linux-arm64-gnu",
"/app/frontend/node_modules/@next/swc-linux-x64-musl",
"/app/frontend/node_modules/@next/swc-linux-arm64-musl",
"/root/.npm", "/root/.cache/pip", "/usr/local/lib/node_modules/npm",
):
check(not Path(forbidden).exists(), f"Unnecessary build/private artifact: {forbidden}")
for directory in (Path("/app"), Path("/app/frontend")):
check(not any(directory.glob(".env*")), f"Private environment file in {directory}")
check(not Path("/app/data/bootstrap-secrets.json").exists(), "Managed secrets baked into image")
check(not any(Path("/app/frontend/.next/cache").iterdir()), "Frontend build cache shipped in runtime")
check(Path("/usr/share/licenses/magent/LICENSE").is_file(), "Magent license is missing")
check(Path("/usr/local/share/doc/nodejs/LICENSE").is_file(), "Node distribution license is missing")
check(Path("/usr/share/licenses/magent/frontend/dependencies.json").is_file(),
"Frontend dependency inventory is missing")
print("Standalone packaging, non-root runtime and absent development tools/private files: PASS")
def check_runtime() -> None:
check(os.getuid() == 1000 and os.getgid() == 1000, "Runtime is not the default non-root UID/GID 1000")
check(Path("/app/data").stat().st_uid == os.getuid(), "Fresh data volume is not owned by runtime user")
check(os.access("/app/data", os.W_OK), "Data volume is not writable")
for path in ("/api/health", "/api/setup/status"):
http(path)
http("/health", base="http://127.0.0.1:8000")
asset_cache: set[str] = set()
first_nonce = check_page("/login", asset_cache)
second_nonce = check_page("/login", asset_cache)
check(first_nonce != second_nonce, "CSP nonce is reused between requests")
check_page("/setup", asset_cache)
# Check the retained curl command because some deployed stacks override the
# image HEALTHCHECK with this exact runtime dependency.
subprocess.run(["curl", "--fail", "--silent", "--show-error", FRONTEND + "/api/health"],
check=True, stdout=subprocess.DEVNULL)
print(f"Runtime, API rewrite, CSP nonce consistency and {len(asset_cache)} static assets: PASS")
def check_origin_guards() -> None:
# MAGENT_APPLICATION_URL must permit the public origin even while CORS uses
# its localhost default. Test both direct backend and Next's API rewrite.
for base, prefix in ((FRONTEND, "/api"), ("http://127.0.0.1:8000", "")):
for endpoint in ("/auth/login", "/auth/jellyfin/login"):
for origin, expected in ((ORIGIN, 422), ("https://untrusted.example.test", 403)):
http(prefix + endpoint, base=base, method="POST", form={}, expected=expected,
headers={"Origin": origin})
print("Both login Origin guards, directly and via frontend: PASS")
def sign_in() -> dict:
_, headers = http("/api/auth/login", method="POST", form={
"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
})
cookies = SimpleCookie()
for raw_cookie in headers.get_all("Set-Cookie", []):
cookies.load(raw_cookie)
check("magent_auth" in cookies, "Local login did not issue an authentication cookie")
auth_cookie = cookies["magent_auth"]
check(bool(auth_cookie["httponly"]), "Authentication cookie missing HttpOnly")
check(bool(auth_cookie["secure"]), "Authentication cookie missing Secure")
check(auth_cookie["samesite"].lower() == "strict", "Authentication cookie missing SameSite=strict")
# These requests traverse HTTP loopback behind the simulated HTTPS public
# origin. Forward only our synthetic cookie explicitly; never print tokens.
authenticated_headers = {"Cookie": "magent_auth=" + auth_cookie.value}
identity = api("/auth/me", headers=authenticated_headers)
check(identity["username"] == ADMIN_USERNAME and identity["role"] == "admin",
"Local administrator identity did not survive login")
return authenticated_headers
def check_persisted_settings(headers: dict) -> None:
values = {item["key"]: item for item in api("/admin/settings", headers=headers)["settings"]}
check(values["site_login_message"]["value"] == LOGIN_MESSAGE, "Public configuration did not persist")
check(values["jellyfin_api_key"]["value"] is None and values["jellyfin_api_key"]["isSet"],
"Integration secret is missing or exposed by settings API")
with sqlite3.connect("file:/app/data/magent.db?mode=ro", uri=True) as connection:
check(connection.execute("PRAGMA quick_check").fetchone()[0] == "ok", "SQLite integrity failure")
check(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1,
"Fresh smoke instance has unexpected users")
stored = connection.execute("SELECT value FROM settings WHERE key = 'jellyfin_api_key'").fetchone()
check(stored is not None and INTEGRATION_SECRET not in str(stored[0]),
"Integration secret was stored without encryption")
def backup_restore_upload(content: bytes, passphrase: str) -> tuple[bytes, str]:
boundary = "magent-smoke-" + secrets.token_hex(24)
parts = []
for name, value in (("passphrase", passphrase), ("confirmation", "RESTORE")):
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n'
f'\r\n{value}\r\n').encode())
parts.extend([
(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="smoke.magent-backup"\r\n'
'Content-Type: application/octet-stream\r\n\r\n').encode(),
content,
f"\r\n--{boundary}--\r\n".encode(),
])
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def stage_backup_roundtrip(headers: dict) -> None:
# All paths and values belong to this disposable CI volume, never real data.
CACHE_FIXTURE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FIXTURE.write_bytes(CACHE_CONTENT)
content, response_headers = http("/api/admin/backups/export", method="POST", headers=headers,
payload={"passphrase": BACKUP_PASSPHRASE, "include_cache": True})
check(content.startswith(b"MAGENT-BACKUP\x00\x01"), "Backup is not the encrypted portable format")
check(INTEGRATION_SECRET.encode() not in content, "Backup exposed plaintext integration credentials")
check(response_headers.get("Cache-Control") == "no-store", "Backup download is cacheable")
api("/admin/settings", method="PUT", headers=headers,
payload={"site_login_message": "Changed after backup"})
CACHE_FIXTURE.write_bytes(b"changed after backup")
body, content_type = backup_restore_upload(content, BACKUP_PASSPHRASE)
restored = api("/admin/backups/restore", method="POST", expected=202, raw=body,
headers={**headers, "Content-Type": content_type})
check(restored["restart_required"], "Restore did not require a restart")
status = api("/admin/backups", headers=headers)
check(status["pending_restore"] is not None, "Backup was not staged")
check(CACHE_FIXTURE.read_bytes() == b"changed after backup", "Restore applied before restart")
print("Encrypted config/database/cache backup and authenticated restore staging: PASS")
def fresh_install() -> None:
check(api("/setup/status") == {"setup_required": True, "needs_admin": True},
"Fresh volume did not open authorized first-install setup")
api("/setup/state", expected=401)
api("/admin/settings", expected=401)
token = SETUP_TOKEN
if managed_installation():
# docker exec does not inherit the entrypoint's generated environment:
# the console command must read persistent state, not rely on getenv.
token = subprocess.check_output(
[sys.executable, "-m", "app.container_bootstrap", "setup-token"], text=True,
).strip()
check(len(token) == 64 and token != SETUP_TOKEN, "Managed setup token was not generated")
state_file = Path("/app/data/bootstrap-secrets.json")
check(state_file.stat().st_mode & 0o777 == 0o600, "Managed secrets file is not private")
Path("/app/data/.smoke-managed-digest").write_text(hashlib.sha256(state_file.read_bytes()).hexdigest())
bootstrap = {"setup_token": token, "username": ADMIN_USERNAME, "password": ADMIN_PASSWORD,
"application_url": ORIGIN}
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=403,
headers={"Origin": "https://untrusted.example.test"})
api("/setup/bootstrap", method="POST", payload={**bootstrap, "setup_token": "wrong-token"}, expected=403)
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=201)
api("/setup/bootstrap", method="POST", payload=bootstrap, expected=409)
headers = sign_in()
check(api("/setup/state", headers=headers)["step"] == "apps", "Setup did not advance to apps")
updated = api("/admin/settings", method="PUT", headers=headers, payload={
"site_login_message": LOGIN_MESSAGE,
"jellyfin_api_key": INTEGRATION_SECRET,
})
check(updated["updated"] == 2, "Setup configuration was not saved")
api("/setup/state", method="PUT", payload={"step": "review"}, headers=headers)
check(api("/setup/complete", method="POST", headers=headers)["completed"], "Setup did not complete")
check(api("/setup/status") == {"setup_required": False, "needs_admin": False}, "Setup remained public")
check_persisted_settings(headers)
print("Token-authorized setup, local admin login, secure cookies and encrypted settings: PASS")
stage_backup_roundtrip(headers)
def persisted_install() -> None:
check(api("/setup/status") == {"setup_required": False, "needs_admin": False},
"Setup reopened after restart/recreation")
api("/setup/bootstrap", method="POST", payload={
"setup_token": SETUP_TOKEN, "username": "must-not-exist", "password": ADMIN_PASSWORD,
}, expected=409)
headers = sign_in()
check_persisted_settings(headers)
status = api("/admin/backups", headers=headers)
check(status["pending_restore"] is None, "Restore remained pending after restart")
check(status["last_restore"] and status["last_restore"]["status"] == "restored",
"Backup restore did not complete")
check(CACHE_FIXTURE.read_bytes() == CACHE_CONTENT, "Artwork cache was not restored")
if managed_installation():
state_file = Path("/app/data/bootstrap-secrets.json")
check(hashlib.sha256(state_file.read_bytes()).hexdigest()
== Path("/app/data/.smoke-managed-digest").read_text(), "Managed keys changed on restart/restore")
result = subprocess.run([sys.executable, "-m", "app.container_bootstrap", "setup-token"],
capture_output=True, text=True, check=False)
check(result.returncode != 0 and not result.stdout, "Setup token remains available after admin creation")
print("Generated keys persisted unchanged; initial setup token is no longer available: PASS")
print("Persistent setup state, administrator login, encrypted settings and database integrity: PASS")
print("Restored configuration, database and artwork cache: PASS")
def main() -> None:
check(len(sys.argv) == 2 and sys.argv[1] in ("packaging", "fresh", "persisted"),
"Expected packaging, fresh or persisted mode")
if sys.argv[1] == "packaging":
check_packaging()
return
check_runtime()
if sys.argv[1] == "fresh":
fresh_install()
else:
persisted_install()
check_origin_guards()
if __name__ == "__main__":
main()