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