341 lines
18 KiB
Python
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()
|