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
261 lines
10 KiB
Python
261 lines
10 KiB
Python
#!/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=<hmac-sha256(body)>`, 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/<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
|
|
|
|
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/<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")
|
|
|
|
|
|
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/<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"
|
|
|
|
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/<route> (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()
|