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 0000000..5c52818 Binary files /dev/null and b/secrets/ci-deploy-key.age differ diff --git a/secrets/coturn-auth-secret.age b/secrets/coturn-auth-secret.age index d70dd04..6c2046b 100644 Binary files a/secrets/coturn-auth-secret.age and b/secrets/coturn-auth-secret.age differ diff --git a/secrets/git-crypt-key-dotfiles.age b/secrets/git-crypt-key-dotfiles.age new file mode 100644 index 0000000..2547cfe Binary files /dev/null and b/secrets/git-crypt-key-dotfiles.age differ diff --git a/secrets/git-crypt-key-server-config.age b/secrets/git-crypt-key-server-config.age new file mode 100644 index 0000000..19931fe Binary files /dev/null and b/secrets/git-crypt-key-server-config.age differ diff --git a/secrets/gitea-runner-token.age b/secrets/gitea-runner-token.age new file mode 100644 index 0000000..3c9d38e Binary files /dev/null and b/secrets/gitea-runner-token.age differ diff --git a/secrets/matrix-reg-token.age b/secrets/matrix-reg-token.age index e5a7398..e2e7405 100644 Binary files a/secrets/matrix-reg-token.age and b/secrets/matrix-reg-token.age differ diff --git a/secrets/murmur-password-env.age b/secrets/murmur-password-env.age index 615f912..825b10e 100644 Binary files a/secrets/murmur-password-env.age and b/secrets/murmur-password-env.age differ diff --git a/secrets/ntfy-alerts-topic.age b/secrets/ntfy-alerts-topic.age index 2fc770f..0e67825 100644 Binary files a/secrets/ntfy-alerts-topic.age and b/secrets/ntfy-alerts-topic.age differ 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; }