{ 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" ]; # One gitea webhook per Hermes route. `route` is the path segment Hermes # dispatches on (http://mars.orbit.sol:8644/webhooks/), so it must # match a key in the route config that hosts/mars/hermes-agent.nix writes. # # `events` are SUBSCRIPTION names, and gitea reuses these strings in a # second, colliding namespace on the wire — see the long comment on the # route unit in hosts/mars/hermes-agent.nix. "pull_request_comment" HERE # means a timeline comment on a pull request; the same string in # X-GitHub-Event means a review. The two files therefore name the same # event differently on purpose, and neither is a typo: # # here (subscription) there (route events) # ---------------------------- -------------------- # pull_request_comment issue_comment # pull_request_review_comment pull_request_comment # pull_request_review_rejected pull_request_rejected # # Hermes would drop everything else anyway (each route matches on # X-GitHub-Event before any LLM call, and then runs a filter script), so # subscribing narrowly here is defence in depth rather than the only gate: # it keeps traffic that can never be acted on from crossing the wire and # reaching the agent's process at all. # # Approvals (pull_request_review_approved) are deliberately absent: an # approval is darman signing off, not asking for work, and waking an agent # run on every LGTM is pure cost. Adding it means adding it BOTH here and # to prReviewEvents/ALLOWED_REVIEW_TYPES on mars — as "pull_request_approved" # there, per the table above. 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_comment" "pull_request_review_rejected" ]; } ]; 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: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. 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 — 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=, 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 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. 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 # Neither secret is ever passed as an argument. This unit runs as the # gitea user on a multi-user box, where /proc//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 @-. 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. 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: 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. 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} ''; }; }