| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | from http.client import HTTPConnection |
| 4 | from http.server import BaseHTTPRequestHandler, HTTPServer |
| 5 | import json |
| 6 | import os |
| 7 | from pathlib import Path |
| 8 | import subprocess |
| 9 | import socket |
| 10 | import tempfile |
| 11 | import threading |
| 12 | import time |
| 13 | import urllib.error |
| 14 | import urllib.request |
| 15 | import uuid |
| 16 | |
| 17 | import router |
| 18 | |
| 19 | |
| 20 | def main(): |
| 21 | parser = argparse.ArgumentParser() |
| 22 | parser.add_argument("--image", required=True) |
| 23 | parser.add_argument("--output", type=Path) |
| 24 | args = parser.parse_args() |
| 25 | container = "studio-dashboard-package-test-" + uuid.uuid4().hex[:12] |
| 26 | proof = uuid.uuid4().hex + uuid.uuid4().hex |
| 27 | |
| 28 | def shell(*argv): |
| 29 | return subprocess.run(argv, capture_output=True, text=True, check=True, timeout=60).stdout |
| 30 | |
| 31 | with tempfile.TemporaryDirectory(prefix="studio-dashboard-package-", dir="/run") as temporary: |
| 32 | data = Path(temporary) / "data" |
| 33 | data.mkdir() |
| 34 | os.chown(data, 65534, 65534) |
| 35 | token = Path(temporary) / "proxy.token" |
| 36 | token.write_text(proof) |
| 37 | os.chown(token, 0, 65534) |
| 38 | token.chmod(0o440) |
| 39 | season = data / "media/jellyfin/Indie Shows/Fixture/Season 1" |
| 40 | season.mkdir(parents=True) |
| 41 | (season / "S01E01 - Fixture.mp4").write_bytes(b"fixture") |
| 42 | (season / "S01E01 - Fixture.nfo").write_text("<episodedetails><title>Packaged worker</title></episodedetails>") |
| 43 | try: |
| 44 | shell("podman", "run", "--detach", "--name", container, "--read-only", "--cap-drop=ALL", |
| 45 | "--security-opt=no-new-privileges", "--memory=512m", "--cpus=2", "--pids-limit=128", |
| 46 | "--publish=127.0.0.1::7072", "--volume=" + str(data) + ":/data:rw", |
| 47 | "--volume=" + str(token) + ":/run/secrets/dashboard-proxy.token:ro", |
| 48 | "--tmpfs=/tmp:rw,noexec,nosuid,size=64m", "--env=STUDIO_DATA_DIR=/data", |
| 49 | "--env=STUDIO_DOMAIN=studio.test", "--env=STUDIO_MEDIA_READ_ONLY=true", |
| 50 | "--env=STUDIO_YT_STATE=/data/yt", "--env=STUDIO_YT_CONFIG=/data/config", |
| 51 | "--env=STUDIO_YT_MEDIA=/data/media", args.image) |
| 52 | info = json.loads(shell("podman", "inspect", container))[0] |
| 53 | port = info["NetworkSettings"]["Ports"]["7072/tcp"][0]["HostPort"] |
| 54 | base = "http://127.0.0.1:" + port |
| 55 | |
| 56 | def get(path, supplied=proof): |
| 57 | headers = { |
| 58 | "User-Name": "fixture", "User-Groups": "infra-admin,media,media-manage", |
| 59 | } |
| 60 | if supplied is not None: |
| 61 | headers["Studio-Proxy-Token"] = supplied |
| 62 | request = urllib.request.Request(base + path, headers=headers) |
| 63 | with urllib.request.urlopen(request, timeout=30) as response: |
| 64 | return response.read() |
| 65 | |
| 66 | deadline = time.monotonic() + 40 |
| 67 | while True: |
| 68 | try: |
| 69 | get("/") |
| 70 | break |
| 71 | except OSError: |
| 72 | if time.monotonic() >= deadline: |
| 73 | raise |
| 74 | time.sleep(.1) |
| 75 | for path in ["/", "/api/me", "/api/launcher", "/assets/probe"]: |
| 76 | for supplied in [None, "0" * 64, proof[:-1], proof + "0"]: |
| 77 | try: |
| 78 | get(path, supplied) |
| 79 | raise AssertionError("forged ingress reached " + path) |
| 80 | except urllib.error.HTTPError as error: |
| 81 | assert error.code == 403, (path, error.code) |
| 82 | assert json.loads(get("/api/me"))["name"] == "fixture" |
| 83 | python = next(value.split("=", 1)[1] for value in info["Config"]["Env"] if value.startswith("STUDIO_YT_PYTHON=")) |
| 84 | address = next(network["IPAddress"] for network in info["NetworkSettings"]["Networks"].values() if network["IPAddress"]) |
| 85 | neighbor = f"""import urllib.request, urllib.error |
| 86 | request = urllib.request.Request('http://{address}:7072/api/me', headers={{ |
| 87 | 'User-Name': 'snow', 'User-Groups': 'infra-admin', 'Studio-Proxy-Token': '0' * 64, |
| 88 | }}) |
| 89 | try: |
| 90 | urllib.request.urlopen(request, timeout=5) |
| 91 | raise AssertionError('forged identity accepted') |
| 92 | except urllib.error.HTTPError as error: |
| 93 | assert error.code == 403, error.code |
| 94 | print('refused') |
| 95 | """ |
| 96 | assert shell("podman", "run", "--rm", "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", |
| 97 | "--entrypoint=" + python, args.image, "-c", neighbor).strip() == "refused" |
| 98 | unsecured = subprocess.run(["podman", "run", "--rm", "--read-only", "--network=none", "--cap-drop=ALL", |
| 99 | "--security-opt=no-new-privileges", "--unsetenv=STUDIO_PROXY_TOKEN_FILE", args.image], |
| 100 | capture_output=True, text=True, timeout=10) |
| 101 | assert unsecured.returncode != 0 and "Set STUDIO_PROXY_TOKEN_FILE" in unsecured.stderr, unsecured.stderr |
| 102 | |
| 103 | class Auth(BaseHTTPRequestHandler): |
| 104 | def do_GET(self): |
| 105 | self.send_response(200 if self.headers.get("Cookie") == "fixture=1" else 401) |
| 106 | self.send_header("X-Auth-Request-Preferred-Username", "fixture") |
| 107 | self.send_header("X-Auth-Request-Groups", "role:media") |
| 108 | self.end_headers() |
| 109 | |
| 110 | def log_message(self, *args): |
| 111 | pass |
| 112 | |
| 113 | with HTTPServer(("127.0.0.1", 0), Auth) as auth: |
| 114 | threading.Thread(target=auth.serve_forever, daemon=True).start() |
| 115 | router.nomad = lambda path, _: { |
| 116 | "/v1/services": [{"Namespace": "default", "Services": [{"ServiceName": "forward-auth"}]}], |
| 117 | "/v1/service/forward-auth": [{"ServiceName": "forward-auth", "AllocID": "fixture", "Address": "127.0.0.1", "Port": auth.server_port, "Tags": []}], |
| 118 | "/v1/allocation/fixture/checks": {"ready": {"Status": "success"}}, |
| 119 | }[path] |
| 120 | os.environ.update(STUDIO_DOMAIN="studio.test", STUDIO_DASHBOARD_PORT=port, STUDIO_PROXY_TOKEN_FILE=str(token)) |
| 121 | with socket.socket() as reservation: |
| 122 | reservation.bind(("127.0.0.1", 0)) |
| 123 | proxy_port = reservation.getsockname()[1] |
| 124 | config = Path(temporary) / "Caddyfile" |
| 125 | config.write_text("{\n admin off\n auto_https off\n}\n" + router.render("fixture").replace( |
| 126 | "globe.studio.test {", "http://127.0.0.1:" + str(proxy_port) + " {" |
| 127 | ).replace(" tls internal\n", "")) |
| 128 | config.chmod(0o600) |
| 129 | log = Path(temporary) / "caddy.log" |
| 130 | caddy_binary = (Path("/proc") / shell("systemctl", "show", "-P", "MainPID", "caddy").strip() / "exe").resolve(strict=True) |
| 131 | with log.open("w") as output: |
| 132 | caddy = subprocess.Popen([str(caddy_binary), "run", "--config", str(config), "--adapter", "caddyfile"], |
| 133 | stdout=output, stderr=output, env={**os.environ, "XDG_DATA_HOME": temporary, "XDG_CONFIG_HOME": temporary}) |
| 134 | try: |
| 135 | deadline = time.monotonic() + 10 |
| 136 | while True: |
| 137 | try: |
| 138 | with socket.create_connection(("127.0.0.1", proxy_port), timeout=.2): |
| 139 | break |
| 140 | except OSError: |
| 141 | if time.monotonic() >= deadline or caddy.poll() is not None: |
| 142 | raise AssertionError(log.read_text().replace(proof, "<redacted>")) |
| 143 | time.sleep(.1) |
| 144 | forged = {"User-Name": "snow", "User-Groups": "infra-admin", "Studio-Proxy-Token": "0" * 64} |
| 145 | request = urllib.request.Request("http://127.0.0.1:" + str(proxy_port) + "/api/me", |
| 146 | headers={**forged, "Cookie": "fixture=1"}) |
| 147 | with urllib.request.urlopen(request, timeout=10) as response: |
| 148 | me = json.load(response) |
| 149 | assert me["name"] == "fixture" and "infra-admin" not in me["groups"], me |
| 150 | request.full_url = "http://127.0.0.1:" + str(proxy_port) + "/api/deploys" |
| 151 | try: |
| 152 | urllib.request.urlopen(request, timeout=10) |
| 153 | raise AssertionError("forged admin role survived Caddy") |
| 154 | except urllib.error.HTTPError as error: |
| 155 | assert error.code == 403, error.code |
| 156 | connection = HTTPConnection("127.0.0.1", proxy_port, timeout=10) |
| 157 | try: |
| 158 | connection.request("GET", "/api/me", headers=forged) |
| 159 | response = connection.getresponse() |
| 160 | assert response.status == 302 and response.getheader("Location").startswith("/snow.oauth2/sign_in?"), response.status |
| 161 | response.read() |
| 162 | finally: |
| 163 | connection.close() |
| 164 | finally: |
| 165 | caddy.terminate() |
| 166 | try: |
| 167 | caddy.wait(timeout=10) |
| 168 | except subprocess.TimeoutExpired: |
| 169 | caddy.kill() |
| 170 | caddy.wait() |
| 171 | auth.shutdown() |
| 172 | apps = json.loads(get("/api/launcher")) |
| 173 | assert any(app["id"] == "shale" for app in apps), apps |
| 174 | assert all(app["url"].endswith(".studio.test") for app in apps), apps |
| 175 | icon = next(url for app in apps for url in (app["icon"] or {}).values() if url) |
| 176 | assert b"<svg" in get(icon) |
| 177 | library = json.loads(get("/api/youtube/library")) |
| 178 | assert len(library) == 1 and library[0]["title"] == "Packaged worker" and library[0]["episode"] == 1, library |
| 179 | assert "ffmpeg version" in shell("podman", "exec", container, "ffmpeg", "-version") |
| 180 | assert shell("podman", "exec", container, "yt-dlp", "--version").strip() |
| 181 | yaml = shell("podman", "exec", container, "python3", "-c", "import yaml; print(yaml.safe_load('ready: true')['ready'])") |
| 182 | assert yaml.strip() == "True", yaml |
| 183 | assert {mount["Destination"] for mount in info["Mounts"]} == {"/data", "/run/secrets/dashboard-proxy.token"}, info["Mounts"] |
| 184 | assert not next(mount["RW"] for mount in info["Mounts"] if mount["Destination"] == "/run/secrets/dashboard-proxy.token") |
| 185 | assert shell("podman", "exec", container, "id", "-u").strip() == "65534" |
| 186 | for denied in ["/opt/studio/current", "/var/lib/studio/nomad.token", "/run/podman/podman.sock", "/dev/zfs"]: |
| 187 | assert subprocess.run(["podman", "exec", container, "test", "-e", denied], capture_output=True).returncode == 1, denied |
| 188 | result = {"proxy_proof_required": "passed", "neighbor_identity_forgery_refused": "passed", |
| 189 | "wildcard_startup_without_proof_refused": "passed", |
| 190 | "caddy_identity_scrubbing_and_proof_injection": "passed", "anonymous_forgery_redirected_to_signin": "passed", |
| 191 | "launcher_from_immutable_image": "passed", "service_icons": "passed", |
| 192 | "unprivileged_youtube_library": "passed", "python_yaml": "passed", |
| 193 | "ffmpeg_and_ytdlp": "passed", "host_release_and_control_paths_absent": "passed"} |
| 194 | if args.output: |
| 195 | args.output.write_text(json.dumps(result, indent=2) + "\n") |
| 196 | print(json.dumps(result)) |
| 197 | finally: |
| 198 | subprocess.run(["podman", "rm", "--force", container], capture_output=True) |
| 199 | |
| 200 | |
| 201 | if __name__ == "__main__": |
| 202 | main() |