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
+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
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
@@ -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/<route>), 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/<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):
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/<route> (default route %s)",
LISTEN_HOST, LISTEN_PORT, HERMES_WEBHOOK_BASE, DEFAULT_ROUTE,
)
try:
server.serve_forever()
except KeyboardInterrupt: