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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user