340 lines
15 KiB
Nix
340 lines
15 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;
|
|
};
|
|
actions = {
|
|
ENABLED = true;
|
|
};
|
|
};
|
|
};
|
|
|
|
networking.firewall.allowedTCPPorts = [ 2222 ];
|
|
|
|
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 is provisioning parity with ci-bot only (account + collaborator +
|
|
# branch protection) — it does NOT wire a token into mars/hermes-agent.nix
|
|
# yet; that's a separate step once luna actually has git tooling to call.
|
|
#
|
|
# luna's own push token (used by whatever git tooling gets wired into
|
|
# hermes-agent.nix later) 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'
|
|
# 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.
|
|
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 PR comments";
|
|
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")
|
|
target="http://mars.orbit.sol:8645/gitea"
|
|
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
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" -d "$body" >/dev/null
|
|
else
|
|
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
|
|
-X POST "$api/repos/darman/homelab/hooks" -d "$body" >/dev/null
|
|
fi
|
|
'';
|
|
};
|
|
}
|