diff --git a/README.md b/README.md index 1143188..bc3fd5c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,14 @@ 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`. +registers the webhook idempotently at +`http://mars.orbit.sol:8645/gitea/gitea-pr-comments`. + +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. 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 diff --git a/services/dev/gitea-hermes-webhook-relay-test.py b/services/dev/gitea-hermes-webhook-relay-test.py index d5e42b6..73fe1ca 100644 --- a/services/dev/gitea-hermes-webhook-relay-test.py +++ b/services/dev/gitea-hermes-webhook-relay-test.py @@ -39,7 +39,8 @@ 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"} + "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) @@ -49,8 +50,8 @@ for _ in range(50): 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, +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) @@ -90,7 +91,8 @@ check("signature still valid over forwarded body", 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") +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() @@ -120,6 +122,38 @@ check("normal-size body still ok", st == 200) 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)}") diff --git a/services/dev/gitea-hermes-webhook-relay.nix b/services/dev/gitea-hermes-webhook-relay.nix index f482138..db2cd84 100644 --- a/services/dev/gitea-hermes-webhook-relay.nix +++ b/services/dev/gitea-hermes-webhook-relay.nix @@ -42,7 +42,11 @@ in environment = { LISTEN_HOST = "0.0.0.0"; LISTEN_PORT = "8645"; - HERMES_WEBHOOK_URL = "http://127.0.0.1:8644/webhooks/gitea-events"; + # 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"; }; @@ -76,12 +80,19 @@ in # 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 either - # branching inside the prompt on {action}/{issue.number}, or adding a second - # subscription (and a second relay URL) for the other events. The Gitea-side - # hook still sends the full event set at the relay; Hermes drops the + # 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 @@ -127,8 +138,12 @@ in sleep 1 done - podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true + # gitea-events is the old name of this route (renamed to say what it + # actually handles); removing it keeps a redeployed host from serving + # both. The second remove is the idempotency step for the subscribe + # below, not cleanup. podman exec hermes-agent hermes webhook remove gitea-events >/dev/null 2>&1 || true + 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 @@ -138,13 +153,12 @@ in 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-events \ + 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" \ - --deliver telegram --deliver-chat-id "15151223" + --prompt "$prompt" ' ''; }; diff --git a/services/dev/gitea-hermes-webhook-relay.py b/services/dev/gitea-hermes-webhook-relay.py index d77d6e0..f3f093d 100644 --- a/services/dev/gitea-hermes-webhook-relay.py +++ b/services/dev/gitea-hermes-webhook-relay.py @@ -19,6 +19,11 @@ 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 @@ -27,6 +32,7 @@ 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 @@ -36,10 +42,23 @@ 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", -) +# 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") @@ -86,6 +105,25 @@ def signature_matches(secret: bytes, body: bytes, headers) -> bool: 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" @@ -107,9 +145,12 @@ class Handler(BaseHTTPRequestHandler): self.send_json(404, {"status": "not_found"}) def do_POST(self) -> None: - if self.path not in {"/gitea", "/"}: + 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: @@ -171,7 +212,7 @@ class Handler(BaseHTTPRequestHandler): forwarded_headers["X-Gitea-Delivery"] = delivery_id request = Request( - HERMES_URL, + hermes_url, data=body, headers=forwarded_headers, method="POST", @@ -189,11 +230,12 @@ class Handler(BaseHTTPRequestHandler): return LOG.info( - "forwarded Gitea event=%s delivery=%s", + "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"}) + self.send_json(200, {"status": "forwarded", "route": route}) def main() -> None: @@ -202,7 +244,10 @@ def main() -> None: 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) + 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: diff --git a/services/dev/gitea.nix b/services/dev/gitea.nix index cb914c9..fd10e55 100644 --- a/services/dev/gitea.nix +++ b/services/dev/gitea.nix @@ -332,7 +332,17 @@ in admin_token="$(cat "$TOKEN_FILE")" secret="$(cat "$SECRET_FILE")" auth=(-H "Authorization: token $admin_token") - target="http://mars.orbit.sol:8645/gitea" + # 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" + + # Retire the pre-rename hook, which posted to the relay's bare path and + # would now double-deliver alongside $target. Matched by its EXACT old + # URL, deliberately: anything else pointing at $relay is a hook for a + # different Hermes route and must survive. + legacy_target="$relay/gitea" # Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision # above: After=gitea.service only means the process started, not that it @@ -351,7 +361,14 @@ in --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" \ + hooks="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/hooks")" + + for stale in $(printf '%s' "$hooks" \ + | jq -r --arg url "$legacy_target" '.[] | select(.type == "gitea" and .config.url == $url) | .id'); do + curl -fsS "''${auth[@]}" -X DELETE "$api/repos/darman/homelab/hooks/$stale" >/dev/null + done + + hook_id="$(printf '%s' "$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' \