add a generic HMAC-validating Gitea webhook relay on Mars
normalize Gitea event, signature, and delivery headers for Hermes
forward authenticated JSON events without repository/event/action filtering
configure Hermes to own event selection and Telegram response behavior
provision the Gitea webhook idempotently from Jupiter
Validation
relay integration test passed: health, HMAC forwarding, event normalization, and invalid-signature rejection
arbitrary non-PR events are forwarded to Hermes for policy handling
nixosConfigurations.mars evaluation passed
nixosConfigurations.jupiter evaluation passed
git diff --check passed
no deployment performed
Deployment prerequisite
Add the same random gitea_hermes_webhook_secret to both host SOPS files before deployment. The secret is intentionally not included in this PR.
## Summary
- add a generic HMAC-validating Gitea webhook relay on Mars
- normalize Gitea event, signature, and delivery headers for Hermes
- forward authenticated JSON events without repository/event/action filtering
- configure Hermes to own event selection and Telegram response behavior
- provision the Gitea webhook idempotently from Jupiter
## Validation
- relay integration test passed: health, HMAC forwarding, event normalization, and invalid-signature rejection
- arbitrary non-PR events are forwarded to Hermes for policy handling
- `nixosConfigurations.mars` evaluation passed
- `nixosConfigurations.jupiter` evaluation passed
- `git diff --check` passed
- no deployment performed
## Deployment prerequisite
Add the same random `gitea_hermes_webhook_secret` to both host SOPS files before deployment. The secret is intentionally not included in this PR.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Validation
nixosConfigurations.marsevaluation passednixosConfigurations.jupiterevaluation passedgit diff --checkpassedDeployment prerequisite
Add the same random
gitea_hermes_webhook_secretto both host SOPS files before deployment. The secret is intentionally not included in this PR.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