relay: take the Hermes route from the request path

Renames the subscription to gitea-pr-comments (it handles one event; the old
gitea-events name promised more than it delivered) and drops --deliver.

Rather than move the hardcoded route from one constant to another, the relay
now reads it from the request path: POST /gitea/<route> forwards to
<base>/webhooks/<route>. The route name was the last thing tying this service
to a specific subscription, so a second Hermes route is now a `hermes webhook
subscribe <name>` plus a Gitea hook at /gitea/<name>, with no relay change --
previously it would also have needed a second relay URL baked in here.

The path segment is interpolated into an outbound URL, so it is validated
against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ and refused rather than sanitised
when it does not match. The path is matched raw and never URL-decoded, so
percent-encoded separators fail the charset check instead of surviving it;
requiring an alphanumeric first character also rejects "." and "..". Without
this, POST /gitea/..%2fadmin would let anything that can reach the relay
steer it at other Hermes endpoints. Tests cover traversal, encoded traversal,
embedded slashes, leading dot/dash, and the length bound, and assert nothing
reaches the stub Hermes in any of those cases.

Dropping --deliver leaves it at its default of `log`. The prompt tells her to
answer in the pull request, so the PR comment is the delivery and a Telegram
copy would only duplicate it; this also removes the hardcoded chat id that
was a third copy of TELEGRAM_HOME_CHANNEL.

Provisioning retires the pre-rename hook by its EXACT old URL rather than by
"points at the relay". Now that sibling hooks for other routes are the
intended pattern, a prefix match would delete them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
2026-08-23 06:52:52 +02:00
co-authored by Claude Opus 5
parent 31ba001d06
commit f982c6dc14
5 changed files with 142 additions and 25 deletions
+8 -1
View File
@@ -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 completely unchanged, and copies `X-Gitea-Event` into `X-GitHub-Event`. It has
no event, repository, action, payload, or prompt policy; Hermes owns no event, repository, action, payload, or prompt policy; Hermes owns
interpretation and response behavior. Jupiter's Gitea provisioning service 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 <name>` and a Gitea hook pointing at
`/gitea/<name>`, 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 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 webhook with `X-Hub-Signature-256` in GitHub's exact format, which Hermes
@@ -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", env = {**os.environ, "CREDENTIALS_DIRECTORY": creds, "LISTEN_HOST": "127.0.0.1",
"LISTEN_PORT": str(RELAY_PORT), "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 = str(pathlib.Path(__file__).with_name('gitea-hermes-webhook-relay.py'))
relay = subprocess.Popen([sys.executable, RELAY], relay = subprocess.Popen([sys.executable, RELAY],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 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 urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health", timeout=1); break
except Exception: time.sleep(0.1) except Exception: time.sleep(0.1)
def post(body, headers): def post(body, headers, path="/gitea"):
req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}/gitea", data=body, req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}{path}", data=body,
headers=headers, method="POST") headers=headers, method="POST")
try: try:
with urllib.request.urlopen(req, timeout=5) as r: return r.status, json.load(r) 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="), fwd["headers"]["x-hub-signature-256"].removeprefix("sha256="),
hmac.new(SECRET, fwd["body"], hashlib.sha256).hexdigest())) hmac.new(SECRET, fwd["body"], hashlib.sha256).hexdigest()))
check("delivery id propagated", fwd["headers"].get("x-request-id") == "abc-123") 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) # 3. gitea-only signature header (no X-Hub-Signature-256)
received.clear() received.clear()
@@ -120,6 +122,38 @@ check("normal-size body still ok", st == 200)
st, _ = post(payload, {"X-Hub-Signature-256": "sha256=" + sig}) st, _ = post(payload, {"X-Hub-Signature-256": "sha256=" + sig})
check("POST /gitea ok baseline", st == 200) check("POST /gitea ok baseline", st == 200)
# 7. route travels in the path: /gitea/<route> -> /webhooks/<route>
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() relay.terminate(); relay.wait(timeout=5); hermes.shutdown()
print() print()
print(f"{'ALL PASSED' if not fails else 'FAILURES: ' + ', '.join(fails)}") print(f"{'ALL PASSED' if not fails else 'FAILURES: ' + ', '.join(fails)}")
+23 -9
View File
@@ -42,7 +42,11 @@ in
environment = { environment = {
LISTEN_HOST = "0.0.0.0"; LISTEN_HOST = "0.0.0.0";
LISTEN_PORT = "8645"; 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/<route>), 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"; MAX_BODY_BYTES = "1048576";
}; };
@@ -76,12 +80,19 @@ in
# pull_request_comment as a value distinct from issue_comment, so plain issue # pull_request_comment as a value distinct from issue_comment, so plain issue
# comments do not reach the agent. # comments do not reach the agent.
# #
# A route carries exactly one prompt, so widening this list means either # A route carries exactly one prompt, so widening this list means branching
# branching inside the prompt on {action}/{issue.number}, or adding a second # inside the prompt on {action}, or adding a second subscription. The second
# subscription (and a second relay URL) for the other events. The Gitea-side # subscription is cheap now: the relay takes its target route from the
# hook still sends the full event set at the relay; Hermes drops the # request path, so it is a new `hermes webhook subscribe <name>` plus a
# Gitea hook pointing at /gitea/<name>, 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. # 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. # --script does the selection that MUST NOT be retunable at runtime.
# hosts/mars/gitea-pr-comment-filter.py drops luna's own comments before # 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 # any LLM call, which is what stops the reply loop: the prompt tells her to
@@ -127,8 +138,12 @@ in
sleep 1 sleep 1
done 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-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 # `set -eu` inside the container shell is load-bearing: without it a
# missing prompt file makes `cat` fail, the command substitution yields # missing prompt file makes `cat` fail, the command substitution yields
# an empty string, and the subscription is created with an EMPTY prompt # an empty string, and the subscription is created with an EMPTY prompt
@@ -138,13 +153,12 @@ in
set -eu set -eu
prompt="$(cat /opt/data/prompts/gitea-pr-comment.md)" prompt="$(cat /opt/data/prompts/gitea-pr-comment.md)"
[ -n "$prompt" ] || { echo "gitea-pr-comment prompt is empty" >&2; exit 1; } [ -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" \ --secret "$GITEA_HERMES_WEBHOOK_SECRET" \
--description "Gitea PR comments -> L.U.N.A." \ --description "Gitea PR comments -> L.U.N.A." \
--events pull_request_comment \ --events pull_request_comment \
--script gitea-pr-comment-filter.py \ --script gitea-pr-comment-filter.py \
--prompt "$prompt" \ --prompt "$prompt"
--deliver telegram --deliver-chat-id "15151223"
' '
''; '';
}; };
+54 -9
View File
@@ -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 It still verifies the signature itself rather than forwarding blindly, so an
unauthenticated caller that reaches this port never reaches the agent. unauthenticated caller that reaches this port never reaches the agent.
The target Hermes route travels in the request path (POST /gitea/<route> ->
POST <base>/webhooks/<route>) 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 from __future__ import annotations
@@ -27,6 +32,7 @@ import hmac
import json import json
import logging import logging
import os import os
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError, URLError 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_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8645")) LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8645"))
HERMES_URL = os.environ.get( # The Hermes route is taken from the request path (POST /gitea/<route>), not
"HERMES_WEBHOOK_URL", # baked in here, so one relay serves every subscription: a new Hermes route
"http://127.0.0.1:8644/webhooks/gitea-events", # 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))) MAX_BODY_BYTES = int(os.environ.get("MAX_BODY_BYTES", str(1024 * 1024)))
CREDENTIAL_NAME = os.environ.get("WEBHOOK_CREDENTIAL_NAME", "webhook_secret") 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 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/<route> -> <route>; /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): class Handler(BaseHTTPRequestHandler):
server_version = "gitea-hermes-relay/1.0" server_version = "gitea-hermes-relay/1.0"
@@ -107,9 +145,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(404, {"status": "not_found"}) self.send_json(404, {"status": "not_found"})
def do_POST(self) -> None: 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"}) self.send_json(404, {"status": "not_found"})
return return
hermes_url = f"{HERMES_WEBHOOK_BASE}/{route}"
raw_length = self.headers.get("Content-Length") raw_length = self.headers.get("Content-Length")
if raw_length is None: if raw_length is None:
@@ -171,7 +212,7 @@ class Handler(BaseHTTPRequestHandler):
forwarded_headers["X-Gitea-Delivery"] = delivery_id forwarded_headers["X-Gitea-Delivery"] = delivery_id
request = Request( request = Request(
HERMES_URL, hermes_url,
data=body, data=body,
headers=forwarded_headers, headers=forwarded_headers,
method="POST", method="POST",
@@ -189,11 +230,12 @@ class Handler(BaseHTTPRequestHandler):
return return
LOG.info( 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", gitea_event or gitea_event_type or "unknown",
delivery_id or "none", delivery_id or "none",
route,
) )
self.send_json(200, {"status": "forwarded"}) self.send_json(200, {"status": "forwarded", "route": route})
def main() -> None: def main() -> None:
@@ -202,7 +244,10 @@ def main() -> None:
format="%(asctime)s %(levelname)s %(name)s: %(message)s", format="%(asctime)s %(levelname)s %(name)s: %(message)s",
) )
server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) 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/<route> (default route %s)",
LISTEN_HOST, LISTEN_PORT, HERMES_WEBHOOK_BASE, DEFAULT_ROUTE,
)
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
+19 -2
View File
@@ -332,7 +332,17 @@ in
admin_token="$(cat "$TOKEN_FILE")" admin_token="$(cat "$TOKEN_FILE")"
secret="$(cat "$SECRET_FILE")" secret="$(cat "$SECRET_FILE")"
auth=(-H "Authorization: token $admin_token") 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 # Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
# above: After=gitea.service only means the process started, not that it # above: After=gitea.service only means the process started, not that it
@@ -351,7 +361,14 @@ in
--argjson events '${builtins.toJSON giteaWebhookEvents}' \ --argjson events '${builtins.toJSON giteaWebhookEvents}' \
'{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')" '{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')" | jq -r --arg url "$target" 'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')"
if [ -n "$hook_id" ]; then if [ -n "$hook_id" ]; then
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \