gitea: register a hook per hermes route, and keep both secrets out of argv

One webhook per route, from a list, so adding a route is an entry rather
than a copy of the unit. The PR-review hook subscribes
pull_request_review_comment and pull_request_review_rejected.

The 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"` published the admin token and
`jq --arg secret "$s"` the webhook secret -- which is exactly what the
existing comment claimed to be avoiding by putting the body on stdin. The
token now goes through a 0600 `curl -K` config written with printf (a
shell builtin, so the substitution never reaches an argv) and the secret
through jq --rawfile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
This commit is contained in:
2026-08-24 03:10:43 +02:00
co-authored by Claude Opus 5
parent c18413d16d
commit 753573aeea
+88 -53
View File
@@ -21,28 +21,46 @@ let
# nothing she does lands without darman clicking merge. # nothing she does lands without darman clicking merge.
lunaRepos = [ "darman/homelab" ]; lunaRepos = [ "darman/homelab" ];
# Only the event Hermes's gitea-pr-comments route actually handles. # 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.
# #
# This is a SUBSCRIPTION name, and gitea reuses these strings in a second, # `events` are SUBSCRIPTION names, and gitea reuses these strings in a
# colliding namespace on the wire — see the long comment on --events in # second, colliding namespace on the wire — see the long comment on the
# hosts/mars/hermes-agent.nix. "pull_request_comment" HERE means a timeline # route unit in hosts/mars/hermes-agent.nix. "pull_request_comment" HERE
# comment on a pull request; the same string in X-GitHub-Event means a # means a timeline comment on a pull request; the same string in
# review submission. The two files therefore name the same event # X-GitHub-Event means a review. The two files therefore name the same
# differently on purpose, and neither is a typo: # event differently on purpose, and neither is a typo:
# #
# here (subscription): pull_request_comment # here (subscription) there (route events)
# there (--events): issue_comment # ---------------------------- --------------------
# pull_request_comment issue_comment
# pull_request_review_comment pull_request_comment
# pull_request_review_rejected pull_request_rejected
# #
# Hermes would drop the rest anyway (its route filters on X-GitHub-Event # Hermes would drop everything else anyway (each route matches on
# before any LLM call), so this is defence in depth rather than the only # X-GitHub-Event before any LLM call, and then runs a filter script), so
# gate: it keeps traffic that can never be acted on from crossing the wire # subscribing narrowly here is defence in depth rather than the only gate:
# and reaching the agent's process at all. Adding a second Hermes route # it keeps traffic that can never be acted on from crossing the wire and
# means adding its event here as well as subscribing it. # reaching the agent's process at all.
giteaWebhookEvents = [ #
"pull_request_comment" # 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" ];
}
]; ];
giteaWebhookName = "PR comments Hermes";
in in
{ {
services.gitea = { services.gitea = {
@@ -335,11 +353,16 @@ in
''; '';
}; };
# Register the generic Gitea webhook. This is idempotent: it updates the # Register one Gitea webhook per Hermes route (giteaHermesHooks above).
# existing hook for the relay target or creates it when absent. Event policy # Idempotent: each target URL is updated if a hook for it already exists and
# belongs to Hermes, so the source sends the complete Gitea event set. # 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 = { systemd.services.gitea-hermes-webhook-provision = {
description = "Provision Gitea webhook for Hermes events"; description = "Provision Gitea webhooks for Hermes routes";
after = [ "gitea.service" ]; after = [ "gitea.service" ];
requires = [ "gitea.service" ]; requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -356,49 +379,61 @@ in
script = '' script = ''
set -euo pipefail set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1 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")
# 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 # Neither secret is ever passed as an argument. This unit runs as the
# NOT delete anything, including the pre-rename hook on the relay's bare # gitea user on a multi-user box, where /proc/<pid>/cmdline is
# path that is a one-off migration, done by hand, not a thing this # world-readable for the lifetime of the process so `-H "Authorization:
# runs on every boot. See the README for the command. # 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 # Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
# above: After=gitea.service only means the process started, not that it # above: After=gitea.service only means the process started, not that it
# is serving HTTP yet. Without this the first curl below fails under # is serving HTTP yet. Without this the first curl below fails under
# `set -e`, and a Type=oneshot with no Restart= stays failed leaving # `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 for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1 sleep 1
done done
# The secret goes to curl on stdin (--data @-), never in argv: this unit upsert_hook() {
# runs as the gitea user on a multi-user box, and a request body passed local name="$1" route="$2" events="$3" url body hook_id
# with -d is world-readable in /proc/<pid>/cmdline for its lifetime. url="http://mars.orbit.sol:8644/webhooks/$route"
body="$(jq -n --arg url "$target" --arg secret "$secret" \
--arg name ${lib.escapeShellArg giteaWebhookName} \
--argjson events '${builtins.toJSON giteaWebhookEvents}' \
'{type: "gitea", name: $name, config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')"
hook_id="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/hooks" \ # rtrimstr: sops stores this without a trailing newline, but one
| jq -r --arg url "$target" 'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')" # slipping in would change the key the HMAC is computed with and make
if [ -n "$hook_id" ]; then # every delivery fail signature validation on the Hermes side. The
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ # same trim happens there, so both ends agree either way.
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \
else --arg url "$url" --arg name "$name" --argjson events "$events" \
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ '{type: "gitea", name: $name, active: true, events: $events,
-X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null config: {content_type: "json", url: $url,
fi 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}
''; '';
}; };
} }