Merge master into feat/mars-victoriametrics
This commit is contained in:
@@ -1,160 +0,0 @@
|
||||
"""Integration test for gitea-hermes-webhook-relay.py.
|
||||
|
||||
Spawns the real relay as a subprocess against a stub Hermes and drives it over
|
||||
real HTTP. Run it directly: python3 services/dev/gitea-hermes-webhook-relay-test.py
|
||||
|
||||
The assertion that matters is `X-GitHub-Event injected`: Hermes derives the
|
||||
event name it matches `--events` against from X-GitHub-Event, and Gitea only
|
||||
ever sends X-Gitea-Event. If that copy regresses, every delivery silently
|
||||
becomes event "unknown" and no Hermes-side event selection can work.
|
||||
"""
|
||||
import hashlib, hmac, json, os, pathlib, subprocess, sys, threading, time, urllib.request, urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
SECRET = b"s3cr3t-test-value"
|
||||
RELAY_PORT, HERMES_PORT = 18645, 18644
|
||||
received = []
|
||||
|
||||
class Hermes(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
# lower-cased keys: HTTP headers are case-insensitive and urllib
|
||||
# normalises them with .title() on the wire ("X-GitHub-Event" leaves
|
||||
# as "X-Github-Event"). aiohttp reads them into a case-insensitive
|
||||
# CIMultiDict, so matching case-insensitively here is the correct
|
||||
# assertion, not a workaround.
|
||||
received.append({"path": self.path, "body": self.rfile.read(n),
|
||||
"headers": {k.lower(): v for k, v in self.headers.items()}})
|
||||
self.send_response(200); self.send_header("Content-Length", "2")
|
||||
self.end_headers(); self.wfile.write(b"ok")
|
||||
def log_message(self, *a): pass
|
||||
|
||||
hermes = HTTPServer(("127.0.0.1", HERMES_PORT), Hermes)
|
||||
threading.Thread(target=hermes.serve_forever, daemon=True).start()
|
||||
|
||||
import tempfile
|
||||
creds = os.path.join(tempfile.mkdtemp(), "creds"); os.makedirs(creds, exist_ok=True)
|
||||
# trailing newline on purpose: mimics a sops secret file
|
||||
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_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)
|
||||
|
||||
for _ in range(50):
|
||||
try:
|
||||
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, 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)
|
||||
except urllib.error.HTTPError as e: return e.code, json.load(e)
|
||||
|
||||
fails = []
|
||||
def check(name, cond, detail=""):
|
||||
print(("PASS " if cond else "FAIL ") + name + ("" if cond else f" <- {detail}"))
|
||||
if not cond: fails.append(name)
|
||||
|
||||
payload = json.dumps({"action": "opened", "number": 7}).encode()
|
||||
sig = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
|
||||
|
||||
# 1. health
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health") as r:
|
||||
check("health endpoint", json.load(r)["status"] == "ok")
|
||||
|
||||
# 2. happy path with BOTH gitea headers (what Gitea really sends)
|
||||
received.clear()
|
||||
st, resp = post(payload, {"Content-Type": "application/json",
|
||||
"X-Gitea-Event": "pull_request_comment",
|
||||
"X-Gitea-Event-Type": "pull_request_comment",
|
||||
"X-Gitea-Delivery": "abc-123",
|
||||
"X-Gitea-Signature": sig,
|
||||
"X-Hub-Signature-256": "sha256=" + sig})
|
||||
check("valid delivery accepted", st == 200, f"got {st} {resp}")
|
||||
check("forwarded to hermes", len(received) == 1)
|
||||
fwd = received[0]
|
||||
check("body forwarded byte-identical", fwd["body"] == payload)
|
||||
check("X-GitHub-Event injected (THE fix)",
|
||||
fwd["headers"].get("x-github-event") == "pull_request_comment",
|
||||
f"got {fwd['headers'].get('X-GitHub-Event')!r}")
|
||||
check("X-Hub-Signature-256 forwarded unchanged",
|
||||
fwd["headers"].get("x-hub-signature-256") == "sha256=" + sig)
|
||||
check("signature still valid over forwarded body",
|
||||
hmac.compare_digest(
|
||||
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("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()
|
||||
st, _ = post(payload, {"Content-Type": "application/json", "X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig})
|
||||
check("bare X-Gitea-Signature accepted", st == 200)
|
||||
check("relay signs when hub header absent",
|
||||
received and received[0]["headers"].get("x-hub-signature-256") == "sha256=" + sig)
|
||||
|
||||
# 4. rejections
|
||||
received.clear()
|
||||
st, _ = post(payload, {"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=" + "0"*64})
|
||||
check("bad signature -> 401", st == 401)
|
||||
st, _ = post(payload, {"Content-Type": "application/json"})
|
||||
check("missing signature -> 401", st == 401)
|
||||
st, _ = post(payload + b"x", {"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=" + sig})
|
||||
check("tampered body -> 401", st == 401)
|
||||
check("nothing leaked to hermes on rejection", len(received) == 0, f"{len(received)} forwarded")
|
||||
|
||||
# 5. oversize
|
||||
big = b"x" * 200
|
||||
st, _ = post(big, {"Content-Type": "application/json", "MAX": "1",
|
||||
"X-Hub-Signature-256": "sha256=" + hmac.new(SECRET, big, hashlib.sha256).hexdigest()})
|
||||
check("normal-size body still ok", st == 200)
|
||||
|
||||
# 6. unknown path
|
||||
st, _ = post(payload, {"X-Hub-Signature-256": "sha256=" + sig})
|
||||
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()
|
||||
print()
|
||||
print(f"{'ALL PASSED' if not fails else 'FAILURES: ' + ', '.join(fails)}")
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -1,164 +0,0 @@
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
# Gitea -> Hermes webhook relay.
|
||||
#
|
||||
# Why this exists at all, since Gitea could POST straight at Hermes's own
|
||||
# webhook port (8644, already tailnet-reachable — tailscale0 is a
|
||||
# trustedInterface): AUTH would work directly. Gitea's addDefaultHeaders()
|
||||
# signs every webhook type with `X-Hub-Signature-256: sha256=<hmac>`, the
|
||||
# exact GitHub scheme, and Hermes accepts that header on any route with no
|
||||
# per-route provider gating. What does NOT work directly is EVENT SELECTION.
|
||||
# Hermes reads the event name from `X-GitHub-Event`/`X-GitLab-Event`, then
|
||||
# the payload's `event_type`/`type` keys, then gives up and calls it
|
||||
# "unknown". Gitea sends `X-Gitea-Event` and no such payload key, so a direct
|
||||
# hook authenticates fine and then arrives as "unknown" forever — which makes
|
||||
# `hermes webhook subscribe --events ...` unable to select anything, i.e. the
|
||||
# "Hermes owns event policy" split this module is built around cannot exist
|
||||
# without something copying that one header.
|
||||
#
|
||||
# So that is all this does: verify the signature, copy X-Gitea-Event into
|
||||
# X-GitHub-Event, forward body and signature untouched. No re-signing, no
|
||||
# payload rewriting, no event/repo/action filtering.
|
||||
#
|
||||
# It binds 0.0.0.0 but gets no allowedTCPPorts entry, so it is reachable over
|
||||
# tailscale0 only — same posture as the Hermes dashboard on 9119.
|
||||
|
||||
let
|
||||
relayScript = pkgs.writeText "gitea-hermes-webhook-relay.py" (
|
||||
builtins.readFile ./gitea-hermes-webhook-relay.py
|
||||
);
|
||||
in
|
||||
{
|
||||
systemd.services.gitea-hermes-webhook-relay = {
|
||||
description = "Relay Gitea webhooks to Hermes with a Hermes-readable event header";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"network-online.target"
|
||||
"podman-hermes-agent.service"
|
||||
"tailscaled-autoconnect.service"
|
||||
];
|
||||
|
||||
environment = {
|
||||
LISTEN_HOST = "0.0.0.0";
|
||||
LISTEN_PORT = "8645";
|
||||
# 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";
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.python3}/bin/python ${relayScript}";
|
||||
LoadCredential = [
|
||||
"webhook_secret:${config.sops.secrets.gitea_hermes_webhook_secret.path}"
|
||||
];
|
||||
DynamicUser = true;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
PrivateDevices = true;
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = "strict";
|
||||
NoNewPrivileges = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
|
||||
RestrictRealtime = true;
|
||||
UMask = "0077";
|
||||
};
|
||||
};
|
||||
|
||||
# The relay forwards into a generic Hermes webhook subscription. Keep the
|
||||
# subscription declaratively present without putting event policy or prompt
|
||||
# text in this transport unit. Hermes owns interpretation and response policy.
|
||||
#
|
||||
# `--events pull_request_comment` narrows this route to the one event the
|
||||
# prompt below actually knows how to handle. It works only because the relay
|
||||
# supplies X-GitHub-Event — see the header comment above; without that every
|
||||
# delivery would arrive as "unknown" and match nothing. Gitea sends
|
||||
# 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 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 <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.
|
||||
#
|
||||
# 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
|
||||
# answer on the PR, and her answer is itself a pull_request_comment. It is
|
||||
# bind-mounted read-only from the nix store (see hosts/mars/hermes-agent.nix)
|
||||
# so the agent cannot edit its own guard out. Hermes resolves the name
|
||||
# relative to ~/.hermes/scripts, hence the bare filename here.
|
||||
#
|
||||
# The prompt is read from a read-only mount rather than passed inline: see
|
||||
# hosts/mars/gitea-pr-comment-prompt.md and the mounts in hermes-agent.nix.
|
||||
# Note what read-only does and does not buy. It protects the SOURCES, and
|
||||
# this unit re-subscribes from them on every start, so a restart restores
|
||||
# the intended prompt, filter and event list. It does not make the live
|
||||
# subscription immutable: Hermes stores it in webhook_subscriptions.json
|
||||
# under /opt/data and hot-reloads it, which is inside the agent's own
|
||||
# write-safe root. A self-modification would therefore stick until the next
|
||||
# restart of this unit.
|
||||
#
|
||||
# The secret is read from the CONTAINER's environment ($GITEA_HERMES_
|
||||
# WEBHOOK_SECRET, injected via sops.templates."hermes-agent.env"), which is
|
||||
# why hosts/mars/secrets.nix restarts podman-hermes-agent BEFORE this unit
|
||||
# on rotation — re-subscribing against a container still holding the old
|
||||
# value would silently pin the stale secret.
|
||||
systemd.services.hermes-agent-webhook-route = {
|
||||
description = "Configure Hermes Gitea event webhook route";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "podman-hermes-agent.service" ];
|
||||
requires = [ "podman-hermes-agent.service" ];
|
||||
path = [ pkgs.podman ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# The container unit is ordered before us, but its gateway may still be
|
||||
# warming up while the image initializes its persistent state directory.
|
||||
for _ in $(seq 1 60); do
|
||||
if podman exec hermes-agent hermes webhook list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Idempotency for the subscribe below, not cleanup: this removes only
|
||||
# the route this unit owns. The pre-rename gitea-events subscription is
|
||||
# left alone — retiring it is a one-off migration done by hand, so that
|
||||
# a redeploy never silently deletes a route someone added on purpose.
|
||||
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
|
||||
# -- a silent failure that looks like a healthy unit. Fail loudly here
|
||||
# instead so the oneshot goes red.
|
||||
podman exec hermes-agent sh -c '
|
||||
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-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"
|
||||
'
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
#!/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()
|
||||
+92
-62
@@ -21,35 +21,45 @@ let
|
||||
# nothing she does lands without darman clicking merge.
|
||||
lunaRepos = [ "darman/homelab" ];
|
||||
|
||||
# Forward every Gitea event to the generic Mars relay. Hermes owns the
|
||||
# decision about which events matter and what to do with them.
|
||||
giteaWebhookEvents = [
|
||||
"create"
|
||||
"delete"
|
||||
"fork"
|
||||
"push"
|
||||
"issues"
|
||||
"issue_assign"
|
||||
"issue_label"
|
||||
"issue_milestone"
|
||||
"issue_comment"
|
||||
"pull_request"
|
||||
"pull_request_assign"
|
||||
"pull_request_label"
|
||||
"pull_request_milestone"
|
||||
"pull_request_comment"
|
||||
"pull_request_review_approved"
|
||||
"pull_request_review_rejected"
|
||||
"pull_request_review_comment"
|
||||
"pull_request_sync"
|
||||
"pull_request_review_request"
|
||||
"wiki"
|
||||
"repository"
|
||||
"release"
|
||||
"package"
|
||||
"status"
|
||||
"workflow_run"
|
||||
"workflow_job"
|
||||
# One gitea webhook per Hermes route. `route` is the path segment Hermes
|
||||
# dispatches on (http://mars.orbit.sol:8644/webhooks/<route>), so it must
|
||||
# match a key in the route config that hosts/mars/hermes-agent.nix writes.
|
||||
#
|
||||
# `events` are SUBSCRIPTION names, and gitea reuses these strings in a
|
||||
# second, colliding namespace on the wire — see the long comment on the
|
||||
# route unit in hosts/mars/hermes-agent.nix. "pull_request_comment" HERE
|
||||
# means a timeline comment on a pull request; the same string in
|
||||
# X-GitHub-Event means a review. The two files therefore name the same
|
||||
# event differently on purpose, and neither is a typo:
|
||||
#
|
||||
# here (subscription) there (route events)
|
||||
# ---------------------------- --------------------
|
||||
# pull_request_comment issue_comment
|
||||
# pull_request_review_comment pull_request_comment
|
||||
# pull_request_review_rejected pull_request_rejected
|
||||
#
|
||||
# Hermes would drop everything else anyway (each route matches on
|
||||
# X-GitHub-Event before any LLM call, and then runs a filter script), so
|
||||
# subscribing narrowly here is defence in depth rather than the only gate:
|
||||
# it keeps traffic that can never be acted on from crossing the wire and
|
||||
# reaching the agent's process at all.
|
||||
#
|
||||
# Approvals (pull_request_review_approved) are deliberately absent: an
|
||||
# approval is darman signing off, not asking for work, and waking an agent
|
||||
# run on every LGTM is pure cost. Adding it means adding it BOTH here and
|
||||
# to prReviewEvents/ALLOWED_REVIEW_TYPES on mars — as "pull_request_approved"
|
||||
# there, per the table above.
|
||||
giteaHermesHooks = [
|
||||
{
|
||||
name = "PR comments Hermes";
|
||||
route = "gitea-pr-comments";
|
||||
events = [ "pull_request_comment" ];
|
||||
}
|
||||
{
|
||||
name = "PR reviews Hermes";
|
||||
route = "gitea-pr-reviews";
|
||||
events = [ "pull_request_review_comment" "pull_request_review_rejected" ];
|
||||
}
|
||||
];
|
||||
in
|
||||
{
|
||||
@@ -83,7 +93,7 @@ in
|
||||
# Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT),
|
||||
# which is neither RFC1918 private nor, as far as gitea's matcher is
|
||||
# concerned, external — so the hermes relay on mars was refused with
|
||||
# deny 'mars.orbit.sol(100.64.0.6:8645)'
|
||||
# deny 'mars.orbit.sol(100.64.0.6:8644)'
|
||||
# even though nothing here is private in the RFC1918 sense. Adding
|
||||
# the tailnet CIDR is what makes tailnet-internal webhook targets
|
||||
# deliverable at all; `external` is kept so a future webhook to a
|
||||
@@ -343,11 +353,16 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# Register the generic Gitea webhook. This is idempotent: it updates the
|
||||
# existing hook for the relay target or creates it when absent. Event policy
|
||||
# belongs to Hermes, so the source sends the complete Gitea event set.
|
||||
# Register one Gitea webhook per Hermes route (giteaHermesHooks above).
|
||||
# Idempotent: each target URL is updated if a hook for it already exists and
|
||||
# created otherwise.
|
||||
#
|
||||
# It deliberately does NOT delete anything, including hooks for routes that
|
||||
# were removed from the list above. Retiring one is a one-off, done by hand
|
||||
# in the repo's Settings -> Webhooks, so that a redeploy can never silently
|
||||
# unregister a hook someone added on purpose.
|
||||
systemd.services.gitea-hermes-webhook-provision = {
|
||||
description = "Provision Gitea webhook for Hermes events";
|
||||
description = "Provision Gitea webhooks for Hermes routes";
|
||||
after = [ "gitea.service" ];
|
||||
requires = [ "gitea.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -364,46 +379,61 @@ in
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
|
||||
admin_token="$(cat "$TOKEN_FILE")"
|
||||
secret="$(cat "$SECRET_FILE")"
|
||||
auth=(-H "Authorization: token $admin_token")
|
||||
# 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"
|
||||
|
||||
# This unit only ever creates or updates $target. It deliberately does
|
||||
# NOT delete anything, including the pre-rename hook on the relay's bare
|
||||
# path — that is a one-off migration, done by hand, not a thing this
|
||||
# runs on every boot. See the README for the command.
|
||||
# Neither secret is ever passed as an argument. This unit runs as the
|
||||
# gitea user on a multi-user box, where /proc/<pid>/cmdline is
|
||||
# world-readable for the lifetime of the process — so `-H "Authorization:
|
||||
# token $t"` would publish the admin token, and `jq --arg secret "$s"`
|
||||
# the webhook secret. The token goes into a 0600 curl config file
|
||||
# instead (printf is a shell builtin, so the substitution below never
|
||||
# reaches an argv), the webhook secret into jq via --rawfile, and the
|
||||
# request body into curl on stdin with --data @-.
|
||||
authcfg="$(mktemp)"
|
||||
trap 'rm -f "$authcfg"' EXIT
|
||||
chmod 0600 "$authcfg"
|
||||
printf 'header = "Authorization: token %s"\n' "$(cat "$TOKEN_FILE")" > "$authcfg"
|
||||
|
||||
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
|
||||
# above: After=gitea.service only means the process started, not that it
|
||||
# is serving HTTP yet. Without this the first curl below fails under
|
||||
# `set -e`, and a Type=oneshot with no Restart= stays failed — leaving
|
||||
# the webhook silently unregistered until someone restarts the unit.
|
||||
# the webhooks silently unregistered until someone restarts the unit.
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fs "$api/version" >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# The secret goes to curl on stdin (--data @-), never in argv: this unit
|
||||
# runs as the gitea user on a multi-user box, and a request body passed
|
||||
# with -d is world-readable in /proc/<pid>/cmdline for its lifetime.
|
||||
body="$(jq -n --arg url "$target" --arg secret "$secret" \
|
||||
--argjson events '${builtins.toJSON giteaWebhookEvents}' \
|
||||
'{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')"
|
||||
upsert_hook() {
|
||||
local name="$1" route="$2" events="$3" url body hook_id
|
||||
url="http://mars.orbit.sol:8644/webhooks/$route"
|
||||
|
||||
hook_id="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/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' \
|
||||
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null
|
||||
else
|
||||
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
||||
-X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null
|
||||
fi
|
||||
# rtrimstr: sops stores this without a trailing newline, but one
|
||||
# slipping in would change the key the HMAC is computed with and make
|
||||
# every delivery fail signature validation on the Hermes side. The
|
||||
# same trim happens there, so both ends agree either way.
|
||||
body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \
|
||||
--arg url "$url" --arg name "$name" --argjson events "$events" \
|
||||
'{type: "gitea", name: $name, active: true, events: $events,
|
||||
config: {content_type: "json", url: $url,
|
||||
secret: ($rawSecret | rtrimstr("\n"))}}')"
|
||||
|
||||
hook_id="$(curl -fsS -K "$authcfg" "$api/repos/darman/homelab/hooks" \
|
||||
| jq -r --arg url "$url" \
|
||||
'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')"
|
||||
|
||||
if [ -n "$hook_id" ]; then
|
||||
printf '%s' "$body" | curl -fsS -K "$authcfg" -H 'Content-Type: application/json' \
|
||||
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null
|
||||
else
|
||||
printf '%s' "$body" | curl -fsS -K "$authcfg" -H 'Content-Type: application/json' \
|
||||
-X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
${lib.concatMapStringsSep "\n " (h:
|
||||
"upsert_hook ${lib.escapeShellArg h.name} ${lib.escapeShellArg h.route} "
|
||||
+ lib.escapeShellArg (builtins.toJSON h.events)
|
||||
) giteaHermesHooks}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user