The relay was forwarding X-Gitea-Event and re-signing the body into the
deprecated generic-V1 X-Webhook-Signature header. Neither is something
Hermes acts on, which left the PR's core premise — "Hermes owns event
selection" — impossible to reach:
- Hermes reads the event name only from X-GitHub-Event/X-GitLab-Event,
then payload event_type/type, then falls back to the literal string
"unknown" (gateway/platforms/webhook.py). Gitea sends X-Gitea-Event and
no such payload key, so every delivery arrived as "unknown" and
`hermes webhook subscribe --events ...` could never select anything.
- Gitea's addDefaultHeaders() already signs every webhook type with
X-Hub-Signature-256 in GitHub's exact format, and Hermes accepts that
header on any route with no per-route provider gating. Re-signing into
V1 was both redundant and on a deprecated path.
So the relay now verifies the signature (accepting either X-Hub-Signature-256
or X-Gitea-Signature), forwards body and signature byte-for-byte, and copies
the one header Hermes actually needs. Authentication alone never justified
this service; that header copy does, and the module comment now says so.
Also fixed:
- gitea-hermes-webhook-provision had no API readiness wait, unlike both
sibling units in the same file. After=gitea.service does not mean gitea
is serving HTTP, so under `set -e` a Type=oneshot with no Restart= would
fail on first boot and stay failed, leaving the webhook unregistered.
- podman-hermes-agent added to the secret's restartUnits. The secret
reaches the container only via sops.templates, whose rendered path never
changes, so systemd would not restart the container when the secret was
first added — hermes-agent-webhook-route then read an empty value back
out of it and subscribed with an empty secret.
- Webhook provisioning passes the request body to curl on stdin rather
than in argv, keeping the shared secret out of /proc/<pid>/cmdline.
- Missing Content-Length now returns 411 rather than 413; dropped the
unreachable non-2xx branch (urlopen raises on non-2xx); env-var secret
fallback is stripped to match the credential-file path.
Adds gitea-hermes-webhook-relay-test.py, which drives the real relay over
real HTTP against a stub Hermes and covers the header copy as a regression
test. Both nixosConfigurations still evaluate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
116 lines
4.6 KiB
Nix
116 lines
4.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";
|
|
HERMES_WEBHOOK_URL = "http://127.0.0.1:8644/webhooks/gitea-events";
|
|
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` is deliberately omitted: an empty events list means "accept
|
|
# everything", and the selection is Hermes-side policy that darman can
|
|
# retune with `hermes webhook subscribe` at runtime without a redeploy.
|
|
# That only works because the relay supplies X-GitHub-Event — see the
|
|
# header comment above.
|
|
#
|
|
# 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
|
|
|
|
podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true
|
|
podman exec hermes-agent hermes webhook remove gitea-events >/dev/null 2>&1 || true
|
|
podman exec hermes-agent sh -c '
|
|
hermes webhook subscribe gitea-events \
|
|
--secret "$GITEA_HERMES_WEBHOOK_SECRET" \
|
|
--description "Forward authenticated Gitea events to L.U.N.A." \
|
|
--deliver telegram --deliver-chat-id "15151223"
|
|
'
|
|
'';
|
|
};
|
|
}
|