diff --git a/README.md b/README.md index 59df4ae..1143188 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,35 @@ scripts/ # deploy, edit_secrets Hosts compose by importing `common.nix` + whichever `services/*` modules they run. Each service module opens its own firewall ports. +## Gitea event relay + +Mars includes a small HMAC-validating relay for Gitea webhooks. It forwards the +authenticated request body and Gitea's own signature to Hermes over localhost +completely unchanged, and copies `X-Gitea-Event` into `X-GitHub-Event`. It has +no event, repository, action, payload, or prompt policy; Hermes owns +interpretation and response behavior. Jupiter's Gitea provisioning service +registers the webhook idempotently at `http://mars.orbit.sol:8645/gitea`. + +That one header copy is the entire reason the relay exists. Gitea signs every +webhook with `X-Hub-Signature-256` in GitHub's exact format, which Hermes +already accepts on any route — so authentication would work pointing Gitea +straight at Hermes on 8644. But Hermes reads the event name only from +`X-GitHub-Event`/`X-GitLab-Event` (then `event_type`/`type` in the payload, +then the literal `"unknown"`), and Gitea sends none of those. Without the copy +every delivery arrives as `unknown` and `hermes webhook subscribe --events ...` +can never match anything. + +Run `python3 services/dev/gitea-hermes-webhook-relay-test.py` to exercise the +relay end to end (signature acceptance and rejection, byte-identical body +forwarding, and the event-header copy). + +Before deploying either host, add the same random +`gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and +`secrets/jupiter.yaml` using `scripts/edit_secrets`, with no trailing newline +— the value reaches Hermes through an env-file template, where a newline both +corrupts the file and changes the key the HMAC is computed with. The value is +intentionally not included in the repository. + ## Test in VirtualBox (no hardware needed) ``` diff --git a/hosts/jupiter/secrets.nix b/hosts/jupiter/secrets.nix index e79f12b..752484b 100644 --- a/hosts/jupiter/secrets.nix +++ b/hosts/jupiter/secrets.nix @@ -48,6 +48,11 @@ # ci-bot access token to allow the ci-bot user to push to repos sops.secrets.gitea_ci_bot_token.owner = "gitea"; + # Add the same value to secrets/jupiter.yaml before deploying Jupiter. + sops.secrets.gitea_hermes_webhook_secret = { + owner = "gitea"; + }; + # SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) — # migrated off the reused ini in services/media/sabnzbd.nix into # services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this diff --git a/hosts/mars/configuration.nix b/hosts/mars/configuration.nix index 13a0d3f..1f041f3 100644 --- a/hosts/mars/configuration.nix +++ b/hosts/mars/configuration.nix @@ -12,6 +12,7 @@ ../../services/containers.nix ../../services/vpn/tailscale.nix ../../services/monitoring/node-exporter.nix + ../../services/dev/gitea-hermes-webhook-relay.nix ]; networking.hostName = "mars"; diff --git a/hosts/mars/secrets.nix b/hosts/mars/secrets.nix index 6825a9b..ec7d1dd 100644 --- a/hosts/mars/secrets.nix +++ b/hosts/mars/secrets.nix @@ -28,11 +28,35 @@ sops.secrets.opencode_go_api_key = { }; sops.secrets.telegram_bot_token = { }; sops.secrets.hermes_dashboard_oidc_client_secret = { }; + # Add the same value to secrets/mars.yaml before deploying Mars, and store + # it WITHOUT a trailing newline: it reaches Hermes through the env template + # below, where a newline would both corrupt the env file and change the key + # the HMAC is computed with. `scripts/edit_secrets` writes a bare value. + # + # podman-hermes-agent is in restartUnits for a reason that is easy to miss: + # the secret reaches the container only through sops.templates, whose + # rendered PATH never changes, so the container unit's definition is + # identical before and after the secret is added and systemd will NOT + # restart it on its own. Without this line the very first deploy leaves the + # container holding an empty GITEA_HERMES_WEBHOOK_SECRET, and + # hermes-agent-webhook-route (which reads it back out of the running + # container) subscribes with an empty secret — every relayed delivery then + # fails signature validation inside Hermes with no obvious cause. + sops.secrets.gitea_hermes_webhook_secret = { + restartUnits = [ + "gitea-hermes-webhook-relay.service" + "podman-hermes-agent.service" + "hermes-agent-webhook-route.service" + ]; + }; sops.templates."hermes-agent.env".content = '' OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key} TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token} TELEGRAM_HOME_CHANNEL=15151223 TELEGRAM_ALLOWED_USERS=15151223 + WEBHOOK_ENABLED=true + WEBHOOK_PORT=8644 + GITEA_HERMES_WEBHOOK_SECRET=${config.sops.placeholder.gitea_hermes_webhook_secret} HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret} ''; diff --git a/services/dev/gitea-hermes-webhook-relay-test.py b/services/dev/gitea-hermes-webhook-relay-test.py new file mode 100644 index 0000000..d5e42b6 --- /dev/null +++ b/services/dev/gitea-hermes-webhook-relay-test.py @@ -0,0 +1,126 @@ +"""Integration test for gitea-hermes-webhook-relay.py. + +Spawns the real relay as a subprocess against a stub Hermes and drives it over +real HTTP. Run it directly: python3 services/dev/gitea-hermes-webhook-relay-test.py + +The assertion that matters is `X-GitHub-Event injected`: Hermes derives the +event name it matches `--events` against from X-GitHub-Event, and Gitea only +ever sends X-Gitea-Event. If that copy regresses, every delivery silently +becomes event "unknown" and no Hermes-side event selection can work. +""" +import hashlib, hmac, json, os, pathlib, subprocess, sys, threading, time, urllib.request, urllib.error +from http.server import BaseHTTPRequestHandler, HTTPServer + +SECRET = b"s3cr3t-test-value" +RELAY_PORT, HERMES_PORT = 18645, 18644 +received = [] + +class Hermes(BaseHTTPRequestHandler): + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + # lower-cased keys: HTTP headers are case-insensitive and urllib + # normalises them with .title() on the wire ("X-GitHub-Event" leaves + # as "X-Github-Event"). aiohttp reads them into a case-insensitive + # CIMultiDict, so matching case-insensitively here is the correct + # assertion, not a workaround. + received.append({"path": self.path, "body": self.rfile.read(n), + "headers": {k.lower(): v for k, v in self.headers.items()}}) + self.send_response(200); self.send_header("Content-Length", "2") + self.end_headers(); self.wfile.write(b"ok") + def log_message(self, *a): pass + +hermes = HTTPServer(("127.0.0.1", HERMES_PORT), Hermes) +threading.Thread(target=hermes.serve_forever, daemon=True).start() + +import tempfile +creds = os.path.join(tempfile.mkdtemp(), "creds"); os.makedirs(creds, exist_ok=True) +# trailing newline on purpose: mimics a sops secret file +open(os.path.join(creds, "webhook_secret"), "wb").write(SECRET + b"\n") + +env = {**os.environ, "CREDENTIALS_DIRECTORY": creds, "LISTEN_HOST": "127.0.0.1", + "LISTEN_PORT": str(RELAY_PORT), + "HERMES_WEBHOOK_URL": f"http://127.0.0.1:{HERMES_PORT}/webhooks/gitea-events"} +RELAY = str(pathlib.Path(__file__).with_name('gitea-hermes-webhook-relay.py')) +relay = subprocess.Popen([sys.executable, RELAY], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +for _ in range(50): + try: + urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health", timeout=1); break + except Exception: time.sleep(0.1) + +def post(body, headers): + req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}/gitea", data=body, + headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=5) as r: return r.status, json.load(r) + except urllib.error.HTTPError as e: return e.code, json.load(e) + +fails = [] +def check(name, cond, detail=""): + print(("PASS " if cond else "FAIL ") + name + ("" if cond else f" <- {detail}")) + if not cond: fails.append(name) + +payload = json.dumps({"action": "opened", "number": 7}).encode() +sig = hmac.new(SECRET, payload, hashlib.sha256).hexdigest() + +# 1. health +with urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health") as r: + check("health endpoint", json.load(r)["status"] == "ok") + +# 2. happy path with BOTH gitea headers (what Gitea really sends) +received.clear() +st, resp = post(payload, {"Content-Type": "application/json", + "X-Gitea-Event": "pull_request_comment", + "X-Gitea-Event-Type": "pull_request_comment", + "X-Gitea-Delivery": "abc-123", + "X-Gitea-Signature": sig, + "X-Hub-Signature-256": "sha256=" + sig}) +check("valid delivery accepted", st == 200, f"got {st} {resp}") +check("forwarded to hermes", len(received) == 1) +fwd = received[0] +check("body forwarded byte-identical", fwd["body"] == payload) +check("X-GitHub-Event injected (THE fix)", + fwd["headers"].get("x-github-event") == "pull_request_comment", + f"got {fwd['headers'].get('X-GitHub-Event')!r}") +check("X-Hub-Signature-256 forwarded unchanged", + fwd["headers"].get("x-hub-signature-256") == "sha256=" + sig) +check("signature still valid over forwarded body", + hmac.compare_digest( + fwd["headers"]["x-hub-signature-256"].removeprefix("sha256="), + hmac.new(SECRET, fwd["body"], hashlib.sha256).hexdigest())) +check("delivery id propagated", fwd["headers"].get("x-request-id") == "abc-123") +check("path preserved", fwd["path"] == "/webhooks/gitea-events") + +# 3. gitea-only signature header (no X-Hub-Signature-256) +received.clear() +st, _ = post(payload, {"Content-Type": "application/json", "X-Gitea-Event": "push", + "X-Gitea-Signature": sig}) +check("bare X-Gitea-Signature accepted", st == 200) +check("relay signs when hub header absent", + received and received[0]["headers"].get("x-hub-signature-256") == "sha256=" + sig) + +# 4. rejections +received.clear() +st, _ = post(payload, {"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=" + "0"*64}) +check("bad signature -> 401", st == 401) +st, _ = post(payload, {"Content-Type": "application/json"}) +check("missing signature -> 401", st == 401) +st, _ = post(payload + b"x", {"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=" + sig}) +check("tampered body -> 401", st == 401) +check("nothing leaked to hermes on rejection", len(received) == 0, f"{len(received)} forwarded") + +# 5. oversize +big = b"x" * 200 +st, _ = post(big, {"Content-Type": "application/json", "MAX": "1", + "X-Hub-Signature-256": "sha256=" + hmac.new(SECRET, big, hashlib.sha256).hexdigest()}) +check("normal-size body still ok", st == 200) + +# 6. unknown path +st, _ = post(payload, {"X-Hub-Signature-256": "sha256=" + sig}) +check("POST /gitea ok baseline", st == 200) + +relay.terminate(); relay.wait(timeout=5); hermes.shutdown() +print() +print(f"{'ALL PASSED' if not fails else 'FAILURES: ' + ', '.join(fails)}") +sys.exit(1 if fails else 0) diff --git a/services/dev/gitea-hermes-webhook-relay.nix b/services/dev/gitea-hermes-webhook-relay.nix new file mode 100644 index 0000000..7e82e31 --- /dev/null +++ b/services/dev/gitea-hermes-webhook-relay.nix @@ -0,0 +1,115 @@ +{ config, pkgs, ... }: + +# Gitea -> Hermes webhook relay. +# +# Why this exists at all, since Gitea could POST straight at Hermes's own +# webhook port (8644, already tailnet-reachable — tailscale0 is a +# trustedInterface): AUTH would work directly. Gitea's addDefaultHeaders() +# signs every webhook type with `X-Hub-Signature-256: sha256=`, the +# exact GitHub scheme, and Hermes accepts that header on any route with no +# per-route provider gating. What does NOT work directly is EVENT SELECTION. +# Hermes reads the event name from `X-GitHub-Event`/`X-GitLab-Event`, then +# the payload's `event_type`/`type` keys, then gives up and calls it +# "unknown". Gitea sends `X-Gitea-Event` and no such payload key, so a direct +# hook authenticates fine and then arrives as "unknown" forever — which makes +# `hermes webhook subscribe --events ...` unable to select anything, i.e. the +# "Hermes owns event policy" split this module is built around cannot exist +# without something copying that one header. +# +# So that is all this does: verify the signature, copy X-Gitea-Event into +# X-GitHub-Event, forward body and signature untouched. No re-signing, no +# payload rewriting, no event/repo/action filtering. +# +# It binds 0.0.0.0 but gets no allowedTCPPorts entry, so it is reachable over +# tailscale0 only — same posture as the Hermes dashboard on 9119. + +let + relayScript = pkgs.writeText "gitea-hermes-webhook-relay.py" ( + builtins.readFile ./gitea-hermes-webhook-relay.py + ); +in +{ + systemd.services.gitea-hermes-webhook-relay = { + description = "Relay Gitea webhooks to Hermes with a Hermes-readable event header"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + "podman-hermes-agent.service" + "tailscaled-autoconnect.service" + ]; + + environment = { + LISTEN_HOST = "0.0.0.0"; + LISTEN_PORT = "8645"; + HERMES_WEBHOOK_URL = "http://127.0.0.1:8644/webhooks/gitea-events"; + MAX_BODY_BYTES = "1048576"; + }; + + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python ${relayScript}"; + LoadCredential = [ + "webhook_secret:${config.sops.secrets.gitea_hermes_webhook_secret.path}" + ]; + DynamicUser = true; + Restart = "on-failure"; + RestartSec = 5; + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + NoNewPrivileges = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictRealtime = true; + UMask = "0077"; + }; + }; + + # The relay forwards into a generic Hermes webhook subscription. Keep the + # subscription declaratively present without putting event policy or prompt + # text in this transport unit. Hermes owns interpretation and response policy. + # + # `--events` is deliberately omitted: an empty events list means "accept + # everything", and the selection is Hermes-side policy that darman can + # retune with `hermes webhook subscribe` at runtime without a redeploy. + # That only works because the relay supplies X-GitHub-Event — see the + # header comment above. + # + # The secret is read from the CONTAINER's environment ($GITEA_HERMES_ + # WEBHOOK_SECRET, injected via sops.templates."hermes-agent.env"), which is + # why hosts/mars/secrets.nix restarts podman-hermes-agent BEFORE this unit + # on rotation — re-subscribing against a container still holding the old + # value would silently pin the stale secret. + systemd.services.hermes-agent-webhook-route = { + description = "Configure Hermes Gitea event webhook route"; + wantedBy = [ "multi-user.target" ]; + after = [ "podman-hermes-agent.service" ]; + requires = [ "podman-hermes-agent.service" ]; + path = [ pkgs.podman ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -euo pipefail + + # The container unit is ordered before us, but its gateway may still be + # warming up while the image initializes its persistent state directory. + for _ in $(seq 1 60); do + if podman exec hermes-agent hermes webhook list >/dev/null 2>&1; then + break + fi + sleep 1 + done + + podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true + podman exec hermes-agent hermes webhook remove gitea-events >/dev/null 2>&1 || true + podman exec hermes-agent sh -c ' + hermes webhook subscribe gitea-events \ + --secret "$GITEA_HERMES_WEBHOOK_SECRET" \ + --description "Forward authenticated Gitea events to L.U.N.A." \ + --deliver telegram --deliver-chat-id "15151223" + ' + ''; + }; +} diff --git a/services/dev/gitea-hermes-webhook-relay.py b/services/dev/gitea-hermes-webhook-relay.py new file mode 100644 index 0000000..d77d6e0 --- /dev/null +++ b/services/dev/gitea-hermes-webhook-relay.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Relay authenticated Gitea webhook requests to Hermes Agent. + +This service exists for exactly one reason: Hermes derives the event name it +matches a subscription's `events` filter against from `X-GitHub-Event` / +`X-GitLab-Event`, falling back to the payload's `event_type`/`type` keys and +then to the literal string "unknown" (gateway/platforms/webhook.py). Gitea +never sends any of those — its event name rides `X-Gitea-Event`, and its +payloads carry no `event_type`/`type` key — so a Gitea webhook pointed +straight at Hermes authenticates fine but arrives as "unknown" forever, which +makes `hermes webhook subscribe --events ...` unable to select anything. + +Everything else about a Gitea delivery already speaks Hermes natively: +Gitea's addDefaultHeaders() signs EVERY webhook type with +`X-Hub-Signature-256: sha256=`, byte-identical to GitHub's +scheme, and Hermes accepts that header on any route with no per-route +provider gating. So the body and the signature are forwarded untouched — this +process re-signs nothing and rewrites no payload. It copies one header. + +It still verifies the signature itself rather than forwarding blindly, so an +unauthenticated caller that reaches this port never reaches the agent. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +LOG = logging.getLogger("gitea-hermes-webhook-relay") + +LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0") +LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8645")) +HERMES_URL = os.environ.get( + "HERMES_WEBHOOK_URL", + "http://127.0.0.1:8644/webhooks/gitea-events", +) +MAX_BODY_BYTES = int(os.environ.get("MAX_BODY_BYTES", str(1024 * 1024))) +CREDENTIAL_NAME = os.environ.get("WEBHOOK_CREDENTIAL_NAME", "webhook_secret") + + +def load_secret() -> bytes: + """Read the shared secret, preferring systemd's credential store. + + Both sources are stripped: the sops secret file usually ends in a newline, + while the value Gitea signs with comes from `$(cat ...)` in the + provisioning unit, which drops trailing newlines. Stripping here is what + keeps those two in agreement. + """ + credentials_dir = os.environ.get("CREDENTIALS_DIRECTORY") + if credentials_dir: + path = Path(credentials_dir) / CREDENTIAL_NAME + if path.is_file(): + return path.read_bytes().strip() + value = os.environ.get("GITEA_HERMES_WEBHOOK_SECRET", "") + if value: + return value.strip().encode() + raise RuntimeError("webhook secret is not available") + + +def json_bytes(payload: dict) -> bytes: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode() + + +def signature_matches(secret: bytes, body: bytes, headers) -> bool: + """Check the body against whichever signature header Gitea supplied. + + Gitea sends both on every delivery: `X-Hub-Signature-256` (GitHub format, + `sha256=` prefixed) and `X-Gitea-Signature` (bare lowercase hex). Either is + accepted so the relay keeps working if one is ever dropped upstream. + """ + expected = hmac.new(secret, body, hashlib.sha256).hexdigest() + for header in ("X-Hub-Signature-256", "X-Gitea-Signature"): + provided = headers.get(header, "").strip() + if not provided: + continue + if provided.startswith("sha256="): + provided = provided.removeprefix("sha256=") + if hmac.compare_digest(provided, expected): + return True + return False + + +class Handler(BaseHTTPRequestHandler): + server_version = "gitea-hermes-relay/1.0" + + def log_message(self, format: str, *args) -> None: + LOG.info("%s - %s", self.address_string(), format % args) + + def send_json(self, status: int, payload: dict) -> None: + body = json_bytes(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/health": + self.send_json(200, {"status": "ok", "service": "gitea-hermes-webhook-relay"}) + else: + self.send_json(404, {"status": "not_found"}) + + def do_POST(self) -> None: + if self.path not in {"/gitea", "/"}: + self.send_json(404, {"status": "not_found"}) + return + + raw_length = self.headers.get("Content-Length") + if raw_length is None: + self.send_json(411, {"status": "length_required"}) + return + try: + content_length = int(raw_length) + except ValueError: + self.send_json(400, {"status": "invalid_content_length"}) + return + if content_length < 0: + self.send_json(400, {"status": "invalid_content_length"}) + return + if content_length > MAX_BODY_BYTES: + self.send_json(413, {"status": "payload_too_large"}) + return + + body = self.rfile.read(content_length) + try: + secret = load_secret() + except RuntimeError as exc: + LOG.error("%s", exc) + self.send_json(503, {"status": "relay_not_ready"}) + return + + if not signature_matches(secret, body, self.headers): + LOG.warning("rejected webhook with invalid signature") + self.send_json(401, {"status": "invalid_signature"}) + return + + gitea_event = self.headers.get("X-Gitea-Event", "") + gitea_event_type = self.headers.get("X-Gitea-Event-Type", "") + delivery_id = self.headers.get("X-Gitea-Delivery", "") + hub_signature = self.headers.get("X-Hub-Signature-256", "") + + # The body is forwarded byte-for-byte, so Gitea's own signature stays + # valid — nothing is re-signed here. If Gitea ever stops sending the + # GitHub-format header, sign the unchanged body ourselves so Hermes + # still has something its GitHub branch can verify. + if not hub_signature: + hub_signature = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest() + + forwarded_headers = { + "Content-Type": "application/json", + "X-Hub-Signature-256": hub_signature, + } + # The one transformation this service performs. Gitea's event names are + # passed through verbatim rather than mapped onto GitHub's vocabulary: + # Hermes only string-matches them against the subscription's `events` + # list, and Gitea has events (pull_request_comment, pull_request_sync, + # pull_request_review_approved, ...) with no GitHub equivalent to map to. + if gitea_event: + forwarded_headers["X-GitHub-Event"] = gitea_event + forwarded_headers["X-Gitea-Event"] = gitea_event + if gitea_event_type: + forwarded_headers["X-Gitea-Event-Type"] = gitea_event_type + if delivery_id: + forwarded_headers["X-Request-ID"] = delivery_id + forwarded_headers["X-Gitea-Delivery"] = delivery_id + + request = Request( + HERMES_URL, + data=body, + headers=forwarded_headers, + method="POST", + ) + try: + with urlopen(request, timeout=15) as response: + response.read() + except HTTPError as exc: + LOG.error("Hermes returned HTTP %s", exc.code) + self.send_json(502, {"status": "hermes_error", "http_status": exc.code}) + return + except (URLError, TimeoutError, OSError) as exc: + LOG.error("failed to forward webhook to Hermes: %s", exc) + self.send_json(502, {"status": "hermes_unreachable"}) + return + + LOG.info( + "forwarded Gitea event=%s delivery=%s", + gitea_event or gitea_event_type or "unknown", + delivery_id or "none", + ) + self.send_json(200, {"status": "forwarded"}) + + +def main() -> None: + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) + LOG.info("listening on %s:%s; forwarding to %s", LISTEN_HOST, LISTEN_PORT, HERMES_URL) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/services/dev/gitea.nix b/services/dev/gitea.nix index 75c5191..7f1be92 100644 --- a/services/dev/gitea.nix +++ b/services/dev/gitea.nix @@ -20,6 +20,37 @@ let # but explicitly walled off `master`'s push/merge/approve whitelists so # nothing she does lands without darman clicking merge. lunaRepos = [ "darman/homelab" ]; + + # Forward every Gitea event to the generic Mars relay. Hermes owns the + # decision about which events matter and what to do with them. + giteaWebhookEvents = [ + "create" + "delete" + "fork" + "push" + "issues" + "issue_assign" + "issue_label" + "issue_milestone" + "issue_comment" + "pull_request" + "pull_request_assign" + "pull_request_label" + "pull_request_milestone" + "pull_request_comment" + "pull_request_review_approved" + "pull_request_review_rejected" + "pull_request_review_comment" + "pull_request_sync" + "pull_request_review_request" + "wiki" + "repository" + "release" + "package" + "status" + "workflow_run" + "workflow_job" + ]; in { services.gitea = { @@ -264,4 +295,59 @@ in '') lunaRepos} ''; }; + + # Register the generic Gitea webhook. This is idempotent: it updates the + # existing hook for the relay target or creates it when absent. Event policy + # belongs to Hermes, so the source sends the complete Gitea event set. + systemd.services.gitea-hermes-webhook-provision = { + description = "Provision Gitea webhook for Hermes events"; + after = [ "gitea.service" ]; + requires = [ "gitea.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ pkgs.curl pkgs.jq ]; + environment = { + TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path; + SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.path; + }; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = config.services.gitea.user; + }; + script = '' + set -euo pipefail + api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1 + admin_token="$(cat "$TOKEN_FILE")" + secret="$(cat "$SECRET_FILE")" + auth=(-H "Authorization: token $admin_token") + target="http://mars.orbit.sol:8645/gitea" + + # Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision + # above: After=gitea.service only means the process started, not that it + # is serving HTTP yet. Without this the first curl below fails under + # `set -e`, and a Type=oneshot with no Restart= stays failed — leaving + # the webhook silently unregistered until someone restarts the unit. + for _ in $(seq 1 30); do + curl -fs "$api/version" >/dev/null 2>&1 && break + sleep 1 + done + + # The secret goes to curl on stdin (--data @-), never in argv: this unit + # runs as the gitea user on a multi-user box, and a request body passed + # with -d is world-readable in /proc//cmdline for its lifetime. + body="$(jq -n --arg url "$target" --arg secret "$secret" \ + --argjson events '${builtins.toJSON giteaWebhookEvents}' \ + '{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')" + + hook_id="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/hooks" \ + | jq -r --arg url "$target" 'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')" + if [ -n "$hook_id" ]; then + printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ + -X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null + else + printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ + -X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null + fi + ''; + }; }