docs: condense comments across the repo

Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
This commit is contained in:
2026-09-18 21:36:30 +02:00
co-authored by Claude Sonnet 5
parent 3899290c5b
commit 6f24ab69ad
47 changed files with 1051 additions and 1965 deletions
+72 -153
View File
@@ -1,14 +1,12 @@
{ 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
# Gitea — self-hosted git. Repos were migrated from the old ZimaOS docker
# instance straight into stateDir's default layout, so after first deploy
# just: 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).
# SSH uses gitea's own server on :2222, since the unprivileged gitea user
# can't bind :22 or :222 (<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
@@ -21,40 +19,15 @@ let
# nothing she does lands without darman clicking merge.
lunaRepos = [ "darman/homelab" ];
# 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.
# One gitea webhook per Hermes route; `route` must match a key in the route
# config hosts/mars/hermes-agent.nix writes.
#
# `events` are the strings gitea's HOOK API accepts. That set is coarser
# than gitea's internal HookEventType set, and both collide on spelling with
# the wire names Hermes matches on — three namespaces, one of which is a
# trap. From routers/api/v1/utils/hook.go (updateHookEvents),
# models/webhook/webhook.go (HasEvent) and modules/webhook/type.go (Event()):
#
# api event (here) delivers wire name (mars route)
# -------------------- ------------------- ----------------------
# pull_request_comment comment on a PR issue_comment
# pull_request_review review with a body pull_request_comment
# changes requested pull_request_rejected
# approval pull_request_approved
#
# So this file and hosts/mars/hermes-agent.nix name the same event
# differently on purpose, and neither is a typo.
#
# THE TRAP: updateHookEvents silently ignores strings it does not recognise,
# so a plausible-looking but non-API name leaves the hook registered with no
# events at all, delivering nothing and reporting no error. That is exactly
# what "pull_request_review_comment" did here — a real HookEventType, and a
# real value of X-GitHub-Event-Type, but not an API event name.
#
# There is no narrower name for reviews: HasEvent collapses approved,
# rejected and review-comment onto HookEventPullRequestReview, so
# `pull_request_review` is a single switch for all three. Approvals
# therefore cannot be excluded here. They are dropped on the mars side
# instead — the route's event list has no "pull_request_approved", so Hermes
# answers {"status": "ignored"} without running the filter or spending a
# token. Expect approvals in gitea's delivery log, answered 200 and ignored;
# that is the design, not a failure.
# `events` must be gitea's HOOK API event names, which gitea silently drops
# if unrecognized — registering with no events and no error ("pull_request_
# review_comment" did this: a real HookEventType, but not an API name).
# `pull_request_review` also covers approvals with no narrower option, so
# those are filtered on the mars side instead (answered 200 and ignored —
# expected, not a failure).
giteaHermesHooks = [
{
name = "PR comments Hermes";
@@ -81,9 +54,8 @@ in
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.
# https, not http: neptun's Caddy terminates TLS here, and gitea builds
# its absolute URLs (clone buttons, webhooks) from ROOT_URL.
ROOT_URL = "https://git.mgaction.town/";
HTTP_PORT = 3000;
START_SSH_SERVER = true;
@@ -94,20 +66,11 @@ in
DISABLE_REGISTRATION = true;
};
security = {
# Gitea refuses to deliver a webhook to any host outside this list,
# which defaults to `external` — "a valid non-private unicast IP".
# Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT),
# which is neither RFC1918 private nor, as far as gitea's matcher is
# concerned, external — so the hermes relay on mars was refused with
# deny 'mars.orbit.sol(100.64.0.6:8644)'
# even though nothing here is private in the RFC1918 sense. Adding
# the tailnet CIDR is what makes tailnet-internal webhook targets
# deliverable at all; `external` is kept so a future webhook to a
# public service (discord, slack) still works without another edit.
#
# This lives in [security], not [webhook]: the webhook-section key is
# deprecated and now just falls back to this one, which is the name
# the delivery error itself reports.
# Gitea's default `external` webhook target filter treats tailnet
# addresses (100.64.0.0/10, CGNAT) as neither private nor external, so
# the mars hermes relay was refused until the CIDR was added here.
# Lives under [security], not the deprecated [webhook] key it falls
# back to.
ALLOWED_HOST_LIST = "external,100.64.0.0/10";
};
actions = {
@@ -118,14 +81,10 @@ in
networking.firewall.allowedTCPPorts = [ 2222 ];
# `gitea <args>` == the admin CLI, as the gitea user, against the real
# state dir — mirrors the `hermes` alias on mars. Worth having because none
# of that is discoverable: the package is not in systemPackages (so `gitea`
# is not otherwise on PATH at all), every admin subcommand needs
# GITEA_WORK_DIR pointed at a stateDir that is not the module default, and
# it has to run as the gitea user or it writes root-owned files into that
# directory. Both paths come from the config rather than being spelled out,
# so a package bump or a stateDir move cannot leave this stale.
# `gitea <args>` == the admin CLI as the gitea user against the real state
# dir. Not otherwise usable: the package isn't on PATH, and admin
# subcommands need GITEA_WORK_DIR set and root-owned files avoided by
# running as gitea.
#
# Handy ones:
# gitea admin user generate-access-token --username luna \
@@ -138,15 +97,13 @@ in
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).
# Runner instance registered against this same gitea. Jobs run in podman
# containers (services/containers.nix), one image per `runs-on` label, using
# the catthehacker act-compatible images.
#
# 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:
# tokenFile (not `token`) keeps the sops-rendered secret out of the Nix
# store. The registration token isn't generated by this module — get it
# 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.
@@ -161,23 +118,18 @@ in
];
};
# 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).
# ci-bot: dedicated account CI workflows push as, so its PAT can be scoped
# and rotated independently of any human account. Collaborator access and
# branch-protection whitelisting have no CLI/config-file surface in gitea —
# only the HTTP API — so this oneshot re-applies the desired state via
# PUT/PATCH on every deploy (won't self-heal a manual UI revert unless
# 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.
# Auth is darman's own token (write:repository + write:user, see
# hosts/jupiter/secrets.nix): an owner-scoped token is required by the
# collaborator/branch-protection endpoints, and write:user is needed to
# push ci-bot's token as a secret on darman's account — ci-bot can't grant
# itself access.
#
# ci-bot's own push token (separate secret, ci_bot_token) is generated
# once via:
@@ -255,47 +207,26 @@ in
'';
};
# 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 covers the SERVER side only (account + collaborator + branch
# protection). The client side — git/tea inside the hermes-agent container,
# and the token below — lives in hosts/mars/hermes-agent.nix.
# luna: Hermes Agent's gitea identity, deliberately PR-tier only (not
# push-tier like ci-bot) — Hermes runs on mars, takes Telegram instructions,
# and can be prompt-injected via tool output, so branch protection below
# keeps her off `master` regardless of what her token can technically do:
# - enable_push_whitelist(darman only): nobody but darman pushes to master.
# - enable_merge_whitelist(darman only): opening a PR isn't merging one.
# - required_approvals=1 + enable_approvals_whitelist(darman only): no
# self-approval from a second identity.
# This is the server side only; the client side (git/tea, token) is in
# hosts/mars/hermes-agent.nix.
#
# luna's own push token 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,write:issue,read:user'
# 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.
# luna's push token is generated once (same as ci-bot's, username luna,
# scopes write:repository,write:issue,read:user) and stored as a secret —
# NOT pushed into gitea as an Actions secret, since she's an external agent
# calling in, not a CI workflow.
#
# **write:issue is NOT optional and is easy to miss**: this token started
# life as `write:repository` alone, which clones, fetches and pushes
# branches perfectly well — so everything looks fine right up until the
# first `tea pr create`, which gitea rejects with
# token scope=write:repository,read:user required=read:issue
# A pull request IS an issue in gitea's data model, so every /pulls
# endpoint is gated on the *issue* scope category, not the repository one.
# write:issue covers it (in gitea's scope model write:X implies read:X);
# read:issue alone would satisfy the GET half and then fail the POST that
# actually opens the PR. The error names read:issue only because that's
# the first check tea trips on. Rotating the token is free — the prepare
# oneshot on mars does delete-then-add for the tea login on every start.
# write:issue is required, not optional: a PR is an issue in gitea's data
# model, so `tea pr create` needs it even though push/fetch work fine on
# write:repository alone. The resulting error misleadingly names read:issue
# (the first check tea trips), not write:issue.
systemd.services.gitea-luna-provision = {
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
after = [ "gitea.service" ];
@@ -359,14 +290,10 @@ in
'';
};
# 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.
# Register one Gitea webhook per Hermes route (giteaHermesHooks above),
# idempotently (update if the target URL exists, else create). Deliberately
# never deletes — a hook for a route removed from the list is retired by
# hand in Settings -> Webhooks, not silently by a redeploy.
systemd.services.gitea-hermes-webhook-provision = {
description = "Provision Gitea webhooks for Hermes routes";
after = [ "gitea.service" ];
@@ -386,24 +313,17 @@ in
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
# 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 @-.
# Secrets never go on argv, since /proc/<pid>/cmdline is world-readable
# on this multi-user box: the token goes into a 0600 curl config file
# (printf avoids argv entirely), the webhook secret into jq via
# --rawfile, and the body into curl via stdin.
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 webhooks silently unregistered until someone restarts the unit.
# Same readiness gate as the other provisioning units: After=gitea.service
# only means the process started, not that it's serving HTTP yet.
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
@@ -413,10 +333,9 @@ in
local name="$1" route="$2" events="$3" url body hook_id
url="http://mars.orbit.sol:8644/webhooks/$route"
# 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.
# rtrimstr: a stray trailing newline would change the HMAC key and
# break signature validation on the Hermes side, which trims the same
# 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,