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
365 lines
16 KiB
Nix
365 lines
16 KiB
Nix
{ config, lib, pkgs, ... }:
|
|
|
|
# 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 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
|
|
# 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" ];
|
|
|
|
# One gitea webhook per Hermes route; `route` must match a key in the route
|
|
# config hosts/mars/hermes-agent.nix writes.
|
|
#
|
|
# `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";
|
|
route = "gitea-pr-comments";
|
|
events = [ "pull_request_comment" ];
|
|
}
|
|
{
|
|
name = "PR reviews Hermes";
|
|
route = "gitea-pr-reviews";
|
|
events = [ "pull_request_review" ];
|
|
}
|
|
];
|
|
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 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;
|
|
SSH_PORT = 2222;
|
|
SSH_LISTEN_PORT = 2222;
|
|
};
|
|
service = {
|
|
DISABLE_REGISTRATION = true;
|
|
};
|
|
security = {
|
|
# 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 = {
|
|
ENABLED = true;
|
|
};
|
|
};
|
|
};
|
|
|
|
networking.firewall.allowedTCPPorts = [ 2222 ];
|
|
|
|
# `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 \
|
|
# --token-name luna-$(date +%Y%m%d) \
|
|
# --scopes write:repository,write:issue,read:user --raw
|
|
# gitea admin user list
|
|
# gitea actions generate-runner-token
|
|
programs.zsh.shellAliases.gitea =
|
|
"sudo -u ${config.services.gitea.user} env GITEA_WORK_DIR=${config.services.gitea.stateDir} ${config.services.gitea.package}/bin/gitea";
|
|
|
|
users.users.gitea.extraGroups = [ "users" ];
|
|
|
|
# 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 (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.
|
|
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, 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 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:
|
|
# 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 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 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 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" ];
|
|
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 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" ];
|
|
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
|
|
|
|
# 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 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
|
|
done
|
|
|
|
upsert_hook() {
|
|
local name="$1" route="$2" events="$3" url body hook_id
|
|
url="http://mars.orbit.sol:8644/webhooks/$route"
|
|
|
|
# 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,
|
|
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}
|
|
'';
|
|
};
|
|
}
|