{ 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 ` == 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//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} ''; }; }