Files
homelab/services/dev/gitea.nix
T
darmanandClaude Opus 5 941a6731bb gitea: add a gitea admin CLI alias on jupiter
Mirrors the `hermes` alias on mars. The admin CLI is effectively
undiscoverable without it: the package is not in systemPackages so `gitea` is
not 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 drops root-owned files into that directory. Getting any of the three
wrong fails in a different and unhelpful way.

Both the package path and the stateDir come from the config rather than being
written out, so a gitea bump or a stateDir move cannot leave the alias
pointing at something stale — which is exactly what a hardcoded /nix/store
path would do.

Lives in services/dev/gitea.nix, which only jupiter imports, so it does not
leak onto hosts with no gitea to administer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-23 07:49:05 +02:00

410 lines
19 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;
};
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:8645)'
# 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.
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 — 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.
#
# 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 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 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'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.
#
# **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.
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")
# The path carries the Hermes route the relay should forward into, so
# each Hermes subscription gets its own hook here and the relay itself
# stays generic. Adding one is a new subscribe + a new hook URL.
relay="http://mars.orbit.sol:8645"
target="$relay/gitea/gitea-pr-comments"
# This unit only ever creates or updates $target. It deliberately does
# NOT delete anything, including the pre-rename hook on the relay's bare
# path — that is a one-off migration, done by hand, not a thing this
# runs on every boot. See the README for the command.
# 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
'';
};
}