diff --git a/README.md b/README.md index 8482ec4..3e139f5 100644 --- a/README.md +++ b/README.md @@ -37,58 +37,27 @@ 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 +## Gitea events to Hermes -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/gitea-pr-comments`. +Jupiter's Gitea registers a webhook straight at Hermes on mars, +`http://mars.orbit.sol:8644/webhooks/gitea-pr-comments`, with no relay in +between. Gitea's `addDefaultHeaders` signs every webhook type with +`X-Hub-Signature-256` in GitHub's exact format and sends `X-GitHub-Event` +unconditionally — which is exactly what Hermes validates against the +subscription secret and reads the event name from, so the two speak the same +protocol without translation. The URL path is the Hermes route name, so +another subscription is just another hook. -The path after `/gitea/` names the Hermes route to forward into, so the relay -is not tied to any one subscription: another Hermes route needs a -`hermes webhook subscribe ` and a Gitea hook pointing at -`/gitea/`, and no relay change. Route names are validated against a -strict charset before being used in the outbound URL. +Gitea will only deliver to hosts in `[security] ALLOWED_HOST_LIST`, which +defaults to `external` and does NOT include tailnet addresses +(100.64.0.0/10 is RFC 6598 carrier-grade NAT, neither private nor external as +gitea classifies it). `services/dev/gitea.nix` sets it accordingly; without +that, deliveries fail with `webhook can only call allowed HTTP servers`. -Neither provisioning unit deletes anything: Jupiter's only creates or updates -its own hook, and Mars's only removes the route it is about to re-subscribe. -Retiring the pre-rename `gitea-events` route is therefore a one-off, done by -hand after the first deploy of both hosts: - -``` -# on mars — drop the old subscription (`hermes` is the alias in common.nix) -hermes webhook remove gitea-events - -# on jupiter — delete the old hook (it posts to the relay's bare /gitea path) -api=http://127.0.0.1:3000/api/v1; repo=darman/homelab -auth=(-H "Authorization: token $(sudo cat /run/secrets/gitea_provisioning_token)") -for id in $(curl -fsS "${auth[@]}" "$api/repos/$repo/hooks" \ - | jq -r '.[] | select(.config.url == "http://mars.orbit.sol:8645/gitea") | .id'); do - curl -fsS "${auth[@]}" -X DELETE "$api/repos/$repo/hooks/$id" -done -``` - -Or just delete it in the web UI: repo Settings -> Webhooks, the entry whose -URL ends in `:8645/gitea` with no route after it. - -Check `hermes webhook list` and the repo's webhook page afterwards; until the -old hook is gone both it and the new one fire, so events arrive twice. - -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). +The route's prompt and its filter script live in `hosts/mars/`, bind-mounted +read-only from the nix store so the agent cannot edit its own loop guard out, +and are re-subscribed by `hermes-agent-webhook-route` on every start. Run +`python3 hosts/mars/gitea-pr-comment-filter-test.py` after editing the filter. Before deploying either host, add the same random `gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and diff --git a/hosts/mars/configuration.nix b/hosts/mars/configuration.nix index 1f041f3..13a0d3f 100644 --- a/hosts/mars/configuration.nix +++ b/hosts/mars/configuration.nix @@ -12,7 +12,6 @@ ../../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/hermes-agent.nix b/hosts/mars/hermes-agent.nix index a6f4238..731beb2 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -303,4 +303,87 @@ in requires = [ "hermes-agent-prepare-dirs.service" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; }; + + # The Gitea PR-comment route. Gitea posts straight here (jupiter's + # gitea-hermes-webhook-provision registers the hook at + # http://mars.orbit.sol:8644/webhooks/gitea-pr-comments) -- there is no relay + # in between. Gitea's addDefaultHeaders sends X-Hub-Signature-256 in GitHub's + # exact format AND X-GitHub-Event, unconditionally, for every webhook type, + # which is precisely what Hermes validates and reads the event name from. + # + # --events pull_request_comment narrows the route to the one event the prompt + # handles; Gitea sends that value distinctly from issue_comment, so plain + # issue comments never reach the agent. A route carries exactly one prompt, + # so another event means either branching on {action} in the prompt or a + # second subscription plus a second Gitea hook at /webhooks/. + # + # No --deliver: it defaults to `log`. The prompt tells her to answer in the + # pull request, so the PR comment IS the delivery. + # + # --script is the selection that MUST NOT be retunable at runtime. + # gitea-pr-comment-filter.py drops luna's own comments before any LLM call, + # which is what stops the reply loop: the prompt tells her to answer on the + # PR, and her answer is itself a pull_request_comment. Both it and the prompt + # are bind-mounted read-only from the store above so the agent cannot edit + # its own guard out. Hermes resolves both names relative to ~/.hermes, hence + # the bare filename. + # + # What read-only does NOT buy: it protects the sources, and this unit + # re-subscribes from them on every start, so a restart restores the intended + # prompt, filter and event list. The live subscription lives in + # webhook_subscriptions.json under /opt/data and is hot-reloaded, which is + # inside the agent's own write-safe root -- a self-modification sticks until + # this unit next runs. + # + # The secret comes from the CONTAINER's environment, injected via + # sops.templates."hermes-agent.env", which is why 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 PR-comment 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 + + # Idempotency for the subscribe below, not cleanup: this removes only the + # route this unit owns. Retiring an old route is a one-off done by hand, + # so that a redeploy never silently deletes one added on purpose. + podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true + + # `set -eu` plus both emptiness checks are load-bearing. Without them a + # missing prompt file or an unset secret yields an empty string, and the + # subscription is created with an empty prompt or -- worse -- an empty + # secret, which silently fails EVERY delivery signature check afterwards + # while the unit still looks healthy. Fail loudly here instead. + podman exec hermes-agent sh -c ' + set -eu + [ -n "''${GITEA_HERMES_WEBHOOK_SECRET:-}" ] || { + echo "GITEA_HERMES_WEBHOOK_SECRET is unset in the container" >&2; exit 1; } + prompt="$(cat /opt/data/prompts/gitea-pr-comment.md)" + [ -n "$prompt" ] || { echo "gitea-pr-comment prompt is empty" >&2; exit 1; } + hermes webhook subscribe gitea-pr-comments \ + --secret "$GITEA_HERMES_WEBHOOK_SECRET" \ + --description "Gitea PR comments -> L.U.N.A." \ + --events pull_request_comment \ + --script gitea-pr-comment-filter.py \ + --prompt "$prompt" + ' + ''; + }; } diff --git a/hosts/mars/secrets.nix b/hosts/mars/secrets.nix index ec7d1dd..c867491 100644 --- a/hosts/mars/secrets.nix +++ b/hosts/mars/secrets.nix @@ -40,11 +40,12 @@ # 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. + # container) subscribes with an empty secret — every delivery then fails + # signature validation inside Hermes with no obvious cause. That unit now + # refuses to subscribe on an unset secret rather than doing it quietly, but + # the ordering here is still what makes the rotation correct. sops.secrets.gitea_hermes_webhook_secret = { restartUnits = [ - "gitea-hermes-webhook-relay.service" "podman-hermes-agent.service" "hermes-agent-webhook-route.service" ]; diff --git a/services/dev/gitea-hermes-webhook-relay-test.py b/services/dev/gitea-hermes-webhook-relay-test.py deleted file mode 100644 index 73fe1ca..0000000 --- a/services/dev/gitea-hermes-webhook-relay-test.py +++ /dev/null @@ -1,160 +0,0 @@ -"""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_BASE": f"http://127.0.0.1:{HERMES_PORT}/webhooks", - "DEFAULT_ROUTE": "gitea-pr-comments"} -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, path="/gitea"): - req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}{path}", 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("bare /gitea uses DEFAULT_ROUTE", fwd["path"] == "/webhooks/gitea-pr-comments", - f"got {fwd['path']}") - -# 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) - -# 7. route travels in the path: /gitea/ -> /webhooks/ -hdrs = {"Content-Type": "application/json", "X-Gitea-Event": "push", - "X-Hub-Signature-256": "sha256=" + sig} -for route in ("gitea-pr-comments", "some-other_route.v2", "a"): - received.clear() - st, resp = post(payload, hdrs, path=f"/gitea/{route}") - check(f"route {route!r} forwarded to /webhooks/{route}", - st == 200 and received and received[0]["path"] == f"/webhooks/{route}", - f"status={st} path={received[0]['path'] if received else None}") - check(f"route {route!r} echoed in response", resp.get("route") == route, f"got {resp}") - -# 8. route validation — these must never reach Hermes at all -for bad, label in [ - ("../admin", "parent-dir traversal"), - ("..%2fadmin", "encoded traversal"), - ("..", "bare .."), - (".", "bare ."), - (".hidden", "leading dot"), - ("-dash", "leading dash"), - ("route%20name", "percent-encoded space"), - ("route/extra", "embedded slash"), - ("x" * 65, "over length limit"), -]: - received.clear() - st, _ = post(payload, hdrs, path=f"/gitea/{bad}") - check(f"rejects {label}", st == 404 and not received, - f"status={st} forwarded={len(received)}") - -received.clear() -st, _ = post(payload, hdrs, path="/webhooks/gitea-pr-comments") -check("rejects non-/gitea prefix", st == 404 and not received, f"status={st}") - -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 deleted file mode 100644 index d47dfbe..0000000 --- a/services/dev/gitea-hermes-webhook-relay.nix +++ /dev/null @@ -1,164 +0,0 @@ -{ 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"; - # Base only. The Hermes route rides in the request path - # (/gitea/), so this relay is not tied to any one subscription; - # DEFAULT_ROUTE only serves the legacy bare /gitea path. - HERMES_WEBHOOK_BASE = "http://127.0.0.1:8644/webhooks"; - DEFAULT_ROUTE = "gitea-pr-comments"; - 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 pull_request_comment` narrows this route to the one event the - # prompt below actually knows how to handle. It works only because the relay - # supplies X-GitHub-Event — see the header comment above; without that every - # delivery would arrive as "unknown" and match nothing. Gitea sends - # pull_request_comment as a value distinct from issue_comment, so plain issue - # comments do not reach the agent. - # - # A route carries exactly one prompt, so widening this list means branching - # inside the prompt on {action}, or adding a second subscription. The second - # subscription is cheap now: the relay takes its target route from the - # request path, so it is a new `hermes webhook subscribe ` plus a - # Gitea hook pointing at /gitea/, with no relay change at all. The - # Gitea-side hook still sends the full event set; Hermes drops the - # non-matching ones cheaply, before any LLM call. - # - # No --deliver: it defaults to `log`. The prompt tells her to answer in the - # pull request, so the PR comment IS the delivery, and a Telegram copy would - # just duplicate it. This also drops the hardcoded chat id that used to be a - # third copy of TELEGRAM_HOME_CHANNEL. - # - # --script does the selection that MUST NOT be retunable at runtime. - # hosts/mars/gitea-pr-comment-filter.py drops luna's own comments before - # any LLM call, which is what stops the reply loop: the prompt tells her to - # answer on the PR, and her answer is itself a pull_request_comment. It is - # bind-mounted read-only from the nix store (see hosts/mars/hermes-agent.nix) - # so the agent cannot edit its own guard out. Hermes resolves the name - # relative to ~/.hermes/scripts, hence the bare filename here. - # - # The prompt is read from a read-only mount rather than passed inline: see - # hosts/mars/gitea-pr-comment-prompt.md and the mounts in hermes-agent.nix. - # Note what read-only does and does not buy. It protects the SOURCES, and - # this unit re-subscribes from them on every start, so a restart restores - # the intended prompt, filter and event list. It does not make the live - # subscription immutable: Hermes stores it in webhook_subscriptions.json - # under /opt/data and hot-reloads it, which is inside the agent's own - # write-safe root. A self-modification would therefore stick until the next - # restart of this unit. - # - # 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 - - # Idempotency for the subscribe below, not cleanup: this removes only - # the route this unit owns. The pre-rename gitea-events subscription is - # left alone — retiring it is a one-off migration done by hand, so that - # a redeploy never silently deletes a route someone added on purpose. - podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true - # `set -eu` inside the container shell is load-bearing: without it a - # missing prompt file makes `cat` fail, the command substitution yields - # an empty string, and the subscription is created with an EMPTY prompt - # -- a silent failure that looks like a healthy unit. Fail loudly here - # instead so the oneshot goes red. - podman exec hermes-agent sh -c ' - set -eu - prompt="$(cat /opt/data/prompts/gitea-pr-comment.md)" - [ -n "$prompt" ] || { echo "gitea-pr-comment prompt is empty" >&2; exit 1; } - hermes webhook subscribe gitea-pr-comments \ - --secret "$GITEA_HERMES_WEBHOOK_SECRET" \ - --description "Gitea PR comments -> L.U.N.A." \ - --events pull_request_comment \ - --script gitea-pr-comment-filter.py \ - --prompt "$prompt" - ' - ''; - }; -} diff --git a/services/dev/gitea-hermes-webhook-relay.py b/services/dev/gitea-hermes-webhook-relay.py deleted file mode 100644 index f3f093d..0000000 --- a/services/dev/gitea-hermes-webhook-relay.py +++ /dev/null @@ -1,260 +0,0 @@ -#!/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. - -The target Hermes route travels in the request path (POST /gitea/ -> -POST /webhooks/) rather than being configured here, so one relay -serves every subscription and adding a Hermes route means adding a Gitea hook -URL, nothing more. -""" -from __future__ import annotations - -import hashlib -import hmac -import json -import logging -import os -import re -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")) -# The Hermes route is taken from the request path (POST /gitea/), not -# baked in here, so one relay serves every subscription: a new Hermes route -# needs a new Gitea hook URL and nothing else. HERMES_WEBHOOK_BASE is the -# prefix the route name is appended to; DEFAULT_ROUTE serves the legacy bare -# /gitea and / paths. -HERMES_WEBHOOK_BASE = os.environ.get( - "HERMES_WEBHOOK_BASE", - "http://127.0.0.1:8644/webhooks", -).rstrip("/") -DEFAULT_ROUTE = os.environ.get("DEFAULT_ROUTE", "gitea-pr-comments") - -# The route name is interpolated into an outbound URL, so it is validated -# strictly rather than sanitised: anything outside this charset is refused -# instead of being cleaned up. This is what stops POST /gitea/..%2fadmin (or -# any other traversal) from steering the relay at a different Hermes endpoint. -# Leading character must be alphanumeric, which also rejects "." and "..". -ROUTE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") -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 - - -def route_from_path(path: str) -> str | None: - """Map a request path to a Hermes route name, or None if it is not ours. - - /gitea/ -> ; /gitea and / -> DEFAULT_ROUTE. - The path is matched raw, never URL-decoded, so percent-encoded separators - fail the charset check rather than surviving it. - """ - path = path.split("?", 1)[0].split("#", 1)[0] - if path in ("/", "/gitea"): - return DEFAULT_ROUTE - prefix = "/gitea/" - if not path.startswith(prefix): - return None - route = path[len(prefix):].rstrip("/") - if not ROUTE_RE.fullmatch(route): - return None - return route - - -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: - route = route_from_path(self.path) - if route is None: - LOG.warning("rejected POST to unroutable path %r", self.path) - self.send_json(404, {"status": "not_found"}) - return - hermes_url = f"{HERMES_WEBHOOK_BASE}/{route}" - - 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 to route=%s", - gitea_event or gitea_event_type or "unknown", - delivery_id or "none", - route, - ) - self.send_json(200, {"status": "forwarded", "route": route}) - - -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/ (default route %s)", - LISTEN_HOST, LISTEN_PORT, HERMES_WEBHOOK_BASE, DEFAULT_ROUTE, - ) - 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 9b929e2..0b5c51b 100644 --- a/services/dev/gitea.nix +++ b/services/dev/gitea.nix @@ -21,8 +21,10 @@ let # 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. + # Send every Gitea event to Hermes on mars. Hermes owns the decision about + # which events matter and what to do with them: its route filters on + # X-GitHub-Event and drops the rest before any LLM call, so narrowing this + # list would only move that policy to the wrong side of the wire. giteaWebhookEvents = [ "create" "delete" @@ -83,7 +85,7 @@ in # Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT), # which is neither RFC1918 private nor, as far as gitea's matcher is # concerned, external — so the hermes relay on mars was refused with - # deny 'mars.orbit.sol(100.64.0.6:8645)' + # deny 'mars.orbit.sol(100.64.0.6:8644)' # even though nothing here is private in the RFC1918 sense. Adding # the tailnet CIDR is what makes tailnet-internal webhook targets # deliverable at all; `external` is kept so a future webhook to a @@ -367,11 +369,13 @@ in admin_token="$(cat "$TOKEN_FILE")" secret="$(cat "$SECRET_FILE")" auth=(-H "Authorization: token $admin_token") - # The path carries the Hermes route the relay should forward into, so - # each Hermes subscription gets its own hook here and the relay itself - # stays generic. Adding one is a new subscribe + a new hook URL. - relay="http://mars.orbit.sol:8645" - target="$relay/gitea/gitea-pr-comments" + # Straight at Hermes's own webhook listener on mars, no relay in + # between: gitea signs every webhook type with X-Hub-Signature-256 in + # GitHub's exact format and sends X-GitHub-Event unconditionally, which + # is exactly what Hermes validates and reads the event name from. The + # path is the Hermes route name, so a second subscription is just a + # second hook here. + target="http://mars.orbit.sol:8644/webhooks/gitea-pr-comments" # This unit only ever creates or updates $target. It deliberately does # NOT delete anything, including the pre-rename hook on the relay's bare