Harden server and add Nix-native CI + self-hosted Gitea Actions
CI / flake-check (aarch64-linux) (push) Failing after 8s
CI / flake-check (x86_64-linux) (push) Failing after 2s
CI / pre-commit (aarch64-linux) (push) Failing after 2s
CI / pre-commit (x86_64-linux) (push) Failing after 2s

- caddy: security headers (X-Content-Type-Options/X-XSS-Protection/
  X-Frame-Options) on all vhosts + baseline CSP; strip SnappyMail
  upstream copies via header_down on mail.severijnse.eu
- tlsa-updater: compute TLSA 3 1 1 from cert SPKI (SHA-256), sync
  _25/_465/_993, fail-safe placeholders; coredns zone updated
- pre-commit: wire cachix/git-hooks.nix (alejandra, statix, actionlint,
  ...); CI pre-commit job over x86_64 + aarch64 matrix
- gitea: enable Gitea Actions + self-hosted runner (native:host,
  aarch64 via binfmt); add .gitea/workflows/ci.yml and local hook
- fix statix warnings (merge repeated systemd/database/configFile keys,
  inherit, bool-compare guards); add missing trailing newlines
This commit is contained in:
2026-07-12 00:05:13 +02:00
parent c5f771bd53
commit 7ed54e51a2
23 changed files with 336 additions and 147 deletions
+40 -44
View File
@@ -71,37 +71,48 @@
severijnse.eu IN SSHFP 4 2 c02f4b13aef78579f3466851fcc741c0169eaa63237463ceacc9bb72a2be0519
'';
in {
# Decrypt DNSSEC keys from sops-encrypted file using the age key at /etc/age/keys.txt
# Uses sops CLI directly instead of sops-nix's sops-install-secrets (avoids Go 1.25 build dep)
systemd.services.decrypt-coredns-keys = {
description = "Decrypt CoreDNS DNSSEC signing keys";
before = ["coredns.service"];
wantedBy = ["coredns.service"];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Environment = "SOPS_AGE_KEY_FILE=/etc/age/keys.txt";
systemd = {
services = {
decrypt-coredns-keys = {
description = "Decrypt CoreDNS DNSSEC signing keys";
before = ["coredns.service"];
wantedBy = ["coredns.service"];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Environment = "SOPS_AGE_KEY_FILE=/etc/age/keys.txt";
};
script = ''
SOPS_FILE=${../../secrets/coredns-keys.yaml}
KEYS_DIR=/var/lib/coredns/keys
mkdir -p "$KEYS_DIR"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_ksk_key"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+20930.key"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_ksk_private"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+20930.private"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_zsk_key"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+38678.key"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_zsk_private"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+38678.private"
chmod 644 "$KEYS_DIR/"*
'';
};
coredns = {
after = ["decrypt-coredns-keys.service"];
requires = ["decrypt-coredns-keys.service"];
# Copy zone file from Nix store to writable location on service start
# TLSA updater will modify the writable copy at runtime
preStart = ''
cp -f ${zoneFile} /var/lib/coredns/zones/severijnse.eu.db
chown coredns:coredns /var/lib/coredns/zones/severijnse.eu.db
'';
};
};
script = ''
SOPS_FILE=${../../secrets/coredns-keys.yaml}
KEYS_DIR=/var/lib/coredns/keys
mkdir -p "$KEYS_DIR"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_ksk_key"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+20930.key"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_ksk_private"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+20930.private"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_zsk_key"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+38678.key"
${pkgs.sops}/bin/sops --decrypt --extract '["coredns_zsk_private"]' "$SOPS_FILE" > "$KEYS_DIR/Kseverijnse.eu.+013+38678.private"
chmod 644 "$KEYS_DIR/"*
'';
tmpfiles.rules = [
"d /var/lib/coredns 0750 coredns coredns -"
"d /var/lib/coredns/zones 0750 coredns coredns -"
"d /var/lib/coredns/keys 0750 coredns coredns -"
];
};
# Ensure coredns waits for key decryption
systemd.services.coredns = {
after = ["decrypt-coredns-keys.service"];
requires = ["decrypt-coredns-keys.service"];
};
services.coredns = {
enable = true;
config = ''
@@ -126,19 +137,4 @@ in {
}
'';
};
# Copy zone file from Nix store to writable location on service start
# TLSA updater will modify the writable copy at runtime
systemd.services.coredns = {
preStart = ''
cp -f ${zoneFile} /var/lib/coredns/zones/severijnse.eu.db
chown coredns:coredns /var/lib/coredns/zones/severijnse.eu.db
'';
};
systemd.tmpfiles.rules = [
"d /var/lib/coredns 0750 coredns coredns -"
"d /var/lib/coredns/zones 0750 coredns coredns -"
"d /var/lib/coredns/keys 0750 coredns coredns -"
];
}
+79 -38
View File
@@ -5,50 +5,85 @@
unstablePkgs,
...
}: {
services.postgresql = {
enable = true;
package = pkgs.postgresql_14;
ensureDatabases = ["gitea"];
ensureUsers = [
{
services = {
postgresql = {
enable = true;
package = pkgs.postgresql_14;
ensureDatabases = ["gitea"];
ensureUsers = [
{
name = "gitea";
ensureDBOwnership = true;
}
];
};
gitea = {
enable = true;
package = unstablePkgs.gitea;
database = {
type = "postgres";
name = "gitea";
ensureDBOwnership = true;
}
];
user = "gitea";
};
appName = "Jory's Git";
lfs.enable = true;
settings = {
server = {
DOMAIN = "git.severijnse.eu";
ROOT_URL = "https://git.severijnse.eu/";
HTTP_PORT = 3000;
SSH_PORT = 222;
SSH_LISTEN_PORT = 2222;
START_SSH_SERVER = true;
SSH_USER = "git";
BUILTIN_SSH_SERVER_USER = "git";
LANDING_PAGE = "explore";
};
service = {
DISABLE_REGISTRATION = true;
REQUIRE_SIGNIN_VIEW = false;
};
repository = {
DEFAULT_BRANCH = "main";
};
actions = {
ENABLED = true;
};
};
};
gitea-actions-runner = {
# nixos-24-05's gitea-actions-runner module hardcodes bin/act_runner,
# but the current upstream package (1.0.3, matching gitea 1.26) ships
# bin/gitea-runner. Wrap it so both names resolve.
package = pkgs.runCommand "gitea-actions-runner-wrapped" {} ''
mkdir -p $out/bin
ln -s ${unstablePkgs.gitea-actions-runner}/bin/gitea-runner $out/bin/act_runner
'';
instances.default = {
enable = true;
name = "hetzner";
url = "https://git.severijnse.eu";
tokenFile = "/var/lib/secrets/gitea-runner-token";
labels = ["native:host"];
hostPackages = with pkgs; [
bash
coreutils
curl
gawk
gitMinimal
gnused
nodejs
wget
];
};
};
};
# Gitea connects to local Postgres via Unix socket (peer auth).
# No password needed — the socket is at /run/postgresql by default.
# createDatabase = true ensures the DB + user are set up automatically.
services.gitea = {
enable = true;
package = unstablePkgs.gitea;
database.type = "postgres";
database.name = "gitea";
database.user = "gitea";
appName = "Jory's Git";
lfs.enable = true;
settings = {
server = {
DOMAIN = "git.severijnse.eu";
ROOT_URL = "https://git.severijnse.eu/";
HTTP_PORT = 3000;
SSH_PORT = 222;
SSH_LISTEN_PORT = 2222;
START_SSH_SERVER = true;
SSH_USER = "git";
BUILTIN_SSH_SERVER_USER = "git";
LANDING_PAGE = "explore";
};
service = {
DISABLE_REGISTRATION = true;
REQUIRE_SIGNIN_VIEW = false;
};
repository = {
DEFAULT_BRANCH = "main";
};
};
};
# Gitea built-in SSH server: listens on high port 2222 (no privileged-cap needed),
# while clone URLs advertise port 222. Firewall redirects 222 -> 2222.
@@ -61,4 +96,10 @@
${pkgs.nftables}/bin/nft add chain inet gitea-redirect prerouting '{ type nat hook prerouting priority dstnat; }' 2>/dev/null || true
${pkgs.nftables}/bin/nft add rule inet gitea-redirect prerouting tcp dport 222 redirect to :2222 2>/dev/null || true
'';
# --- Gitea Actions self-hosted CI runner ---
# Jobs install their own Nix inside the runner (official installer,
# --no-daemon) so no system Nix daemon / nix-users group is needed.
# aarch64 builds run under QEMU user-emulation via boot.binfmt below.
boot.binfmt.emulatedSystems = ["aarch64-linux"];
}
@@ -58,42 +58,44 @@
echo "tlsa-update: TLSA set to $HEX"
'';
in {
# Ensure the distribution dir exists (Caddy does not write here).
systemd.tmpfiles.rules = [
"d ${distCertDir} 0755 root root - -"
];
systemd = {
# Ensure the distribution dir exists (Caddy does not write here).
tmpfiles.rules = [
"d ${distCertDir} 0755 root root - -"
];
systemd.services.tlsa-update = {
description = "Sync Caddy TLS certificate to mail server and update DANE/TLSA records";
after = ["caddy.service" "coredns.service"];
partOf = ["coredns.service"];
wantedBy = ["multi-user.target"];
path = with pkgs; [openssl coreutils gnused podman systemd];
serviceConfig = {
Type = "oneshot";
ExecStart = "${syncScript}";
User = "root";
Group = "root";
services.tlsa-update = {
description = "Sync Caddy TLS certificate to mail server and update DANE/TLSA records";
after = ["caddy.service" "coredns.service"];
partOf = ["coredns.service"];
wantedBy = ["multi-user.target"];
path = with pkgs; [openssl coreutils gnused podman systemd];
serviceConfig = {
Type = "oneshot";
ExecStart = "${syncScript}";
User = "root";
Group = "root";
};
};
};
# Fire as soon as Caddy rewrites the certificate on renewal (the atomic rewrite
# changes the directory mtime), eliminating the up-to-24h DANE drift window.
systemd.paths.tlsa-update = {
description = "Watch Caddy certificate directory for renewal";
wantedBy = ["paths.target"];
pathConfig = {
PathModified = [caddyCertDir];
Unit = "tlsa-update.service";
# Fire as soon as Caddy rewrites the certificate on renewal (the atomic rewrite
# changes the directory mtime), eliminating the up-to-24h DANE drift window.
paths.tlsa-update = {
description = "Watch Caddy certificate directory for renewal";
wantedBy = ["paths.target"];
pathConfig = {
PathModified = [caddyCertDir];
Unit = "tlsa-update.service";
};
};
};
# Fallback in case a renewal event is missed (e.g. inotify overflow).
systemd.timers.tlsa-update = {
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
# Fallback in case a renewal event is missed (e.g. inotify overflow).
timers.tlsa-update = {
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
};
};
}