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
354 lines
16 KiB
Nix
354 lines
16 KiB
Nix
{ config, lib, pkgs, ... }:
|
|
|
|
# Gitea — self-hosted git. stateDir/repositories were migrated from the old
|
|
# ZimaOS docker instance straight into stateDir's default layout, so no
|
|
# import step is needed — just chown it to the gitea user after first deploy
|
|
# (currently darman:users from the CIFS copy):
|
|
# chown -R gitea:gitea /mnt/data/AppData/gitea
|
|
#
|
|
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
|
|
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not
|
|
# :222 — the unpriv gitea user can't bind <1024).
|
|
let
|
|
# Repos where the ci-bot account (see below) should be a Write collaborator
|
|
# and whitelisted to push past branch protection. Add a repo here and
|
|
# redeploy — no manual UI clicking needed.
|
|
ciBotRepos = [ "darman/hypr-chrome" ];
|
|
|
|
# Repos where luna (Hermes Agent's own gitea identity — see below) gets PR-tier
|
|
# access: Write collaborator (so she can push feature branches and open PRs)
|
|
# but explicitly walled off `master`'s push/merge/approve whitelists so
|
|
# nothing she does lands without darman clicking merge.
|
|
lunaRepos = [ "darman/homelab" ];
|
|
|
|
# Forward every Gitea event to the generic Mars relay. Hermes owns the
|
|
# decision about which events matter and what to do with them.
|
|
giteaWebhookEvents = [
|
|
"create"
|
|
"delete"
|
|
"fork"
|
|
"push"
|
|
"issues"
|
|
"issue_assign"
|
|
"issue_label"
|
|
"issue_milestone"
|
|
"issue_comment"
|
|
"pull_request"
|
|
"pull_request_assign"
|
|
"pull_request_label"
|
|
"pull_request_milestone"
|
|
"pull_request_comment"
|
|
"pull_request_review_approved"
|
|
"pull_request_review_rejected"
|
|
"pull_request_review_comment"
|
|
"pull_request_sync"
|
|
"pull_request_review_request"
|
|
"wiki"
|
|
"repository"
|
|
"release"
|
|
"package"
|
|
"status"
|
|
"workflow_run"
|
|
"workflow_job"
|
|
];
|
|
in
|
|
{
|
|
services.gitea = {
|
|
enable = true;
|
|
stateDir = "/mnt/data/AppData/gitea";
|
|
lfs.enable = true;
|
|
|
|
settings = {
|
|
repository = {
|
|
DEFAULT_BRANCH = "master";
|
|
};
|
|
server = {
|
|
DOMAIN = "git.mgaction.town";
|
|
SSH_DOMAIN = "git.mgaction.town";
|
|
# https, not http: neptun's Caddy terminates TLS for this name. Gitea
|
|
# builds its absolute URLs (clone buttons, redirects, webhooks) from
|
|
# ROOT_URL, so an http:// value hands out downgraded links.
|
|
ROOT_URL = "https://git.mgaction.town/";
|
|
HTTP_PORT = 3000;
|
|
START_SSH_SERVER = true;
|
|
SSH_PORT = 2222;
|
|
SSH_LISTEN_PORT = 2222;
|
|
};
|
|
service = {
|
|
DISABLE_REGISTRATION = true;
|
|
};
|
|
actions = {
|
|
ENABLED = true;
|
|
};
|
|
};
|
|
};
|
|
|
|
networking.firewall.allowedTCPPorts = [ 2222 ];
|
|
|
|
users.users.gitea.extraGroups = [ "users" ];
|
|
|
|
# Runner instance registered against this same gitea. Jobs run in containers
|
|
# (podman, via services/containers.nix — already enabled on jupiter), one
|
|
# image per requested `runs-on` label using the catthehacker act-compatible
|
|
# images (same ones upstream `act`/Forgejo docs recommend).
|
|
#
|
|
# tokenFile points at an env file rendered by sops (TOKEN=<registration
|
|
# token>, see hosts/jupiter/secrets.nix) rather than a plain `token`, so the
|
|
# secret never lands in the Nix store. The registration token itself is NOT
|
|
# generated by this module — it comes from gitea once Actions is enabled:
|
|
# su gitea -s /bin/sh -c \
|
|
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token'
|
|
# then written into secrets/jupiter.yaml as gitea_runner_token.
|
|
services.gitea-actions-runner.instances.jupiter = {
|
|
enable = true;
|
|
name = "jupiter";
|
|
url = "https://git.mgaction.town/";
|
|
tokenFile = config.sops.templates."gitea-runner.env".path;
|
|
labels = [
|
|
"ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"
|
|
"ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04"
|
|
];
|
|
};
|
|
|
|
# ci-bot: dedicated account CI workflows push as (kept separate from any
|
|
# human account so its own PAT can be scoped/rotated/revoked independently).
|
|
# Collaborator access + branch-protection push-whitelisting have no CLI or
|
|
# config-file surface in gitea — only the HTTP API — so this is the one
|
|
# part of the setup that stays imperative even though it's nix-triggered:
|
|
# a oneshot that PUTs/PATCHes the API into the desired state on every
|
|
# deploy where its script changed (adding a repo to `ciBotRepos` and
|
|
# redeploying is enough to pick it up; it won't self-heal a manual revert
|
|
# done via the web UI unless the unit is also restarted).
|
|
#
|
|
# Auth for those API calls is darman's OWN token (named
|
|
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository +
|
|
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos
|
|
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on
|
|
# the collaborator/branch-protection endpoints; write:user is additionally
|
|
# needed to push ci-bot's token below as a secret on darman's own account.
|
|
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
|
|
#
|
|
# ci-bot's own push token (separate secret, ci_bot_token) is generated
|
|
# once via:
|
|
# su gitea -s /bin/sh -c \
|
|
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
|
|
# --username ci-bot --scopes write:repository'
|
|
# and this service pushes it into gitea itself as a user-level Actions
|
|
# secret (CI_BOT_TOKEN, on darman's account — see the PUT below) so
|
|
# workflows in ciBotRepos can push as ci-bot without a per-repo secret.
|
|
systemd.services.gitea-ci-bot-provision = {
|
|
description = "Provision ci-bot gitea account + repo access";
|
|
after = [ "gitea.service" ];
|
|
requires = [ "gitea.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
|
|
environment = {
|
|
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
|
|
CI_BOT_TOKEN_FILE = config.sops.secrets.gitea_ci_bot_token.path;
|
|
};
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
User = config.services.gitea.user;
|
|
};
|
|
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")"
|
|
auth=(-H "Authorization: token $admin_token")
|
|
|
|
for _ in $(seq 1 30); do
|
|
curl -fs "$api/version" >/dev/null 2>&1 && break
|
|
sleep 1
|
|
done
|
|
|
|
if ! curl -fs "''${auth[@]}" "$api/users/ci-bot" >/dev/null 2>&1; then
|
|
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
|
|
--username ci-bot \
|
|
--email ci-bot@${config.services.gitea.settings.server.DOMAIN} \
|
|
--random-password --must-change-password=false
|
|
fi
|
|
|
|
# No instance-wide secret scope exists in Gitea (it's an open feature
|
|
# request) - a user-level secret on darman's own account is the closest
|
|
# equivalent, since every repo below is owned directly by darman, not
|
|
# an org, and repo-level secrets fall back to user-level when unset.
|
|
ci_bot_token="$(cat "$CI_BOT_TOKEN_FILE")"
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PUT "$api/user/actions/secrets/CI_BOT_TOKEN" \
|
|
-d "$(jq -n --arg data "$ci_bot_token" '{data: $data}')"
|
|
|
|
${lib.concatMapStringsSep "\n" (repo: ''
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PUT "$api/repos/${repo}/collaborators/ci-bot" \
|
|
-d '{"permission":"write"}'
|
|
|
|
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
|
|
|
|
# ci-bot needs push access on every branch a workflow might commit
|
|
# back to (currently just `develop`, where version-bump.yml pushes),
|
|
# in addition to whatever the repo's actual default branch is.
|
|
branches="$(printf '%s\n' "$default_branch" develop | sort -u)"
|
|
for branch in $branches; do
|
|
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$branch" >/dev/null 2>&1; then
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PATCH "$api/repos/${repo}/branch_protections/$branch" \
|
|
-d '{"enable_push":true,"enable_push_whitelist":true,"push_whitelist_usernames":["ci-bot"]}'
|
|
else
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X POST "$api/repos/${repo}/branch_protections" \
|
|
-d "{\"branch_name\":\"$branch\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"ci-bot\"]}"
|
|
fi
|
|
done
|
|
'') ciBotRepos}
|
|
'';
|
|
};
|
|
|
|
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A.,
|
|
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot:
|
|
# Hermes runs on mars, takes instructions over Telegram, and can be
|
|
# prompt-injected via tool output — a dedicated account with its own
|
|
# scoped, revocable token keeps that blast radius off darman's own
|
|
# credentials, and the branch-protection whitelists below keep it off
|
|
# `master` entirely regardless of what the token can technically do.
|
|
# She gets Write collaborator access (needed to push a branch and open a
|
|
# PR against the same repo — this instance has no fork workflow), but:
|
|
# - enable_push + enable_push_whitelist(darman only): nobody but darman
|
|
# can push straight to master; luna can only land on a side branch.
|
|
# - enable_merge_whitelist(darman only): opening a PR is not the same
|
|
# as merging one — only darman can click merge.
|
|
# - required_approvals=1 + enable_approvals_whitelist(darman only):
|
|
# an approval has to come from darman specifically, not luna
|
|
# rubber-stamping her own PR from a second identity.
|
|
# This is provisioning parity with ci-bot only (account + collaborator +
|
|
# branch protection) — it does NOT wire a token into mars/hermes-agent.nix
|
|
# yet; that's a separate step once luna actually has git tooling to call.
|
|
#
|
|
# luna's own push token (used by whatever git tooling gets wired into
|
|
# hermes-agent.nix later) is generated once, the same way ci-bot's was:
|
|
# su gitea -s /bin/sh -c \
|
|
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
|
|
# --username luna --scopes write:repository'
|
|
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
|
|
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
|
|
# since luna isn't a CI workflow running inside gitea, she's an external
|
|
# agent calling out to it.
|
|
systemd.services.gitea-luna-provision = {
|
|
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
|
|
after = [ "gitea.service" ];
|
|
requires = [ "gitea.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
|
|
environment = {
|
|
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
|
|
};
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
User = config.services.gitea.user;
|
|
};
|
|
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")"
|
|
auth=(-H "Authorization: token $admin_token")
|
|
|
|
for _ in $(seq 1 30); do
|
|
curl -fs "$api/version" >/dev/null 2>&1 && break
|
|
sleep 1
|
|
done
|
|
|
|
if ! curl -fs "''${auth[@]}" "$api/users/luna" >/dev/null 2>&1; then
|
|
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
|
|
--username luna \
|
|
--email luna@${config.services.gitea.settings.server.DOMAIN} \
|
|
--random-password --must-change-password=false
|
|
fi
|
|
|
|
${lib.concatMapStringsSep "\n" (repo: ''
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PUT "$api/repos/${repo}/collaborators/luna" \
|
|
-d '{"permission":"write"}'
|
|
|
|
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
|
|
|
|
protect_body="$(jq -n '{
|
|
enable_push: true,
|
|
enable_push_whitelist: true,
|
|
push_whitelist_usernames: ["darman"],
|
|
enable_merge_whitelist: true,
|
|
merge_whitelist_usernames: ["darman"],
|
|
required_approvals: 1,
|
|
enable_approvals_whitelist: true,
|
|
approvals_whitelist_username: ["darman"]
|
|
}')"
|
|
|
|
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$default_branch" >/dev/null 2>&1; then
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PATCH "$api/repos/${repo}/branch_protections/$default_branch" \
|
|
-d "$protect_body"
|
|
else
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X POST "$api/repos/${repo}/branch_protections" \
|
|
-d "$(echo "$protect_body" | jq --arg b "$default_branch" '. + {branch_name: $b}')"
|
|
fi
|
|
'') lunaRepos}
|
|
'';
|
|
};
|
|
|
|
# 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.
|
|
systemd.services.gitea-hermes-webhook-provision = {
|
|
description = "Provision Gitea webhook for Hermes events";
|
|
after = [ "gitea.service" ];
|
|
requires = [ "gitea.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
path = [ pkgs.curl pkgs.jq ];
|
|
environment = {
|
|
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
|
|
SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.path;
|
|
};
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
User = config.services.gitea.user;
|
|
};
|
|
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")
|
|
target="http://mars.orbit.sol:8645/gitea"
|
|
|
|
# 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.
|
|
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" \
|
|
--argjson events '${builtins.toJSON giteaWebhookEvents}' \
|
|
'{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')"
|
|
|
|
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
|
|
'';
|
|
};
|
|
}
|