diff --git a/AGENTS.md b/AGENTS.md index b3fd80c..4954fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ modules/ storage.nix # External HD mount + per-service directory layout caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth) cloudflared.nix # Cloudflare Tunnel for remote access - backup.nix # Restic daily backups + backup.nix # Restic daily backups (S3 primary + manual offload) services/ openldap.nix # OpenLDAP — central identity provider authelia.nix # Authelia — SSO gateway @@ -226,19 +226,54 @@ production-ready: - [ ] **`hosts/pi-main/default.nix` — fill in real values**: - SSH public key in `users.users.admin.openssh.authorizedKeys.keys` - External HD device path in `homey.storage.device` - - Backup repository URL in `homey.backup.repository` + - Backup repository URL in `homey.backup.repository` — must be an S3-compatible + URL, e.g. `"s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name"` - [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret - values (old passwords from k8s + freshly generated ones), then run + values (old passwords from k8s + freshly generated ones, including + `restic/s3_access_key_id` and `restic/s3_secret_access_key`), then run `sops --encrypt --in-place secrets/secrets.yaml` before committing. -- [ ] **`secrets/.sops.yaml` — add real age keys**: Replace both - `AGE-PUBLIC-KEY-*` placeholders with actual public keys (workstation + Pi). +- [x] **`secrets/.sops.yaml` — PGP key**: The encryption subkey + `076AA297579A0064` is already in `.sops.yaml`. - [ ] **Cloudflare Tunnel**: Create the tunnel in the Zero Trust dashboard, copy the tunnel token into secrets, and configure public hostnames. See `modules/cloudflared.nix` and Phase 3 of `PORTING.md` for details. +- [ ] **Second machine**: When ready, add `hosts/pi-secondary/` and uncomment + the `pi-secondary` entry in `flake.nix`. Services communicating cross-machine + should reference the primary Pi's LAN IP instead of `127.0.0.1`. + +- [ ] **Jellyfin and Transmission**: Both modules are written and importable + but disabled. Enable in `hosts/pi-main/default.nix` when ready: + ```nix + homey.jellyfin.enable = true; + homey.transmission.enable = true; + ``` + +- [ ] **Backup — S3 credentials**: Add `restic/s3_access_key_id` and + `restic/s3_secret_access_key` to secrets, and set `homey.backup.repository` + to your S3-compatible bucket URL in `hosts/pi-main/default.nix`. + +- [ ] **Backup — offload script**: Write `scripts/offload-backup.sh` for + manually copying snapshots to a local disk (USB attached to Pi, or a disk + on your workstation). Uses `restic copy` to clone from the S3 repo into a + local restic repo on the target path. See `TODO.org` for design notes. + +### Post- Pi first boot + +These items require the Pi to be built, flashed, and booted at least once. + +- [ ] **`secrets/.sops.yaml` — add Pi age key**: After generating the age key + on the Pi (`age-keygen -o /var/lib/sops-nix/key.txt`), add the public key + to `.sops.yaml` alongside the existing PGP key, then run + `sops updatekeys secrets/secrets.yaml`. + +- [ ] **`hosts/pi-main/hardware.nix` — verify SD card labels**: The file + assumes partition labels `NIXOS_SD` (root) and `FIRMWARE` (boot). Relabel + after flashing if they differ, or update the `fileSystems` entries. + - [ ] **Gitea LDAP auth**: After first start, configure LDAP authentication in Gitea's admin panel (Admin → Authentication Sources → Add LDAP source). The old Helm chart had this commented out; it must be done manually once. @@ -250,18 +285,3 @@ production-ready: - [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify the LDAP Users and Contacts app is still configured correctly (Admin → LDAP/AD Integration). - -- [ ] **`hosts/pi-main/hardware.nix` — verify SD card labels**: The file - assumes partition labels `NIXOS_SD` (root) and `FIRMWARE` (boot). Relabel - after flashing if they differ, or update the `fileSystems` entries. - -- [ ] **Second machine**: When ready, add `hosts/pi-secondary/` and uncomment - the `pi-secondary` entry in `flake.nix`. Services communicating cross-machine - should reference the primary Pi's LAN IP instead of `127.0.0.1`. - -- [ ] **Jellyfin and Transmission**: Both modules are written and importable - but disabled. Enable in `hosts/pi-main/default.nix` when ready: - ```nix - homey.jellyfin.enable = true; - homey.transmission.enable = true; - ``` diff --git a/flake.nix b/flake.nix index 8ae7fe4..d03d987 100644 --- a/flake.nix +++ b/flake.nix @@ -1,8 +1,20 @@ { description = "Homey - self-hosted home server NixOS configuration"; + # Binary cache for pre-built Raspberry Pi kernel + firmware packages. + # nixos-raspberrypi builds against its own pinned nixpkgs and publishes + # to this cache — using it avoids compiling linuxPackages_rpi4 from source. + nixConfig = { + extra-substituters = [ + "https://nixos-raspberrypi.cachix.org" + ]; + extra-trusted-public-keys = [ + "nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI=" + ]; + }; + inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; # sops-nix for secret management sops-nix = { @@ -10,17 +22,18 @@ inputs.nixpkgs.follows = "nixpkgs"; }; - # Caddy with Cloudflare DNS plugin (not in nixpkgs mainline) - caddy-cloudflare = { - url = "github:NixOS/nixpkgs/nixos-24.11"; # see modules/caddy.nix for override - }; + # Raspberry Pi hardware support — provides vendor kernel, firmware, + # bootloader management, and a binary cache for pre-built aarch64 packages. + # Intentionally NOT following our nixpkgs: the cache is built against the + # flake's own pinned nixpkgs, so following would invalidate all cache hits. + nixos-raspberrypi.url = "github:nvmd/nixos-raspberrypi/main"; }; - outputs = { self, nixpkgs, sops-nix, ... }@inputs: + outputs = { self, nixpkgs, sops-nix, nixos-raspberrypi, ... }@inputs: let # Shared specialArgs passed to every host commonArgs = { - inherit inputs; + inherit inputs nixos-raspberrypi; # Top-level site config — override per-host if needed homeyConfig = { domain = "home.zakobar.com"; # base domain for all services @@ -31,12 +44,24 @@ }; }; - mkHost = { system, hostPath, extraModules ? [] }: - nixpkgs.lib.nixosSystem { - inherit system; + # nixos-raspberrypi.lib.nixosSystem is a drop-in replacement for + # nixpkgs.lib.nixosSystem that: + # - injects vendor kernel/firmware overlays + # - wires up the trusted cache substituters + # - passes nixos-raspberrypi into specialArgs automatically + # It uses the flake's own pinned nixpkgs by default (currently 25.11). + mkHost = { hostPath, extraModules ? [] }: + nixos-raspberrypi.lib.nixosSystem { specialArgs = commonArgs; modules = [ sops-nix.nixosModules.sops + # RPi 4 base: vendor kernel (linuxPackages_rpi4), firmware, + # bootloader (u-boot), initrd modules, config.txt management + nixos-raspberrypi.nixosModules.raspberry-pi-4.base + # SD image target — provides system.build.sdImage + ({ modulesPath, ... }: { + imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ]; + }) hostPath ./modules/common.nix ./modules/storage.nix @@ -56,15 +81,72 @@ in { nixosConfigurations = { + # Bootstrap image — flash this first. + # Minimal: SSH key, WiFi, static IP. No sops, no services. + # Purpose: boot the Pi, generate the age key, then deploy pi-main. + pi-main-bootstrap = nixos-raspberrypi.lib.nixosSystem { + specialArgs = commonArgs; + modules = [ + nixos-raspberrypi.nixosModules.raspberry-pi-4.base + ({ modulesPath, ... }: { + imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ]; + }) + ./hosts/pi-main/hardware.nix + ({ pkgs, lib, ... }: { + networking.hostName = "pi-main"; + time.timeZone = commonArgs.homeyConfig.timezone; + i18n.defaultLocale = "en_US.UTF-8"; + system.stateVersion = "25.05"; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + nixpkgs.config.allowUnfree = true; + + # WiFi — PSK inline (bootstrap only, not in Nix store long-term) + networking.wireless = { + enable = true; + networks."Zakobar".psk = "0502711157"; + }; + networking.interfaces.wlan0.ipv4.addresses = [{ + address = "192.168.1.100"; + prefixLength = 24; + }]; + networking.useDHCP = false; + networking.interfaces.wlan0.useDHCP = false; + networking.defaultGateway = "192.168.1.1"; + networking.nameservers = [ "1.1.1.1" "8.8.8.8" ]; + networking.firewall.allowedTCPPorts = [ 22 ]; + + # SSH — key only, no passwords, no root + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + PermitRootLogin = "no"; + }; + }; + + users.mutableUsers = false; + users.users.admin = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + openssh.authorizedKeys.keys = [ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDBFZRqiTsOCAJPMqUyMeLd2MbyjdGoyqDVq5/Inhb6EOaM1NUGG4b6FPmYgFLyJIm5LC9BOo6M7npiaiOs/zMqp+hoGLNQUNwm5/G0uy1bjkEfKdUTdGnJ2+M9rkxrR1c+KXrjkiqECqTbnPE4mJbGyVxBW2MwMeP5w8c0DB5KO528PetvHMPPQuEdXyZzDI4kKtVpMlJoPIrIGlNFX0G/wrgXcM4zU1snOTuYGqZnWW++4kBsgIlRKpf/bLJyUMTp30eLVr0fQ6OMBtj1tzUUBaaowU6VGYQQDU/rIh/NpkA2cEVPXZegM4OohkAqrJBFPIAg90WD9Z/SyQlz0Jn8PpAloP0Cuq2vVRr+QLEwxqGiFq91YQ2VtwksMHwJGVrXRCNegpxTZQijWMEd+o0FD2cEd7Ftw6v2L6g12GJ3QGX/q0d/u0GongLLa9fPXl4VoAu7AL+cUcbX/SS7RCG8kYAR3DwOazVbK0NWEdwvWdoSU4lZ3j2at1xqMGjHjyLiTeUqZBjm+Sl5MJWIYNg+8hnONljvggg4SzDFDAkgVLZtOCaZibsMA1ucGR7VRCM09uoaEI4/ZS5pCBtYcp8X67Bv67Og8s2NFf5sUfYBPPKpdBSs+dEPycNVff6JlmzfNiyzLawacGKIDWYSgkOl43N/5ehtpsL3HMZ+5SVNIw==" + ]; + }; + security.sudo.wheelNeedsPassword = false; + + environment.systemPackages = [ pkgs.age pkgs.vim ]; + }) + ]; + }; + # Primary Raspberry Pi 4 pi-main = mkHost { - system = "aarch64-linux"; hostPath = ./hosts/pi-main/default.nix; }; # Future second machine (placeholder — uncomment and configure when ready) # pi-secondary = mkHost { - # system = "x86_64-linux"; # or aarch64-linux for another Pi # hostPath = ./hosts/pi-secondary/default.nix; # }; diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 87b5e2c..747b513 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -14,6 +14,34 @@ # ------------------------------------------------------------------------- networking.hostName = "pi-main"; + # ------------------------------------------------------------------------- + # WiFi — static IP, always connect to home network + # ------------------------------------------------------------------------- + networking.wireless = { + enable = true; + # secretsFile is read by wpa_supplicant at runtime; values are literal + # (not env vars). The key name after "ext:" must match a line in the file + # formatted as: key_name=the-actual-password + secretsFile = config.sops.secrets."wifi/psk".path; + networks."Zakobar".pskRaw = "ext:wifi_psk"; + }; + + # Static IP on wlan0 + networking.interfaces.wlan0.ipv4.addresses = [{ + address = "192.168.1.100"; + prefixLength = 24; + }]; + networking.defaultGateway = "192.168.1.1"; + networking.nameservers = [ "1.1.1.1" "8.8.8.8" ]; + + # Disable DHCP on wlan0 — we're using a static address + networking.useDHCP = false; + networking.interfaces.wlan0.useDHCP = false; + + # The secret file must contain exactly one line: wifi_psk= + # Add it with: sops secrets/secrets.yaml → wifi/psk: "wifi_psk=YourPassword" + sops.secrets."wifi/psk" = { owner = "root"; mode = "0400"; }; + # ------------------------------------------------------------------------- # Admin user # ------------------------------------------------------------------------- @@ -22,7 +50,7 @@ extraGroups = [ "wheel" "podman" ]; # Paste your SSH public key here openssh.authorizedKeys.keys = [ - "ssh-ed25519 AAAA... your-key-here" + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDBFZRqiTsOCAJPMqUyMeLd2MbyjdGoyqDVq5/Inhb6EOaM1NUGG4b6FPmYgFLyJIm5LC9BOo6M7npiaiOs/zMqp+hoGLNQUNwm5/G0uy1bjkEfKdUTdGnJ2+M9rkxrR1c+KXrjkiqECqTbnPE4mJbGyVxBW2MwMeP5w8c0DB5KO528PetvHMPPQuEdXyZzDI4kKtVpMlJoPIrIGlNFX0G/wrgXcM4zU1snOTuYGqZnWW++4kBsgIlRKpf/bLJyUMTp30eLVr0fQ6OMBtj1tzUUBaaowU6VGYQQDU/rIh/NpkA2cEVPXZegM4OohkAqrJBFPIAg90WD9Z/SyQlz0Jn8PpAloP0Cuq2vVRr+QLEwxqGiFq91YQ2VtwksMHwJGVrXRCNegpxTZQijWMEd+o0FD2cEd7Ftw6v2L6g12GJ3QGX/q0d/u0GongLLa9fPXl4VoAu7AL+cUcbX/SS7RCG8kYAR3DwOazVbK0NWEdwvWdoSU4lZ3j2at1xqMGjHjyLiTeUqZBjm+Sl5MJWIYNg+8hnONljvggg4SzDFDAkgVLZtOCaZibsMA1ucGR7VRCM09uoaEI4/ZS5pCBtYcp8X67Bv67Og8s2NFf5sUfYBPPKpdBSs+dEPycNVff6JlmzfNiyzLawacGKIDWYSgkOl43N/5ehtpsL3HMZ+5SVNIw==" ]; }; @@ -34,7 +62,7 @@ homey.storage = { # Replace with the actual by-id path of your USB drive. # Find it: ls -la /dev/disk/by-id/ | grep -v part - device = "/dev/disk/by-id/REPLACE-WITH-YOUR-DRIVE-ID"; + device = "/dev/disk/by-id/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1"; mountPoint = "/mnt/data"; fsType = "ext4"; }; @@ -66,7 +94,7 @@ # "sftp:user@nas.local:/backups/homey" # "b2:your-bucket-name:homey" # "rclone:remote:homey" - homey.backup.repository = "sftp:REPLACE-WITH-BACKUP-DESTINATION"; + homey.backup.repository = "s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup"; # ------------------------------------------------------------------------- # Local DNS overrides (optional — makes LAN clients hit the Pi directly diff --git a/hosts/pi-main/hardware.nix b/hosts/pi-main/hardware.nix index 4127cca..d341ecf 100644 --- a/hosts/pi-main/hardware.nix +++ b/hosts/pi-main/hardware.nix @@ -2,56 +2,37 @@ # Hardware configuration for the primary Raspberry Pi 4 (8 GB). # -# SD card layout assumed: -# /dev/mmcblk0p1 — /boot/firmware (FAT32, ~256 MB) -# /dev/mmcblk0p2 — / (ext4) +# nixos-raspberrypi's raspberry-pi-4.base module (imported in flake.nix) +# provides everything that nixos-hardware.raspberry-pi-4 previously did: +# - linuxPackages_rpi4 vendor kernel + matching firmware +# - u-boot bootloader with /boot/firmware partition management +# - initrd modules (xhci_pci, usbhid, usb_storage, vc4, pcie_brcmstb, etc.) +# - config.txt generation +# +# This file adds only host-specific overrides on top of that. # # External HD: # Set homey.storage.device to the by-id path of your USB drive. -# Example: /dev/disk/by-id/usb-WD_Elements_12345-0:0-part1 # Find it with: ls -la /dev/disk/by-id/ # -# To generate this file fresh after installing NixOS on the Pi, run: -# nixos-generate-config --show-hardware-config -# and merge the output here. +# TODO: Verify SD card partition labels after first flash. +# The config assumes labels NIXOS_SD (root) and FIRMWARE (boot). +# Check with: lsblk -o NAME,LABEL +# Update fileSystems entries below if they differ. { - imports = [ - (modulesPath + "/installer/scan/not-detected.nix") - ]; + # tmpfs for /tmp — keep the SD card writes down + boot.tmp.useTmpfs = true; - # ------------------------------------------------------------------------- - # Boot loader — Raspberry Pi 4 uses U-Boot / extlinux - # ------------------------------------------------------------------------- - boot = { - loader = { - grub.enable = false; - generic-extlinux-compatible.enable = true; - }; - - # Pi 4 kernel — use the mainline kernel with RPi patches - kernelPackages = pkgs.linuxPackages_rpi4; - - # tmpfs for /tmp — keep the SD card writes down - tmp.useTmpfs = true; - - # Modules needed for USB storage (external HD) - initrd.availableKernelModules = [ "xhci_pci" "usbhid" "usb_storage" "uas" ]; - kernelModules = []; - extraModulePackages = []; - }; - - # ------------------------------------------------------------------------- # Filesystems - # ------------------------------------------------------------------------- fileSystems."/" = { - device = "/dev/disk/by-label/NIXOS_SD"; # label the root partition NIXOS_SD when flashing + device = "/dev/disk/by-label/NIXOS_SD"; fsType = "ext4"; options = [ "noatime" ]; }; fileSystems."/boot/firmware" = { - device = "/dev/disk/by-label/FIRMWARE"; # FAT32 boot partition + device = "/dev/disk/by-label/FIRMWARE"; fsType = "vfat"; options = [ "fmask=0022" "dmask=0022" ]; }; @@ -61,24 +42,9 @@ swapDevices = []; - # ------------------------------------------------------------------------- - # Hardware - # ------------------------------------------------------------------------- - hardware = { - # Enable the RPi firmware (needed for GPU, WiFi, Bluetooth) - raspberry-pi."4".apply-overlays-dtmerge.enable = true; - - # Disable GPU memory split for a headless server (gives more RAM to OS) - # Set via config.txt if needed: gpu_mem=16 - }; - - # ------------------------------------------------------------------------- # Platform - # ------------------------------------------------------------------------- nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux"; - # ------------------------------------------------------------------------- # Power management - # ------------------------------------------------------------------------- powerManagement.cpuFreqGovernor = lib.mkDefault "ondemand"; } diff --git a/modules/backup.nix b/modules/backup.nix index b89aefa..8e3f248 100644 --- a/modules/backup.nix +++ b/modules/backup.nix @@ -8,11 +8,27 @@ # Before a backup, Nextcloud is put into maintenance mode and postgres is # pg_dump'd to a file. This ensures consistent DB backups. # +# Backup strategy — two tiers: +# +# 1. Automatic daily backup to an S3-compatible bucket (primary offsite copy). +# Set the repository URL to your bucket in hosts/pi-main/default.nix, e.g.: +# homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket"; +# S3 credentials are injected via environment variables from sops secrets: +# restic/s3_access_key_id → AWS_ACCESS_KEY_ID +# restic/s3_secret_access_key → AWS_SECRET_ACCESS_KEY +# +# 2. Manual offload to a local disk (USB drive plugged into Pi, or workstation disk). +# Use scripts/offload-backup.sh --target /path/to/mounted/disk +# That script uses `restic copy` to clone snapshots from the S3 repo into a +# local restic repo on the target disk, preserving deduplication. +# # Secrets consumed from sops: # restic/password +# restic/s3_access_key_id (if using S3 backend) +# restic/s3_secret_access_key (if using S3 backend) # # The backup repository URL is set per-host in default.nix: -# homey.backup.repository = "sftp:user@nas:/backups/homey"; +# homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/bucket"; # # Restore: # restic -r restore latest --target /mnt/data @@ -58,7 +74,9 @@ in # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- - sops.secrets."restic/password" = { owner = "root"; }; + sops.secrets."restic/password" = { owner = "root"; }; + sops.secrets."restic/s3_access_key_id" = { owner = "root"; }; + sops.secrets."restic/s3_secret_access_key" = { owner = "root"; }; # ----------------------------------------------------------------------- # Pre-backup hook: pg_dump + nextcloud maintenance mode @@ -105,7 +123,9 @@ in services.restic.backups.homey = { repository = cfg.repository; passwordFile = config.sops.secrets."restic/password".path; - cacheDir = "${dataDir}/restic-cache"; + + # Runtime env file written by ExecStartPre (see systemd override below) + environmentFile = "/run/restic-homey-secrets.env"; paths = [ "${dataDir}/openldap" @@ -136,10 +156,31 @@ in ]; }; - # Wire the pre/post hooks around the restic job + # Wire the pre/post hooks around the restic job and inject secrets systemd.services."restic-backups-homey" = { requires = [ "homey-backup-pre.service" ]; after = [ "homey-backup-pre.service" ]; + serviceConfig = { + # Write runtime env file with actual secret values (restic needs the + # raw values; it does not support _FILE suffix env vars). + ExecStartPre = [ + (pkgs.writeShellScript "restic-inject-secrets" '' + install -m 0600 /dev/null /run/restic-homey-secrets.env + { + printf 'AWS_ACCESS_KEY_ID=%s\n' \ + "$(cat ${config.sops.secrets."restic/s3_access_key_id".path})" + printf 'AWS_SECRET_ACCESS_KEY=%s\n' \ + "$(cat ${config.sops.secrets."restic/s3_secret_access_key".path})" + printf 'RESTIC_CACHE_DIR=%s\n' "${dataDir}/restic-cache" + } >> /run/restic-homey-secrets.env + '') + ]; + ExecStopPost = [ + (pkgs.writeShellScript "restic-cleanup-secrets" '' + rm -f /run/restic-homey-secrets.env + '') + ]; + }; }; systemd.services."homey-backup-post" = { diff --git a/modules/caddy.nix b/modules/caddy.nix index 901ca2b..06eae27 100644 --- a/modules/caddy.nix +++ b/modules/caddy.nix @@ -18,16 +18,14 @@ let cfg = config.homey.caddy; domain = homeyConfig.domain; - # Build Caddy with the Cloudflare DNS plugin. - # This compiles on the Pi (slow once, cached after). - caddyWithCloudflare = pkgs.caddy.override { - externalPlugins = [ - { - name = "github.com/caddy-dns/cloudflare"; - version = "89f16b99c18ef49c8bb470a82f895bce01cbaece"; - } + # Build Caddy with the Cloudflare DNS plugin using the nixos-25.05 API. + # `withPlugins` is a passthru function on the caddy package; it uses xcaddy + # under the hood to produce a fixed-output derivation. + caddyWithCloudflare = pkgs.caddy.withPlugins { + plugins = [ + "github.com/caddy-dns/cloudflare@v0.2.2-0.20250724223520-f589a18c0f5d" ]; - vendorHash = lib.fakeHash; # replace with real hash after first build + hash = "sha256-2Fb2fgM7YhWk9kBnnNGb85MJkAkgzXiI1fb6eK3ykIE="; }; # Reusable Authelia forward_auth snippet @@ -147,34 +145,41 @@ in "torrent.${domain}" = { extraConfig = '' ${autheliaForwardAuth} - reverse_proxy localhost:9091_transmission + reverse_proxy localhost:9092 ''; - # NOTE: transmission uses 9091 too; we'll bind it to 9092 in its - # module to avoid a clash with authelia. + # NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091. }; }; }; # ----------------------------------------------------------------------- - # Pass Cloudflare token as env var to the caddy systemd unit + # Pass Cloudflare token as env var to the caddy systemd unit. + # + # The caddy-dns/cloudflare plugin reads CLOUDFLARE_API_TOKEN directly. + # sops decrypts the secret to a file at runtime; we write a transient + # env file to /run/ in ExecStartPre so systemd picks it up via + # EnvironmentFile. The file is removed in ExecStopPost. # ----------------------------------------------------------------------- systemd.services.caddy = { serviceConfig = { - EnvironmentFile = pkgs.writeText "caddy-cf-env" - "CLOUDFLARE_API_TOKEN_FILE=${config.sops.secrets."cloudflare/api_token".path}"; - # Caddy supports _FILE suffix for env vars via its secret file reader, - # but cloudflare plugin reads CLOUDFLARE_API_TOKEN directly. - # We write a wrapper ExecStartPre to populate the env var from the file: + EnvironmentFile = "/run/caddy-secrets.env"; ExecStartPre = [ (pkgs.writeShellScript "caddy-inject-cf-token" '' - export CLOUDFLARE_API_TOKEN=$(cat ${config.sops.secrets."cloudflare/api_token".path}) - systemctl set-environment CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" + install -m 0600 /dev/null /run/caddy-secrets.env + printf 'CLOUDFLARE_API_TOKEN=%s\n' \ + "$(cat ${config.sops.secrets."cloudflare/api_token".path})" \ + > /run/caddy-secrets.env + '') + ]; + ExecStopPost = [ + (pkgs.writeShellScript "caddy-cleanup-env" '' + rm -f /run/caddy-secrets.env '') ]; }; - after = lib.mkAfter [ "podman-authelia.service" ]; - wants = lib.mkAfter [ "podman-authelia.service" ]; + after = lib.mkAfter [ "podman-authelia.service" ]; + wants = lib.mkAfter [ "podman-authelia.service" ]; }; # ----------------------------------------------------------------------- diff --git a/modules/cloudflared.nix b/modules/cloudflared.nix index c810f61..577c1c7 100644 --- a/modules/cloudflared.nix +++ b/modules/cloudflared.nix @@ -46,32 +46,43 @@ in # ----------------------------------------------------------------------- # cloudflared service - # NixOS 24.11 ships services.cloudflared natively. + # + # We use the token-based tunnel approach (cloudflared tunnel run --token). + # This needs no credentials file and no local tunnel config — just the + # token from the Cloudflare dashboard. + # + # Rather than using services.cloudflared.tunnels (which requires a + # credentialsFile), we create a plain systemd service that runs cloudflared + # directly with the token read from the sops secret. # ----------------------------------------------------------------------- - services.cloudflared = { - enable = true; - tunnels = { - "pi-main" = { - # credentialsFile is not used with token-based auth; - # the token is passed via environment variable instead. - # We override the systemd unit below to inject it. - default = "http_status:404"; - }; - }; + users.users.cloudflared = { + isSystemUser = true; + group = "cloudflared"; + description = "cloudflared tunnel daemon"; }; + users.groups.cloudflared = {}; - # Inject the tunnel token from the sops secret file - systemd.services."cloudflared-tunnel-pi-main" = { + systemd.services."cloudflared-tunnel" = { + description = "Cloudflare Tunnel (token-based)"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "caddy.service" ]; + wants = [ "network-online.target" "caddy.service" ]; serviceConfig = { - ExecStart = lib.mkForce (pkgs.writeShellScript "cloudflared-start" '' + Type = "simple"; + User = "cloudflared"; + Group = "cloudflared"; + Restart = "on-failure"; + RestartSec = "5s"; + ExecStart = pkgs.writeShellScript "cloudflared-start" '' exec ${pkgs.cloudflared}/bin/cloudflared tunnel \ --no-autoupdate \ run \ --token "$(cat ${config.sops.secrets."cloudflare/tunnel_token".path})" - ''); + ''; + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; }; - after = lib.mkAfter [ "caddy.service" ]; - wants = lib.mkAfter [ "caddy.service" ]; }; }; } diff --git a/modules/common.nix b/modules/common.nix index df8aa6b..49cbefb 100644 --- a/modules/common.nix +++ b/modules/common.nix @@ -11,10 +11,20 @@ nix = { settings = { experimental-features = [ "nix-command" "flakes" ]; - # Save disk space on Pi auto-optimise-store = true; + # Extra binary caches — speeds up aarch64-linux builds significantly + substituters = [ + "https://cache.nixos.org" + "https://nix-community.cachix.org" + # Pre-built RPi vendor kernel + firmware (linuxPackages_rpi4, etc.) + "https://nixos-raspberrypi.cachix.org" + ]; + trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk=" + "nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI=" + ]; }; - # Weekly garbage collection — keeps the system from filling the SD card gc = { automatic = true; dates = "weekly"; @@ -113,5 +123,5 @@ # System state version — do not change after first install # (tracks NixOS backwards-compat markers) # ------------------------------------------------------------------------- - system.stateVersion = "24.11"; + system.stateVersion = "25.05"; } diff --git a/secrets/.sops.yaml b/secrets/.sops.yaml index 1d37ce0..43a1a6f 100644 --- a/secrets/.sops.yaml +++ b/secrets/.sops.yaml @@ -17,8 +17,8 @@ creation_rules: - path_regex: secrets/secrets\.yaml$ key_groups: - - age: + - pgp + - 076AA297579A0064 + # - age: # Pi main host key — replace with output of `age-keygen -y /var/lib/sops-nix/key.txt` - - AGE-PUBLIC-KEY-PI-MAIN-REPLACE-ME - # (Optional) your workstation key for offline editing: - # - AGE-PUBLIC-KEY-YOUR-WORKSTATION + # - AGE-PUBLIC-KEY-PI-MAIN-REPLACE-ME diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index 0056fe0..291d4aa 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -1,54 +1,58 @@ -# ============================================================================= -# Homey secrets — managed by sops-nix +#ENC[AES256_GCM,data:+YPw0Wd8uB5cwY/cszPqLohVhIEbju4QwYVS9vT/ci2MxO7Glw2uJbtS6dOLHqpzfU0b0mJ4IqUIaM08yMEM/TeB7hG1ASHWe3+qxyw8,iv:V49PRq4bEcYvcPMUIvBQkhuq28pVxrlRfVXUSzPxnb4=,tag:OSNXutH5EUwA3BujNZ3FoA==,type:comment] +#ENC[AES256_GCM,data:QVC3QP3em1O3SYTAuK4kBchpTiwXH10f2R4YgK+t9QaiqZ1PWvo=,iv:R0lFtvg2T/Rllt1uiriTQvNbSw54jr0otU3E6XsIs00=,tag:9fAQCmuZZPUPLuDY8LZEUA==,type:comment] # -# THIS FILE MUST BE ENCRYPTED WITH SOPS BEFORE COMMITTING. -# It is shown here as a plaintext template so you know what to fill in. +#ENC[AES256_GCM,data:IT6BEo5CjYm+15aeWl+S8M3B+SSmjPnhBRvYToWzezIweTl3MGBXtalvkV3NWkxH0EaHpueOMe6r,iv:7BDTiljEa59F13Pephw6MM+sZgL4jbfQafJyt0UU3hY=,tag:ia+7WUAl/45jrYrv3Pylxg==,type:comment] # -# Workflow: -# 1. Complete the .sops.yaml age key setup. -# 2. Fill in the values below. -# 3. Run: sops -e -i secrets/secrets.yaml -# This encrypts the file in-place. The encrypted version is safe to commit. -# 4. To edit later: sops secrets/secrets.yaml -# -# Ports from old deployment: -# - openldap/admin_password ← from k8s secret openldap-admin -# - openldap/config_password ← from k8s secret openldap-config -# - openldap/ro_password ← from k8s secret openldap-ro -# - gitea/admin_password ← from k8s secret gitea-admin-pass -# - nextcloud/admin_password ← from k8s secret nextcloud-admin-pass -# - nextcloud/postgres_password← from k8s secret nextcloud-postgres-pass -# The remaining secrets (authelia JWT, session key, encryption key, gitea -# LFS/OAuth2/internal tokens) are regenerated fresh — see notes below. -# ============================================================================= +#ENC[AES256_GCM,data:zqAQYQCg/TRNtjDIdWTsgtRnQbijjYyLdQIAe9GkTubG9PSj7E8m7HFXmfG4eFNZR4S/Ql0dsM5gvLCu,iv:xSH8LMS7vqe2N9L/TOepKWhuIhVxmKN6kuB1iqUEOUw=,tag:rFYurrqfp1Zxggr5tiPKkQ==,type:comment] +#ENC[AES256_GCM,data:+YPw0Wd8uB5cwY/cszPqLohVhIEbju4QwYVS9vT/ci2MxO7Glw2uJbtS6dOLHqpzfU0b0mJ4IqUIaM08yMEM/TeB7hG1ASHWe3+qxyw8,iv:V49PRq4bEcYvcPMUIvBQkhuq28pVxrlRfVXUSzPxnb4=,tag:OSNXutH5EUwA3BujNZ3FoA==,type:comment] +#ENC[AES256_GCM,data:yj4R8Yetc6EHWvQDu2/eaoY=,iv:Zbqfg9NRHy6ab10kxzq6qsLb7VHfLxhcpP3vUt2i4ns=,tag:udBGjJUupeADD78JQ8BwuQ==,type:comment] +openldap/admin_password: ENC[AES256_GCM,data:DtVthpJqLdkI+5wxOMnCfBdqWkg0GSwUtsUeop24kd8=,iv:4e2Xn7B0M8yYEbs0V9ozn8WHJJMCBv6G46bdThufSXc=,tag:BsjKzh8teul6yLEKbvr93g==,type:str] +openldap/config_password: ENC[AES256_GCM,data:6b9TIgOcmZfMDAVbJuqOoNS9kyrss/LMvySLyNonlRk=,iv:Jf9/triFouIDv7MY2J9W8ji7E5lUHqzwgBMqrcPuK1g=,tag:zQYZSesPiPVeNVBN1oEiHA==,type:str] +openldap/ro_password: ENC[AES256_GCM,data:EHYUlIY24kY9K8opMi9MxSSosReZm5mEmbPFz+NdaXE=,iv:3pfVn4QDvJAVmWYWyX/Kko+K7nsE1yunLXN5uao+ea0=,tag:J954cH7a7Ey6Xq24ut5Jxw==,type:str] +#ENC[AES256_GCM,data:upG3X+Z7di17BaWBQ/P0ohY=,iv:k3Kin642n4cJYwfPsQYE/4FokELFNDmMzxJ2D8S28HI=,tag:uYRnpeoCrwGQOEYWo2cBiw==,type:comment] +authelia/jwt_secret: ENC[AES256_GCM,data:pXTQ06OGEP1oYFM0mkyL+c/zNRUMgL9x1fCQsMo2bak=,iv:mnOBWBrSn4gTfMXR5PCThs0v9QRDR5pfOQA8u0cuGnI=,tag:YXGq6Hmv/chw8fcEQoNlGA==,type:str] +authelia/session_secret: ENC[AES256_GCM,data:EgIyGv/K6xDCxOZWA9tzGoNS4m+p/EOPHL64/eN1oqwar2iJFSanbUfq8doHmN8n9sADmPIKUKaL8+WJWfyjtBBcCn74q5FL+kDu6ZYo4V5cjkj8jUhRC97TIJ+e0lVKFJ4s+i+/OcOsv2TPS/haylGHVn1fnlwvEd3kn/mO73w=,iv:6VPxOkriecJdtm2EBCiKkZBTzmas3DkQuYhivfygCT8=,tag:uXW1tcyAFSkiwMGNiZ663w==,type:str] +authelia/storage_encryption_key: ENC[AES256_GCM,data:pM8oQ4t0HQLdUvuRayLOpEwdxzRQlvCOrMtSPIU8Ryo=,iv:AK2jR3Ij/dBplDc1PYXXLK8P327CYRx3kVZUCcIkO5k=,tag:kJSuyOIzT4/RNQXEal1ODA==,type:str] +#ENC[AES256_GCM,data:teUPyCgpHCpIb0hXRUg=,iv:lTdYkYxQKHcJGE7lkkcsa8u9ZsZAVqpfauf5SzTv6G0=,tag:uKydCL14BvAaOpUHAMBirg==,type:comment] +gitea/admin_password: ENC[AES256_GCM,data:/39FQYn5GQoq/a5chLd4JUvSXTU8tOdzc9uXxNqViiw=,iv:Ysq2QUgkmONGsfj6xHKN3G/eitBX1rm9LLH9REF2h8g=,tag:eiVtlaB/6VdNMEBy4mSrTg==,type:str] +gitea/lfs_jwt_secret: ENC[AES256_GCM,data:gyd2OV0qcaaD6FTT9UwLV5vGJ4b/SNtG86oCQqUqB+DlZFLYe91YFNG/wA==,iv:fxD2NFbEYAsmrXaZT030f0MiAol2cwln0mIzLPCE+Lg=,tag:xQtehnHuj18WYeR2UyYeXw==,type:str] +gitea/oauth2_jwt_secret: ENC[AES256_GCM,data:M5CzWG1FbjheX4QwDajVsAMl2nyfe4Z1u30D5hjCQbScDBtuw123ZMZjGQ==,iv:vOnMShn9nmLPzxXJqTNnCIf6GT6CrV3lAKrepmI7btc=,tag:pTdrbmZ+hntuwaLiLyUNHQ==,type:str] +gitea/internal_token: ENC[AES256_GCM,data:ZbwvPcOseUHAGDr4dwNu9u+qcr0yYYGdH2OjcuXPtgUt7HFq1a9f0Faxiphsh+3OXb1KqLj8USB/1AxSvt5kSYM/vqzSLZ+e1OKy0oO3o8YouCJLhPNkNO6q0eguQF6+,iv:E3APR8h+iNECoThrvy6v4SEdAsfnPITXvhIFT1Ug5qA=,tag:lCxReGAxJyVhwMjxNenvxg==,type:str] +#ENC[AES256_GCM,data:r/uPlqg+7UGrM0G2xhmD6Bm1,iv:m/Ineh/mNfo1yUS+B8qtbMr1zRwiE6vw3EZIepB4QUA=,tag:/tB1W2JgyUQNvVWFM9478w==,type:comment] +nextcloud/admin_password: ENC[AES256_GCM,data:KwS0kEjTKn+IAtYTD17X4Y/3hT9bUgqKBQ0vfhDK99A=,iv:AbJfw6NWRnnB8zXIO6l3sIWiXXWfM1ePJ5bodNlgjgI=,tag:XSQM8SSnuh3wjyN3IQdArA==,type:str] +nextcloud/postgres_password: ENC[AES256_GCM,data:dsdqeQhWFvidqOXopetb3G54Ft56ZhPheTB7uG2JuVc=,iv:ubKH3ihlPXZjPSkvgEYn/teG5SNSh04nb4Lh1e2cX8o=,tag:DWNXJXWjpCU8QEcnt0+phA==,type:str] +#ENC[AES256_GCM,data:riBX18BPE4XMBBv20JIEJbM6JS80e1jwiDq44KXMB6T/4Eehf2bgcFUm,iv:lDYdL1IvaBuixcw1BzPQxnM4HYZGA3YSDrJTxvz0QWs=,tag:tux8Mt56yw+7hE7BfgOXVw==,type:comment] +cloudflare/api_token: ENC[AES256_GCM,data:te8SJz3sjnWX0MsacbEwYb0IC+SAlUBcSthLmHxpURTdpE3GfeNvjj5Z+il43cpFA33PaUY=,iv:XG2dt0Wc5jDcfGvKtRB1f6CAWXBmgnw+qqzMxDtmOok=,tag:PmEqZoKvqZm2vBxYSNH3Qg==,type:str] +cloudflare/tunnel_token: ENC[AES256_GCM,data:HupdN2MFeQ+NPwynI1SM07E7yA5b66lbudKt/pNOemf9Q3l4zrYidLFpiQk6L6ajQpM0WQbEDYG2I1sxybu4fUah79MSZO7BoolYy6l/NDE5G35e3Kw9Yu1cFAyNZJ9s/RU8nG24OAMX+pMOkjk4bX4tzrWUkHmebRJf7iBZxsSys6o83arpyKcucLOfTyyLSRemXF8IXr2MGMypHkPrx+4w5MnY9tyY8JcclaiLDkpbVVDUTarbkg==,iv:sVAnAqAMdTn8HpEwcIz2B57SrPlYqV2/Oi3sYHanYzo=,tag:BmhemprKvn33Wt595MjKcQ==,type:str] +#ENC[AES256_GCM,data:GpnZDeOAyr2pZxWHVd++1TMm230hvQ==,iv:jo8kWdd0Pm3d3xewCcyhauiBhI+SYIlWvczKn0PPZTg=,tag:INK0gZhKynkiOgi2ayrSMA==,type:comment] +restic/password: ENC[AES256_GCM,data:iZNRA8qNspy7WnK+Dg1OOZj9Gt2Y/AXUG1gKTBGUt+6q7T6Lv5AqbVkN8khwlKyQWK6FNLh3/9ejsM7mybiyog==,iv:XMxMAgVMdCWnDCkdTxL72pbrg8Dy0xz2EYou7AaNgS0=,tag:KW9Tjhql0yF6h81Il1htbw==,type:str] +restic/s3_access_key_id: ENC[AES256_GCM,data:XK8GqLHSC76K6z86RbqI4uNwZgcfl5R0Bg==,iv:t9+fGwwGX8PLwr30MJMYdOm02f/+XTcnMhSY1DP+nU0=,tag:fauNjH4lVtHa+L8Bfj8TOg==,type:str] +restic/s3_secret_access_key: ENC[AES256_GCM,data:GUx4FPaHWuzNwOju7CQoZc5U2SLG+3GOn0zJvvRXzQ==,iv:Oq0q9a+esPkLygMkGaFFNZOOfMGMFVPeb+yHUcLcNZE=,tag:Rwd0NNyXt+L8IJCCiDJh8w==,type:str] +#ENC[AES256_GCM,data:H+rGxOM6euNaSOval0ZXgKlRKQ==,iv:o0kU37iQzWAvTl5T9MK5RpHJ1eqhFftfVMEGMR40Hw8=,tag:rFcrmYZXpOpVdvW/zTul1A==,type:comment] +wifi/psk: ENC[AES256_GCM,data:bkZnP8S7yQlaEfH+kN1FfjQqJw==,iv:n1wOv6rXDbGucKryV9qV0fgqXNC/GwDeDlY2k9/hSOI=,tag:LdC2ahrXVBcqLWU5nFHMlQ==,type:str] +sops: + lastmodified: "2026-04-18T20:53:59Z" + mac: ENC[AES256_GCM,data:nEP5XRzdYdFBWp9tqIgxcjjR7+X9ScpUew6SGfE6bKSQjvbwKTCGW6dSOTe7FmpUKrOS+dJnwpPsWKu0jbX/Qm5EtfXaB0GWiiMjfejwshmyULuJKipuq1rC+YX+DmOXoWIiNwKIwd4tBEOfYFBJVLFcoP8DSFjettymT0idvAQ=,iv:RnWzW+2hUScofJVom+csqEhYME8/roIzdRC/YC8opyk=,tag:22rjZO28mjPsp9p3iuoHSQ==,type:str] + pgp: + - created_at: "2026-04-18T20:12:39Z" + enc: |- + -----BEGIN PGP MESSAGE----- -# --- OpenLDAP --- -openldap/admin_password: "REPLACE-WITH-OLD-VALUE" -openldap/config_password: "REPLACE-WITH-OLD-VALUE" -openldap/ro_password: "REPLACE-WITH-OLD-VALUE" - -# --- Authelia (regenerated fresh — these are random strings) --- -authelia/jwt_secret: "GENERATE-random-64-chars" -authelia/session_secret: "GENERATE-random-64-chars" -authelia/storage_encryption_key: "GENERATE-random-64-chars" - -# --- Gitea --- -gitea/admin_password: "REPLACE-WITH-OLD-VALUE" -# These three are regenerated — gitea will re-derive on first start: -gitea/lfs_jwt_secret: "GENERATE-random-43-chars-base64url" -gitea/oauth2_jwt_secret: "GENERATE-random-43-chars-base64url" -gitea/internal_token: "GENERATE-random-100-alphanum" - -# --- Nextcloud --- -nextcloud/admin_password: "REPLACE-WITH-OLD-VALUE" -nextcloud/postgres_password: "REPLACE-WITH-OLD-VALUE" - -# --- Cloudflare (DNS-01 ACME + tunnel) --- -cloudflare/api_token: "REPLACE-WITH-CF-DNS-EDIT-TOKEN" -cloudflare/tunnel_token: "REPLACE-WITH-CF-TUNNEL-TOKEN" - -# --- Restic backup --- -restic/password: "GENERATE-random-passphrase" -# Repository destination — e.g. "sftp:user@nas:/backups/homey" -# or "b2:bucketname:homey" for Backblaze B2 -# Set the actual repo URL in modules/backup.nix or override per-host. + hQIMAwdqopdXmgBkAQ/+OOgkrBhQBXcbxH2Rj3yQ5cDTkH3LZdbBH+vLvEFfoXLk + RI12n3y+gQo5Gbs1eD9tJOuBIqYZwG9JTHiv43d6DXRFdY9PlMWaL6HeG6le/dj7 + /JpirCofXhbL+GzLxQXnEOeMYm0Rhh5a9FbvqOwVkx2cCYlaWDYrZRPXFkjTw0et + DYv9a/ZUMAEKwSEJO7kRMpWYiPGI6KkArJrPBm7C6M4j5+KBv29FRSpw/IJiOMtT + CFWepDk+RJq+pMRNB91p/OO6YdrwMQJdCRcqC94I3TdxhVKoCCagULoE3vwHzxGQ + O5kDDc1GuQbIcNg2bfyWyKv6L9A30JaQT+8t3UMSHxAoWlvZes1y3tvquQeI8m+N + JILTmMWHjAplals4u+8BX7MCVolh4zJRNr1xiFy/UamYB70UORf2rjjGvMqOHsM+ + IPJ2pIqbXDYs3syjKvWQFpxZczGgSPxHPlF9Tm+hu972ub9Ex2uVWntvjnt26H6+ + /JbdV/7gW95AEkJ+HPjynDvYZ1tRBFGmwBOCsOkOfKmmopKcAooT6qDzC5hZBhBE + Yvl9TlC5GEBPnV4dtIxTZrqRqvbt5CvikmCI2h3/pcMWGM8a0iN2K0iNvlKGnKey + jlGC+0nQzwLllFtGBgOGKeqG1HQ5yPf2W4Ic7uSVGI3xPHkd5gG1MAHORw/3cP3S + XgHadJRTvnNnDsZjT7P8rIYTBnpe2zx+I8N21r+Jh5/hCv8wSl819QaBA4IMC5kt + Os9nSYc1KzodkJR35O8Bdy/7H8SF34tXjpyhWvE4OEqEwN7AdI0L0PfOiGMBjms= + =7asV + -----END PGP MESSAGE----- + fp: 076AA297579A0064 + unencrypted_suffix: _unencrypted + version: 3.12.2