From 5375f8ee345d53954cb8000729bc021b45f3ac49 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Mon, 30 Mar 2026 17:26:21 -0400 Subject: [PATCH] gitea: add actions runner and CI/CD deploy workflow This will avoid me having to run "deploy" myself on my laptop. All I will need to do is push a commit and it will self-deploy. --- .gitea/workflows/deploy.yml | 60 ++++++++++++++++++++++++ AGENTS.md | 6 ++- configuration.nix | 21 +++++++++ modules/age-secrets.nix | 41 ++++++++++++++-- modules/impermanence.nix | 1 + secrets/ci-deploy-key.age | Bin 0 -> 645 bytes secrets/coturn-auth-secret.age | Bin 286 -> 298 bytes secrets/git-crypt-key-dotfiles.age | Bin 0 -> 382 bytes secrets/git-crypt-key-server-config.age | Bin 0 -> 382 bytes secrets/gitea-runner-token.age | Bin 0 -> 281 bytes secrets/matrix-reg-token.age | Bin 286 -> 298 bytes secrets/murmur-password-env.age | Bin 255 -> 267 bytes secrets/ntfy-alerts-topic.age | Bin 254 -> 254 bytes services/gitea-actions-runner.nix | 46 ++++++++++++++++++ services/gitea.nix | 1 + services/ssh.nix | 5 +- tests/gitea-runner.nix | 60 ++++++++++++++++++++++++ tests/tests.nix | 3 ++ 18 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 secrets/ci-deploy-key.age create mode 100644 secrets/git-crypt-key-dotfiles.age create mode 100644 secrets/git-crypt-key-server-config.age create mode 100644 secrets/gitea-runner-token.age create mode 100644 services/gitea-actions-runner.nix create mode 100644 tests/gitea-runner.nix diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a31cd96 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: Build and Deploy +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: nix + env: + GIT_SSH_COMMAND: "ssh -i /run/agenix/ci-deploy-key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ci-known-hosts" + steps: + - uses: https://github.com/actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Unlock git-crypt + run: | + git-crypt unlock /run/agenix/git-crypt-key-server-config + + - name: Build NixOS configuration + run: | + nix build .#nixosConfigurations.muffin.config.system.build.toplevel -L + + - name: Deploy via deploy-rs + run: | + eval $(ssh-agent -s) + ssh-add /run/agenix/ci-deploy-key + nix run github:serokell/deploy-rs -- .#muffin --skip-checks --ssh-opts="-o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ci-known-hosts" + + - name: Health check + run: | + sleep 10 + ssh -i /run/agenix/ci-deploy-key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ci-known-hosts root@server-public \ + "systemctl is-active gitea && systemctl is-active caddy && systemctl is-active continuwuity && systemctl is-active coturn" + + - name: Notify success + if: success() + run: | + TOPIC=$(cat /run/agenix/ntfy-alerts-topic | tr -d '[:space:]') + TOKEN=$(cat /run/agenix/ntfy-alerts-token | tr -d '[:space:]') + curl -sf -o /dev/null -X POST \ + "https://ntfy.sigkill.computer/$TOPIC" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Title: [muffin] Deploy succeeded" \ + -H "Priority: default" \ + -H "Tags: white_check_mark" \ + -d "server-config deployed from commit ${GITHUB_SHA::8}" + + - name: Notify failure + if: failure() + run: | + TOPIC=$(cat /run/agenix/ntfy-alerts-topic 2>/dev/null | tr -d '[:space:]') + TOKEN=$(cat /run/agenix/ntfy-alerts-token 2>/dev/null | tr -d '[:space:]') + curl -sf -o /dev/null -X POST \ + "https://ntfy.sigkill.computer/$TOPIC" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Title: [muffin] Deploy FAILED" \ + -H "Priority: urgent" \ + -H "Tags: rotating_light" \ + -d "server-config deploy failed at commit ${GITHUB_SHA::8}" || true diff --git a/AGENTS.md b/AGENTS.md index e109cf4..356feab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,11 @@ Each service file in `services/` follows this structure: - **git-crypt**: `secrets/` directory and `usb-secrets/usb-secrets-key*` are encrypted (see `.gitattributes`) - **agenix**: secrets declared in `modules/age-secrets.nix`, decrypted at runtime to `/run/agenix/` - **Identity**: USB drive at `/mnt/usb-secrets/usb-secrets-key` -- **Encrypting new secrets**: The agenix encryption key is in `usb-secrets/usb-secrets-key` (SSH private key, git-crypt encrypted). To create a new secret: derive the age public key with `ssh-keygen -y -f usb-secrets/usb-secrets-key | ssh-to-age`, then encrypt with `age -r -o secrets/.age`. +- **Encrypting new secrets**: The agenix identity is an SSH private key at `usb-secrets/usb-secrets-key` (git-crypt encrypted). To encrypt a new secret, use the SSH public key directly with `age -R`: + ```bash + age -R <(ssh-keygen -y -f usb-secrets/usb-secrets-key) -o secrets/.age /path/to/plaintext + ``` +- **DO NOT use `ssh-to-age`**. Using `ssh-to-age` to derive a native age public key and then encrypting with `age -r age1...` produces `X25519` recipient stanzas. The SSH private key identity on the server can only decrypt `ssh-ed25519` stanzas. This mismatch causes `age: error: no identity matched any of the recipients` at deploy time. Always use `age -R` with the SSH public key directly. - Never read or commit plaintext secrets. Never log secret values. ### Important Patterns diff --git a/configuration.nix b/configuration.nix index 9af329f..65d960f 100644 --- a/configuration.nix +++ b/configuration.nix @@ -26,6 +26,7 @@ ./services/caddy.nix ./services/immich.nix ./services/gitea.nix + ./services/gitea-actions-runner.nix ./services/minecraft.nix ./services/wg.nix @@ -73,6 +74,18 @@ ./services/mollysocket.nix ]; + # Hosts entries for CI/CD deploy targets + networking.hosts."192.168.1.50" = [ "server-public" ]; + networking.hosts."192.168.1.223" = [ "desktop" ]; + + # SSH known_hosts for CI runner (pinned host keys) + environment.etc."ci-known-hosts".text = '' + server-public ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu + 192.168.1.50 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu + git.sigkill.computer ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu + git.gardling.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFMjgaMnE+zS7tL+m5E7gh9Q9U1zurLdmU0qcmEmaucu + ''; + services.kmscon.enable = true; systemd.targets = { @@ -249,6 +262,14 @@ users.groups.${service_configs.media_group} = { }; + users.users.gitea-runner = { + isSystemUser = true; + group = "gitea-runner"; + home = "/var/lib/gitea-runner"; + description = "Gitea Actions CI runner"; + }; + users.groups.gitea-runner = { }; + users.users.${username} = { isNormalUser = true; extraGroups = [ diff --git a/modules/age-secrets.nix b/modules/age-secrets.nix index 81cb4be..2effde8 100644 --- a/modules/age-secrets.nix +++ b/modules/age-secrets.nix @@ -68,19 +68,19 @@ group = "root"; }; - # ntfy-alerts secrets + # ntfy-alerts secrets (group-readable for CI runner notifications) ntfy-alerts-topic = { file = ../secrets/ntfy-alerts-topic.age; - mode = "0400"; + mode = "0440"; owner = "root"; - group = "root"; + group = "gitea-runner"; }; ntfy-alerts-token = { file = ../secrets/ntfy-alerts-token.age; - mode = "0400"; + mode = "0440"; owner = "root"; - group = "root"; + group = "gitea-runner"; }; # Firefox Sync server secrets (SYNC_MASTER_SECRET) @@ -128,5 +128,36 @@ group = "continuwuity"; }; + # CI deploy SSH key + ci-deploy-key = { + file = ../secrets/ci-deploy-key.age; + mode = "0400"; + owner = "gitea-runner"; + group = "gitea-runner"; + }; + + # Git-crypt symmetric key for dotfiles repo + git-crypt-key-dotfiles = { + file = ../secrets/git-crypt-key-dotfiles.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + + # Git-crypt symmetric key for server-config repo + git-crypt-key-server-config = { + file = ../secrets/git-crypt-key-server-config.age; + mode = "0400"; + owner = "gitea-runner"; + group = "gitea-runner"; + }; + + # Gitea Actions runner registration token + gitea-runner-token = { + file = ../secrets/gitea-runner-token.age; + mode = "0400"; + owner = "gitea-runner"; + group = "gitea-runner"; + }; }; } diff --git a/modules/impermanence.nix b/modules/impermanence.nix index 86d36f0..0eee73c 100644 --- a/modules/impermanence.nix +++ b/modules/impermanence.nix @@ -24,6 +24,7 @@ # ZFS cache directory - persisting the directory instead of the file # avoids "device busy" errors when ZFS atomically updates the cache "/etc/zfs" + "/var/lib/gitea-runner" ]; files = [ diff --git a/secrets/ci-deploy-key.age b/secrets/ci-deploy-key.age new file mode 100644 index 0000000000000000000000000000000000000000..5c52818c617b7e9d4db8ea218df564fab4531ed9 GIT binary patch literal 645 zcmZQ@_Y83kiVO&0SoD@xlIixJB(WBTN%!x*U!QL`yY*cE^<8;w$GEiSd%yal)+M|4 zfc}cz%?}Kb#jy$}~Jz?V2MW4T&xa*Ow&0w!nQ!!J1ZQmt} z*8KawJg3H4l&$bKe!RfyXZNSTHjC28;a9`Aj& z`Pn1aws+5%CjT{mqHOZ=;#turytUeq)ieKYobf?l+Osi~f=`;#`eC3 z=LLJGFa#|~>HPj|zj9joJXfWJ_*a}I?E;#Y*r$ZtQIlTVqd2!9Qe3vaQ~1B%leDN` zrCuMxxmx%()ozwP#JK70OcC$P8uE!Mo!%|#p~5xsyN-X3xGQpiGkRXq1)C209wOV|BRr~NkrLaP%$}W&JK(0K{Xg63#N6N? LSvAL&?QQ@7(z>7@ literal 286 zcmZQ@_Y83kiVO&0xI5oQ>6z@D`1@>GiFcX!ZydSasrTyW@(w9P4xV32Sk5O`Fa&-N&Ptzb|54@3jR|4%m0528Wt=C+<{*&PxO)~xM$f6ePw|KvA;dB-#i`&+bsB@T^1+bAM|}Cq zBR{>lXsG)CW^2EK!ZwZ$I|e;xy_1t~SbvfF^`qM5mz+W370)J*8nbZc{F;3%c8*(e vXIaXpU#j8xeem>whqsHCT}jPe%-#5z>EH>e8Rb&zJ#u0iqoRKEo!SckVpEI2 diff --git a/secrets/git-crypt-key-dotfiles.age b/secrets/git-crypt-key-dotfiles.age new file mode 100644 index 0000000000000000000000000000000000000000..2547cfef9cc9272125eaf30aec31cc91d087e8b5 GIT binary patch literal 382 zcmZQ@_Y83kiVO&0$jy7;bwawToG~x%+zRcKj!(DVXRp8h@`$X^73JNRzI1CGY!z+1 z%CSVAUraK5{qfMS$ha@;UQ90wfykgIbyl4g}WTKN%QhwPfY21V5aJqF>}*;jZI<~v+}j#cR#3o z`}fr`)2sZcaTCIQ56&%#`)$opRq?`>B64 z_|}}>AGyT~Z7#V*TuRySzJFT!lH&O_EYtVtyg#@2n_Q9h(zA+1%psj3rLUG3pO_FD zbz`|iu&Go<)O_v`^Y}|M=Nwn!2s|FD>K-Xsv&ZOo@d>l<4T`t6rJ2dE;nmLk@58QG zw{BfRd(f`q9xaMmJ3TU^DnCkcfAbWM74HAwE3$udG&G6vb6ISOTMNyWZ&m2-uW%=zIhY?@>sPx literal 0 HcmV?d00001 diff --git a/secrets/git-crypt-key-server-config.age b/secrets/git-crypt-key-server-config.age new file mode 100644 index 0000000000000000000000000000000000000000..19931fe507a756a4e2633973abb4301b5491acb9 GIT binary patch literal 382 zcmZQ@_Y83kiVO&0c+0nbafa%PjN>>DE`IQsiPBMSxpOpKs z_l%m-@AMz8mK`cKuim{B?>ZFbyP6QgZ``oY6^3gaRk!mzQ>WkDn zzs3n|sf@XH%}XbVFAV84UAZB57n7Ic>!1A$(osQQj)y#-8FsM2T5zVV`NBh%4=XjqJm09<5ha;!LEAicpt0FDF?EdX5Wo>=@=IvWs zPbPf4K7mhcZQEIy+}Y0;uAN(-__;_+{=IvUNcGamTw9B`7m6gk-^~0hT3DiL%h~`B z)u~r6h6()g^EcMte){2c=R+@B*IPN26 zuwU6XSHW%ll*YZ2POjmMnyw*TAh>tuPZ7)Vnl%SS4%$_DY~OYI-!d((H8Z|yiQylQ@)@tjRxpLd+DZ1a+ToMPJ@{b;A| z{%R)M5^{ZzX1-mx#6FZb$i@Jxul^5ECYQ?IvPI)6v> z*;MbnQ3r(TGC24AH(EWr*g?ETD=l3|eQKVR#W&ZJiLY~ZFWvV;{v+d2eSw>Y9XT!) zH#;m0Em#)&RqSc;r{g!KY!H+c3XGSE-;lGoU1UmUu;HqsJD)4A`(5F>r)$m9ocX1D qg3MkB9@RT(8}eTJelyqNV$c74B2p%wKb>TF@5pv%)g7zPXKVo10E;C6 literal 0 HcmV?d00001 diff --git a/secrets/matrix-reg-token.age b/secrets/matrix-reg-token.age index e5a73988daae62b7a2bd5084d60fc6cdf5270f5a..e2e74054faceb1fb33af1d2abfcea53d10e4cec4 100644 GIT binary patch literal 298 zcmZQ@_Y83kiVO&0xM#1;yrr(>+T*fxeMyI=UpefHI1Sx8m}3`z(1|RYl(0|S>*dCj zf3_2(`T3`-?&p5d(`FJhWpm7k01ENw%|dr*$%<4hA;W6 zK69|_VA9}v&%ZPM`>)d%rN5?Bq|{!2mdO@YAwN0e+{*Qg)BRfK@EI&xZ@$mB($i$F zLC9>0{5{qkeCODAP5F91i8DY)ar>=#HSd34nJjieKlVR^^0ke++x;%wmFf5{b9UyY z1jBQwjsab#XP$K3yL-*-3C=7+@-Y`1JJ$9b6BU+ldY9_JuxDAJqpIU?m#~x8Hcg+i It~y)-0Mt#1@&Et; literal 286 zcmZQ@_Y83kiVO&0U@FKK*HmKKwCUX6>dfg&*?OjPdAiP!J(l?EoL%CBy^k5T2mIGO z5qaZY?8ElQA??eX@6EY=WAE<^$9nVM)wxw%J%8-Rk$LYAH)7SK*Z?fBRPyJG}N8{qd6Z`ay zTUCl@PCaXN`o%^0$BUbjFV~!RsSRO1@kDLM{Jl$#+`4muZ-aPQ`pKSSRyBvFuJf49 xT`BNNKU{l4cCy#0slK)x%X?3A*y&j%P;8W>@ZvwUFV=LQzvqR-W%3G>GZetO?aNp;F?sPi|(G4Y>sSZ5HWo*5S zWK}vsL;tI+H06-sT)1jW{G9L`i}aG6CU=N*Z2#zZZ0VgvlQ!u`2&gak`n=w2hT{<( zd2a!ITi^AyXALLqE%UjW^(*!8y_(5EW$O)g1@GuHvL$mge+x8K

Yw##I(zD*U3;DH$L^$H6vbJlc)XVNjP~ZG Z*A7eadE2>2`IpKZepcu3D>BNy4FHg)b%g)` literal 255 zcmZQ@_Y83kiVO&0Fi-Evj#iw0zS;cz`}KM6l$T}cmgmS;yGh1)*H+X%>{j_MfQ`XQeKmQWcYt@IPa`sqEY&YZs03h)GtCSq@$@^={{@ z7BmOgnBNx)KfNSlbNM$mJzM6Fg3_LKavQc6R)bhb^rP005IxaU=i$ diff --git a/secrets/ntfy-alerts-topic.age b/secrets/ntfy-alerts-topic.age index 2fc770fbbef742c789afc491d5ff04bbf238bc69..0e67825c07a9dc89bc9edee77788d951ee07dc23 100644 GIT binary patch literal 254 zcmZQ@_Y83kiVO&0aFy_m+aTcdCGS+d{>8+k?5iAHJ+*66HUDueetbzZxYPBAQedE- z@k8%`Uk@~M&tLHQ;cAfT+_9p0^TE!Qwl~imasJ}B^_a82%jV9zX~*gqE#uQ`)vez% zw0{V_m6?(3`goBd*SWj=$6wXdYOaa3zn*(X`d%NC^@rb$Yu+#CSe)pez2L~jt|LqO zcyomOYu>n?s+T?O=}}g*U~;{Q!R*aCic0EvPv=kBedNyvzB^UAs=9ZNSn$64$fEpI zEwJ_OB#(xexVEr#<2A7}HvQUY(7){6zl7jp{zuKa9tpZ06MCLopr3E`VOGPEiY8B6 MzSg|td*O};0f8`jh5!Hn literal 254 zcmZQ@_Y83kiVO&0*c!KaUy|7piG9M~mgGh(+v>pAvf!UerPvJjt)}bq6u8Y_ZT#i9 z{6NKyla}v7VdfiMIs*PyR*gK3J@6@8&g?Okw@Or~HC9c2%J4uK8!DAH8QfBkoj9 ze?sO2v3dW^+J4{5GJNg-PWZ0<@^sGCm9|fGwtl)-^7_Ohi7L6Nf-kP@oFj8($|-xB znFszeCtk|G{;1n%wwAHdSMS5S51)TNKjFN9!_(6VMFJ1q_swaWxF>qzs%6dpPH`1v PFzhV!RS1{4RV)qw<<))X diff --git a/services/gitea-actions-runner.nix b/services/gitea-actions-runner.nix new file mode 100644 index 0000000..748d47b --- /dev/null +++ b/services/gitea-actions-runner.nix @@ -0,0 +1,46 @@ +{ + config, + lib, + pkgs, + service_configs, + ... +}: +{ + services.gitea-actions-runner.instances.muffin = { + enable = true; + name = "muffin"; + url = config.services.gitea.settings.server.ROOT_URL; + tokenFile = config.age.secrets.gitea-runner-token.path; + labels = [ "nix:host" ]; + hostPackages = with pkgs; [ + bash + coreutils + curl + gawk + git + git-crypt + gnugrep + gnused + jq + nix + nodejs + openssh + ]; + settings = { + runner = { + capacity = 1; + timeout = "3h"; + }; + }; + }; + + # Override DynamicUser to use our static gitea-runner user + systemd.services."gitea-runner-muffin" = { + serviceConfig = { + DynamicUser = lib.mkForce false; + User = "gitea-runner"; + Group = "gitea-runner"; + }; + environment.GIT_SSH_COMMAND = "ssh -i /run/agenix/ci-deploy-key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ci-known-hosts"; + }; +} diff --git a/services/gitea.nix b/services/gitea.nix index dff1abe..907abe5 100644 --- a/services/gitea.nix +++ b/services/gitea.nix @@ -37,6 +37,7 @@ }; # only I shall use gitea service.DISABLE_REGISTRATION = true; + actions.ENABLED = true; }; }; diff --git a/services/ssh.nix b/services/ssh.nix index d5b0730..e0f2a4f 100644 --- a/services/ssh.nix +++ b/services/ssh.nix @@ -31,5 +31,8 @@ # used for deploying configs to server users.users.root.openssh.authorizedKeys.keys = - config.users.users.${username}.openssh.authorizedKeys.keys; + config.users.users.${username}.openssh.authorizedKeys.keys + ++ [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC5ZYN6idL/w/mUIfPOH1i+Q/SQXuzAMQUEuWpipx1Pc ci-deploy@muffin" + ]; } diff --git a/tests/gitea-runner.nix b/tests/gitea-runner.nix new file mode 100644 index 0000000..dbf98d3 --- /dev/null +++ b/tests/gitea-runner.nix @@ -0,0 +1,60 @@ +{ + config, + lib, + pkgs, + ... +}: +pkgs.testers.runNixOSTest { + name = "gitea-runner"; + nodes.machine = + { pkgs, ... }: + { + services.gitea = { + enable = true; + database.type = "sqlite3"; + settings = { + server = { + HTTP_PORT = 3000; + ROOT_URL = "http://localhost:3000"; + DOMAIN = "localhost"; + }; + actions.ENABLED = true; + service.DISABLE_REGISTRATION = true; + }; + }; + + specialisation.runner = { + inheritParentConfig = true; + configuration.services.gitea-actions-runner.instances.test = { + enable = true; + name = "ci"; + url = "http://localhost:3000"; + labels = [ "native:host" ]; + tokenFile = "/var/lib/gitea/runner_token"; + }; + }; + }; + + testScript = '' + start_all() + + machine.wait_for_unit("gitea.service") + machine.wait_for_open_port(3000) + + # Generate runner token + machine.succeed( + "su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea actions generate-runner-token --work-path /var/lib/gitea' | tail -1 | sed 's/^/TOKEN=/' > /var/lib/gitea/runner_token" + ) + + # Switch to runner specialisation + machine.succeed( + "/run/current-system/specialisation/runner/bin/switch-to-configuration test" + ) + + # Start the runner (specialisation switch doesn't auto-start new services) + machine.succeed("systemctl start gitea-runner-test.service") + machine.wait_for_unit("gitea-runner-test.service") + machine.succeed("sleep 5") + machine.succeed("test -f /var/lib/gitea-runner/test/.runner") + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index 27684a2..44b1db0 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -27,4 +27,7 @@ in # torrent audit test torrentAuditTest = handleTest ./torrent-audit.nix; + + # gitea runner test + giteaRunnerTest = handleTest ./gitea-runner.nix; }