relay: remove it; gitea already speaks Hermes's protocol

The relay existed on the premise that Gitea sends no header Hermes can read
an event name from, so something had to copy X-Gitea-Event into
X-GitHub-Event. That premise was wrong. Gitea's addDefaultHeaders sets

  req.Header["X-GitHub-Delivery"]   = []string{t.UUID}
  req.Header["X-GitHub-Event"]      = []string{event}
  req.Header["X-GitHub-Event-Type"] = []string{eventType}

unconditionally, for every webhook type, alongside X-Hub-Signature-256 in
GitHub's exact format. (Direct map assignment rather than .Add() specifically
to keep the "GitHub" casing that canonicalisation would destroy.) Hermes
validates that signature on any route without provider gating and reads the
event name from that header, so gitea and hermes already speak the same
protocol and the translation layer was translating nothing.

Gitea now posts straight at http://mars.orbit.sol:8644/webhooks/gitea-pr-comments.
The URL path is the Hermes route name, so a second subscription is a second
hook and nothing else -- the route-in-path indirection the relay grew was a
reimplementation of something Hermes already had.

Removes the module, the 200-line relay, its test, the mars import, the 8645
listener, and the stale gitea-hermes-webhook-relay.service entry left in the
secret's restartUnits. hermes-agent-webhook-route moves to
hosts/mars/hermes-agent.nix, next to the container and the read-only prompt
and filter mounts it depends on.

Also makes that unit refuse to subscribe when GITEA_HERMES_WEBHOOK_SECRET is
unset in the container, matching the existing empty-prompt check. An empty
secret silently fails every delivery signature check afterwards while the
unit still reports success -- the worst possible failure shape, and one this
setup can actually produce on a first deploy.

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 08:09:01 +02:00
co-authored by Claude Opus 5
parent 503623551a
commit 2a1a1628e1
8 changed files with 117 additions and 645 deletions
+18 -49
View File
@@ -37,58 +37,27 @@ scripts/ # deploy, edit_secrets
Hosts compose by importing `common.nix` + whichever `services/*` modules they
run. Each service module opens its own firewall ports.
## Gitea event relay
## Gitea events to Hermes
Mars includes a small HMAC-validating relay for Gitea webhooks. It forwards the
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
no event, repository, action, payload, or prompt policy; Hermes owns
interpretation and response behavior. Jupiter's Gitea provisioning service
registers the webhook idempotently at
`http://mars.orbit.sol:8645/gitea/gitea-pr-comments`.
Jupiter's Gitea registers a webhook straight at Hermes on mars,
`http://mars.orbit.sol:8644/webhooks/gitea-pr-comments`, with no relay in
between. Gitea's `addDefaultHeaders` signs every webhook type with
`X-Hub-Signature-256` in GitHub's exact format and sends `X-GitHub-Event`
unconditionally — which is exactly what Hermes validates against the
subscription secret and reads the event name from, so the two speak the same
protocol without translation. The URL path is the Hermes route name, so
another subscription is just another hook.
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.
Gitea will only deliver to hosts in `[security] ALLOWED_HOST_LIST`, which
defaults to `external` and does NOT include tailnet addresses
(100.64.0.0/10 is RFC 6598 carrier-grade NAT, neither private nor external as
gitea classifies it). `services/dev/gitea.nix` sets it accordingly; without
that, deliveries fail with `webhook can only call allowed HTTP servers`.
Neither provisioning unit deletes anything: Jupiter's only creates or updates
its own hook, and Mars's only removes the route it is about to re-subscribe.
Retiring the pre-rename `gitea-events` route is therefore a one-off, done by
hand after the first deploy of both hosts:
```
# on mars — drop the old subscription (`hermes` is the alias in common.nix)
hermes webhook remove gitea-events
# on jupiter — delete the old hook (it posts to the relay's bare /gitea path)
api=http://127.0.0.1:3000/api/v1; repo=darman/homelab
auth=(-H "Authorization: token $(sudo cat /run/secrets/gitea_provisioning_token)")
for id in $(curl -fsS "${auth[@]}" "$api/repos/$repo/hooks" \
| jq -r '.[] | select(.config.url == "http://mars.orbit.sol:8645/gitea") | .id'); do
curl -fsS "${auth[@]}" -X DELETE "$api/repos/$repo/hooks/$id"
done
```
Or just delete it in the web UI: repo Settings -> Webhooks, the entry whose
URL ends in `:8645/gitea` with no route after it.
Check `hermes webhook list` and the repo's webhook page afterwards; until the
old hook is gone both it and the new one fire, so events arrive twice.
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
already accepts on any route — so authentication would work pointing Gitea
straight at Hermes on 8644. But Hermes reads the event name only from
`X-GitHub-Event`/`X-GitLab-Event` (then `event_type`/`type` in the payload,
then the literal `"unknown"`), and Gitea sends none of those. Without the copy
every delivery arrives as `unknown` and `hermes webhook subscribe --events ...`
can never match anything.
Run `python3 services/dev/gitea-hermes-webhook-relay-test.py` to exercise the
relay end to end (signature acceptance and rejection, byte-identical body
forwarding, and the event-header copy).
The route's prompt and its filter script live in `hosts/mars/`, bind-mounted
read-only from the nix store so the agent cannot edit its own loop guard out,
and are re-subscribed by `hermes-agent-webhook-route` on every start. Run
`python3 hosts/mars/gitea-pr-comment-filter-test.py` after editing the filter.
Before deploying either host, add the same random
`gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and
-1
View File
@@ -12,7 +12,6 @@
../../services/containers.nix
../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/dev/gitea-hermes-webhook-relay.nix
];
networking.hostName = "mars";
+83
View File
@@ -303,4 +303,87 @@ in
requires = [ "hermes-agent-prepare-dirs.service" ];
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
};
# The Gitea PR-comment route. Gitea posts straight here (jupiter's
# gitea-hermes-webhook-provision registers the hook at
# http://mars.orbit.sol:8644/webhooks/gitea-pr-comments) -- there is no relay
# in between. Gitea's addDefaultHeaders sends X-Hub-Signature-256 in GitHub's
# exact format AND X-GitHub-Event, unconditionally, for every webhook type,
# which is precisely what Hermes validates and reads the event name from.
#
# --events pull_request_comment narrows the route to the one event the prompt
# handles; Gitea sends that value distinctly from issue_comment, so plain
# issue comments never reach the agent. A route carries exactly one prompt,
# so another event means either branching on {action} in the prompt or a
# second subscription plus a second Gitea hook at /webhooks/<name>.
#
# No --deliver: it defaults to `log`. The prompt tells her to answer in the
# pull request, so the PR comment IS the delivery.
#
# --script is the selection that MUST NOT be retunable at runtime.
# 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. Both it and the prompt
# are bind-mounted read-only from the store above so the agent cannot edit
# its own guard out. Hermes resolves both names relative to ~/.hermes, hence
# the bare filename.
#
# What read-only 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. The live subscription lives in
# webhook_subscriptions.json under /opt/data and is hot-reloaded, which is
# inside the agent's own write-safe root -- a self-modification sticks until
# this unit next runs.
#
# The secret comes from the CONTAINER's environment, injected via
# sops.templates."hermes-agent.env", which is why 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 PR-comment 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. Retiring an old route is a one-off done by hand,
# so that a redeploy never silently deletes one added on purpose.
podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true
# `set -eu` plus both emptiness checks are load-bearing. Without them a
# missing prompt file or an unset secret yields an empty string, and the
# subscription is created with an empty prompt or -- worse -- an empty
# secret, which silently fails EVERY delivery signature check afterwards
# while the unit still looks healthy. Fail loudly here instead.
podman exec hermes-agent sh -c '
set -eu
[ -n "''${GITEA_HERMES_WEBHOOK_SECRET:-}" ] || {
echo "GITEA_HERMES_WEBHOOK_SECRET is unset in the container" >&2; exit 1; }
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"
'
'';
};
}
+4 -3
View File
@@ -40,11 +40,12 @@
# restart it on its own. Without this line the very first deploy leaves the
# container holding an empty GITEA_HERMES_WEBHOOK_SECRET, and
# hermes-agent-webhook-route (which reads it back out of the running
# container) subscribes with an empty secret — every relayed delivery then
# fails signature validation inside Hermes with no obvious cause.
# container) subscribes with an empty secret — every delivery then fails
# signature validation inside Hermes with no obvious cause. That unit now
# refuses to subscribe on an unset secret rather than doing it quietly, but
# the ordering here is still what makes the rotation correct.
sops.secrets.gitea_hermes_webhook_secret = {
restartUnits = [
"gitea-hermes-webhook-relay.service"
"podman-hermes-agent.service"
"hermes-agent-webhook-route.service"
];
@@ -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)
-164
View File
@@ -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"
'
'';
};
}
-260
View File
@@ -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()
+12 -8
View File
@@ -21,8 +21,10 @@ 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.
# Send every Gitea event to Hermes on mars. Hermes owns the decision about
# which events matter and what to do with them: its route filters on
# X-GitHub-Event and drops the rest before any LLM call, so narrowing this
# list would only move that policy to the wrong side of the wire.
giteaWebhookEvents = [
"create"
"delete"
@@ -83,7 +85,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
@@ -367,11 +369,13 @@ in
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"
# Straight at Hermes's own webhook listener on mars, no relay in
# between: gitea signs every webhook type with X-Hub-Signature-256 in
# GitHub's exact format and sends X-GitHub-Event unconditionally, which
# is exactly what Hermes validates and reads the event name from. The
# path is the Hermes route name, so a second subscription is just a
# second hook here.
target="http://mars.orbit.sol:8644/webhooks/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