Files
homelab/services/dev/gitea-hermes-webhook-relay.nix
T
darmanandClaude Opus 5 3567591ecf provisioning: stop deleting the pre-rename hook and subscription
Retiring gitea-events is a one-off migration, not something worth re-running
on every boot. Both units now only touch what they own: jupiter's creates or
updates its own hook and deletes nothing, and mars's removes only the route
it is about to re-subscribe, as the idempotency step for `subscribe`.

Keeping the deletes would have meant a redeploy could silently remove a hook
or route someone added deliberately -- a real risk now that sibling hooks
for other Hermes routes are the intended pattern.

README carries the manual commands, and the note that both hooks fire until
the old one is removed by hand, so events arrive twice in the meantime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 06:57:58 +02:00

165 lines
7.6 KiB
Nix

{ 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"
'
'';
};
}