diff --git a/flake.lock b/flake.lock index f77fde1..984e95b 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,25 @@ { "nodes": { + "eurovote": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778959671, + "narHash": "sha256-MR70Q1lNOX7lqO7PwQUtJdB4+exZr8R10YPQanc5SwE=", + "owner": "anerisgreat", + "repo": "eurovote", + "rev": "245d9b1f3e182653e5cfa0d9689a97f263eb4354", + "type": "github" + }, + "original": { + "owner": "anerisgreat", + "repo": "eurovote", + "type": "github" + } + }, "nixos-hardware": { "locked": { "lastModified": 1776983936, @@ -34,6 +54,7 @@ }, "root": { "inputs": { + "eurovote": "eurovote", "nixos-hardware": "nixos-hardware", "nixpkgs": "nixpkgs", "sops-nix": "sops-nix" diff --git a/flake.nix b/flake.nix index cac0085..6c9c474 100644 --- a/flake.nix +++ b/flake.nix @@ -14,9 +14,15 @@ # We use only the minimal pieces needed for a headless server — # no display, audio, or bluetooth modules. nixos-hardware.url = "github:NixOS/nixos-hardware/master"; + + # Eurovision voting app + eurovote = { + url = "github:anerisgreat/eurovote"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, sops-nix, nixos-hardware, ... }@inputs: + outputs = { self, nixpkgs, sops-nix, nixos-hardware, eurovote, ... }@inputs: let # Shared specialArgs passed to every host commonArgs = { @@ -74,6 +80,8 @@ ./modules/services/uptime-kuma.nix ./modules/services/ntfy.nix ./modules/monitoring.nix + eurovote.nixosModules.default + ./modules/services/eurovote.nix ] ++ extraModules; }; diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 03aca29..2f75a1f 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -95,6 +95,9 @@ # CI/CD homey.giteaRunner.enable = true; + # Eurovision voting app + homey.eurovote.enable = true; + # Monitoring stack homey.uptimeKuma.enable = true; homey.ntfy.enable = true; @@ -186,7 +189,17 @@ # hdparm -B udev rule removed: USB-SATA bridges often don't support APM # commands and hdparm can hang indefinitely, causing boot-time crashes. - environment.systemPackages = [ pkgs.hdparm ]; + environment.systemPackages = [ pkgs.hdparm pkgs.tmux ]; + + systemd.services.nextcloud-generate-previews = { + description = "Generate missing Nextcloud preview thumbnails"; + after = [ "podman-nextcloud.service" ]; + requires = [ "podman-nextcloud.service" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.podman}/bin/podman exec -u www-data nextcloud php occ preview:generate-all"; + }; + }; # ------------------------------------------------------------------------- # Local DNS overrides (optional — makes LAN clients hit the Pi directly diff --git a/modules/backup.nix b/modules/backup.nix index d9f2703..0432f76 100644 --- a/modules/backup.nix +++ b/modules/backup.nix @@ -139,12 +139,15 @@ in # Monitoring — uptime-kuma has monitors/history, ntfy has user accounts "${dataDir}/uptime-kuma" "${dataDir}/ntfy" + # Eurovision Vote — SQLite DB with votes and rankings + "/var/lib/eurovote" ]; # Exclude Nextcloud's raw DB directory in favour of the pg_dump file exclude = [ "${dataDir}/nextcloud/db" "${dataDir}/restic-cache" + "${dataDir}/media" ]; timerConfig = { diff --git a/modules/caddy.nix b/modules/caddy.nix index 78ebc15..f11a0c1 100644 --- a/modules/caddy.nix +++ b/modules/caddy.nix @@ -42,19 +42,16 @@ let # Reusable Authelia forward_auth snippet # Returns a Caddyfile snippet block that applies forward_auth. + # Uses the v4.38+ /api/authz/forward-auth endpoint which correctly honours + # one_factor policy without forcing TOTP enrollment on new users. # copy_headers makes Authelia's Remote-* headers available downstream. autheliaForwardAuth = '' forward_auth localhost:9091 { - uri /api/verify?rd=https://auth.${domain} + uri /api/authz/forward-auth?authelia_url=https://auth.${domain} copy_headers Remote-User Remote-Name Remote-Groups Remote-Email # Always tell Authelia the scheme is https (cloudflared terminates TLS # externally; Caddy's http:// vhosts are only for the tunnel loopback). header_up X-Forwarded-Proto https - # On auth failure, redirect to the authelia login page - @goauth status 401 - handle_response @goauth { - redir https://auth.${domain}?rm={method} 302 - } } ''; @@ -236,6 +233,30 @@ in extraConfig = cfProxy 2586; }; + # ------------------------------------------------------------------ + # Eurovision Vote — one_factor for all authenticated users. + # /admin/* is restricted to group:admins by Authelia access_control. + # Caddy passes Remote-User → X-Remote-User so Django auto-logs in + # the SSO-authenticated user via RemoteUserMiddleware. + # ------------------------------------------------------------------ + "eurovision-vote.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + reverse_proxy localhost:8007 { + header_up X-Remote-User {http.request.header.Remote-User} + } + ''; + }; + "http://eurovision-vote.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + reverse_proxy localhost:8007 { + header_up X-Forwarded-Proto https + header_up X-Remote-User {http.request.header.Remote-User} + } + ''; + }; + # ------------------------------------------------------------------ # Grafana — two_factor, admins only (enforced by authelia policy). # After Authelia verifies the user, Caddy maps the Remote-User header diff --git a/modules/common.nix b/modules/common.nix index ac5d666..c7f8985 100644 --- a/modules/common.nix +++ b/modules/common.nix @@ -26,6 +26,9 @@ # between weekly GC runs. min-free = 2147483648; # 2 GiB max-free = 5368709120; # 5 GiB + # Use the external drive for sandbox builds — the default /tmp is a + # small RAM-backed tmpfs that fills up during large builds (e.g. wrangler). + build-dir = "/mnt/data/nix-build"; }; gc = { automatic = true; @@ -37,6 +40,10 @@ # Allow unfree packages (e.g. cloudflared binary) nixpkgs.config.allowUnfree = true; + systemd.tmpfiles.rules = [ + "d /mnt/data/nix-build 0755 root root -" + ]; + # ------------------------------------------------------------------------- # Boot — set in hardware.nix; this is just a safe default # ------------------------------------------------------------------------- diff --git a/modules/services/authelia.nix b/modules/services/authelia.nix index a20752c..0c36cbf 100644 --- a/modules/services/authelia.nix +++ b/modules/services/authelia.nix @@ -128,6 +128,22 @@ let - domain: - "ntfy.${domain}" policy: "bypass" + # Eurovision Vote: /admin/* for admins only; all others one_factor + - domain: + - "eurovision-vote.${domain}" + resources: + - "^/admin.*$" + subject: + - "group:admins" + policy: "two_factor" + - domain: + - "eurovision-vote.${domain}" + resources: + - "^/admin.*$" + policy: "deny" + - domain: + - "eurovision-vote.${domain}" + policy: "one_factor" notifier: filesystem: diff --git a/modules/services/eurovote.nix b/modules/services/eurovote.nix new file mode 100644 index 0000000..8d96c82 --- /dev/null +++ b/modules/services/eurovote.nix @@ -0,0 +1,60 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Eurovision Vote — Django app for ranking Eurovision performances. +# +# Uses the NixOS module from the eurovote flake (eurovote.nixosModules.default). +# This wrapper wires it into the homey module system: enable flag, sops secret, +# and uptime monitoring. +# +# The app uses DynamicUser + StateDirectory so systemd owns /var/lib/eurovote/; +# no tmpfiles.rules entry needed. +# +# Authentication: Caddy forward_auth → Authelia; the app reads the +# X-Remote-User header set by Caddy (from Authelia's Remote-User). +# All authenticated users get app access; /admin/* is restricted to +# group:admins by Authelia's access_control rules (see authelia.nix). +# +# Secrets consumed from sops: +# eurovote/secret_key + +let + cfg = config.homey.eurovote; + domain = homeyConfig.domain; +in +{ + options.homey.eurovote = { + enable = lib.mkEnableOption "Eurovision Vote app"; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + # mode 0444: the service runs as a DynamicUser (random UID) so it cannot + # read a root-owned 0400 file. /run/secrets/ itself is not world-listable + # (mode 0751), so world-readable here is acceptable on a home server. + sops.secrets."eurovote/secret_key" = { owner = "root"; mode = "0444"; }; + + # ----------------------------------------------------------------------- + # Service (options provided by eurovote.nixosModules.default) + # ----------------------------------------------------------------------- + services.eurovote = { + enable = true; + port = 8007; + allowedHosts = "localhost 127.0.0.1 eurovision-vote.${domain}"; + secretKeyFile = config.sops.secrets."eurovote/secret_key".path; + trustedOrigins = "https://eurovision-vote.${domain}"; + # After SSO logout, send the user back to Authelia's logout page + logoutRedirectUrl = "https://auth.${domain}/logout"; + }; + + # ----------------------------------------------------------------------- + # Uptime Kuma monitor + # ----------------------------------------------------------------------- + homey.monitoring.monitors = [{ + name = "Eurovision Vote"; + url = "https://eurovision-vote.${domain}"; + interval = 60; + }]; + }; +} diff --git a/modules/services/gitea-runner.nix b/modules/services/gitea-runner.nix index 523644f..6f2589f 100644 --- a/modules/services/gitea-runner.nix +++ b/modules/services/gitea-runner.nix @@ -8,8 +8,8 @@ # nested containers on a Pi 4. # # The service uses DynamicUser=true so there is no persistent system user. -# nix/git/bash are available in jobs via the system PATH inherited from the -# service environment. +# Job step PATH is controlled by hostPackages (not the service PATH). +# nix is not in the NixOS module's default hostPackages and must be added. # # Setup (one-time): # 1. In Gitea: Site Administration → Actions → Runners → Create Runner Token @@ -61,8 +61,12 @@ in tokenFile = config.sops.secrets."gitea/runner_token".path; name = cfg.name; labels = cfg.labels; - # nix/git/bash are accessible via the system PATH (/run/current-system/sw/bin/) - # without any extra configuration — the runner inherits it as a system user. + # hostPackages controls the PATH available to job steps (host executor). + # nix is not in the default list so must be added explicitly. + hostPackages = with pkgs; [ + bash coreutils curl gawk gitMinimal gnused nodejs wget + nix + ]; }; }; } diff --git a/modules/services/uptime-kuma.nix b/modules/services/uptime-kuma.nix index d3afa0a..aae647d 100644 --- a/modules/services/uptime-kuma.nix +++ b/modules/services/uptime-kuma.nix @@ -130,11 +130,6 @@ let api.disconnect() sys.exit(1) - # Collect all configured notification IDs so every monitor gets them. - notification_ids = [n["id"] for n in api.get_notifications()] - if notification_ids: - print(f"uptime-kuma-sync: attaching notifications: {notification_ids}") - # Sync monitors: add missing, update changed try: existing = {m["name"]: m for m in api.get_monitors()} @@ -150,7 +145,6 @@ let url=m["url"], interval=m.get("interval", 60), maxretries=maxretries, - notification_id_list={str(nid): True for nid in notification_ids}, **extra, ) print(f"uptime-kuma-sync: created monitor: {m['name']}") @@ -163,7 +157,6 @@ let url=m["url"], interval=m.get("interval", 60), maxretries=maxretries, - notification_id_list={str(nid): True for nid in notification_ids}, **extra, ) print(f"uptime-kuma-sync: updated monitor: {m['name']}") diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index bc8c132..d6e74ef 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -31,6 +31,8 @@ restic: s3_secret_access_key: ENC[AES256_GCM,data:9ZWyhGJm4t2benDrLmnyQ9ZA5Jjl6l+pza1VmymTlw==,iv:xYsG6QlxXhQNO9szmsycxP6lT0cFF7lq3iNg6j+ED0E=,tag:wOJT4Vg3DuNFWTtx3QS9IQ==,type:str] wifi: psk: ENC[AES256_GCM,data:znk9Wr+vsntzbJ3H0TORUrAiDw==,iv:wbl8fUuKlgTqhajwjlTgFS7ijaTwXBFPRW2AmtiTklg=,tag:IK4oe8cJcccPaQ0V0NlncQ==,type:str] +eurovote: + secret_key: ENC[AES256_GCM,data:Re9MTYA46ERXsxucT19K4Pj3rV5i74s8zQ/WYj6GlxeoN1r0Oit6PP0C3PY5Arp6Y6g=,iv:0BnuZ9Uv2RgDwlisrVSvg7ESmNZvd8trggQDSJ42ewM=,tag:SXW2hbprj2qSRzjKY3Aw3Q==,type:str] sops: age: - recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p @@ -42,8 +44,8 @@ sops: QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-05-10T20:36:03Z" - mac: ENC[AES256_GCM,data:aEC9pHnupssTzcw9HdtqkzzhsNkkJMYT3qiwKPLCcIfDMN1Lv3Msi0TJyFjqjR/vzOfAyHFgsPjSWFladL7fOZHpqq2VeNYHPF9/GKEuoEMqsISN2FczqrTHNC8aI/vhZxe3BxgkX9neiHR9v31MRpX9lq6AbCrEJ42hCh6rCxs=,iv:41Dx92loo4zgKt+7iqjgaOZZUe58VAGEGgOEHEuztzQ=,tag:Oagh8hxWfXWuNI70WHULYA==,type:str] + lastmodified: "2026-05-13T20:49:38Z" + mac: ENC[AES256_GCM,data:2iQyp3U5KYCHIWvBsEyz8XFLTtQ5dN+2TF1gkFADFCyyJLAPWAxYPSH60d7fhJ5qhs7IJ7GV/N1J23JsXV+jyqS95foF9ThYT/wNeh4cAPGWB5RbnpP9RsYt8nCEIl/RHkkGmnS9HUO2HHpqo7hMUGRCHMLYMxJxHdPGrm+KHgA=,iv:D+x06308n14/xkRR9WvD6MYcORVM+crIH20+oHesHds=,tag:q7L7OyXEXThYFEkPrgzSBw==,type:str] pgp: - created_at: "2026-04-21T06:39:49Z" enc: |- diff --git a/shells/defaultShell.nix b/shells/defaultShell.nix index f4bd164..e07d0d4 100644 --- a/shells/defaultShell.nix +++ b/shells/defaultShell.nix @@ -2,6 +2,7 @@ pkgs.mkShell { buildInputs = with pkgs; [ alejandra + sops (pkgs.writeShellScriptBin "homey-deploy-rpi-main" '' nixos-rebuild switch \ --flake .#pi-main \