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:
+72
-153
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user