Files
Unraid-AppOps-Gateway-Public/backend/app/services/privatemuse_webhook_repair.py
T

325 lines
12 KiB
Python

from __future__ import annotations
import time
from typing import Any
from urllib.parse import quote
from ..connectors.portainer import PortainerConnector
from ..core.redaction import redact_message
_CONTROLLER = "gitea-deploy-control"
_REPOSITORY = "jens/privatemuse"
_ENDPOINT = "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse"
# Fixed, one-target repair. The model/operator cannot supply repository, URL,
# secret, token, hook id or command text. Both credentials stay inside the
# existing root-owned deployment-control boundary.
_REPAIR_SCRIPT = r"""
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
repository, endpoint = sys.argv[1:3]
if repository != "jens/privatemuse" or endpoint != "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse":
raise SystemExit(40)
projects_path = Path("/controller-config/projects.json")
secret_root = Path("/controller-config/webhook-secrets")
secret_path = secret_root / "jens__privatemuse"
admin_token_path = Path("/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token")
expected_validation = {
"validation_gate_version": 1,
"required": True,
"contexts": ["Managed validation / full (push)"],
"timeout_seconds": 1800,
"poll_interval_seconds": 5,
"max_poll_interval_seconds": 30,
}
def safe_regular(path: Path, *, private: bool = False) -> None:
if path.is_symlink() or not path.is_file():
raise SystemExit(20)
info = path.stat()
if os.name != "nt" and info.st_uid != os.geteuid():
raise SystemExit(21)
if private and os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o077:
raise SystemExit(22)
safe_regular(projects_path)
safe_regular(secret_path, private=True)
safe_regular(admin_token_path, private=True)
try:
projects = json.loads(projects_path.read_text(encoding="utf-8"))
except Exception:
raise SystemExit(23)
project = projects.get(repository) if isinstance(projects, dict) else None
repository_id = project.get("gitea_repository_id") if isinstance(project, dict) else None
if (
not isinstance(project, dict)
or project.get("branch") != "main"
or isinstance(repository_id, bool)
or not isinstance(repository_id, int)
or repository_id <= 0
or project.get("gitea_webhook_secret_file") != "jens__privatemuse"
or project.get("validation") != expected_validation
):
raise SystemExit(24)
secret = secret_path.read_text(encoding="utf-8").strip()
token = admin_token_path.read_text(encoding="utf-8").strip()
if not 32 <= len(secret) <= 256 or any(ord(ch) < 33 or ord(ch) > 126 for ch in secret):
raise SystemExit(25)
if not 20 <= len(token) <= 512 or any(ch in token for ch in "\r\n"):
raise SystemExit(26)
# Prove the effective Gitea outbound boundary directly instead of inferring it
# from app.ini syntax. This is stronger than a text parse: the Gitea container
# must actually reach the fixed private controller endpoint and receive the
# expected controller protocol identity. No credential is sent.
health = subprocess.run(
[
"docker", "exec", "Gitea",
"curl", "--fail", "--silent", "--show-error", "--max-time", "5",
"http://198.19.0.1:1299/health",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=10,
check=False,
)
if health.returncode != 0:
raise SystemExit(28)
try:
health_payload = json.loads(health.stdout)
except json.JSONDecodeError:
raise SystemExit(29)
if (
not isinstance(health_payload, dict)
or health_payload.get("status") != "ok"
or health_payload.get("protocolVersion") != "itworx-controller-request/v2"
):
raise SystemExit(29)
def request(method: str, path: str, payload=None):
cmd = [
"docker", "exec", "-i", "Gitea",
"curl", "--fail", "--silent", "--show-error", "--max-time", "15",
"--request", method,
"--header", f"Authorization: token {token}",
"--header", "Accept: application/json",
]
raw = None
if payload is not None:
cmd.extend(["--header", "Content-Type: application/json", "--data-binary", "@-"])
raw = json.dumps(payload, separators=(",", ":"))
cmd.append(f"http://127.0.0.1:3000/api/v1{path}")
result = subprocess.run(
cmd,
input=raw,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=20,
check=False,
)
if result.returncode != 0:
raise SystemExit(30)
if not result.stdout.strip():
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
raise SystemExit(31)
repo = request("GET", "/repos/Jens/PrivateMuse")
if (
not isinstance(repo, dict)
or repo.get("id") != repository_id
or str(repo.get("full_name") or "").lower() != repository
or repo.get("default_branch") != "main"
):
raise SystemExit(32)
hooks = request("GET", "/repos/Jens/PrivateMuse/hooks")
if not isinstance(hooks, list):
raise SystemExit(33)
exact = []
semantic_other = []
for hook in hooks:
if not isinstance(hook, dict):
continue
hook_config = hook.get("config")
url = str(hook_config.get("url") or "") if isinstance(hook_config, dict) else ""
if url == endpoint:
exact.append(hook)
elif url.endswith("/v1/gitea/push/jens/privatemuse"):
semantic_other.append(hook)
if len(exact) > 1 or semantic_other:
raise SystemExit(34)
payload = {
"active": True,
"branch_filter": "main",
"events": ["push"],
"config": {
"url": endpoint,
"content_type": "json",
"secret": secret,
"insecure_ssl": "0",
},
}
if exact:
hook_id = exact[0].get("id")
if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0:
raise SystemExit(35)
updated = request("PATCH", f"/repos/Jens/PrivateMuse/hooks/{hook_id}", payload)
else:
created = request(
"POST",
"/repos/Jens/PrivateMuse/hooks",
{"type": "gitea", **payload},
)
hook_id = created.get("id") if isinstance(created, dict) else None
if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0:
raise SystemExit(36)
updated = created
if not isinstance(updated, dict):
updated = request("GET", f"/repos/Jens/PrivateMuse/hooks/{hook_id}")
config_out = updated.get("config") if isinstance(updated, dict) else None
events_out = updated.get("events") if isinstance(updated, dict) else None
if (
not isinstance(updated, dict)
or updated.get("id") != hook_id
or updated.get("active") is not True
or str(updated.get("branch_filter") or "") != "main"
or not isinstance(config_out, dict)
or config_out.get("url") != endpoint
or str(config_out.get("content_type") or "").lower() != "json"
or events_out != ["push"]
):
raise SystemExit(37)
# Never print the hook payload, token or secret. Hook id is non-sensitive
# evidence that the bounded Gitea object exists.
print(f"privatemuse_runnerless_webhook_ready hook_id={hook_id}", flush=True)
"""
class PrivateMuseWebhookRepair:
"""Repair only the fixed PrivateMuse runnerless repository webhook."""
def __init__(self, portainer: PortainerConnector | None = None):
self.portainer = portainer or PortainerConnector()
def run(self) -> dict[str, Any]:
status = self.portainer.get_container_status(_CONTROLLER)
controller = (
dict(status.get("container") or {})
if status.get("status") == "ok"
else {}
)
container_id = str(controller.get("id") or "")
config = self.portainer._config()
if (
controller.get("name") != _CONTROLLER
or controller.get("state") != "running"
or not container_id
or not config
):
return {
"status": "controller_unavailable",
"reason": "deployment controller is unavailable",
}
endpoint = quote(str(config.endpoint_id), safe="")
safe_container = quote(container_id, safe="")
command = ["python3", "-c", _REPAIR_SCRIPT, _REPOSITORY, _ENDPOINT]
try:
created = self.portainer._request(
"POST",
f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec",
json={
"AttachStdout": False,
"AttachStderr": False,
"Tty": False,
"Cmd": command,
},
).json()
exec_id = str(created.get("Id") or "")
if not exec_id:
return {
"status": "error",
"reason": "PrivateMuse webhook repair could not start",
}
safe_exec = quote(exec_id, safe="")
self.portainer._request(
"POST",
f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start",
json={"Detach": True, "Tty": False},
)
deadline = time.monotonic() + 60
exit_code: int | None = None
while time.monotonic() < deadline:
inspected = self.portainer._request(
"GET",
f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json",
).json()
if not bool(inspected.get("Running")):
raw_exit = inspected.get("ExitCode")
exit_code = int(raw_exit) if raw_exit is not None else -1
break
time.sleep(0.25)
except (RuntimeError, ValueError, TypeError) as exc:
return {"status": "error", "reason": redact_message(str(exc))}
if exit_code is None:
return {
"status": "outcome_unknown",
"reason": "PrivateMuse webhook repair timed out; inspect before retrying",
}
if exit_code == 0:
return {
"status": "repaired",
"repository": _REPOSITORY,
"endpoint": _ENDPOINT,
"branch": "main",
"event": "push",
}
reasons = {
20: "required controller/Gitea file is unavailable or unsafe",
21: "required repair file is not owned by the controller user",
22: "required credential file permissions are too broad",
23: "deployment projects registry is invalid",
24: "PrivateMuse runnerless target metadata differs from the fixed contract",
25: "PrivateMuse controller webhook secret is invalid",
26: "Gitea admin credential is unavailable or invalid",
27: "reserved legacy Gitea config validation failure",
28: "Gitea cannot reach the fixed private deployment controller endpoint",
29: "Private deployment controller health identity is invalid or unavailable",
30: "bounded Gitea API request failed",
31: "Gitea returned an invalid JSON response",
32: "Gitea repository identity or default branch differs from the fixed contract",
33: "Gitea webhook inventory is invalid",
34: "PrivateMuse has ambiguous or stale runnerless webhook endpoints",
35: "existing PrivateMuse runnerless hook id is invalid",
36: "Gitea did not return a valid hook after creation",
37: "PrivateMuse runnerless hook verification failed after repair",
40: "fixed PrivateMuse webhook repair contract was altered",
}
return {
"status": "validation_failed",
"reason": reasons.get(
exit_code,
"PrivateMuse runnerless webhook repair failed closed",
),
}