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.
|
# 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}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user