1#!/usr/bin/env python3
2import argparse
3from http.client import HTTPConnection
4from http.server import BaseHTTPRequestHandler, HTTPServer
5import json
6import os
7from pathlib import Path
8import subprocess
9import socket
10import tempfile
11import threading
12import time
13import urllib.error
14import urllib.request
15import uuid
16
17import router
18
19
20def 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
86request = urllib.request.Request('http://{address}:7072/api/me', headers={{
87 'User-Name': 'snow', 'User-Groups': 'infra-admin', 'Studio-Proxy-Token': '0' * 64,
88}})
89try:
90 urllib.request.urlopen(request, timeout=5)
91 raise AssertionError('forged identity accepted')
92except urllib.error.HTTPError as error:
93 assert error.code == 403, error.code
94print('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
201if __name__ == "__main__":
202 main()