"""Validated OCI image installs for shipdeck-bridge.""" import json import os import re import shutil import subprocess import time RE_SERVICE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") RE_IMAGE = re.compile(r"^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?/)?[A-Za-z0-9][A-Za-z0-9._/-]*(?::[A-Za-z0-9][A-Za-z0-9._-]{0,127}|@sha256:[a-f0-9]{64})$") RE_SHA = re.compile(r"^[a-f0-9]{64}$") RE_ENV = re.compile(r"^[A-Z_][A-Z0-9_]*$") RE_USER = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?$") RE_CMD0 = re.compile(r"^/?[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$") RESTARTS = {"no", "always", "unless-stopped", "on-failure"} DRAFT_ROOT = os.environ.get("SHIPDECK_IMAGE_DRAFTS", "/var/lib/shipdeck/drafts") _STATE = {} def init_shared(lock, run_fn): _STATE.update(LOCK=lock, RUN=run_fn) def _q(value): return json.dumps(value, ensure_ascii=True) def install_image(payload): image = payload.get("image") name = payload.get("name") subdomain = payload.get("subdomain") port = payload.get("port") sha = payload.get("sha256") env = payload.get("env") or {} mounts = payload.get("mounts") or [] user = payload.get("user") or "" restart = payload.get("restart") or "unless-stopped" cmd = payload.get("cmd") or [] if not isinstance(image, str) or not RE_IMAGE.match(image): return 400, {"ok": False, "error": "invalid image"} if not isinstance(name, str) or not RE_SERVICE.match(name): return 400, {"ok": False, "error": "invalid service name"} if not isinstance(subdomain, str) or not RE_SERVICE.match(subdomain): return 400, {"ok": False, "error": "invalid subdomain"} if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535: return 400, {"ok": False, "error": "invalid port"} if sha is not None and (not isinstance(sha, str) or not RE_SHA.match(sha)): return 400, {"ok": False, "error": "invalid sha256"} if user and (not isinstance(user, str) or not RE_USER.match(user)): return 400, {"ok": False, "error": "invalid user"} if restart not in RESTARTS: return 400, {"ok": False, "error": "invalid restart"} if not isinstance(env, dict) or any(not isinstance(k, str) or not isinstance(v, str) or not RE_ENV.match(k) or len(v) > 300 or any(ord(c) < 32 or ord(c) == 127 for c in v) or '"' in v or '\\' in v for k, v in env.items()): return 400, {"ok": False, "error": "invalid env"} if not isinstance(cmd, list) or len(cmd) > 64 or any(not isinstance(v, str) or not v or len(v) > 1024 or any(c in v for c in "\x00\r\n") for v in cmd): return 400, {"ok": False, "error": "invalid cmd"} if cmd and not RE_CMD0.match(cmd[0]): return 400, {"ok": False, "error": "invalid cmd executable"} if not isinstance(mounts, list) or len(mounts) > 32: return 400, {"ok": False, "error": "invalid mounts"} clean_mounts = [] for mount in mounts: if not isinstance(mount, dict): return 400, {"ok": False, "error": "invalid mount"} source, target = mount.get("source"), mount.get("target") if not isinstance(source, str) or not isinstance(target, str) or not source.startswith("/") or not target.startswith("/") or ".." in source or ".." in target or not re.match(r"^/[A-Za-z0-9._/-]+$", source + target): return 400, {"ok": False, "error": "invalid mount path"} if mount.get("read_only") not in (None, True, False): return 400, {"ok": False, "error": "invalid mount mode"} clean_mounts.append({"source": source, "target": target, "read_only": mount.get("read_only") is True}) with _STATE["LOCK"]: pull_args = ["pull", "--json"] if sha: pull_args += ["--sha256", sha] pull_args.append(image) code, pull_out = _STATE["RUN"](pull_args, 900) if code != 0: return 500, {"ok": False, "error": "image pull failed", "output": pull_out[-4000:]} try: pulled = json.loads(pull_out) digest = pulled["digest"] if not re.match(r"^sha256:[a-f0-9]{64}$", digest): raise ValueError("bad digest") pin = digest.split(":", 1)[1] if not cmd: image_cfg = (pulled.get("config") or {}).get("config") or {} cmd = list(image_cfg.get("Entrypoint") or []) + list(image_cfg.get("Cmd") or []) except (ValueError, KeyError, TypeError): return 500, {"ok": False, "error": "shipdeck pull returned invalid metadata"} if not cmd or not RE_CMD0.match(cmd[0]): return 400, {"ok": False, "error": "image has no safe default command; provide cmd"} ts = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=10).stdout.split() if not ts: return 500, {"ok": False, "error": "could not determine fleet host Tailscale IP"} draft = os.path.join(DRAFT_ROOT, name) if os.path.exists(draft): shutil.rmtree(draft) os.makedirs(draft, mode=0o700, exist_ok=False) lines = [ "# Generated by shipdeck-bridge; immutable manifest pin.", "[service]", "name = " + _q(name), "binary = " + _q(cmd[0] if cmd else "/bin/sh"), "", "[source]", "image = " + _q(image), "sha256 = " + _q(pin), "", "[deploy]", 'host = "dns2"', "systemd_unit = " + _q(name + ".service"), "port = " + str(port), "", "[runtime]", "user = " + _q(user), "restart = " + _q(restart), "cmd = " + _q(cmd), "", ] if env: lines.append("[env]") lines.extend(k + " = " + _q(v) for k, v in sorted(env.items())) lines.append("") for mount in clean_mounts: lines += ["[[volumes]]", "source = " + _q(mount["source"]), "target = " + _q(mount["target"]), "read_only = " + ("true" if mount["read_only"] else "false"), ""] lines += ["[caddy]", 'block = """', subdomain + ".sami {", "\treverse_proxy 127.0.0.1:" + str(port), "}", '"""', "tailnet_only = true", "", "[dns]", 'zone = "sami"', "record = " + _q(subdomain + ".sami"), "target = " + _q(ts[0]), "", "[verify]", "http = " + _q("https://" + subdomain + ".sami/"), "timeout = 30", ""] path = os.path.join(draft, "Shipdeckfile") with open(path, "w", encoding="utf-8") as fh: fh.write("\n".join(lines)) os.chmod(path, 0o600) code, out = _STATE["RUN"](["deploy", draft], 900) if code != 0: return 500, {"ok": False, "error": "image deploy failed", "output": out[-4000:]} installed_path = "/var/lib/shipdeck/services/" + name + "/Shipdeckfile" row_id = name + ":" + str(int(time.time())) return 200, {"ok": True, "service": {"name": name, "image": image + "@sha256:" + pin, "port": port, "shipdeckfile": installed_path, "journal_row_id": row_id}, "output": out[-2000:]}