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:
+88
-53
@@ -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/<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,
|
||||
# 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/<pid>/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/<pid>/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}
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user