From 2f0d0b5e4c57ddffe0e03d722c8b57a2d02f6392 Mon Sep 17 00:00:00 2001 From: Aner Zakobar Date: Wed, 15 Apr 2026 17:18:12 +0300 Subject: [PATCH 1/4] Port to NixOS: replace Helm chart with flake-based NixOS config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Helm/k3s setup with a declarative NixOS configuration targeting a Raspberry Pi 4. Services run as podman containers under systemd, with data on an external HD at /mnt/data. Key components: - flake.nix: multi-host flake with pi-main (aarch64) and a placeholder for a second machine - modules/common.nix: shared system config (nix, podman, sops, SSH) - modules/storage.nix: external HD mount with per-service subdirs - modules/caddy.nix: Caddy with cloudflare DNS-01 ACME + authelia forward_auth - modules/cloudflared.nix: Cloudflare tunnel for remote access - modules/backup.nix: restic daily backups with NC maintenance mode pre-hook - modules/services/{openldap,authelia,gitea,nextcloud,phpldapadmin}.nix: core services - modules/services/{jellyfin,transmission}.nix: media services (disabled by default) - secrets/: sops-nix scaffold with .sops.yaml age key config - hosts/pi-main/: hardware config + service selection for the Pi - PORTING.md: step-by-step migration guide (SD card → data restore → verify) --- Chart.yaml | 6 - PORTING.md | 400 +++++++++++++++++ README.org | 115 ----- backup-longhorn-volumes.sh | 60 --- backup-nextcloud.sh | 120 ------ copy-longhorn-from-hd.sh | 41 -- files/authelia-config.yaml | 87 ---- files/baikal-server.php | 30 -- files/davical-config.php | 580 ------------------------- files/gitea-app.ini | 95 ---- files/radicale-configmap.ini | 11 - files/sabre-server.php | 30 -- files/sogo.conf | 94 ---- flake.nix | 73 ++++ get-secret-val.sh | 1 - gitea-commands.txt | 10 - hosts/pi-main/default.nix | 83 ++++ hosts/pi-main/hardware.nix | 84 ++++ modules/backup.nix | 150 +++++++ modules/caddy.nix | 185 ++++++++ modules/cloudflared.nix | 77 ++++ modules/common.nix | 117 +++++ modules/services/authelia.nix | 200 +++++++++ modules/services/gitea.nix | 198 +++++++++ modules/services/jellyfin.nix | 55 +++ modules/services/nextcloud.nix | 135 ++++++ modules/services/openldap.nix | 116 +++++ modules/services/phpldapadmin.nix | 46 ++ modules/services/transmission.nix | 61 +++ modules/storage.nix | 105 +++++ scripts/backup-longhorn-to-disk.sh | 184 -------- scripts/get-pvc-mapping.sh | 16 - scripts/list-longhorn-backups.sh | 37 -- scripts/longhorn-fuse.py | 70 --- scripts/longhorn-nbdkit.py | 49 --- scripts/mount-longhorn-volume.sh | 146 ------- scripts/restore-fast.py | 48 --- scripts/restore-longhorn-backup.sh | 94 ---- scripts/restore-longhorn-volume.sh | 135 ------ secrets/.gitignore | 10 + secrets/.sops.yaml | 24 ++ secrets/secrets.yaml | 54 +++ templates/_definitions.yaml | 27 -- templates/auth.yaml | 668 ----------------------------- templates/media.yaml | 211 --------- templates/phpldapadmin.yaml | 80 ---- unused/auth-templates.yaml | 24 -- unused/baikal.yaml | 117 ----- unused/dav.yaml | 71 --- unused/davical.yaml | 213 --------- unused/gitea.yaml | 131 ------ unused/jellyfin.yaml | 92 ---- unused/ldap-auth.yaml | 70 --- unused/nextcloud.yaml | 206 --------- unused/paperless.yaml | 230 ---------- unused/radicale.yaml | 122 ------ unused/sabre.yaml | 118 ----- unused/sogo.yaml | 162 ------- values.yaml | 65 --- 59 files changed, 2173 insertions(+), 4666 deletions(-) delete mode 100644 Chart.yaml create mode 100644 PORTING.md delete mode 100644 README.org delete mode 100755 backup-longhorn-volumes.sh delete mode 100755 backup-nextcloud.sh delete mode 100755 copy-longhorn-from-hd.sh delete mode 100644 files/authelia-config.yaml delete mode 100644 files/baikal-server.php delete mode 100644 files/davical-config.php delete mode 100644 files/gitea-app.ini delete mode 100644 files/radicale-configmap.ini delete mode 100644 files/sabre-server.php delete mode 100644 files/sogo.conf create mode 100644 flake.nix delete mode 100644 get-secret-val.sh delete mode 100644 gitea-commands.txt create mode 100644 hosts/pi-main/default.nix create mode 100644 hosts/pi-main/hardware.nix create mode 100644 modules/backup.nix create mode 100644 modules/caddy.nix create mode 100644 modules/cloudflared.nix create mode 100644 modules/common.nix create mode 100644 modules/services/authelia.nix create mode 100644 modules/services/gitea.nix create mode 100644 modules/services/jellyfin.nix create mode 100644 modules/services/nextcloud.nix create mode 100644 modules/services/openldap.nix create mode 100644 modules/services/phpldapadmin.nix create mode 100644 modules/services/transmission.nix create mode 100644 modules/storage.nix delete mode 100755 scripts/backup-longhorn-to-disk.sh delete mode 100644 scripts/get-pvc-mapping.sh delete mode 100755 scripts/list-longhorn-backups.sh delete mode 100644 scripts/longhorn-fuse.py delete mode 100644 scripts/longhorn-nbdkit.py delete mode 100755 scripts/mount-longhorn-volume.sh delete mode 100644 scripts/restore-fast.py delete mode 100755 scripts/restore-longhorn-backup.sh delete mode 100755 scripts/restore-longhorn-volume.sh create mode 100644 secrets/.gitignore create mode 100644 secrets/.sops.yaml create mode 100644 secrets/secrets.yaml delete mode 100644 templates/_definitions.yaml delete mode 100644 templates/auth.yaml delete mode 100644 templates/media.yaml delete mode 100644 templates/phpldapadmin.yaml delete mode 100644 unused/auth-templates.yaml delete mode 100644 unused/baikal.yaml delete mode 100644 unused/dav.yaml delete mode 100644 unused/davical.yaml delete mode 100644 unused/gitea.yaml delete mode 100644 unused/jellyfin.yaml delete mode 100644 unused/ldap-auth.yaml delete mode 100644 unused/nextcloud.yaml delete mode 100644 unused/paperless.yaml delete mode 100644 unused/radicale.yaml delete mode 100644 unused/sabre.yaml delete mode 100644 unused/sogo.yaml delete mode 100644 values.yaml diff --git a/Chart.yaml b/Chart.yaml deleted file mode 100644 index 369173c..0000000 --- a/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: homey -description: Deploy a fancy home environment! -type: application -version: 0.1.0 -appVersion: "1.16.0" diff --git a/PORTING.md b/PORTING.md new file mode 100644 index 0000000..cffd3b7 --- /dev/null +++ b/PORTING.md @@ -0,0 +1,400 @@ +# Porting Guide — Helm/k3s → NixOS + +This document walks through setting up the new NixOS-based home server from +scratch on a Raspberry Pi 4, restoring data from old Longhorn volumes, and +verifying each service. + +--- + +## Prerequisites (on your workstation) + +- `nix` with flakes enabled (`~/.config/nix/nix.conf`: `experimental-features = nix-command flakes`) +- `sops` + `age` CLI tools (`nix-shell -p sops age`) +- An SSH key pair + +--- + +## Phase 0 — Secrets + +### 0.1 Generate your age key (workstation) + +```bash +age-keygen -o ~/.config/sops/age/keys.txt +age-keygen -y ~/.config/sops/age/keys.txt # print public key +``` + +You will add the Pi's public key in step 2.2; for now add your workstation +public key so you can edit the secrets file offline. + +Edit `secrets/.sops.yaml`, replace the placeholder with your workstation pubkey: + +```yaml +- age: + - AGE-PUBLIC-KEY-YOUR-WORKSTATION # ← paste here +``` + +### 0.2 Fill in secrets.yaml + +`secrets/secrets.yaml` is a **plaintext template** — do not commit it until +encrypted. Fill in all values: + +| Key | Source | +|-----|--------| +| `openldap/admin_password` | From old k8s secret `openldap-admin` | +| `openldap/config_password` | From old k8s secret `openldap-config` | +| `openldap/ro_password` | From old k8s secret `openldap-ro` | +| `gitea/admin_password` | From old k8s secret `gitea-admin-pass` | +| `nextcloud/admin_password` | From old k8s secret `nextcloud-admin-pass` | +| `nextcloud/postgres_password` | From old k8s secret `nextcloud-postgres-pass` | +| `authelia/jwt_secret` | Generate: `openssl rand -hex 64` | +| `authelia/session_secret` | Generate: `openssl rand -hex 64` | +| `authelia/storage_encryption_key` | Generate: `openssl rand -hex 64` | +| `gitea/lfs_jwt_secret` | Generate: `openssl rand -base64 32 \| tr -d '='` | +| `gitea/oauth2_jwt_secret` | Generate: `openssl rand -base64 32 \| tr -d '='` | +| `gitea/internal_token` | Generate: `openssl rand -base64 75 \| tr -d '\n='` | +| `cloudflare/api_token` | Cloudflare dashboard → API Tokens → DNS:Edit | +| `cloudflare/tunnel_token` | Created in Phase 3 (Cloudflare setup) | +| `restic/password` | Generate: `openssl rand -base64 32` | + +To get old k8s secrets (if the cluster is still running): + +```bash +kubectl get secret openldap-admin -n homey -o jsonpath='{.data.password}' | base64 -d +kubectl get secret openldap-config -n homey -o jsonpath='{.data.password}' | base64 -d +kubectl get secret openldap-ro -n homey -o jsonpath='{.data.password}' | base64 -d +kubectl get secret gitea-admin-pass -n homey -o jsonpath='{.data.password}' | base64 -d +kubectl get secret nextcloud-admin-pass -n homey -o jsonpath='{.data.password}' | base64 -d +kubectl get secret nextcloud-postgres-pass -n homey -o jsonpath='{.data.password}' | base64 -d +``` + +### 0.3 Encrypt secrets.yaml (workstation, before committing) + +```bash +sops --encrypt --in-place secrets/secrets.yaml +git add secrets/secrets.yaml secrets/.sops.yaml +``` + +--- + +## Phase 1 — Install NixOS on the Raspberry Pi 4 + +### 1.1 Flash the SD card + +Download the NixOS aarch64 SD card image: + +``` +https://nixos.org/download#nixos-iso +→ "Raspberry Pi (aarch64) SD card image" +``` + +Flash with: + +```bash +# macOS +sudo dd if=nixos-*-aarch64-linux.img of=/dev/rdiskN bs=4m status=progress + +# Linux +sudo dd if=nixos-*-aarch64-linux.img of=/dev/sdX bs=4M status=progress conv=fsync +``` + +Label the partitions to match `hardware.nix`: + +```bash +# After flashing, mount the root partition and relabel if needed: +sudo e2label /dev/sdX2 NIXOS_SD +sudo fatlabel /dev/sdX1 FIRMWARE +``` + +Boot the Pi from the SD card. You should get a serial console or HDMI output. + +### 1.2 Initial network setup + +On the Pi (serial or HDMI): + +```bash +# Find your IP +ip addr + +# Set a temporary password for nixos user to SSH in +passwd nixos +``` + +From your workstation: + +```bash +ssh nixos@ +``` + +### 1.3 Copy the flake to the Pi + +```bash +# From your workstation (repo root) +rsync -avz --exclude='.git' . nixos@:/tmp/homey/ +``` + +### 1.4 Generate the Pi's age key + +```bash +# On the Pi +nix-shell -p age --run 'age-keygen -o /tmp/pi-age-key.txt' +age-keygen -y /tmp/pi-age-key.txt # print public key +``` + +Copy the public key back to your workstation. Add it to `secrets/.sops.yaml`: + +```yaml +- age: + - AGE-PUBLIC-KEY-YOUR-WORKSTATION + - AGE-PUBLIC-KEY-PI-MAIN-REPLACE-ME # ← paste Pi's public key here +``` + +Re-encrypt secrets so the Pi can decrypt them: + +```bash +# On workstation +sops updatekeys secrets/secrets.yaml +git add secrets/.sops.yaml secrets/secrets.yaml +git commit -m "Add Pi age key" + +# Copy updated files to Pi +rsync -avz secrets/ nixos@:/tmp/homey/secrets/ +``` + +Place the Pi's private key where sops-nix expects it: + +```bash +# On the Pi +sudo mkdir -p /var/lib/sops-nix +sudo cp /tmp/pi-age-key.txt /var/lib/sops-nix/key.txt +sudo chmod 600 /var/lib/sops-nix/key.txt +``` + +### 1.5 Configure host-specific settings + +Edit `hosts/pi-main/default.nix` on the Pi (or on workstation first): + +1. Set your SSH public key in `users.users.admin.openssh.authorizedKeys.keys` +2. Set `homey.storage.device` to your USB drive: + ```bash + ls -la /dev/disk/by-id/ | grep -v part + ``` +3. Set `homey.backup.repository` to your backup destination + +Edit `hosts/pi-main/hardware.nix` if the disk labels differ from defaults. + +### 1.6 Install NixOS + +```bash +# On the Pi +sudo nixos-install --flake /tmp/homey#pi-main --no-root-passwd + +# Reboot +sudo reboot +``` + +After reboot, SSH in with your admin key: + +```bash +ssh admin@ +``` + +--- + +## Phase 2 — Restore Data from Old Volumes + +Mount the external HD (if not auto-mounted): + +```bash +sudo mount /dev/disk/by-id/ /mnt/data +``` + +Copy data from the old Longhorn volume backups into the new layout: + +```bash +# Adjust source paths to wherever your Longhorn volume dumps are +BACKUP_SRC=/path/to/longhorn/backups + +# OpenLDAP +sudo rsync -av $BACKUP_SRC/openldap/etc-ldap-slapd.d/ /mnt/data/openldap/etc-ldap-slapd.d/ +sudo rsync -av $BACKUP_SRC/openldap/var-lib-ldap/ /mnt/data/openldap/var-lib-ldap/ + +# Gitea +sudo rsync -av $BACKUP_SRC/gitea/data/ /mnt/data/gitea/data/ + +# Nextcloud +sudo rsync -av $BACKUP_SRC/nextcloud/html/ /mnt/data/nextcloud/html/ +# Restore postgres from pg_dump if available, otherwise restore the data dir: +sudo rsync -av $BACKUP_SRC/nextcloud/db/ /mnt/data/nextcloud/db/ +``` + +Fix ownership (containers run as UID 1000 or root depending on image): + +```bash +# openldap runs as root inside the container +sudo chown -R root:root /mnt/data/openldap/ + +# gitea runs as git (UID 1000) +sudo chown -R 1000:1000 /mnt/data/gitea/ + +# nextcloud runs as www-data (UID 33) +sudo chown -R 33:33 /mnt/data/nextcloud/html/ +# postgres data owned by postgres (UID 999 in the postgres image) +sudo chown -R 999:999 /mnt/data/nextcloud/db/ +``` + +--- + +## Phase 3 — Cloudflare Tunnel Setup + +### 3.1 Create the tunnel in Cloudflare Zero Trust + +1. Go to [https://one.dash.cloudflare.com](https://one.dash.cloudflare.com) → Networks → Tunnels +2. Click "Create a tunnel" → Cloudflared → Name it `pi-main` +3. Copy the tunnel token (long string starting with `eyJ...`) +4. Add it to `secrets/secrets.yaml` under `cloudflare/tunnel_token` +5. Re-encrypt: `sops secrets/secrets.yaml` (the file opens in `$EDITOR`) + +### 3.2 Configure public hostnames in the Cloudflare dashboard + +In the tunnel's "Public Hostnames" tab, add: + +| Subdomain | Domain | Service | +|-----------|--------|---------| +| `auth` | `home.zakobar.com` | `https://localhost:443` | +| `git` | `home.zakobar.com` | `https://localhost:443` | +| `nextcloud` | `home.zakobar.com` | `https://localhost:443` | +| `ldapadmin` | `home.zakobar.com` | `https://localhost:443` | +| `jellyfin` | `home.zakobar.com` | `https://localhost:443` | +| `torrent` | `home.zakobar.com` | `https://localhost:443` | + +For each entry, under "Additional settings" → TLS → **No TLS Verify: ON** +(because cloudflared connects to `localhost` but the cert is for the real hostname). + +### 3.3 Update DNS in Cloudflare + +Add a CNAME for `home.zakobar.com` pointing to your tunnel's UUID (Cloudflare +creates this automatically when you add hostnames). You do not need to add +`home.zakobar.com` to your domain's A records — Cloudflare handles it. + +--- + +## Phase 4 — Rebuild and Verify + +After restoring data and completing Cloudflare setup, apply the final config: + +```bash +# On the Pi +sudo nixos-rebuild switch --flake /path/to/homey#pi-main +``` + +### Verification checklist + +```bash +# All container services running? +systemctl list-units 'podman-*' --state=active + +# OpenLDAP responding? +ldapsearch -x -H ldap://127.0.0.1:389 -b dc=home,dc=zakobar,dc=com -D "cn=admin,dc=home,dc=zakobar,dc=com" -W + +# Authelia health? +curl -s http://localhost:9091/api/health | python3 -m json.tool + +# Caddy serving TLS? +curl -I https://auth.home.zakobar.com + +# Gitea login? +# Visit https://git.home.zakobar.com — should redirect to authelia if not logged in + +# Nextcloud? +# Visit https://nextcloud.home.zakobar.com + +# Cloudflare tunnel connected? +systemctl status cloudflared-tunnel-pi-main +``` + +--- + +## Phase 5 — Local DNS (optional but recommended) + +To access services without going through Cloudflare on the LAN, add these +records to your router's DNS or Pi-hole: + +``` +192.168.1.100 home.zakobar.com +192.168.1.100 auth.home.zakobar.com +192.168.1.100 git.home.zakobar.com +192.168.1.100 nextcloud.home.zakobar.com +192.168.1.100 ldapadmin.home.zakobar.com +192.168.1.100 jellyfin.home.zakobar.com +192.168.1.100 torrent.home.zakobar.com +``` + +Replace `192.168.1.100` with your Pi's actual LAN IP. + +--- + +## Day-to-day Operations + +### Apply config changes + +```bash +sudo nixos-rebuild switch --flake /path/to/homey#pi-main +``` + +### Edit secrets + +```bash +sops secrets/secrets.yaml +# Save and exit — sops re-encrypts automatically +# Then copy to Pi and rebuild +``` + +### Browse service data on disk + +```bash +ls /mnt/data/ +ls /mnt/data/gitea/data/ +# No special tools needed — plain filesystem +``` + +### Trigger a manual backup + +```bash +sudo systemctl start restic-backups-homey.service +``` + +### List backup snapshots + +```bash +sudo restic -r \ + --password-file /run/secrets/restic_password \ + snapshots +``` + +### Restore a single service from backup + +```bash +sudo systemctl stop podman-gitea.service +sudo restic -r restore latest \ + --target / \ + --include /mnt/data/gitea +sudo systemctl start podman-gitea.service +``` + +--- + +## Adding a Second Machine (future) + +1. Create `hosts/pi-secondary/default.nix` and `hardware.nix` +2. Enable the services you want on that machine +3. Services communicating cross-machine: reference the primary Pi's LAN IP or + hostname directly in environment variables (e.g. point gitea's LDAP config + at `192.168.1.100:389` rather than `127.0.0.1:389`). +4. Add the new host to `flake.nix`: + ```nix + pi-secondary = mkHost { + system = "x86_64-linux"; + hostPath = ./hosts/pi-secondary/default.nix; + }; + ``` +5. Generate an age key on the new machine and add it to `.sops.yaml`. diff --git a/README.org b/README.org deleted file mode 100644 index cf93173..0000000 --- a/README.org +++ /dev/null @@ -1,115 +0,0 @@ -#+title: Homey - -A home environment for everyone! - -* Installation - -Install using - -#+begin_src bash -helm upgrade --install homey . -n homey -#+end_src - -* Backing up - -We must find a better solution - -https://perfectmediaserver.com/day-two/top10apps.html - -Nefarious - -* LDAP Configuration - -Logins are done to PHPLDAPADMIN - -DN is like: - -cn=admin,dc=home,dc=,dc=io -get-secret-val.sh homey openldap-admin password - -First thing we do is create an organization unit called users - -To add a new user, we create a child entry to ou=users - -It has to be of type inetOrgPerson - -cn = Common Name, sn = Sur Name. -Select RDN = User Name (uid) (FROM DROP DOWN MENU) -UID = USERNAME, that is what is important. (In PHPLdapAdmin it is under User Name) - -Now we may continue! - -* GITEA - -Site Title: whatever - -SSH Server Domain: git. -SSH Server Port: 2222 -Gitea Base URL: http://git. - -Then add Administrator Account Settings: - -Administrator Username: gitea-admin -Password: from gitea-admin-pass -Email address must be populated - -That will work after a few minutes. - -Now we go into Authentication Sources - -Add a new LDAP Authentication source - -Authentication name: Home LDAP -Host: openldap -Port: 389 -Bind DN = cn=readonly,dc=home,dc=,dc=io -Bind Password: openldap-ro password -User Search Base: ou=users,dc=home,dc=,dc=io -user search filter = (uid=%s) -Admin filter (title=admin) -Username Attribute: uid -First Name Attribute: cn -Surname Attribute: sn -Email Attribute: mail - -* AUTHELIA - -https://github.com/authelia/authelia/blob/57d5fbd3f5c82e83296023dc1de6e4f5ff063c00/examples/compose/lite/authelia/configuration.yml -This fucking sucks -https://gist.github.com/james-d-elliott/5152d27c0781aee856a3383f1284998e - -* EVERYTHING -https://www.talkingquickly.co.uk/gitea-sso-with-keycloak-openldap-openid-connect - -* DRONE AND GITEA -? -https://dev.to/ruanbekker/self-hosted-cicd-with-gitea-and-drone-ci-200l - -* DAV - -https://gitlab.com/davical-project/davical/-/blob/master/config/example-config.php - -Line 800 ish for auth from reverse proxy - -* NEXTCLOUD - -I ran THIS command inside -su www-data -s /bin/bash -c php occ ldap:promote-group "admins" - -** When maintenence mode - -#+begin_example -kubectl exec --tty --stdin -n homey deploy/nextcloud -- su -l www-data -s /bin/bash -php /var/www/html/occ maintenance:mode --off -#+end_src - -* I UNDERSTAND - -I need to backup Chen's stuff -And... I need to Jellyfin - -* PAPERLESS - -https://github.com/paperless-ngx/paperless-ngx/blob/74c44fe418a91a526b5dab1a91fde4aaebd28bb1/docker/compose/docker-compose.postgres.yml - -For docker diff --git a/backup-longhorn-volumes.sh b/backup-longhorn-volumes.sh deleted file mode 100755 index 2d42cd8..0000000 --- a/backup-longhorn-volumes.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="$HOME/homey-backup" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -NODE="root@192.168.1.100" - -NC_DATA_PVC="pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842-96d72815" -NC_DB_PVC="pvc-c5b28179-1b9c-462a-be5b-05c4f0bb36ca-5f2dbf4d" - -NC_DATA_DEST="$BACKUP_DIR/longhorn-nextcloud-data/$TIMESTAMP" -NC_DB_DEST="$BACKUP_DIR/longhorn-nextcloud-db/$TIMESTAMP" - -echo "=== Longhorn Volume Backup (Emergency) ===" -echo "Started: $(date)" -echo "" -echo "WARNING: Backing up raw Longhorn volume images" -echo "These are sparse files - actual data is smaller than file size" -echo "" - -mkdir -p "$NC_DATA_DEST" -mkdir -p "$NC_DB_DEST" - -echo "--- Backing up Nextcloud data volume ---" -echo "Source: $NODE:/hda/replicas/$NC_DATA_PVC/" -echo "Dest: $NC_DATA_DEST/" -echo "" - -rsync -avzP --no-owner --no-group --sparse \ - "$NODE:/hda/replicas/$NC_DATA_PVC/" \ - "$NC_DATA_DEST/" - -echo "" -echo "Nextcloud volume backup: $(du -sh "$NC_DATA_DEST" | cut -f1)" - -echo "" -echo "--- Backing up PostgreSQL volume ---" -echo "Source: $NODE:/hda/replicas/$NC_DB_PVC/" -echo "Dest: $NC_DB_DEST/" -echo "" - -rsync -avzP --no-owner --no-group --sparse \ - "$NODE:/hda/replicas/$NC_DB_PVC/" \ - "$NC_DB_DEST/" - -echo "" -echo "PostgreSQL volume backup: $(du -sh "$NC_DB_DEST" | cut -f1)" - -echo "" -echo "=== Backup Complete ===" -echo "Timestamp: $TIMESTAMP" -echo "Finished: $(date)" - -echo "" -echo "Total backup size:" -du -sh "$BACKUP_DIR/longhorn-nextcloud-data/$TIMESTAMP" "$BACKUP_DIR/longhorn-nextcloud-db/$TIMESTAMP" - -echo "" -echo "NOTE: These are raw Longhorn volume images." -echo "To restore, copy back to /hda/replicas/ and restart Longhorn." diff --git a/backup-nextcloud.sh b/backup-nextcloud.sh deleted file mode 100755 index c840e65..0000000 --- a/backup-nextcloud.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="$HOME/homey-backup" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -NAMESPACE="homey" -NODE="root@192.168.1.100" - -NC_DATA_DEST="$BACKUP_DIR/nextcloud-data/backups/$TIMESTAMP" -NC_DB_DEST="$BACKUP_DIR/nextcloud-postgres/backups/$TIMESTAMP" - -show_progress() { - local file="$1" - local label="$2" - local total="${3:-0}" - - while [[ ! -f "$file" ]]; do - sleep 0.2 - done - - while kill -0 "$BACKUP_PID" 2>/dev/null; do - local size=$(stat -c%s "$file" 2>/dev/null || echo "0") - if [[ "$total" -gt 0 ]]; then - local pct=$((size * 100 / total)) - local size_hr=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B") - local total_hr=$(numfmt --to=iec-i --suffix=B "$total" 2>/dev/null || echo "${total}B") - printf "\r%s: %s / %s (%d%%) " "$label" "$size_hr" "$total_hr" "$pct" - else - local size_hr=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B") - printf "\r%s: %s " "$label" "$size_hr" - fi - sleep 0.5 - done - - local final=$(stat -c%s "$file" 2>/dev/null || echo "0") - local final_hr=$(numfmt --to=iec-i --suffix=B "$final" 2>/dev/null || echo "${final}B") - printf "\r%s: %s - done! \n" "$label" "$final_hr" -} - -wait_for_cluster() { - echo "Waiting for Kubernetes cluster..." - local max_wait=300 - local start=$(date +%s) - while true; do - if kubectl get nodes &>/dev/null; then - echo "Cluster ready!" - return 0 - fi - local now=$(date +%s) - if [[ $((now - start)) -ge $max_wait ]]; then - echo "ERROR: Cluster not available after ${max_wait}s" - return 1 - fi - printf "\rWaiting... %ds " $((now - start)) - sleep 5 - done -} - -echo "=== Nextcloud Backup ===" -echo "Started: $(date)" - -mkdir -p "$NC_DATA_DEST" -mkdir -p "$NC_DB_DEST" - -if ! wait_for_cluster; then - echo "Cluster unavailable. Cannot proceed." - exit 1 -fi - -echo "" -echo "--- Backing up PostgreSQL ---" -DB_POD=$(kubectl get pods -n "$NAMESPACE" -l app=nextcloud-postgres -o jsonpath='{.items[0].metadata.name}') -if [[ -z "$DB_POD" ]]; then - echo "ERROR: PostgreSQL pod not found" - exit 1 -fi - -DB_SIZE=$(kubectl exec -n "$NAMESPACE" "$DB_POD" -- psql -U postgres -d nextcloud_db -t -c "SELECT pg_database_size('nextcloud_db');" 2>/dev/null | tr -d ' ' || echo "0") -echo "Database size: $(numfmt --to=iec-i --suffix=B "$DB_SIZE" 2>/dev/null || echo "$DB_SIZE bytes")" - -echo "Dumping database from $DB_POD..." -kubectl exec -n "$NAMESPACE" "$DB_POD" -- sh -c "pg_dump -U postgres nextcloud_db" | gzip > "$NC_DB_DEST/dump.sql.gz" & -BACKUP_PID=$! -show_progress "$NC_DB_DEST/dump.sql.gz" "Database" "$DB_SIZE" -wait $BACKUP_PID 2>/dev/null || true - -echo "Database backup: $(du -sh "$NC_DB_DEST/dump.sql.gz" | cut -f1)" - -echo "" -echo "--- Backing up Nextcloud data ---" -NC_POD=$(kubectl get pods -n "$NAMESPACE" -l app=nextcloud -o jsonpath='{.items[0].metadata.name}') -if [[ -z "$NC_POD" ]]; then - echo "ERROR: Nextcloud pod not found" - exit 1 -fi - -NC_SIZE=$(kubectl exec -n "$NAMESPACE" "$NC_POD" -- du -sb /var/www/html 2>/dev/null | awk '{print $1}' || echo "0") -echo "Data size: $(numfmt --to=iec-i --suffix=B "$NC_SIZE" 2>/dev/null || echo "$NC_SIZE bytes")" - -echo "Creating tar archive from $NC_POD..." -kubectl exec -n "$NAMESPACE" "$NC_POD" -- tar cf - -C /var/www/html . 2>/dev/null | gzip > "$NC_DATA_DEST/data.tar.gz" & -BACKUP_PID=$! -show_progress "$NC_DATA_DEST/data.tar.gz" "Data" "$NC_SIZE" -wait $BACKUP_PID 2>/dev/null || true - -echo "Data backup: $(du -sh "$NC_DATA_DEST/data.tar.gz" | cut -f1)" - -echo "" -echo "--- Creating latest symlinks ---" -rm -f "$BACKUP_DIR/nextcloud-data/latest" "$BACKUP_DIR/nextcloud-postgres/latest" -ln -sf "backups/$TIMESTAMP" "$BACKUP_DIR/nextcloud-data/latest" -ln -sf "backups/$TIMESTAMP" "$BACKUP_DIR/nextcloud-postgres/latest" - -echo "" -echo "=== Backup Complete ===" -echo "Timestamp: $TIMESTAMP" -echo "Finished: $(date)" - -echo "" -du -sh "$NC_DATA_DEST" "$NC_DB_DEST" diff --git a/copy-longhorn-from-hd.sh b/copy-longhorn-from-hd.sh deleted file mode 100755 index fd684b5..0000000 --- a/copy-longhorn-from-hd.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SRC="/mnt/replicas" -DEST="$HOME/homey-backup/longhorn-volumes" -SKIP="pvc-dfe2aa08-bbb8-423b-9001-fb6aea181597-baf06a7f" - -mkdir -p "$DEST" - -echo "=== Copying Longhorn volumes from HD ===" -echo "Source: $SRC" -echo "Dest: $DEST" -echo "Skip: $SKIP (Jellyfin)" -echo "" - -for pvc in "$SRC"/*/; do - name=$(basename "$pvc") - - if [[ "$name" == "$SKIP" ]]; then - echo "Skipping: $name" - continue - fi - - echo "" - echo "Copying: $name" - - src_size=$(sudo du -sb "$pvc" 2>/dev/null | awk '{print $1}' || echo "0") - src_size_hr=$(numfmt --to=iec-i --suffix=B "$src_size" 2>/dev/null || echo "${src_size}B") - echo "Size: $src_size_hr" - - sudo rsync -a --no-owner --no-group --info=progress2 "${pvc%/}" "$DEST/" - sudo chown -R "$USER" "$DEST/$name" - - size=$(du -sh "$DEST/$name" | cut -f1) - echo "Done: $size" -done - -echo "" -echo "=== Copy Complete ===" -echo "Total size:" -sudo du -sh "$DEST" diff --git a/files/authelia-config.yaml b/files/authelia-config.yaml deleted file mode 100644 index ba3ee3e..0000000 --- a/files/authelia-config.yaml +++ /dev/null @@ -1,87 +0,0 @@ -############################################################### -# Authelia minimal configuration # -############################################################### -theme: "light" -log: - level: "debug" -jwt_secret: {{ .homey_authelia_jwt | quote }} -authentication_backend: - ldap: - implementation: "custom" - url: "ldap://openldap:389" - timeout: "5s" - start_tls: false - base_dn: "{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}}" - users_filter: "({username_attribute}={input})" - username_attribute: "uid" - additional_users_dn: "ou=users" - groups_filter: "(&(uniquemember=uid={input},ou=users,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}})(objectclass=groupOfUniqueNames))" - group_name_attribute: "cn" - additional_groups_dn: "ou=groups" - mail_attribute: "mail" - display_name_attribute: "uid" - permit_referrals: false - permit_unauthenticated_bind: false - user: "cn=readonly,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}" - password: {{ .homey_openldap_ro | quote }} -totp: - issuer: "{{ .Values.homey.url }}" - disable: false -session: - name: authelia_session - secret: {{ .homey_authelia_session | quote }} - expiration: 3600 # 1 hour - inactivity: 7200 # 2 hours - domain: "{{ .Values.homey.url}}" # needs to be your root domain -storage: - local: - path: "/config/db.sqlite3" - encryption_key: {{ .homey_authelia_encryption_key | quote }} -access_control: - default_policy: "deny" - rules: - - domain: - - "auth.zakobar.com" - policy: "bypass" - - domain: - - "dav.{{ .Values.homey.url }}" - policy: "one_factor" - - domain: - - "ldapadmin.{{ .Values.homey.url }}" - subject: - - 'group:admins' - policy: "two_factor" - - domain: - - "*.admin.{{ .Values.homey.url }}" - subject: - - 'group:admins' - policy: "two_factor" - - domain: - - "*.admin.{{ .Values.homey.url }}" - policy: "deny" - - domain: - - "torrent.{{ .Values.homey.url }}" - subject: - - 'group:admins' - policy: "two_factor" - - domain: - - "torrent.{{ .Values.homey.url }}" - policy: "deny" - - domain: - - "stash-dl.{{ .Values.homey.url }}" - policy: "one_factor" - - domain: - - "stash.{{ .Values.homey.url }}" - policy: "one_factor" - - domain: - - "paperless.{{ .Values.homey.url }}" - policy: "one_factor" -notifier: - filesystem: - filename: "/var/lib/authelia/emails.txt" -ntp: - address: 'udp://time.cloudflare.com:123' - version: 3 - max_desync: '3s' - disable_startup_check: false - disable_failure: true diff --git a/files/baikal-server.php b/files/baikal-server.php deleted file mode 100644 index bc2fa1c..0000000 --- a/files/baikal-server.php +++ /dev/null @@ -1,30 +0,0 @@ -setBaseUri('server.php'); - -// The lock manager is reponsible for making sure users don't overwrite -// each others changes. -$lockBackend = new DAV\Locks\Backend\File('data/locks'); -$lockPlugin = new DAV\Locks\Plugin($lockBackend); -$server->addPlugin($lockPlugin); - -// This ensures that we get a pretty index in the browser, but it is -// optional. -$server->addPlugin(new DAV\Browser\Plugin()); - -// All we need to do now, is to fire up the server -$server->exec(); diff --git a/files/davical-config.php b/files/davical-config.php deleted file mode 100644 index 34183c4..0000000 --- a/files/davical-config.php +++ /dev/null @@ -1,580 +0,0 @@ -pg_connect[] = "dbname=davical user=postgres port=5432 host=davical-postgres password={{ .homey_davical_postgres_pass }}"; - - -/**************************** -********* Desirable ********* -*****************************/ - -$c->system_name = "{{ .Values.homey.organization }} CalDAV Server"; -$c->dbg = array( 'statistics' => 1, 'request' => 1, 'response' => 1 ); - -// $c->admin_email = 'calendar-admin@example.com'; -$c->restrict_setup_to_admin = true; -/*************************************************************************** -* * -* Caldav Server * -* * -***************************************************************************/ - -/** -* The "collections_always_exist" value defines whether a MKCALENDAR -* command is needed to create a calendar collection before calendar -* resources can be stored in it. You will want to leave this to the -* default (true) if people will be using Evolution or Sunbird / -* Lightning against this because that software does not support the -* creation of calendar collections. -* -* Default: true -*/ -// $c->collections_always_exist = false; - -/** -* The name of a user's "home" calendar and addressbook. These will be created -* for each new user. -* -* Defaults: -* home_calendar_name: 'calendar' -* home_addressbook_name: 'addresses' -*/ -// $c->home_calendar_name = 'calendar'; -// $c->home_addressbook_name = 'addresses'; - -/** -* Sets a numeric value indicating the maximum size in octets (bytes) of a resource -* that the server is willing to accept when an address object resource is stored -* in an address book collection (e.g. contacts with image attachments). -* Note that not all clients respect that property and that DAViCal won't deny creating -* or updating a resource that is larger than the specified limit if the client willingly or -* unwillingly ignores that property. Currently (late 2018) we only know of iOS devices to handle it properly. -* -* Default: 6550000 -*/ -// $c->carddav_max_resource_size = 6550000; - -/** -* If the above options are not suitable for your new users, use this to create -* a more complex default collection management. -* -* Note: if you use this configuration option both $c->home_calendar_name and -* $c->home_addressbook_name are ignored! -* -* See https://wiki.davical.org/index.php/Configuration/settings/default_collections -*/ -// $c->default_collections = array( -// array( -// 'type' => 'addressbook', -// 'name' => 'addresses', -// 'displayname' => '%fn addressbook', -// 'privileges' => null -// ), -// array( -// 'type' => 'calendar', -// 'name' => 'calendar', -// 'displayname' => '%fn calendar', -// 'privileges' => null -// ) -// ); - -/** -* An array of groups / permissions which should be automatically added -* for each new user created. This is a crude mechanism which we -* will hopefully manage to work out some better approach for in the -* future. For now, create an array that looks something like: -* array( 9 => 'R', 4 => 'A' ) -* to create a 'read' relationship to user_no 9 and an 'all' relation -* with user_no 4. -* -* Default: none -*/ -// $c->default_relationships = array(); - -/** -* An array of the privileges which will be configured for a user by default -* from the possible set of real privileges: -* 'read', 'write-properties', 'write-content', 'unlock', 'read-acl', 'read-current-user-privilege-set', -* 'bind', 'unbind', 'write-acl', 'read-free-busy', -* 'schedule-deliver-invite', 'schedule-deliver-reply', 'schedule-query-freebusy', -* 'schedule-send-invite', 'schedule-send-reply', 'schedule-send-freebusy' -* -* Or also from these aggregated privileges: -* 'write', 'schedule-deliver', 'schedule-send', 'all' -*/ -// $c->default_privileges = array('read-free-busy', 'schedule-query-freebusy'); - -/** -* An array of fields on the usr record which should be set to specific -* values when the users are created. -* -* Default: none -*/ -// $c->template_usr = array( -// 'active' => true, -// 'locale' => 'it_IT', -// 'date_format_type' => 'E', -// 'email_ok' => date('Y-m-d') -// ); - -/** -* If "hide_TODO" is true, then VTODO requested from someone other than the -* admin or owner of a calendar will not get an answer. Often these todo are -* only relevant to the owner, but in some shared calendar situations they -* might not be in which case you should set this to false. -* -* Default: true -*/ -// $c->hide_TODO = false; - -/** -* If true, then VALARM from someone other than the admin or owner of a -* calendar will not be included in the response. The default is false because -* the preferred behaviour is to enable/disable the alarms in your CalDAV -* client software. -* -* Default: false -*/ -// $c->hide_alarm = true; - -/** -* If you want to hide older events (in order to save resources, speed up -* clients, etc.) define the desired time interval in number of days. -*/ -// $c->hide_older_than = 90; - -/** -* Hide bound collections from certain clients -* -* If you want to use iOS (which does not support delegation) in combination -* with other software which does supports degation, you can use this option -* to tailor a working solution: bind all collections you want to see on iOS -* (emulation of delegation) and then hide these collections from other clients -* with real delegation support. -* -* Default: false/not set: always show bound collections -* -* If set to true: never show bound collections -* If set to an array: hide if any header => regex tuple matches -* Example: Hide bound collections from clients which send a User-Agent header -* matching regex1 OR an X-Client header matching regex2 -*/ -// $c->hide_bound = array( 'User-Agent'=>'#regex1#', 'X-Client'=>'#regex2#'); - -/** -* External subscription (BIND) minimum refresh interval -* Required if you want to enable remote binding ( webcal subscriptions ) -* -* Default: none -*/ -// $c->external_refresh = 60; - -/** -* External subscription (BIND) user agent string -* Required if your remote calendar only delivers to known user agents. -* -* Default: none -*/ -// $c->external_ua_string = ''; - -/** -* If you want to force DAViCal to use HTTP Digest Authentication for CalDAV -* access. Note that this requires all user passwords to be stored in plain text -* in the database. It is probably better to configure the webserver to do -* Digest auth against a separate user database (see below for Webserver Auth). -*/ -// $c->http_auth_mode = "Digest"; - -/** -* Provide freebusy information to any (unauthenticated) user via the -* freebusy.php URL. Only events marked as PRIVATE will be excluded from the -* report. -* -* Default: false (authentication required) -*/ -// $c->public_freebusy_url = true; - -/** -* The "support_obsolete_free_busy_property" value controls whether, -* during a PROPFIND, the obsolete Scheduling property "calendar-free-busy-set" -* is returned. Set the value to true to support the property only if your -* client requires it, however note that PROPFIND performance may be -* adversely affected if you do so. -* -* Introduced in DAViCal version 1.1.4 in support of Issue #31 Database -* Performance Improvements. -* -* Default: false -*/ -// $c->support_obsolete_free_busy_property = false; - -/** -* The default locale will be "en_NZ"; -* -* If you are in a non-English locale, you can set the default_locale -* configuration to one of the supported locales. -* -* Supported Locales (at present, see: "select * from supported_locales ;" for a full list) -* -* "de_DE", "en_NZ", "es_AR", "fr_FR", "nl_NL", "ru_RU" -* -* If you want locale support you probably know more about configuring it than me, but -* at this stage it should be noted that all translations are UTF-8, and pages are -* served as UTF-8, so you will need to ensure that the UTF-8 versions of these locales -* are supported on your system. -* -* People interested in providing new translations are directed to the Wiki: -* https://wiki.davical.org/w/Translating_DAViCal -*/ -// $c->default_locale = "en_NZ"; - -/** -* This is used to construct URLs which are passed in the answers to the client. You may -* want to force this to a specific domain in responses if your system is accessed by -* multiple names, otherwise you probably won't need to change it. -* -* Default: $_SERVER['SERVER_NAME'] -*/ -// $c->domain_name = 'example.com'; - -/** -* If this option is set to true, then "@$c->domain_name" is appended to the -* user login name if it does not contain the @ character. If email addresses -* are used as user names in Davical, this fixes a problem with MacOS X 10.6 -* Addressbook that cannot login to CardDav account. -* -* Default: false -*/ -// $c->login_append_domain_if_missing = true; - -/** -* Many people want this, but it may be a security issue for you, so it is -* disabled by default. If you enable it, then confidential / private events -* will be visible to the 'organizer' or 'attendee' lists. The reason that -* this becomes a security issue is that this identification needs to be based -* on the user's e-mail address. The user's e-mail address is generally -* something which they can set, so they could change it to be the address of -* an attendee of a meeting and then would be able to read the meeting. -* -* Without this, the only person who can view/change PRIVATE or CONFIDENTIAL -* events in a calendar is someone with full administrative rights to the calendar -* usually the owner. -* -* If the only person that devious is your sysadmin then you probably already -* enabled this option... -* -* Default: false -*/ -// $c->allow_get_email_visibility = false; - -/** -* Disable calendar-proxy-{read,write} on PROPFIND -* -* This can be useful if clients are known to not use this information, -* as it is very expensive to compute (especially on servers with lots of -* users who share their collections) and most clients will never use it, -* or ask for it explicitly using an expand-property REPORT, which is not -* affected by this option. -* -* Default: false/unset -* -* If set to false (or unset): always show -* If set to true: never show -* If set to an array: hide if any header => regex tuple matches -*/ -// $c->disable_caldav_proxy_propfind_collections = array( 'User-Agent'=>'#regex1#', 'X-Client'=>'#regex2#'); - -/** -* A limiter on how many times we'll apply the recurrence rules for an event -* to find the next valid one. -* -* Default: 100 -* -* If you see the following error message, you may want to consider increasing -* it: -* RRULE, loop limit has been hit in GetMoreInstances, you probably want to increase $c->rrule_loop_limit -*/ -// $c->rrule_loop_limit = 100; - -/** -* EXPERIMENTAL: -* If true, names of groups (prefixed with "@") given as an event attendee -* will get resolved to a list of members of that group. Note that CalDAV -* clients might get confused by this server behavior until they get -* synced again. -* -* Default: false. -*/ -// $c->enable_attendee_group_resolution = true; - - -/*************************************************************************** -* * -* Scheduling * -* * -***************************************************************************/ - -/** -* If you want to turn off scheduling functions you can set this to 'false' and -* DAViCal will not advertise the ability to schedule, leaving it to calendar -* clients to send out and receive scheduling requests. -* -* Default: true -*/ -// $c->enable_auto_schedule = false; - -/** -* If true, then remote scheduling will be enabled. There is a possibility -* of receiving spam events in calendars if enabled, you will at least know -* what domain the spam came from as domain key signatures are required for -* events to be accepted. -* -* You probably need to setup Domain Keys for your domain as well as the -* appropiate DNS SRV records. -* -* for example, if DAViCal is installed on cal.example.com you should have -* DNS SRV records like this: -* _ischedules._tcp.example.com. IN SRV 0 1 443 cal.example.com -* _ischedule._tcp.example.com. IN SRV 0 1 80 cal.example.com -* -* DNS TXT record for signing outbound requests -* example: -* cal._domainkey.example.com. 86400 IN TXT "k=rsa\; t=s\; p=PUBKEY" -* -* Default: false -*/ -// $c->enable_scheduling = true; - -/** -* Domain Key domain to use when signing outbound scheduling requests, this -* is the domain with the public key in a TXT record as shown above. -* -* TODO: enable domain/signing by per user keys, patches welcome. -* -* Default: none -*/ -// $c->scheduling_dkim_domain = ''; - -/** -* Domain Key selector to use when signing outbound scheduling requests. -* -* TODO: enable selectors/signing by per user keys, patches welcome. -* -* Default: 'cal' -*/ -// $c->scheduling_dkim_selector = 'cal'; - -/* -* Domain Key private key -* Required if you want to enable outbound remote server scheduling -* -* Default: none -*/ -// $c->schedule_private_key = 'PRIVATE-KEY-BASE-64-DATA'; - - -/*************************************************************************** -* * -* Operation behind a Reverse Proxy * -* * -***************************************************************************/ - -/** -* If you install DAViCal behind a reverse proxy (e.g. an SSL offloader or -* application firewall, or in order to present services from different machines -* on a single public IP / hostname), the client IP, protocol and port used may -* be different from what the web server is reporting to DAViCal. Often, the -* original values are written to the X-Real-IP and/or X-Forwarded-For, -* X-Forwarded-Proto and X-Forwarded-Port headers. You can instruct DAViCal to -* attempt to "do the right thing" and use the content of these headers instead, -* when they are available. -* -* CAUTION: Malicious clients can spoof these headers. When you enable this, you -* need to make sure your reverse proxy erases any pre-existing values of all -* these headers, and that no untrusted requests can reach DAViCal without -* passing the proxy server. -* -* Default: false -*/ -unset( $_SERVER['HTTP_X_REAL_IP'] ); -$c->trust_x_forwarded = true; - -/* Set all values manually. */ -// $_SERVER['HTTPS'] = 'on'; -// $_SERVER['SERVER_PORT'] = 443; -// $_SERVER['REMOTE_ADDR'] = $_SERVER['Client-IP']; - - -/*************************************************************************** -* * -* External Authentication Sources * -* * -***************************************************************************/ - -/** -* Allow specifying another way to control access of the user by authenticating -* him against other drivers such has LDAP (the default is the PgSQL DB) -* $c->authenticate_hook['call'] should be set to the name of the plugin and must -* be a valid function that will be call like this: -* call_user_func( $c->authenticate_hook['call'], $username, $password ) -* -* The login mechanism is used in 2 different places: -* - for the web interface in: index.php that calls DAViCalSession.php that extends -* Session.php (from AWL libraries) -* - for the caldav client in: caldav.php that calls HTTPAuthSession.php -* Both Session.php and HTTPAuthSession.php check against the -* authenticate_hook['call'], although for HTTPAuthSession.php this will be for -* each page. For Session.php this will only occur during login. -* -* $c->authenticate_hook['config'] should be set up with any configuration data -* needed by the authenticate call - see below or in the Wiki for details. -* If you want to develop your own authentication plugin, have a look at -* awl/inc/AuthPlugins.php or any of the inc/drivers_*.php files. -* -* $c->authenticate_hook['optional'] = true; can be set to try default authentication -* as well in case the configured hook should report a failure. -*/ -// $c->authenticate_hook['optional'] = true; - -/********************************/ -/******* Other AWL hook *********/ -/********************************/ -// require_once('auth-functions.php'); -// $c->authenticate_hook = array( -// 'call' => 'AuthExternalAwl', -// 'config' => array( -// // A PgSQL database connection string for the database containing user records -// 'connection' => 'dbname=wrms host=otherhost port=5433 user=general', -// // Which columns should be fetched from the database -// 'columns' => "user_no, active, email_ok, joined, last_update AS updated, last_used, username, password, fullname, email", -// // a WHERE clause to limit the records returned. -// 'where' => "active AND org_code=7" -// ) -// ); - - -/********************************/ -/*********** LDAP hook **********/ -/********************************/ -/* -* For Active Directory go down to the next example. -*/ - -putenv('LDAPTLS_REQCERT=never'); -$c->authenticate_hook['call'] = 'LDAP_check'; -$c->authenticate_hook['config'] = array( - 'uri' => 'ldaps://openldap:636', - 'bindDN' => 'cn=readonly,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}', - 'passDN' => '{{ .homey_openldap_ro }}', - 'protocolVersion' => 3, // Version of LDAP protocol to use - 'optReferrals' => 0, // whether to automatically follow referrals returned by the LDAP server - 'networkTimeout' => 10, // timeout in seconds - 'baseDNUsers' => 'ou=users,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}}', - 'filterUsers' => 'objectClass=person', - 'baseDNGroups' => 'ou=groups,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}}', - 'filterGroups' => 'objectClass=groupOfUniqueNames', - 'mapping_field' => array("username" => "uid", - "modified" => "modifyTimestamp", - "fullname" => "cn" , - "email" => "mail" - ), // used to create the user based on their ldap properties - 'group_mapping_field' => array("username" => "cn", - "modified" => "modifyTimestamp", - "fullname" => "cn" , - "members" => "memberUid" - ), // used to create the group based on the ldap properties - 'group_member_dnfix' => true, // if your "members" field contains the full DN and needs to be truncated to just the uid - 'startTLS' => 'no', -); - -include('drivers_ldap.php'); -/********************************/ -/****** Webserver does Auth *****/ -/********************************/ - -/** -* It is quite common that the webserver can do the authentication for you, -* and you just want DAViCal to trust the username that the webserver will pass -* through (in the REMOTE_USER or REDIRECT_REMOTE_USER environment variable). -* In that case, set server_auth_type (can be an array) to the value provided by -* the webserver in the AUTH_TYPE environment variable, as well as the two -* following options as needed. -* -* Note that this method does not pull account details from anywhere, so you -* will first need to create an account in DAViCal for each username that will -* authenticate in this way - it's just that the password on that account will -* be ignored and authentication will happen through the authentication method -* that the webserver is configured with. -*/ -$c->authenticate_hook['server_auth_type'] = 'Basic'; -include_once('AuthPlugins.php'); - -/** -* Uncomment this to use Webserver Auth for CalDAV access in addition to the -* Admin web pages. -*/ - -/** -* If your Webserver Auth method provides a logout URL (traditional Basic Auth -* does not), you can enter it here so the Logout link in the Admin web pages -* can point to it. -*/ -$c->authenticate_hook['logout'] = 'https://auth.{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}}/logout?rd=dav.{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}}'; - -/*************************************************************************** -* * -* Push Notification Server * -* * -***************************************************************************/ - -/* -* This enable XMPP PubSub push notifications to clients that request them. -* N.B. this will publish urls for ALL updates and does NOT restrict -* subscription permissions on the jabber server! That means anyone with -* read access to the pubsub tree of your jabber server can watch for updates, -* they will only see URL's to the updated entries not the calendar data. -* -* Only tested with ejabberd 2.0.x -*/ - -// $c->notifications_server = array( -// 'host' => $_SERVER['SERVER_NAME'], // jabber server hostname -// 'jid' => 'user@example.com', // user(JID) to login/ publish as -// 'password' => '', // password for above account -// // 'debug_jid' => 'otheruser@example.com' // send a copy of all publishes to this jid -// ); -// include ( 'pubsub.php' ); - - -/*************************************************************************** -* * -* Detailed Metrics * -* * -***************************************************************************/ - -/* -* This enables a /metrics.php URL containing detailed metrics about the -* operation of DAViCal. Ideally you will be running memcache if you are -* interested in keeping metrics, but there is a simple metrics collection -* available to you without running memcache. -* -* Note that there is currently no way of enabling metrics via memcache -* without memcache being enabled for all of DAViCal. -*/ -// $c->metrics_style = 'counters'; // Just the simple counter-based metrics -// $c->metrics_style = 'memcache'; // Only the metrics using memcache -// $c->metrics_style = 'both'; // Both styles of metrics -// $c->metrics_collectors = array('127.0.0.1'); // Restrict access to only this IP address -// $c->metrics_require_user = 'metricsuser'; // Restrict access to only connections authenticating as this user - -/*************************************************************************** -* * -* Audit Logging * -* * -***************************************************************************/ -/* To enable audit logging to syslog you can uncomment the following line. -* -* This file is suitable for basic auditing, if you want/need more comprehensive -* logging then see: -* http://wiki.davical.org/index.php/Configuration/hooks/log_caldav_action -*/ -// include('log_caldav_action.php'); - diff --git a/files/gitea-app.ini b/files/gitea-app.ini deleted file mode 100644 index ad31759..0000000 --- a/files/gitea-app.ini +++ /dev/null @@ -1,95 +0,0 @@ -APP_NAME = {{ .Values.homey.organization }} -RUN_MODE = prod -RUN_USER = git -WORK_PATH = /data/gitea - -[repository] -ROOT = /data/git/repositories - -[repository.local] -LOCAL_COPY_PATH = /data/gitea/tmp/local-repo - -[repository.upload] -TEMP_PATH = /data/gitea/uploads - -[server] -APP_DATA_PATH = /data/gitea -DOMAIN = git.{{ .Values.homey.url }} -HTTP_PORT = 3000 -ROOT_URL = https://git.{{ .Values.homey.url }}/ -DISABLE_SSH = true -SSH_PORT = 443 -SSH_LISTEN_PORT = 22 -LFS_START_SERVER = true -LFS_JWT_SECRET = {{ .homey_gitea_lfs_jwt_secret | b64enc | replace "=" "" }} -OFFLINE_MODE = false - -[lfs] -PATH = /data/git/lfs - -[database] -PATH = /data/gitea/gitea.db -DB_TYPE = sqlite3 -HOST = localhost:3306 -NAME = gitea -USER = root -PASSWD = -LOG_SQL = false -SCHEMA = -SSL_MODE = disable -CHARSET = utf8 - -[indexer] -ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve - -[session] -PROVIDER_CONFIG = /data/gitea/sessions -PROVIDER = file - -[picture] -AVATAR_UPLOAD_PATH = /data/gitea/avatars -REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars -DISABLE_GRAVATAR = false -ENABLE_FEDERATED_AVATAR = false - -[attachment] -PATH = /data/gitea/attachments - -[log] -MODE = console -LEVEL = info -ROUTER = console -ROOT_PATH = /data/gitea/log - -[security] -INSTALL_LOCK = true -SECRET_KEY = -REVERSE_PROXY_LIMIT = 1 -REVERSE_PROXY_TRUSTED_PROXIES = * -INTERNAL_TOKEN = {{ .homey_gitea_random_internal_token }} -PASSWORD_HASH_ALGO = pbkdf2 - -[service] -DISABLE_REGISTRATION = true -REQUIRE_SIGNIN_VIEW = false -REGISTER_EMAIL_CONFIRM = false -ENABLE_NOTIFY_MAIL = false -ALLOW_ONLY_EXTERNAL_REGISTRATION = true -ENABLE_CAPTCHA = false -DEFAULT_KEEP_EMAIL_PRIVATE = false -DEFAULT_ALLOW_CREATE_ORGANIZATION = true -DEFAULT_ENABLE_TIMETRACKING = true -NO_REPLY_ADDRESS = noreply.localhost -ENABLE_REVERSE_PROXY_AUTHENTICATION = true -ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true - -[mailer] -ENABLED = false - -[openid] -ENABLE_OPENID_SIGNIN = false -ENABLE_OPENID_SIGNUP = false - -[oauth2] -ENABLE = false -JWT_SECRET = {{ .homey_gitea_oauth2_jwt_secret | b64enc | replace "=" "" }} diff --git a/files/radicale-configmap.ini b/files/radicale-configmap.ini deleted file mode 100644 index 493827c..0000000 --- a/files/radicale-configmap.ini +++ /dev/null @@ -1,11 +0,0 @@ -[server] -hosts = 0.0.0.0:5232 - -[auth] -type = http_x_remote_user - -[storage] -filesystem_folder = /data/collections - -[web] -type = none diff --git a/files/sabre-server.php b/files/sabre-server.php deleted file mode 100644 index bc2fa1c..0000000 --- a/files/sabre-server.php +++ /dev/null @@ -1,30 +0,0 @@ -setBaseUri('server.php'); - -// The lock manager is reponsible for making sure users don't overwrite -// each others changes. -$lockBackend = new DAV\Locks\Backend\File('data/locks'); -$lockPlugin = new DAV\Locks\Plugin($lockBackend); -$server->addPlugin($lockPlugin); - -// This ensures that we get a pretty index in the browser, but it is -// optional. -$server->addPlugin(new DAV\Browser\Plugin()); - -// All we need to do now, is to fire up the server -$server->exec(); diff --git a/files/sogo.conf b/files/sogo.conf deleted file mode 100644 index cab53bc..0000000 --- a/files/sogo.conf +++ /dev/null @@ -1,94 +0,0 @@ -{ - /* ********************* Main SOGo configuration file ********************** - * * - * Since the content of this file is a dictionary in OpenStep plist format, * - * the curly braces enclosing the body of the configuration are mandatory. * - * See the Installation Guide for details on the format. * - * * - * C and C++ style comments are supported. * - * * - * This example configuration contains only a subset of all available * - * configuration parameters. Please see the installation guide more details. * - * * - * ~sogo/GNUstep/Defaults/.GNUstepDefaults has precedence over this file, * - * make sure to move it away to avoid unwanted parameter overrides. * - * * - * **************************************************************************/ - - /* Database configuration (mysql:// or postgresql://) */ - SOGoProfileURL = "postgresql://sogo:sogo@sogo-postgres:5432/sogo/sogo_user_profile"; - OCSFolderInfoURL = "postgresql://sogo:sogo@sogo-postgres:5432/sogo/sogo_folder_info"; - OCSSessionsFolderURL = "postgresql://sogo:sogo@sogo-postgres:5432/sogo/sogo_sessions_folder"; - - /* Mail */ - SOGoDraftsFolderName = Drafts; - SOGoSentFolderName = Sent; - SOGoTrashFolderName = Trash; - //SOGoIMAPServer = localhost; - //SOGoSieveServer = sieve://127.0.0.1:4190; - //SOGoSMTPServer = smtp://domain:port/?tls=YES; - //SOGoMailDomain = acme.com; - SOGoMailingMechanis = smtp; - //SOGoForceExternalLoginWithEmail = NO; - //SOGoMailSpoolPath = /var/spool/sogo; - //NGImap4ConnectionStringSeparator = "/"; - - /* Notifications */ - //SOGoAppointmentSendEMailNotifications = NO; - //SOGoACLsSendEMailNotifications = NO; - //SOGoFoldersSendEMailNotifications = NO; - - /* Authentication */ - SOGoPasswordChangeEnabled = YES; - - SOGoUserSources = ( - { - type = ldap; - CNFieldName = cn; - UIDFieldName = uid; - IDFieldName = uid; // first field of the DN for direct binds - bindFields = (uid, mail); // array of fields to use for indirect binds - baseDN = "ou=users,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}"; - bindDN = "cn=readonly,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}"; - bindPassword = "{{ .homey_openldap_ro }}"; - canAuthenticate = YES; - displayName = "Shared Addresses"; - hostname = ldap://openldap:389; - id = public; - isAddressBook = YES; - } - ); - - /* Web Interface */ - //SOGoPageTitle = SOGo; - SOGoVacationEnabled = YES; - SOGoForwardEnabled = YES; - SOGoSieveScriptsEnabled = YES; - //SOGoMailAuxiliaryUserAccountsEnabled = YES; - //SOGoTrustProxyAuthentication = NO; - SOGoXSRFValidationEnabled = YES; - - /* General - SOGoTimeZone *MUST* be defined */ - SOGoLanguage = English; - SOGoTimeZone = Asia/Jerusalem; - //SOGoCalendarDefaultRoles = ( - // PublicDAndTViewer, - // ConfidentialDAndTViewer - //); - //SOGoSuperUsernames = (sogo1, sogo2); // This is an array - keep the parens! - SxVMemLimit = 384; - //WOPidFile = "/var/run/sogo/sogo.pid"; - SOGoMemcachedHost = "/var/run/memcached/memcached.sock"; - - /* Debug */ - SOGoDebugRequests = YES; - SoDebugBaseURL = YES; - ImapDebugEnabled = YES; - LDAPDebugEnabled = YES; - PGDebugEnabled = YES; - MySQL4DebugEnabled = YES; - SOGoUIxDebugEnabled = YES; - WODontZipResponse = YES; - //WOLogFile = /var/log/sogo/sogo.log; -} - diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8ae7fe4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,73 @@ +{ + description = "Homey - self-hosted home server NixOS configuration"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + + # sops-nix for secret management + sops-nix = { + url = "github:Mic92/sops-nix"; + 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 + }; + }; + + outputs = { self, nixpkgs, sops-nix, ... }@inputs: + let + # Shared specialArgs passed to every host + commonArgs = { + inherit inputs; + # Top-level site config — override per-host if needed + homeyConfig = { + domain = "home.zakobar.com"; # base domain for all services + organization = "Zakobar Home Server"; + timezone = "Asia/Jerusalem"; + # External HD mount point — set in hardware.nix per host + # dataDir is intentionally NOT set here; each host sets it + }; + }; + + mkHost = { system, hostPath, extraModules ? [] }: + nixpkgs.lib.nixosSystem { + inherit system; + specialArgs = commonArgs; + modules = [ + sops-nix.nixosModules.sops + hostPath + ./modules/common.nix + ./modules/storage.nix + ./modules/caddy.nix + ./modules/cloudflared.nix + ./modules/backup.nix + ./modules/services/openldap.nix + ./modules/services/authelia.nix + ./modules/services/gitea.nix + ./modules/services/nextcloud.nix + ./modules/services/phpldapadmin.nix + ./modules/services/jellyfin.nix + ./modules/services/transmission.nix + ] ++ extraModules; + }; + + in { + nixosConfigurations = { + + # 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/get-secret-val.sh b/get-secret-val.sh deleted file mode 100644 index ee5a868..0000000 --- a/get-secret-val.sh +++ /dev/null @@ -1 +0,0 @@ -kubectl get secret -n $1 $2 --template={{.data.$3}} | base64 -d | xclip -selection c diff --git a/gitea-commands.txt b/gitea-commands.txt deleted file mode 100644 index 3361c42..0000000 --- a/gitea-commands.txt +++ /dev/null @@ -1,10 +0,0 @@ -kubectl exec -it -n homey deploy/gitea -- su - git -c "/usr/local/bin/gitea admin auth update-ldap --id=1 --name ldap --security-protocol unencrypted --host openldap --port 389 --user-search-base ou=users,dc=zakobar,dc=com --user-filter \"(&(objectClass=person)(uid=%s))\" --admin-filter \"(memberOf=CN=admins,ou=groups,dc=zakobar,dc=com)\" --email-attribute mail --bind-dn=cn=readonly,dc=zakobar,dc=com --bind-password=VqxPZHwDCkFsLWaroyb880zdH1JTCvz9" - -kubectl exec -it -n homey deploy/gitea -- su - git -c "/usr/local/bin/gitea admin user delete --username aner" - - -gitea admin auth add-ldap --name ldap --security-protocol unencrypted --host openldap --port 389 --user-search-base ou=users,dc=zakobar,dc=com --user-filter "&(objectClass=inetOrgPerson)(uid=%s)" --email-attribute mail --bind-dn="cn=readonly,dc=zakobar,dc=com" --bind-password=VqxPZHwDCkFsLWaroyb880zdH1JTCvz9 - -gitea admin auth update-ldap --id=1 --name ldap --security-protocol unencrypted --host openldap --port 389 --user-search-base ou=users,dc=zakobar,dc=com --user-filter "(&(objectClass=person)(uid=%s))" --email-attribute mail --bind-dn="cn=readonly,dc=zakobar,dc=com" --bind-password=VqxPZHwDCkFsLWaroyb880zdH1JTCvz9 - -kubectl exec -it -n homey deploy/authelia -- /bin/bash -c "cat /var/lib/authelia/emails.txt" diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix new file mode 100644 index 0000000..87b5e2c --- /dev/null +++ b/hosts/pi-main/default.nix @@ -0,0 +1,83 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Pi-main host configuration. +# This file declares which services run on this machine and any +# host-specific overrides. Hardware config lives in hardware.nix. + +{ + imports = [ + ./hardware.nix + ]; + + # ------------------------------------------------------------------------- + # Identity + # ------------------------------------------------------------------------- + networking.hostName = "pi-main"; + + # ------------------------------------------------------------------------- + # Admin user + # ------------------------------------------------------------------------- + users.users.admin = { + isNormalUser = true; + extraGroups = [ "wheel" "podman" ]; + # Paste your SSH public key here + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAA... your-key-here" + ]; + }; + + security.sudo.wheelNeedsPassword = false; # convenience on a home server + + # ------------------------------------------------------------------------- + # External HD + # ------------------------------------------------------------------------- + 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"; + mountPoint = "/mnt/data"; + fsType = "ext4"; + }; + + # ------------------------------------------------------------------------- + # Services enabled on this host + # ------------------------------------------------------------------------- + + # Auth stack (run these together — authelia depends on openldap) + homey.openldap.enable = true; + homey.authelia.enable = true; + + # Productivity + homey.gitea.enable = true; + homey.nextcloud.enable = true; + homey.phpldapadmin.enable = true; + + # Media (enable when ready) + homey.jellyfin.enable = false; + homey.transmission.enable = false; + + # Reverse proxy + Cloudflare + homey.caddy.enable = true; + homey.cloudflared.enable = true; + + # Backups + homey.backup.enable = true; + # Where to send restic backups — set to your backup destination: + # "sftp:user@nas.local:/backups/homey" + # "b2:your-bucket-name:homey" + # "rclone:remote:homey" + homey.backup.repository = "sftp:REPLACE-WITH-BACKUP-DESTINATION"; + + # ------------------------------------------------------------------------- + # Local DNS overrides (optional — makes LAN clients hit the Pi directly + # instead of going through Cloudflare for *.home.zakobar.com) + # ------------------------------------------------------------------------- + # If you run Pi-hole or Adguard, add these records there instead. + # networking.extraHosts = '' + # 192.168.1.100 home.zakobar.com + # 192.168.1.100 auth.home.zakobar.com + # 192.168.1.100 git.home.zakobar.com + # 192.168.1.100 nextcloud.home.zakobar.com + # 192.168.1.100 ldapadmin.home.zakobar.com + # ''; +} diff --git a/hosts/pi-main/hardware.nix b/hosts/pi-main/hardware.nix new file mode 100644 index 0000000..4127cca --- /dev/null +++ b/hosts/pi-main/hardware.nix @@ -0,0 +1,84 @@ +{ config, lib, pkgs, modulesPath, ... }: + +# Hardware configuration for the primary Raspberry Pi 4 (8 GB). +# +# SD card layout assumed: +# /dev/mmcblk0p1 — /boot/firmware (FAT32, ~256 MB) +# /dev/mmcblk0p2 — / (ext4) +# +# 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. + +{ + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; + + # ------------------------------------------------------------------------- + # 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 + fsType = "ext4"; + options = [ "noatime" ]; + }; + + fileSystems."/boot/firmware" = { + device = "/dev/disk/by-label/FIRMWARE"; # FAT32 boot partition + fsType = "vfat"; + options = [ "fmask=0022" "dmask=0022" ]; + }; + + # External HD — device path is set in default.nix via homey.storage.device. + # storage.nix creates the actual fileSystems entry from that option. + + 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 new file mode 100644 index 0000000..b89aefa --- /dev/null +++ b/modules/backup.nix @@ -0,0 +1,150 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Restic backup module. +# +# Backs up all service data directories from the external HD. +# Schedule: daily at 03:00, keep 7 daily / 4 weekly / 6 monthly snapshots. +# +# Before a backup, Nextcloud is put into maintenance mode and postgres is +# pg_dump'd to a file. This ensures consistent DB backups. +# +# Secrets consumed from sops: +# restic/password +# +# The backup repository URL is set per-host in default.nix: +# homey.backup.repository = "sftp:user@nas:/backups/homey"; +# +# Restore: +# restic -r restore latest --target /mnt/data +# (or restore a single path: --include /mnt/data/openldap) + +let + cfg = config.homey.backup; + dataDir = config.homey.storage.mountPoint; +in +{ + options.homey.backup = { + enable = lib.mkEnableOption "Restic backup jobs"; + + repository = lib.mkOption { + type = lib.types.str; + example = "sftp:user@nas.local:/backups/homey"; + description = '' + Restic repository URL. Examples: + sftp:user@host:/path + b2:bucket-name:prefix + rclone:remote:path + /local/path (for testing) + ''; + }; + + schedule = lib.mkOption { + type = lib.types.str; + default = "03:00"; + description = "systemd OnCalendar expression for the daily backup."; + }; + + pruneRetention = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { + daily = "7"; + weekly = "4"; + monthly = "6"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."restic/password" = { owner = "root"; }; + + # ----------------------------------------------------------------------- + # Pre-backup hook: pg_dump + nextcloud maintenance mode + # ----------------------------------------------------------------------- + systemd.services."homey-backup-pre" = { + description = "Pre-backup hooks (pg_dump, NC maintenance mode)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "backup-pre" '' + set -euo pipefail + + # Put Nextcloud into maintenance mode (if running) + if systemctl is-active --quiet podman-nextcloud.service; then + podman exec nextcloud php occ maintenance:mode --on || true + fi + + # Dump postgres (if running) + if systemctl is-active --quiet podman-nextcloud-postgres.service; then + install -d -m 700 ${dataDir}/nextcloud/db-dump + podman exec nextcloud-postgres \ + pg_dump -U postgres nextcloud_db \ + > ${dataDir}/nextcloud/db-dump/nextcloud.sql + fi + ''; + }; + }; + + systemd.services."homey-backup-post" = { + description = "Post-backup hooks (take NC out of maintenance mode)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "backup-post" '' + set -euo pipefail + if systemctl is-active --quiet podman-nextcloud.service; then + podman exec nextcloud php occ maintenance:mode --off || true + fi + ''; + }; + }; + + # ----------------------------------------------------------------------- + # Restic backup service + # ----------------------------------------------------------------------- + services.restic.backups.homey = { + repository = cfg.repository; + passwordFile = config.sops.secrets."restic/password".path; + cacheDir = "${dataDir}/restic-cache"; + + paths = [ + "${dataDir}/openldap" + "${dataDir}/authelia" + "${dataDir}/gitea" + "${dataDir}/nextcloud" + # media and transmission config included when those services are enabled: + "${dataDir}/jellyfin" + "${dataDir}/transmission" + # Deliberately excluded: media/* (large, can be re-downloaded) + ]; + + # Exclude Nextcloud's raw DB directory in favour of the pg_dump file + exclude = [ + "${dataDir}/nextcloud/db" + "${dataDir}/restic-cache" + ]; + + timerConfig = { + OnCalendar = cfg.schedule; + Persistent = true; # run on next boot if missed + }; + + pruneOpts = [ + "--keep-daily ${cfg.pruneRetention.daily}" + "--keep-weekly ${cfg.pruneRetention.weekly}" + "--keep-monthly ${cfg.pruneRetention.monthly}" + ]; + }; + + # Wire the pre/post hooks around the restic job + systemd.services."restic-backups-homey" = { + requires = [ "homey-backup-pre.service" ]; + after = [ "homey-backup-pre.service" ]; + }; + + systemd.services."homey-backup-post" = { + after = [ "restic-backups-homey.service" ]; + wantedBy = [ "restic-backups-homey.service" ]; + }; + }; +} diff --git a/modules/caddy.nix b/modules/caddy.nix new file mode 100644 index 0000000..901ca2b --- /dev/null +++ b/modules/caddy.nix @@ -0,0 +1,185 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Caddy reverse proxy. +# +# Features: +# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.home.zakobar.com +# - forward_auth to Authelia for protected vhosts +# - Plain reverse_proxy for public vhosts (authelia itself, nextcloud) +# - Listens on :80 (redirect) and :443 (TLS) +# +# Because nixpkgs ships Caddy without the cloudflare DNS plugin by default, +# we build a custom Caddy with it using the xcaddy wrapper from nixpkgs. +# +# Secrets consumed from sops: +# cloudflare/api_token + +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"; + } + ]; + vendorHash = lib.fakeHash; # replace with real hash after first build + }; + + # Reusable Authelia forward_auth snippet + # Returns a Caddyfile snippet block that applies forward_auth. + # copy_headers makes Authelia's Remote-* headers available downstream. + autheliaForwardAuth = '' + forward_auth localhost:9091 { + uri /api/verify?rd=https://auth.${domain} + copy_headers Remote-User Remote-Name Remote-Groups Remote-Email + # On auth failure, redirect to the authelia login page + @goauth status 401 + handle_response @goauth { + redir https://auth.${domain}?rm={method} 302 + } + } + ''; + +in +{ + options.homey.caddy = { + enable = lib.mkEnableOption "Caddy reverse proxy"; + + acmeEmail = lib.mkOption { + type = lib.types.str; + default = "admin@zakobar.com"; + description = "Email for Let's Encrypt ACME registration."; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."cloudflare/api_token" = { + owner = config.services.caddy.user; + }; + + # ----------------------------------------------------------------------- + # Caddy service + # ----------------------------------------------------------------------- + services.caddy = { + enable = true; + package = caddyWithCloudflare; + + # Global options + globalConfig = '' + email ${cfg.acmeEmail} + # Use Cloudflare DNS-01 challenge for wildcard cert + acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} + ''; + + # Each virtual host + virtualHosts = { + + # ------------------------------------------------------------------ + # Authelia — public, no auth gate (it IS the auth gate) + # ------------------------------------------------------------------ + "auth.${domain}" = { + extraConfig = '' + reverse_proxy localhost:9091 + ''; + }; + + # ------------------------------------------------------------------ + # Gitea — protected behind one_factor Authelia + # ------------------------------------------------------------------ + "git.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + reverse_proxy localhost:3000 + ''; + }; + + # ------------------------------------------------------------------ + # Nextcloud — public auth (Nextcloud manages its own users + LDAP) + # Authelia is not gating nextcloud directly because NC has its own + # login flow. We still want HTTPS. + # ------------------------------------------------------------------ + "nextcloud.${domain}" = { + extraConfig = '' + # Redirect CardDAV/CalDAV discovery + redir /.well-known/carddav /remote.php/dav/ 301 + redir /.well-known/caldav /remote.php/dav/ 301 + + # Large uploads (5 GB) + request_body { + max_size 5GB + } + + reverse_proxy localhost:8080 + ''; + }; + + # ------------------------------------------------------------------ + # phpLDAPadmin — two_factor, admins only (enforced by authelia policy) + # ------------------------------------------------------------------ + "ldapadmin.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + reverse_proxy localhost:8081 + ''; + }; + + # ------------------------------------------------------------------ + # Jellyfin — one_factor (added when enabled) + # ------------------------------------------------------------------ + "jellyfin.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + reverse_proxy localhost:8096 + ''; + }; + + # ------------------------------------------------------------------ + # Transmission — two_factor, admins only (enforced by authelia policy) + # ------------------------------------------------------------------ + "torrent.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + reverse_proxy localhost:9091_transmission + ''; + # NOTE: transmission uses 9091 too; we'll bind it to 9092 in its + # module to avoid a clash with authelia. + }; + + }; + }; + + # ----------------------------------------------------------------------- + # Pass Cloudflare token as env var to the caddy systemd unit + # ----------------------------------------------------------------------- + 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: + 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" + '') + ]; + }; + after = lib.mkAfter [ "podman-authelia.service" ]; + wants = lib.mkAfter [ "podman-authelia.service" ]; + }; + + # ----------------------------------------------------------------------- + # Firewall — open HTTP + HTTPS (already in common.nix, explicit here too) + # ----------------------------------------------------------------------- + networking.firewall.allowedTCPPorts = [ 80 443 ]; + }; +} diff --git a/modules/cloudflared.nix b/modules/cloudflared.nix new file mode 100644 index 0000000..c810f61 --- /dev/null +++ b/modules/cloudflared.nix @@ -0,0 +1,77 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Cloudflare Tunnel (cloudflared) — remote access without open inbound ports. +# +# Architecture: +# Internet → Cloudflare edge → cloudflared tunnel (outbound from Pi) +# → Caddy on localhost → service containers +# +# The tunnel is configured to route each hostname to Caddy's HTTPS listener. +# Caddy handles TLS and forward_auth; cloudflared just carries the traffic. +# +# Setup steps (one-time, done from the Cloudflare dashboard): +# 1. Go to Zero Trust → Networks → Tunnels → Create a tunnel +# 2. Name it (e.g. "pi-main") +# 3. Copy the tunnel token — add it to secrets.yaml as cloudflare/tunnel_token +# 4. In the tunnel's "Public Hostnames" config, add routes: +# auth.home.zakobar.com → http://localhost:80 (or https://localhost:443) +# git.home.zakobar.com → https://localhost:443 +# nextcloud.home.zakobar.com → https://localhost:443 +# ldapadmin.home.zakobar.com → https://localhost:443 +# jellyfin.home.zakobar.com → https://localhost:443 +# torrent.home.zakobar.com → https://localhost:443 +# Set "No TLS Verify" = true (Caddy's cert is from Let's Encrypt but +# the hostname seen by cloudflared is localhost, so hostname verification +# would fail without this flag). +# +# The tunnel_token approach (--token) is the simplest: one secret, no config +# file needed on the Pi. +# +# Secrets consumed from sops: +# cloudflare/tunnel_token + +let + cfg = config.homey.cloudflared; +in +{ + options.homey.cloudflared = { + enable = lib.mkEnableOption "Cloudflare Tunnel for remote access"; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."cloudflare/tunnel_token" = { owner = "cloudflared"; }; + + # ----------------------------------------------------------------------- + # cloudflared service + # NixOS 24.11 ships services.cloudflared natively. + # ----------------------------------------------------------------------- + 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"; + }; + }; + }; + + # Inject the tunnel token from the sops secret file + systemd.services."cloudflared-tunnel-pi-main" = { + serviceConfig = { + ExecStart = lib.mkForce (pkgs.writeShellScript "cloudflared-start" '' + exec ${pkgs.cloudflared}/bin/cloudflared tunnel \ + --no-autoupdate \ + run \ + --token "$(cat ${config.sops.secrets."cloudflare/tunnel_token".path})" + ''); + }; + after = lib.mkAfter [ "caddy.service" ]; + wants = lib.mkAfter [ "caddy.service" ]; + }; + }; +} diff --git a/modules/common.nix b/modules/common.nix new file mode 100644 index 0000000..df8aa6b --- /dev/null +++ b/modules/common.nix @@ -0,0 +1,117 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Common configuration shared by every host in the homey ecosystem. +# Hardware-specific settings (disk layout, device trees, etc.) go in +# hosts//hardware.nix instead. + +{ + # ------------------------------------------------------------------------- + # Nix / flakes + # ------------------------------------------------------------------------- + nix = { + settings = { + experimental-features = [ "nix-command" "flakes" ]; + # Save disk space on Pi + auto-optimise-store = true; + }; + # Weekly garbage collection — keeps the system from filling the SD card + gc = { + automatic = true; + dates = "weekly"; + options = "--delete-older-than 14d"; + }; + }; + + # Allow unfree packages (e.g. cloudflared binary) + nixpkgs.config.allowUnfree = true; + + # ------------------------------------------------------------------------- + # Boot — set in hardware.nix; this is just a safe default + # ------------------------------------------------------------------------- + # boot.loader is intentionally left to hardware.nix + + # ------------------------------------------------------------------------- + # Locale / timezone + # ------------------------------------------------------------------------- + time.timeZone = homeyConfig.timezone; + i18n.defaultLocale = "en_US.UTF-8"; + + # ------------------------------------------------------------------------- + # Networking + # ------------------------------------------------------------------------- + networking = { + # hostname is set per-host in default.nix + firewall = { + enable = true; + allowedTCPPorts = [ + 22 # SSH + 80 # Caddy HTTP (redirect to HTTPS or ACME challenge) + 443 # Caddy HTTPS + ]; + }; + # Use systemd-resolved for DNS — supports mDNS and local overrides + nameservers = [ "1.1.1.1" "8.8.8.8" ]; + }; + + # ------------------------------------------------------------------------- + # SSH + # ------------------------------------------------------------------------- + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + PermitRootLogin = "no"; + }; + }; + + # ------------------------------------------------------------------------- + # Container runtime — podman (rootless-capable, no daemon needed) + # ------------------------------------------------------------------------- + virtualisation.podman = { + enable = true; + dockerCompat = true; # allow `docker` CLI commands against podman + defaultNetwork.settings.dns_enabled = true; + }; + + # ------------------------------------------------------------------------- + # Core packages available on every host + # ------------------------------------------------------------------------- + environment.systemPackages = with pkgs; [ + git + vim + htop + curl + wget + rsync + lsof + sops # secret editing + age # key generation for sops + restic # backup (CLI, also used by services.restic) + podman-compose + ]; + + # ------------------------------------------------------------------------- + # sops-nix global config — point at the secrets file and the host's age key + # ------------------------------------------------------------------------- + sops = { + defaultSopsFile = ../secrets/secrets.yaml; + # The age private key must be present on the host at this path. + # Generate on the Pi with: age-keygen -o /var/lib/sops-nix/key.txt + # Then add the PUBLIC key to secrets/.sops.yaml before encrypting. + age.keyFile = "/var/lib/sops-nix/key.txt"; + }; + + # ------------------------------------------------------------------------- + # Admin user — adjust username / SSH key in hosts//default.nix + # ------------------------------------------------------------------------- + users.mutableUsers = false; # all user config must be declared here + + # The actual admin user is declared in hosts//default.nix so the + # SSH authorized key can be host-specific. + + # ------------------------------------------------------------------------- + # System state version — do not change after first install + # (tracks NixOS backwards-compat markers) + # ------------------------------------------------------------------------- + system.stateVersion = "24.11"; +} diff --git a/modules/services/authelia.nix b/modules/services/authelia.nix new file mode 100644 index 0000000..4b1b453 --- /dev/null +++ b/modules/services/authelia.nix @@ -0,0 +1,200 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Authelia — SSO gateway. +# +# Connects to OpenLDAP on 127.0.0.1:389. +# Exposes port 9091 on localhost; Caddy reverse-proxies it and provides +# the forward_auth endpoint for protected vhosts. +# +# Volume layout: +# /authelia/config/ → /config (sqlite db, notification log, etc.) +# +# The configuration file is rendered by Nix (no Go templates) and written +# to a NixOS-managed path, then bind-mounted read-only into the container. +# +# Secrets consumed from sops: +# authelia/jwt_secret +# authelia/session_secret +# authelia/storage_encryption_key +# openldap/ro_password (shared with openldap module) + +let + cfg = config.homey.authelia; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; + + # LDAP base DN derived from domain: home.zakobar.com → dc=home,dc=zakobar,dc=com + ldapBaseDN = lib.concatStringsSep "," + (map (p: "dc=${p}") (lib.splitString "." domain)); + + # The authelia config is written as a Nix string so all values are + # resolved at build time except for secrets, which are injected at + # runtime via a wrapper script (same pattern as openldap). + autheliaConfig = '' + ############################################################### + # Authelia configuration # + # Generated by NixOS — do not edit by hand # + ############################################################### + theme: "light" + log: + level: "info" + + # jwt_secret injected at runtime via env var AUTHELIA_JWT_SECRET_FILE + authentication_backend: + ldap: + implementation: "custom" + url: "ldap://127.0.0.1:389" + timeout: "5s" + start_tls: false + base_dn: "${ldapBaseDN}" + users_filter: "({username_attribute}={input})" + username_attribute: "uid" + additional_users_dn: "ou=users" + groups_filter: "(&(uniquemember=uid={input},ou=users,${ldapBaseDN})(objectclass=groupOfUniqueNames))" + group_name_attribute: "cn" + additional_groups_dn: "ou=groups" + mail_attribute: "mail" + display_name_attribute: "uid" + permit_referrals: false + permit_unauthenticated_bind: false + user: "cn=readonly,${ldapBaseDN}" + # password injected at runtime via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE + + totp: + issuer: "${domain}" + disable: false + + session: + name: authelia_session + # secret injected at runtime via AUTHELIA_SESSION_SECRET_FILE + expiration: 3600 + inactivity: 7200 + domain: "${domain}" + + storage: + local: + path: "/config/db.sqlite3" + # encryption_key injected at runtime via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE + + access_control: + default_policy: "deny" + rules: + - domain: + - "auth.${domain}" + policy: "bypass" + - domain: + - "ldapadmin.${domain}" + subject: + - "group:admins" + policy: "two_factor" + - domain: + - "ldapadmin.${domain}" + policy: "deny" + - domain: + - "torrent.${domain}" + subject: + - "group:admins" + policy: "two_factor" + - domain: + - "torrent.${domain}" + policy: "deny" + - domain: + - "git.${domain}" + policy: "one_factor" + - domain: + - "nextcloud.${domain}" + policy: "one_factor" + - domain: + - "jellyfin.${domain}" + policy: "one_factor" + + notifier: + filesystem: + filename: "/config/emails.txt" + + ntp: + address: "udp://time.cloudflare.com:123" + version: 3 + max_desync: "3s" + disable_startup_check: false + disable_failure: true + ''; + +in +{ + options.homey.authelia = { + enable = lib.mkEnableOption "Authelia SSO gateway"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/authelia/authelia:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9091; + description = "Host port Authelia listens on (bound to 127.0.0.1)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."authelia/jwt_secret" = { owner = "root"; }; + sops.secrets."authelia/session_secret" = { owner = "root"; }; + sops.secrets."authelia/storage_encryption_key" = { owner = "root"; }; + # openldap/ro_password is declared in openldap.nix; reference it here too + # (sops-nix deduplicates identical declarations) + sops.secrets."openldap/ro_password" = { owner = "root"; }; + + # ----------------------------------------------------------------------- + # Write the config file into /etc (read-only in the container) + # ----------------------------------------------------------------------- + environment.etc."authelia/configuration.yml" = { + text = autheliaConfig; + mode = "0444"; + }; + + # ----------------------------------------------------------------------- + # Container + # ----------------------------------------------------------------------- + virtualisation.oci-containers.containers.authelia = { + image = cfg.image; + + ports = [ "127.0.0.1:${toString cfg.port}:9091" ]; + + environment = { + TZ = homeyConfig.timezone; + # Tell authelia to read secrets from files (its native mechanism) + AUTHELIA_JWT_SECRET_FILE = "/run/secrets/jwt_secret"; + AUTHELIA_SESSION_SECRET_FILE = "/run/secrets/session_secret"; + AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE = "/run/secrets/storage_encryption_key"; + AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = "/run/secrets/ldap_ro_password"; + }; + + volumes = [ + "/etc/authelia/configuration.yml:/config/configuration.yml:ro" + "${dataDir}/authelia/config:/config" + # Mount sops secret files into the container under /run/secrets/ + "${config.sops.secrets."authelia/jwt_secret".path}:/run/secrets/jwt_secret:ro" + "${config.sops.secrets."authelia/session_secret".path}:/run/secrets/session_secret:ro" + "${config.sops.secrets."authelia/storage_encryption_key".path}:/run/secrets/storage_encryption_key:ro" + "${config.sops.secrets."openldap/ro_password".path}:/run/secrets/ldap_ro_password:ro" + ]; + + extraOptions = [ + "--network=host" + "--hostname=authelia" + ]; + }; + + # ----------------------------------------------------------------------- + # Systemd — wait for openldap and external HD + # ----------------------------------------------------------------------- + systemd.services."podman-authelia" = { + after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ]; + }; + }; +} diff --git a/modules/services/gitea.nix b/modules/services/gitea.nix new file mode 100644 index 0000000..baaa635 --- /dev/null +++ b/modules/services/gitea.nix @@ -0,0 +1,198 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Gitea — self-hosted Git service. +# +# Auth model: LDAP authentication is configured through Gitea's admin UI +# (or CLI) after first start. Reverse proxy auth headers from Caddy/Authelia +# handle transparent login. +# +# Volume layout: +# /gitea/data/ → /data (repos, sqlite db, avatars, lfs, etc.) +# +# The app.ini is rendered by Nix and bind-mounted read-only. +# +# Secrets consumed from sops: +# gitea/admin_password +# gitea/lfs_jwt_secret +# gitea/oauth2_jwt_secret +# gitea/internal_token + +let + cfg = config.homey.gitea; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; + + # Gitea app.ini — generated at build time. + # Secrets that Gitea reads from env vars are referenced as env var names here. + # The actual values are injected by the ExecStartPre wrapper below. + giteaAppIni = '' + APP_NAME = ${homeyConfig.organization} + RUN_MODE = prod + RUN_USER = git + WORK_PATH = /data/gitea + + [repository] + ROOT = /data/git/repositories + + [repository.local] + LOCAL_COPY_PATH = /data/gitea/tmp/local-repo + + [repository.upload] + TEMP_PATH = /data/gitea/uploads + + [server] + APP_DATA_PATH = /data/gitea + DOMAIN = git.${domain} + HTTP_PORT = 3000 + ROOT_URL = https://git.${domain}/ + DISABLE_SSH = true + LFS_START_SERVER = true + ; LFS_JWT_SECRET injected at container start via env var / startup script + LFS_JWT_SECRET = __GITEA_LFS_JWT_SECRET__ + OFFLINE_MODE = false + + [lfs] + PATH = /data/git/lfs + + [database] + DB_TYPE = sqlite3 + PATH = /data/gitea/gitea.db + LOG_SQL = false + + [indexer] + ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve + + [session] + PROVIDER_CONFIG = /data/gitea/sessions + PROVIDER = file + + [picture] + AVATAR_UPLOAD_PATH = /data/gitea/avatars + REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars + DISABLE_GRAVATAR = false + + [attachment] + PATH = /data/gitea/attachments + + [log] + MODE = console + LEVEL = info + ROUTER = console + ROOT_PATH = /data/gitea/log + + [security] + INSTALL_LOCK = true + REVERSE_PROXY_LIMIT = 1 + REVERSE_PROXY_TRUSTED_PROXIES = * + ; INTERNAL_TOKEN injected at container start + INTERNAL_TOKEN = __GITEA_INTERNAL_TOKEN__ + + [service] + DISABLE_REGISTRATION = true + REQUIRE_SIGNIN_VIEW = false + REGISTER_EMAIL_CONFIRM = false + ENABLE_NOTIFY_MAIL = false + ALLOW_ONLY_EXTERNAL_REGISTRATION = true + ENABLE_CAPTCHA = false + DEFAULT_ALLOW_CREATE_ORGANIZATION = true + DEFAULT_ENABLE_TIMETRACKING = true + NO_REPLY_ADDRESS = noreply.localhost + ENABLE_REVERSE_PROXY_AUTHENTICATION = true + ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true + + [mailer] + ENABLED = false + + [openid] + ENABLE_OPENID_SIGNIN = false + ENABLE_OPENID_SIGNUP = false + + [oauth2] + ENABLE = false + ; JWT_SECRET injected at container start + JWT_SECRET = __GITEA_OAUTH2_JWT_SECRET__ + ''; + +in +{ + options.homey.gitea = { + enable = lib.mkEnableOption "Gitea Git server"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/gitea/gitea:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3000; + description = "Host port Gitea listens on (bound to 127.0.0.1)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."gitea/admin_password" = { owner = "root"; }; + sops.secrets."gitea/lfs_jwt_secret" = { owner = "root"; }; + sops.secrets."gitea/oauth2_jwt_secret" = { owner = "root"; }; + sops.secrets."gitea/internal_token" = { owner = "root"; }; + + # ----------------------------------------------------------------------- + # Write the app.ini template to /etc (will be processed by ExecStartPre) + # ----------------------------------------------------------------------- + environment.etc."gitea/app.ini.tpl" = { + text = giteaAppIni; + mode = "0444"; + }; + + # ----------------------------------------------------------------------- + # Container + # ----------------------------------------------------------------------- + virtualisation.oci-containers.containers.gitea = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:3000" ]; + + environment = { + USER_UID = "1000"; + USER_GID = "1000"; + # Tell gitea where to look for the config + GITEA_CUSTOM = "/data/gitea"; + }; + + volumes = [ + "${dataDir}/gitea/data:/data" + # The processed app.ini is written by ExecStartPre into /run/gitea-conf/ + "/run/gitea-conf/app.ini:/data/gitea/conf/app.ini:ro" + ]; + + extraOptions = [ "--network=host" ]; + }; + + # ----------------------------------------------------------------------- + # ExecStartPre: substitute secret placeholders into the ini template + # ----------------------------------------------------------------------- + systemd.services."podman-gitea" = { + serviceConfig = { + ExecStartPre = [ + (pkgs.writeShellScript "gitea-build-config" '' + set -euo pipefail + install -d -m 700 /run/gitea-conf + LFS=$(cat ${config.sops.secrets."gitea/lfs_jwt_secret".path}) + OAUTH=$(cat ${config.sops.secrets."gitea/oauth2_jwt_secret".path}) + TOKEN=$(cat ${config.sops.secrets."gitea/internal_token".path}) + sed \ + -e "s|__GITEA_LFS_JWT_SECRET__|$LFS|g" \ + -e "s|__GITEA_OAUTH2_JWT_SECRET__|$OAUTH|g" \ + -e "s|__GITEA_INTERNAL_TOKEN__|$TOKEN|g" \ + /etc/gitea/app.ini.tpl > /run/gitea-conf/app.ini + chmod 444 /run/gitea-conf/app.ini + '') + ]; + }; + after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + }; + }; +} diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix new file mode 100644 index 0000000..e3babaa --- /dev/null +++ b/modules/services/jellyfin.nix @@ -0,0 +1,55 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Jellyfin — media server. (Deferred — enable when ready.) +# +# Volume layout: +# /jellyfin/config/ → /config +# /media/movies/ → /data/movies +# /media/tvshows/ → /data/tvshows + +let + cfg = config.homey.jellyfin; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; +in +{ + options.homey.jellyfin = { + enable = lib.mkEnableOption "Jellyfin media server"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/jellyfin/jellyfin:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8096; + }; + }; + + config = lib.mkIf cfg.enable { + virtualisation.oci-containers.containers.jellyfin = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:8096" ]; + + environment = { + JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}"; + PUID = "1000"; + PGID = "1000"; + }; + + volumes = [ + "${dataDir}/jellyfin/config:/config" + "${dataDir}/media/movies:/data/movies:ro" + "${dataDir}/media/tvshows:/data/tvshows:ro" + ]; + + extraOptions = [ "--network=host" ]; + }; + + systemd.services."podman-jellyfin" = { + after = lib.mkAfter [ "mnt-data.mount" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + }; + }; +} diff --git a/modules/services/nextcloud.nix b/modules/services/nextcloud.nix new file mode 100644 index 0000000..6e86423 --- /dev/null +++ b/modules/services/nextcloud.nix @@ -0,0 +1,135 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Nextcloud + PostgreSQL. +# +# Two containers: +# nextcloud-postgres — PostgreSQL, bound to localhost:5432 +# nextcloud — Nextcloud PHP-FPM + Apache, bound to localhost:8080 +# +# Volume layout: +# /nextcloud/db/ → /var/lib/postgresql/data (postgres) +# /nextcloud/html/ → /var/www/html (nextcloud) +# +# Secrets consumed from sops: +# nextcloud/admin_password +# nextcloud/postgres_password + +let + cfg = config.homey.nextcloud; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; +in +{ + options.homey.nextcloud = { + enable = lib.mkEnableOption "Nextcloud file server"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/nextcloud:latest"; + }; + + postgresImage = lib.mkOption { + type = lib.types.str; + default = "docker.io/postgres:16"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "Host port Nextcloud listens on (bound to 127.0.0.1)."; + }; + + postgresPort = lib.mkOption { + type = lib.types.port; + default = 5432; + description = "Host port PostgreSQL listens on (bound to 127.0.0.1)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."nextcloud/admin_password" = { owner = "root"; }; + sops.secrets."nextcloud/postgres_password" = { owner = "root"; }; + + # ----------------------------------------------------------------------- + # PostgreSQL container + # ----------------------------------------------------------------------- + virtualisation.oci-containers.containers.nextcloud-postgres = { + image = cfg.postgresImage; + ports = [ "127.0.0.1:${toString cfg.postgresPort}:5432" ]; + + environment = { + POSTGRES_DB = "nextcloud_db"; + POSTGRES_USER = "postgres"; + # Password injected via env file + }; + + volumes = [ + "${dataDir}/nextcloud/db:/var/lib/postgresql/data" + ]; + + extraOptions = [ "--network=host" ]; + }; + + systemd.services."podman-nextcloud-postgres" = { + serviceConfig = { + ExecStartPre = [ + (pkgs.writeShellScript "nc-postgres-secrets-env" '' + set -euo pipefail + install -m 600 /dev/null /run/nc-postgres-secrets.env + echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" \ + >> /run/nc-postgres-secrets.env + '') + ]; + EnvironmentFile = "/run/nc-postgres-secrets.env"; + }; + postStop = "rm -f /run/nc-postgres-secrets.env"; + after = lib.mkAfter [ "mnt-data.mount" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + }; + + # ----------------------------------------------------------------------- + # Nextcloud container + # ----------------------------------------------------------------------- + virtualisation.oci-containers.containers.nextcloud = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:80" ]; + + environment = { + POSTGRES_HOST = "127.0.0.1"; + POSTGRES_DB = "nextcloud_db"; + POSTGRES_USER = "postgres"; + NEXTCLOUD_ADMIN_USER = "admin"; + NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}"; + OVERWRITEPROTOCOL = "https"; + OVERWRITECLIURL = "https://nextcloud.${domain}"; + # Passwords injected via env file + }; + + volumes = [ + "${dataDir}/nextcloud/html:/var/www/html" + ]; + + extraOptions = [ "--network=host" ]; + }; + + systemd.services."podman-nextcloud" = { + serviceConfig = { + ExecStartPre = [ + (pkgs.writeShellScript "nc-secrets-env" '' + set -euo pipefail + install -m 600 /dev/null /run/nc-secrets.env + echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" >> /run/nc-secrets.env + echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat ${config.sops.secrets."nextcloud/admin_password".path})" >> /run/nc-secrets.env + '') + ]; + EnvironmentFile = "/run/nc-secrets.env"; + }; + postStop = "rm -f /run/nc-secrets.env"; + after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ]; + }; + }; +} diff --git a/modules/services/openldap.nix b/modules/services/openldap.nix new file mode 100644 index 0000000..95f4725 --- /dev/null +++ b/modules/services/openldap.nix @@ -0,0 +1,116 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# OpenLDAP — central identity provider. +# +# Runs as a podman container (osixia/openldap). +# Listens on localhost:389 only — not exposed to the outside world. +# Authelia and other services connect to it over the container network (127.0.0.1). +# +# Volume layout on host: +# /openldap/etc-ldap-slapd.d/ → /etc/ldap/slapd.d (config DB) +# /openldap/var-lib-ldap/ → /var/lib/ldap (data) +# +# Secrets consumed from sops: +# openldap/admin_password +# openldap/config_password +# openldap/ro_password + +let + cfg = config.homey.openldap; + dataDir = config.homey.storage.mountPoint; +in +{ + options.homey.openldap = { + enable = lib.mkEnableOption "OpenLDAP identity provider"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/osixia/openldap:latest"; + description = "Container image to use for OpenLDAP."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 389; + description = "Host port OpenLDAP listens on (bound to 127.0.0.1)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."openldap/admin_password" = { owner = "root"; }; + sops.secrets."openldap/config_password" = { owner = "root"; }; + sops.secrets."openldap/ro_password" = { owner = "root"; }; + + # ----------------------------------------------------------------------- + # Container + # ----------------------------------------------------------------------- + virtualisation.oci-containers.containers.openldap = { + image = cfg.image; + + # Bind only to localhost — no external exposure + ports = [ "127.0.0.1:${toString cfg.port}:389" ]; + + environment = { + LDAP_ORGANISATION = homeyConfig.organization; + LDAP_DOMAIN = homeyConfig.domain; + LDAP_ADMIN_USERNAME = "admin"; + LDAP_READONLY_USER = "true"; + # TLS disabled — traffic stays on localhost + LDAP_TLS = "false"; + }; + + # Inject passwords from sops-managed secret files + environmentFiles = []; # we use secretFiles below instead + + # sops writes secret values to files; we read them into env vars + # via a wrapper script run as ExecStartPre (see systemd override below). + # Podman's --env-file doesn't support arbitrary paths, so we use + # a secrets tmpfile approach via the systemd unit override. + + volumes = [ + "${dataDir}/openldap/etc-ldap-slapd.d:/etc/ldap/slapd.d" + "${dataDir}/openldap/var-lib-ldap:/var/lib/ldap" + ]; + + extraOptions = [ + "--network=host" # simplest for single-host: services talk on 127.0.0.1 + "--hostname=openldap" + ]; + }; + + # ----------------------------------------------------------------------- + # Systemd override to inject sops secrets as env vars + # ----------------------------------------------------------------------- + # podman containers are managed by systemd units named + # podman-.service + systemd.services."podman-openldap" = { + serviceConfig = { + # Write an env file with secret values before the container starts, + # then pass it to podman run via EnvironmentFile. + ExecStartPre = [ + (pkgs.writeShellScript "openldap-secrets-env" '' + set -euo pipefail + install -m 600 /dev/null /run/openldap-secrets.env + echo "LDAP_ADMIN_PASSWORD=$(cat ${config.sops.secrets."openldap/admin_password".path})" >> /run/openldap-secrets.env + echo "LDAP_CONFIG_PASSWORD=$(cat ${config.sops.secrets."openldap/config_password".path})" >> /run/openldap-secrets.env + echo "LDAP_READONLY_USER_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})" >> /run/openldap-secrets.env + '') + ]; + EnvironmentFile = "/run/openldap-secrets.env"; + }; + # Clean up the env file on stop + postStop = "rm -f /run/openldap-secrets.env"; + # Wait for the external HD to be mounted before starting + after = lib.mkAfter [ "mnt-data.mount" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + }; + + # ----------------------------------------------------------------------- + # Firewall — openldap port is NOT opened externally (localhost only) + # ----------------------------------------------------------------------- + # No firewall rule needed; bound to 127.0.0.1. + }; +} diff --git a/modules/services/phpldapadmin.nix b/modules/services/phpldapadmin.nix new file mode 100644 index 0000000..3e5a11a --- /dev/null +++ b/modules/services/phpldapadmin.nix @@ -0,0 +1,46 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# phpLDAPadmin — web UI for OpenLDAP management. +# +# Stateless container (no persistent volumes needed). +# Protected by Authelia two_factor, admins-only policy (defined in authelia.nix). +# Bound to localhost:8081; Caddy reverse-proxies it. + +let + cfg = config.homey.phpldapadmin; +in +{ + options.homey.phpldapadmin = { + enable = lib.mkEnableOption "phpLDAPadmin web interface"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/osixia/phpldapadmin:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8081; + description = "Host port phpLDAPadmin listens on (bound to 127.0.0.1)."; + }; + }; + + config = lib.mkIf cfg.enable { + virtualisation.oci-containers.containers.phpldapadmin = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:80" ]; + + environment = { + PHPLDAPADMIN_HTTPS = "false"; + PHPLDAPADMIN_LDAP_HOSTS = "127.0.0.1"; # openldap on host network + }; + + extraOptions = [ "--network=host" ]; + }; + + systemd.services."podman-phpldapadmin" = { + after = lib.mkAfter [ "podman-openldap.service" ]; + wants = lib.mkAfter [ "podman-openldap.service" ]; + }; + }; +} diff --git a/modules/services/transmission.nix b/modules/services/transmission.nix new file mode 100644 index 0000000..74b50c2 --- /dev/null +++ b/modules/services/transmission.nix @@ -0,0 +1,61 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Transmission — BitTorrent client. (Deferred — enable when ready.) +# +# NOTE: Transmission's web UI also runs on port 9091. To avoid clashing +# with Authelia (also 9091), this module binds Transmission to 9092. +# +# Volume layout: +# /transmission/config/ → /config +# /media/movies/ → /downloads/movies +# /media/tvshows/ → /downloads/tvshows +# /media/general/ → /downloads/general +# /media/complete/ → /downloads/complete + +let + cfg = config.homey.transmission; + dataDir = config.homey.storage.mountPoint; +in +{ + options.homey.transmission = { + enable = lib.mkEnableOption "Transmission torrent client"; + + image = lib.mkOption { + type = lib.types.str; + default = "docker.io/linuxserver/transmission:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9092; + description = "Host port for Transmission web UI (9092 to avoid clash with authelia@9091)."; + }; + }; + + config = lib.mkIf cfg.enable { + virtualisation.oci-containers.containers.transmission = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:9091" ]; + + environment = { + PUID = "1000"; + PGID = "1000"; + }; + + volumes = [ + "${dataDir}/transmission/config:/config" + "${dataDir}/media/movies:/downloads/movies" + "${dataDir}/media/tvshows:/downloads/tvshows" + "${dataDir}/media/general:/downloads/general" + "${dataDir}/media/complete:/downloads/complete" + ]; + + extraOptions = [ "--network=host" ]; + }; + + systemd.services."podman-transmission" = { + after = lib.mkAfter [ "mnt-data.mount" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + }; + }; +} diff --git a/modules/storage.nix b/modules/storage.nix new file mode 100644 index 0000000..e276f5c --- /dev/null +++ b/modules/storage.nix @@ -0,0 +1,105 @@ +{ config, lib, pkgs, ... }: + +# External hard-drive storage module. +# +# Each host sets: +# homey.storage.device = "/dev/disk/by-id/usb-WD_..."; (by-id is stable across reboots) +# homey.storage.mountPoint = "/mnt/data"; (default) +# +# All service data lives under //, so the whole +# dataset can be browsed, backed up, or restored with plain filesystem tools. +# +# Directory layout under mountPoint: +# openldap/ +# etc-ldap-slapd.d/ ← /etc/ldap/slapd.d in container +# var-lib-ldap/ ← /var/lib/ldap in container +# authelia/ +# config/ ← /config in container (sqlite db etc.) +# gitea/ +# data/ ← /data in container +# nextcloud/ +# html/ ← /var/www/html in container +# db/ ← /var/lib/postgresql/data in postgres container +# jellyfin/ +# config/ +# media/ +# movies/ +# tvshows/ +# general/ +# complete/ +# transmission/ +# config/ +# restic-cache/ ← restic local cache (not the backup destination) + +let + cfg = config.homey.storage; +in +{ + options.homey.storage = { + device = lib.mkOption { + type = lib.types.str; + example = "/dev/disk/by-id/usb-WD_Elements_12345-0:0"; + description = '' + Block device for the external hard drive. + Use /dev/disk/by-id/ paths for stable identification across reboots. + Leave empty to skip automount (useful during initial setup). + ''; + default = ""; + }; + + mountPoint = lib.mkOption { + type = lib.types.str; + default = "/mnt/data"; + description = "Where the external HD is mounted. All service data lives here."; + }; + + fsType = lib.mkOption { + type = lib.types.str; + default = "ext4"; + description = "Filesystem type of the external drive."; + }; + }; + + config = lib.mkIf (cfg.device != "") { + # Mount the external drive + fileSystems."${cfg.mountPoint}" = { + device = cfg.device; + fsType = cfg.fsType; + options = [ + "defaults" + "nofail" # Don't block boot if drive is absent + "noatime" # Better performance / less SD wear + "x-systemd.automount" + "x-systemd.idle-timeout=0" + ]; + }; + + # Ensure the mount point directory exists + systemd.tmpfiles.rules = [ + "d ${cfg.mountPoint} 0755 root root -" + + # Service subdirectories — created on boot so containers can start + # even before any data is restored into them. + "d ${cfg.mountPoint}/openldap 0750 root root -" + "d ${cfg.mountPoint}/openldap/etc-ldap-slapd.d 0750 root root -" + "d ${cfg.mountPoint}/openldap/var-lib-ldap 0750 root root -" + "d ${cfg.mountPoint}/authelia 0750 root root -" + "d ${cfg.mountPoint}/authelia/config 0750 root root -" + "d ${cfg.mountPoint}/gitea 0750 root root -" + "d ${cfg.mountPoint}/gitea/data 0750 root root -" + "d ${cfg.mountPoint}/nextcloud 0750 root root -" + "d ${cfg.mountPoint}/nextcloud/html 0750 root root -" + "d ${cfg.mountPoint}/nextcloud/db 0750 root root -" + "d ${cfg.mountPoint}/jellyfin 0750 root root -" + "d ${cfg.mountPoint}/jellyfin/config 0750 root root -" + "d ${cfg.mountPoint}/media 0755 root root -" + "d ${cfg.mountPoint}/media/movies 0755 root root -" + "d ${cfg.mountPoint}/media/tvshows 0755 root root -" + "d ${cfg.mountPoint}/media/general 0755 root root -" + "d ${cfg.mountPoint}/media/complete 0755 root root -" + "d ${cfg.mountPoint}/transmission 0750 root root -" + "d ${cfg.mountPoint}/transmission/config 0750 root root -" + "d ${cfg.mountPoint}/restic-cache 0700 root root -" + ]; + }; +} diff --git a/scripts/backup-longhorn-to-disk.sh b/scripts/backup-longhorn-to-disk.sh deleted file mode 100755 index 21e2713..0000000 --- a/scripts/backup-longhorn-to-disk.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SRC="${SRC:-/mnt/replicas}" -DEST="${DEST:-/mnt2/homey-backup}" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -MANIFEST="$DEST/manifest.json" - -PVC_MAPPING=( - "pvc-0310a337-9642-464b-a458-fcb3439328e7-fbc07d5a:ldap-pvc" - "pvc-1cdc51ee-b965-4cab-baf7-077cc6df6f11-0fcfb9cd:authelia-pvc" - "pvc-4888bf84-62c8-4340-adbc-cb31073d8fd2-d065d20b:gitea-pvc" - "pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842-96d72815:nextcloud-data-pvc" - "pvc-c5b28179-1b9c-462a-be5b-05c4f0bb36ca-5f2dbf4d:nextcloud-postgres-pvc" - "pvc-7f73ee94-5583-4e4a-9788-cba054214b1c-f767850a:radicale-pvc" - "pvc-9e75f35a-27c3-4251-b25a-1a876f82f6c7-c9c8b185:jellyfin-config-pvc" - "pvc-dfe2aa08-bbb8-423b-9001-fb6aea181597-baf06a7f:jellyfin-data-pvc" - "pvc-dd4a069a-a638-49c0-8c95-f954510816e5-7e81a6f6:transmission-config-pvc" - "pvc-e4ba414d-d9c2-4927-b0ae-f6bfb90ce311-a0963101:unknown-pvc-1" - "pvc-ec6afe10-aca3-42ce-9d89-32fc4ac77f9a-8d6baa34:unknown-pvc-2" -) - -progress_bar() { - local current=$1 - local total=$2 - local width=40 - local percent=$((current * 100 / total)) - local filled=$((current * width / total)) - local empty=$((width - filled)) - printf "\r[" - printf "%${filled}s" | tr ' ' '=' - printf "%${empty}s" | tr ' ' ' ' - printf "] %3d%% (%d/%d)" "$percent" "$current" "$total" -} - -get_pvc_name() { - local pvc_id="$1" - for mapping in "${PVC_MAPPING[@]}"; do - if [[ "$mapping" == "$pvc_id:"* ]]; then - echo "${mapping#*:}" - return - fi - done - echo "unknown" -} - -echo "========================================" -echo " Longhorn Volume Backup Tool" -echo "========================================" -echo "" -echo "Source: $SRC" -echo "Destination: $DEST" -echo "Timestamp: $TIMESTAMP" -echo "" - -mkdir -p "$DEST/volumes" -mkdir -p "$DEST/metadata" - -VOLUMES=() -TOTAL_SIZE=0 - -echo "Scanning volumes..." -for pvc_dir in "$SRC"/*/; do - pvc_name=$(basename "$pvc_dir") - friendly_name=$(get_pvc_name "$pvc_name") - VOLUMES+=("$pvc_name:$friendly_name") - - size=$(sudo du -sb "$pvc_dir" 2>/dev/null | awk '{print $1}' || echo "0") - TOTAL_SIZE=$((TOTAL_SIZE + size)) - - printf " %-50s %s\n" "$friendly_name" "$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")" -done - -TOTAL_VOLUMES=${#VOLUMES[@]} -echo "" -echo "Found $TOTAL_VOLUMES volumes, total size: $(numfmt --to=iec-i --suffix=B "$TOTAL_SIZE")" -echo "" -read -p "Continue with backup? [y/N] " -n 1 -r -echo "" -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 1 -fi - -echo "" -echo "Starting backup..." -echo "" - -COPIED_SIZE=0 -START_TIME=$(date +%s) - -for i in "${!VOLUMES[@]}"; do - volume="${VOLUMES[$i]}" - pvc_name="${volume%%:*}" - friendly_name="${volume#*:}" - - CURRENT=$((i + 1)) - progress_bar "$CURRENT" "$TOTAL_VOLUMES" - echo " - $friendly_name" - - sudo rsync -a --no-owner --no-group --info=progress2 \ - "$SRC/$pvc_name/" \ - "$DEST/volumes/$pvc_name/" 2>&1 | while read -r line; do - if [[ "$line" =~ to-chk=*([0-9]+)/([0-9]+) ]]; then - printf "\r %s" "$line" - fi - done - - sudo chown -R "$USER:$USER" "$DEST/volumes/$pvc_name" 2>/dev/null || true - - if [[ -f "$SRC/$pvc_name/volume.meta" ]]; then - sudo cp "$SRC/$pvc_name/volume.meta" "$DEST/metadata/${pvc_name}.meta" 2>/dev/null || true - fi - - echo "" -done - -echo "" -echo "Generating manifest..." - -cat > "$MANIFEST" << EOF -{ - "backup_timestamp": "$TIMESTAMP", - "source_path": "$SRC", - "destination_path": "$DEST", - "total_volumes": $TOTAL_VOLUMES, - "total_size_bytes": $TOTAL_SIZE, - "volumes": [ -EOF - -FIRST=true -for volume in "${VOLUMES[@]}"; do - pvc_name="${volume%%:*}" - friendly_name="${volume#*:}" - - vol_size=$(sudo du -sb "$SRC/$pvc_name" 2>/dev/null | awk '{print $1}' || echo "0") - vol_size_hr=$(numfmt --to=iec-i --suffix=B "$vol_size" 2>/dev/null || echo "${vol_size}B") - - head_file=$(sudo find "$DEST/volumes/$pvc_name" -name "volume-head-*.img" 2>/dev/null | head -1) - head_file=$(basename "$head_file" 2>/dev/null || echo "") - - if [[ "$FIRST" == "true" ]]; then - FIRST=false - else - echo "," >> "$MANIFEST" - fi - - cat >> "$MANIFEST" << EOF - { - "pvc_id": "$pvc_name", - "friendly_name": "$friendly_name", - "size_bytes": $vol_size, - "size_human": "$vol_size_hr", - "volume_head": "$head_file", - "backup_path": "volumes/$pvc_name" - } -EOF -done - -cat >> "$MANIFEST" << EOF - ] -} -EOF - -END_TIME=$(date +%s) -DURATION=$((END_TIME - START_TIME)) - -echo "" -echo "========================================" -echo " Backup Complete!" -echo "========================================" -echo "" -echo "Duration: $((DURATION / 60))m $((DURATION % 60))s" -echo "Location: $DEST" -echo "Manifest: $MANIFEST" -echo "" -echo "Backup size:" -sudo du -sh "$DEST/volumes" -echo "" -echo "To mount a volume, run:" -echo " ./scripts/mount-longhorn-volume.sh " -echo "" -echo "To restore a volume, run:" -echo " ./scripts/restore-longhorn-volume.sh " diff --git a/scripts/get-pvc-mapping.sh b/scripts/get-pvc-mapping.sh deleted file mode 100644 index 87f72f4..0000000 --- a/scripts/get-pvc-mapping.sh +++ /dev/null @@ -1,16 +0,0 @@ -for dir in /mnt/replicas/pvc-*/; do - name=$(basename "$dir") - head=$(sudo find "$dir" -name "volume-head-*.img" | head -1) - - sudo mkdir -p /tmp/inspect - loop=$(sudo losetup -fP --show "$head") - - echo "=== $name ===" - sudo mount "$loop" /tmp/inspect 2>/dev/null && { - sudo ls -la /tmp/inspect | head -10 - sudo umount /tmp/inspect - } || echo "(mount failed)" - - sudo losetup -d "$loop" - echo "" -done diff --git a/scripts/list-longhorn-backups.sh b/scripts/list-longhorn-backups.sh deleted file mode 100755 index 661ff08..0000000 --- a/scripts/list-longhorn-backups.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="${BACKUP_DIR:-/mnt2/homey-backup}" -MANIFEST="$BACKUP_DIR/manifest.json" - -echo "========================================" -echo " Longhorn Volume Backup List" -echo "========================================" -echo "" - -if [[ ! -f "$MANIFEST" ]]; then - echo "No manifest found at $MANIFEST" - echo "Run backup-longhorn-to-disk.sh first." - exit 1 -fi - -echo "Backup timestamp: $(grep -oP '"backup_timestamp":\s*"\K[^"]+' "$MANIFEST")" -echo "Source: $(grep -oP '"source_path":\s*"\K[^"]+' "$MANIFEST")" -echo "Total volumes: $(grep -oP '"total_volumes":\s*\K[0-9]+' "$MANIFEST")" -echo "Total size: $(grep -oP '"total_size_bytes":\s*\K[0-9]+' "$MANIFEST" | numfmt --to=iec-i --suffix=B)" -echo "" -echo "Volumes:" -echo "----------------------------------------" - -grep -A5 '"volumes"' "$MANIFEST" | grep -E '"friendly_name"|"size_human"' | \ - while read -r name_line; read -r size_line; do - name=$(echo "$name_line" | grep -oP '"friendly_name":\s*"\K[^"]+') - size=$(echo "$size_line" | grep -oP '"size_human":\s*"\K[^"]+') - pvc=$(grep -B1 "$name_line" "$MANIFEST" | grep -oP '"pvc_id":\s*"\K[^"]+' || echo "") - printf " %-30s %10s %s\n" "$name" "$size" "$pvc" - done - -echo "" -echo "Commands:" -echo " Mount: ./scripts/mount-longhorn-volume.sh " -echo " Restore: ./scripts/restore-longhorn-volume.sh " diff --git a/scripts/longhorn-fuse.py b/scripts/longhorn-fuse.py deleted file mode 100644 index 863252b..0000000 --- a/scripts/longhorn-fuse.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import sys -from fuse import FUSE, FuseOSError, Operations - -class LonghornBackupFS(Operations): - def __init__(self, backup_dir): - self.backup_dir = backup_dir - self.blocks_dir = f"{backup_dir}/blocks" - - backup_cfg = f"{backup_dir}/backups/backup_backup-eac0221d1cab4a9c.cfg" - with open(backup_cfg) as f: - data = json.load(f) - - self.size = int(data['Size']) - self.block_map = {b['Offset']: b['BlockChecksum'] for b in data['Blocks']} - self.block_size = 2097152 # 2MB - - print(f"Volume size: {self.size}") - print(f"Blocks: {len(self.block_map)}") - - def getattr(self, path, fh=None): - return {'st_size': self.size, 'st_mode': 0o100644, 'st_nlink': 1} - - def read(self, path, size, offset, fh): - result = bytearray() - remaining = size - current_offset = offset - - while remaining > 0: - block_start = (current_offset // self.block_size) * self.block_size - block_offset = current_offset - block_start - read_size = min(remaining, self.block_size - block_offset) - - if block_start in self.block_map: - checksum = self.block_map[block_start] - block_path = f"{self.blocks_dir}/{checksum[:2]}/{checksum[2:4]}/{checksum}.blk" - - if os.path.exists(block_path): - with open(block_path, 'rb') as f: - f.seek(block_offset) - result.extend(f.read(read_size)) - else: - result.extend(b'\x00' * read_size) - else: - result.extend(b'\x00' * read_size) - - current_offset += read_size - remaining -= read_size - - return bytes(result) - -if __name__ == '__main__': - if len(sys.argv) < 3: - print(f"Usage: {sys.argv[0]} ") - print(f"Example: {sys.argv[0]} /mnt2/backed-up-drive/backupstore/volumes/2c/df/pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842 /tmp/longhorn-fuse") - sys.exit(1) - - backup_dir = sys.argv[1] - mount_point = sys.argv[2] - - os.makedirs(mount_point, exist_ok=True) - - print(f"Mounting {backup_dir} at {mount_point}") - print("This creates a virtual block device file at the mount point") - print("Then run: sudo losetup -fP {mount_point}/volume.img && sudo mount /dev/loopX /mnt/point") - - fs = LonghornBackupFS(backup_dir) - fuse = FUSE(fs, mount_point, nothreads=True, foreground=True, allow_other=True) diff --git a/scripts/longhorn-nbdkit.py b/scripts/longhorn-nbdkit.py deleted file mode 100644 index 114953e..0000000 --- a/scripts/longhorn-nbdkit.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -import nbdkit -import json -import os - -backup_dir = "/mnt2/backed-up-drive/backupstore/volumes/2c/df/pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842" -blocks_dir = f"{backup_dir}/blocks" -backup_cfg = f"{backup_dir}/backups/backup_backup-eac0221d1cab4a9c.cfg" - -with open(backup_cfg) as f: - data = json.load(f) - -size = int(data['Size']) -block_map = {b['Offset']: b['BlockChecksum'] for b in data['Blocks']} -block_size = 2097152 - -def thread_model(): - return nbdkit.THREAD_MODEL_SERIALIZE_ALL_REQUESTS - -def get_size(): - return size - -def pread(h, count, offset, flags): - result = bytearray() - remaining = count - current_offset = offset - - while remaining > 0: - block_start = (current_offset // block_size) * block_size - block_offset = current_offset - block_start - read_size = min(remaining, block_size - block_offset) - - if block_start in block_map: - checksum = block_map[block_start] - block_path = f"{blocks_dir}/{checksum[:2]}/{checksum[2:4]}/{checksum}.blk" - - if os.path.exists(block_path): - with open(block_path, 'rb') as f: - f.seek(block_offset) - result.extend(f.read(read_size)) - else: - result.extend(b'\x00' * read_size) - else: - result.extend(b'\x00' * read_size) - - current_offset += read_size - remaining -= read_size - - return bytes(result) diff --git a/scripts/mount-longhorn-volume.sh b/scripts/mount-longhorn-volume.sh deleted file mode 100755 index 58b35f7..0000000 --- a/scripts/mount-longhorn-volume.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="${BACKUP_DIR:-/mnt2/homey-backup}" -MOUNT_BASE="${MOUNT_BASE:-/mnt/longhorn-volumes}" - -usage() { - echo "Usage: $0 [mount-point]" - echo "" - echo "Mounts a Longhorn volume backup for exploration." - echo "" - echo "Arguments:" - echo " pvc-name-or-friendly-name The PVC ID or friendly name (e.g., 'nextcloud-data-pvc')" - echo " mount-point Optional custom mount point (default: $MOUNT_BASE/)" - echo "" - echo "Examples:" - echo " $0 nextcloud-data-pvc" - echo " $0 pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842-96d72815" - echo " $0 nextcloud-data-pvc /mnt/my-mount" - echo "" - echo "To unmount, run:" - echo " sudo umount " - echo " sudo losetup -d /dev/loopX" - exit 1 -} - -if [[ $# -lt 1 ]]; then - usage -fi - -SEARCH_NAME="$1" -CUSTOM_MOUNT="${2:-}" - -MANIFEST="$BACKUP_DIR/manifest.json" - -if [[ ! -f "$MANIFEST" ]]; then - echo "Error: Manifest not found at $MANIFEST" - echo "Make sure you've run the backup script first." - exit 1 -fi - -find_volume() { - local search="$1" - local found="" - - while IFS= read -r line; do - pvc_id=$(echo "$line" | grep -oP '"pvc_id":\s*"\K[^"]+') - friendly=$(echo "$line" | grep -oP '"friendly_name":\s*"\K[^"]+') - - if [[ "$pvc_id" == "$search" ]] || [[ "$friendly" == "$search" ]]; then - echo "$pvc_id:$friendly" - return 0 - fi - done < <(grep -A6 '"volumes"' "$MANIFEST" | grep -E '"pvc_id"|"friendly_name"') - - return 1 -} - -VOLUME_INFO=$(find_volume "$SEARCH_NAME") - -if [[ -z "$VOLUME_INFO" ]]; then - echo "Error: Volume '$SEARCH_NAME' not found in manifest." - echo "" - echo "Available volumes:" - grep -oP '"friendly_name":\s*"\K[^"]+' "$MANIFEST" | while read -r name; do - echo " - $name" - done - exit 1 -fi - -PVC_ID="${VOLUME_INFO%%:*}" -FRIENDLY_NAME="${VOLUME_INFO#*:}" - -VOLUME_DIR="$BACKUP_DIR/volumes/$PVC_ID" - -if [[ ! -d "$VOLUME_DIR" ]]; then - echo "Error: Volume directory not found: $VOLUME_DIR" - exit 1 -fi - -VOLUME_HEAD=$(find "$VOLUME_DIR" -name "volume-head-*.img" | head -1) - -if [[ -z "$VOLUME_HEAD" ]]; then - echo "Error: No volume-head-*.img file found in $VOLUME_DIR" - echo "Contents:" - ls -la "$VOLUME_DIR" - exit 1 -fi - -if [[ -n "$CUSTOM_MOUNT" ]]; then - MOUNT_POINT="$CUSTOM_MOUNT" -else - MOUNT_POINT="$MOUNT_BASE/$FRIENDLY_NAME" -fi - -echo "========================================" -echo " Mount Longhorn Volume" -echo "========================================" -echo "" -echo "PVC ID: $PVC_ID" -echo "Name: $FRIENDLY_NAME" -echo "Volume file: $(basename "$VOLUME_HEAD")" -echo "Mount point: $MOUNT_POINT" -echo "" - -LOOP_DEV=$(sudo losetup -fP --show "$VOLUME_HEAD") -echo "Attached to: $LOOP_DEV" - -sudo mkdir -p "$MOUNT_POINT" - -echo "" -echo "Mounting..." -if sudo mount "$LOOP_DEV" "$MOUNT_POINT" 2>/dev/null; then - echo "" - echo "========================================" - echo " Mounted Successfully!" - echo "========================================" - echo "" - echo "Mount point: $MOUNT_POINT" - echo "Loop device: $LOOP_DEV" - echo "" - echo "Contents:" - ls -la "$MOUNT_POINT" 2>/dev/null | head -20 - echo "" - echo "To unmount:" - echo " sudo umount $MOUNT_POINT" - echo " sudo losetup -d $LOOP_DEV" -else - echo "Mount failed. Trying with filesystem detection..." - FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_DEV" 2>/dev/null || echo "") - - if [[ -n "$FS_TYPE" ]]; then - echo "Detected filesystem: $FS_TYPE" - sudo mount -t "$FS_TYPE" "$LOOP_DEV" "$MOUNT_POINT" - echo "" - echo "Mounted successfully at $MOUNT_POINT" - else - echo "Could not detect filesystem. Volume may be empty or corrupted." - echo "" - echo "Loop device: $LOOP_DEV" - echo "Run 'sudo blkid $LOOP_DEV' to inspect." - echo "" - echo "To detach:" - echo " sudo losetup -d $LOOP_DEV" - fi -fi diff --git a/scripts/restore-fast.py b/scripts/restore-fast.py deleted file mode 100644 index 85f8745..0000000 --- a/scripts/restore-fast.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import sys -import gzip -from concurrent.futures import ThreadPoolExecutor, as_completed - -backup_dir = "/mnt2/backed-up-drive/backupstore/volumes/2c/df/pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842" -output_img = "/mnt/nextcloud-restored.img" - -backup_cfg = f"{backup_dir}/backups/backup_backup-eac0221d1cab4a9c.cfg" -blocks_dir = f"{backup_dir}/blocks" - -with open(backup_cfg) as f: - data = json.load(f) - -blocks = data['Blocks'] -total = len(blocks) -size = int(data['Size']) - -print(f"Volume size: {size // 1024 // 1024 // 1024} GB") -print(f"Block count: {total}") - -os.makedirs(os.path.dirname(output_img) if os.path.dirname(output_img) else '.', exist_ok=True) - -if not os.path.exists(output_img): - import subprocess - subprocess.run(['truncate', '-s', str(size), output_img], check=True) - -with open(output_img, 'r+b') as img: - for i, block in enumerate(blocks): - offset = block['Offset'] - checksum = block['BlockChecksum'] - - block_path = f"{blocks_dir}/{checksum[:2]}/{checksum[2:4]}/{checksum}.blk" - - if os.path.exists(block_path): - with gzip.open(block_path, 'rb') as bf: - img.seek(offset) - img.write(bf.read()) - - if (i + 1) % 500 == 0: - percent = (i + 1) * 100 // total - bar = '=' * (percent // 2) + ' ' * (50 - percent // 2) - sys.stdout.write(f"\r[{bar}] {percent}% ({i + 1}/{total})") - sys.stdout.flush() - -print(f"\nDone! Image: {output_img}") diff --git a/scripts/restore-longhorn-backup.sh b/scripts/restore-longhorn-backup.sh deleted file mode 100755 index 21784d5..0000000 --- a/scripts/restore-longhorn-backup.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="${1:-/mnt2/backed-up-drive/backupstore/volumes/2c/df/pvc-5c1f48e3-346f-4c35-8e6a-8fc0c4c3a842}" -OUTPUT_IMG="${2:-./nextcloud-data-restored.img}" - -BACKUP_CFG="$BACKUP_DIR/backups/backup_backup-eac0221d1cab4a9c.cfg" -BLOCKS_DIR="$BACKUP_DIR/blocks" - -if [[ ! -f "$BACKUP_CFG" ]]; then - echo "Error: Backup config not found at $BACKUP_CFG" - exit 1 -fi - -echo "========================================" -echo " Longhorn Backup Restore Tool" -echo "========================================" -echo "" -echo "Backup: $BACKUP_DIR" -echo "Output: $OUTPUT_IMG" -echo "" - -SIZE=$(python3 -c "import json; print(json.load(open('$BACKUP_CFG'))['Size'])") -BLOCK_COUNT=$(python3 -c "import json; print(len(json.load(open('$BACKUP_CFG'))['Blocks']))") - -echo "Volume size: $((SIZE / 1024 / 1024 / 1024)) GB ($SIZE bytes)" -echo "Block count: $BLOCK_COUNT" -echo "" - -read -p "Continue? [y/N] " -n 1 -r -echo "" -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 1 -fi - -echo "" -echo "Creating sparse image..." -truncate -s "$SIZE" "$OUTPUT_IMG" - -echo "Restoring blocks..." -python3 << 'PYEOF' -import json -import os -import sys - -backup_cfg = os.environ['BACKUP_CFG'] -blocks_dir = os.environ['BLOCKS_DIR'] -output_img = os.environ['OUTPUT_IMG'] - -with open(backup_cfg) as f: - data = json.load(f) - -blocks = data['Blocks'] -total = len(blocks) - -with open(output_img, 'r+b') as img: - for i, block in enumerate(blocks): - offset = block['Offset'] - checksum = block['BlockChecksum'] - - block_path = f"{blocks_dir}/{checksum[:2]}/{checksum[2:4]}/{checksum}.blk" - - if not os.path.exists(block_path): - print(f"Warning: Block not found: {checksum}") - continue - - with open(block_path, 'rb') as bf: - img.seek(offset) - img.write(bf.read()) - - if (i + 1) % 1000 == 0 or i + 1 == total: - percent = (i + 1) * 100 // total - bar = '=' * (percent // 2) + ' ' * (50 - percent // 2) - sys.stdout.write(f"\r[{bar}] {percent}% ({i + 1}/{total})") - sys.stdout.flush() - -print() -PYEOF - -echo "" -echo "========================================" -echo " Restore Complete!" -echo "========================================" -echo "" -echo "Image: $OUTPUT_IMG" -echo "Size: $(du -sh "$OUTPUT_IMG" | cut -f1)" -echo "" -echo "To mount:" -echo " sudo losetup -fP $OUTPUT_IMG" -echo " sudo mount /dev/loopX /mnt/point" -echo "" -echo "Or directly:" -echo " sudo mount -o loop $OUTPUT_IMG /mnt/point" diff --git a/scripts/restore-longhorn-volume.sh b/scripts/restore-longhorn-volume.sh deleted file mode 100755 index 5d78145..0000000 --- a/scripts/restore-longhorn-volume.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="${BACKUP_DIR:-/mnt2/homey-backup}" -RESTORE_BASE="${RESTORE_BASE:-/mnt/replicas}" - -usage() { - echo "Usage: $0 [--dry-run]" - echo "" - echo "Restores a Longhorn volume backup to the replicas directory." - echo "" - echo "Arguments:" - echo " pvc-name-or-friendly-name The PVC ID or friendly name" - echo " --dry-run Show what would be done without copying" - echo "" - echo "Examples:" - echo " $0 nextcloud-data-pvc" - echo " $0 nextcloud-data-pvc --dry-run" - echo "" - echo "WARNING: This will overwrite existing data in $RESTORE_BASE" - exit 1 -} - -if [[ $# -lt 1 ]]; then - usage -fi - -SEARCH_NAME="$1" -DRY_RUN=false - -if [[ "${2:-}" == "--dry-run" ]]; then - DRY_RUN=true -fi - -MANIFEST="$BACKUP_DIR/manifest.json" - -if [[ ! -f "$MANIFEST" ]]; then - echo "Error: Manifest not found at $MANIFEST_DIR" - exit 1 -fi - -find_volume() { - local search="$1" - - while IFS= read -r line; do - pvc_id=$(echo "$line" | grep -oP '"pvc_id":\s*"\K[^"]+') - friendly=$(echo "$line" | grep -oP '"friendly_name":\s*"\K[^"]+') - - if [[ "$pvc_id" == "$search" ]] || [[ "$friendly" == "$search" ]]; then - echo "$pvc_id:$friendly" - return 0 - fi - done < <(grep -A6 '"volumes"' "$MANIFEST" | grep -E '"pvc_id"|"friendly_name"') - - return 1 -} - -VOLUME_INFO=$(find_volume "$SEARCH_NAME") - -if [[ -z "$VOLUME_INFO" ]]; then - echo "Error: Volume '$SEARCH_NAME' not found in manifest." - echo "" - echo "Available volumes:" - grep -oP '"friendly_name":\s*"\K[^"]+' "$MANIFEST" | while read -r name; do - echo " - $name" - done - exit 1 -fi - -PVC_ID="${VOLUME_INFO%%:*}" -FRIENDLY_NAME="${VOLUME_INFO#*:}" - -BACKUP_VOLUME_DIR="$BACKUP_DIR/volumes/$PVC_ID" -RESTORE_VOLUME_DIR="$RESTORE_BASE/$PVC_ID" - -echo "========================================" -echo " Restore Longhorn Volume" -echo "========================================" -echo "" -echo "PVC ID: $PVC_ID" -echo "Name: $FRIENDLY_NAME" -echo "Source: $BACKUP_VOLUME_DIR" -echo "Destination: $RESTORE_VOLUME_DIR" -echo "Dry run: $DRY_RUN" -echo "" - -if [[ "$DRY_RUN" == "true" ]]; then - echo "[DRY RUN] Would copy:" - du -sh "$BACKUP_VOLUME_DIR" 2>/dev/null || echo " (size unknown)" - echo "" - echo "Files to copy:" - find "$BACKUP_VOLUME_DIR" -type f | head -20 - exit 0 -fi - -if [[ -d "$RESTORE_VOLUME_DIR" ]]; then - echo "WARNING: Destination already exists!" - echo "" - read -p "Overwrite existing data? [y/N] " -n 1 -r - echo "" - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 1 - fi - echo "" - echo "Removing existing data..." - sudo rm -rf "$RESTORE_VOLUME_DIR" -fi - -echo "Creating destination directory..." -sudo mkdir -p "$RESTORE_VOLUME_DIR" - -echo "Copying volume data..." -sudo rsync -a --no-owner --no-group --info=progress2 \ - "$BACKUP_VOLUME_DIR/" \ - "$RESTORE_VOLUME_DIR/" - -echo "" -echo "Setting permissions..." -sudo chmod 700 "$RESTORE_VOLUME_DIR" - -echo "" -echo "========================================" -echo " Restore Complete!" -echo "========================================" -echo "" -echo "Restored to: $RESTORE_VOLUME_DIR" -echo "" -echo "Size:" -sudo du -sh "$RESTORE_VOLUME_DIR" -echo "" -echo "Next steps:" -echo "1. Ensure Longhorn is configured to use $RESTORE_BASE" -echo "2. Restart Longhorn or the affected pod" -echo "3. Verify data integrity" diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100644 index 0000000..2288976 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1,10 @@ +# Never commit an unencrypted secrets file. +# The encrypted version (produced by `sops -e -i secrets.yaml`) IS committed. +# +# If you accidentally add the plaintext version, sops-encrypted files +# contain a `sops:` key at the top — check before committing. +# +# Paranoia: ignore any plaintext variants you might create while editing. +secrets.yaml.plaintext +secrets.yaml.bak +*.plain diff --git a/secrets/.sops.yaml b/secrets/.sops.yaml new file mode 100644 index 0000000..1d37ce0 --- /dev/null +++ b/secrets/.sops.yaml @@ -0,0 +1,24 @@ +# sops configuration — controls which keys can decrypt secrets.yaml. +# +# SETUP STEPS (do this once on the Pi): +# +# 1. Install age: nix-shell -p age +# 2. Generate a key: age-keygen -o /var/lib/sops-nix/key.txt +# 3. Print the pubkey: age-keygen -y /var/lib/sops-nix/key.txt +# 4. Replace AGE-PUBLIC-KEY-PI-MAIN below with the output of step 3. +# 5. (Optional) add your own age key or GPG key as a second recipient so +# you can edit secrets from your workstation without the Pi being on. +# +# To encrypt / edit secrets.yaml: +# sops secrets/secrets.yaml +# +# sops will re-encrypt the file for all keys listed here every time you save. + +creation_rules: + - path_regex: secrets/secrets\.yaml$ + key_groups: + - 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 diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml new file mode 100644 index 0000000..0056fe0 --- /dev/null +++ b/secrets/secrets.yaml @@ -0,0 +1,54 @@ +# ============================================================================= +# Homey secrets — managed by sops-nix +# +# THIS FILE MUST BE ENCRYPTED WITH SOPS BEFORE COMMITTING. +# It is shown here as a plaintext template so you know what to fill in. +# +# 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. +# ============================================================================= + +# --- 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. diff --git a/templates/_definitions.yaml b/templates/_definitions.yaml deleted file mode 100644 index 8aadb54..0000000 --- a/templates/_definitions.yaml +++ /dev/null @@ -1,27 +0,0 @@ ---- -{{- define "homey.lookuporgensecret" -}} -{{- $secretObj := (lookup "v1" "Secret" .Release.Namespace .secretname ) | default dict -}} -{{- $secretData := (get $secretObj "data") | default dict -}} -{{- $ret := (get $secretData "password" | b64dec ) | default (randAlphaNum 32 ) -}} -{{ $ret -}} -{{- end -}} ---- -{{- define "homey.randomsecret"}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ (replace "\"" "" .secretname ) }} -type: Opaque -data: - password: {{ .secretval | b64enc | quote }} -{{- end }} ---- -{{- define "homey.randHex"}} - {{- $result := "" }} - {{- range $i := until . }} - {{- $rand_hex_char := mod (randNumeric 4 | atoi) 16 | printf "%x" }} - {{- $result = print $result $rand_hex_char }} - {{- end }} - {{- $result }} -{{- end -}} ---- diff --git a/templates/auth.yaml b/templates/auth.yaml deleted file mode 100644 index 84e37fc..0000000 --- a/templates/auth.yaml +++ /dev/null @@ -1,668 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: ldap-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 100Mi - storageClassName: longhorn ---- -{{- $_ := set $ "homey_openldap_admin" (include "homey.lookuporgensecret" (merge (dict "secretname" "openldap-admin") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "openldap-admin" "secretval" .homey_openldap_admin) $) }} -# --- -{{- $_ := set $ "homey_openldap_config" (include "homey.lookuporgensecret" (merge (dict "secretname" "openldap-config") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "openldap-config" "secretval" .homey_openldap_config) $) }} -# --- -{{- $_ := set $ "homey_openldap_ro" (include "homey.lookuporgensecret" (merge (dict "secretname" "openldap-ro") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "openldap-ro" "secretval" .homey_openldap_ro) $) }} ---- -{{- $_ := set $ "homey_authelia_jwt" (include "homey.lookuporgensecret" (merge (dict "secretname" "authelia-jwt") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "authelia-jwt" "secretval" .homey_authelia_jwt) $) }} ---- -{{- $_ := set $ "homey_authelia_session" (include "homey.lookuporgensecret" (merge (dict "secretname" "authelia-session") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "authelia-session" "secretval" .homey_authelia_session) $) }} ---- -{{- $_ := set $ "homey_authelia_encryption_key" (include "homey.lookuporgensecret" (merge (dict "secretname" "authelia-encryption-key") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "authelia-encryption-key" "secretval" .homey_authelia_encryption_key) $) }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: openldap - labels: - app.kubernetes.io/name: openldap -spec: - selector: - matchLabels: - app.kubernetes.io/name: openldap - replicas: 1 - template: - metadata: - labels: - app.kubernetes.io/name: openldap - spec: - # securityContext: - # fsGroup: 0 - containers: - - name: openldap - image: osixia/openldap - env: - - name: LDAP_ORGANISATION - value: {{ .Values.homey.organization }} - - name: LDAP_DOMAIN - value: {{ .Values.homey.url | quote}} - - name: LDAP_ADMIN_USERNAME - value: "admin" - - name: LDAP_READONLY_USER - value: "true" - - name: LDAP_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - key: password - name: openldap-admin - - name: LDAP_CONFIG_PASSWORD - valueFrom: - secretKeyRef: - key: password - name: openldap-config - - name: LDAP_READONLY_USER_PASSWORD - valueFrom: - secretKeyRef: - key: password - name: openldap-ro - ports: - - name: tcp-ldap - containerPort: 389 - - name: ssl-ldap - containerPort: 636 - volumeMounts: - - mountPath: /etc/ldap/slapd.d - subPath: openldap/etc/ldap/slapd.d - name: openldap-volume - - mountPath: /var/lib/ldap - subPath: openldap/var/lib/ldap - name: openldap-volume - volumes: - - name: openldap-volume - persistentVolumeClaim: - claimName: ldap-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: openldap - labels: - app.kubernetes.io/name: openldap -spec: - type: ClusterIP - ports: - - name: tcp-ldap - port: 389 - targetPort: tcp-ldap - - name: ssl-ldap - port: 636 - targetPort: ssl-ldap - selector: - app.kubernetes.io/name: openldap ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: authelia-conf -data: - configuration.yml: |- -{{ tpl (.Files.Get "files/authelia-config.yaml" | indent 4) . }} ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: authelia-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 100Mi - storageClassName: longhorn ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authelia - labels: - app.kubernetes.io/name: authelia -spec: - selector: - matchLabels: - app.kubernetes.io/name: authelia - replicas: 1 - template: - metadata: - labels: - app.kubernetes.io/name: authelia - spec: - enableServiceLinks: false - containers: - - name: authelia - image: authelia/authelia - imagePullPolicy: "IfNotPresent" - env: - - name: TZ - value: "Jerusalem/Israel" - ports: - - name: tcp - containerPort: 9091 - volumeMounts: - - mountPath: /config/configuration.yml - name: authelia-conf - subPath: configuration.yml - readOnly: true - - mountPath: /config - subPath: authelia/config - name: authelia-volume - volumes: - - name: authelia-conf - configMap: - name: authelia-conf - items: - - key: configuration.yml - path: configuration.yml - - name: authelia-volume - persistentVolumeClaim: - claimName: authelia-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: authelia - labels: - app.kubernetes.io/name: authelia -spec: - type: ClusterIP - ports: - - name: tcp - port: 9091 - targetPort: tcp - selector: - app.kubernetes.io/name: authelia ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: authelia -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - auth.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: auth.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: authelia - port: - number: 9091 ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: gitea-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 5Gi - storageClassName: longhorn ---- -{{- $_ := set $ "homey_gitea_admin_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-admin-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-admin-pass" "secretval" .homey_gitea_admin_pass) $) }} ---- -{{- $_ := set $ "homey_gitea_lfs_jwt_secret" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-lfs-jwt-secret") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-lfs-jwt-secret" "secretval" .homey_gitea_lfs_jwt_secret) $) }} ---- -{{- $_ := set $ "homey_gitea_oauth2_jwt_secret" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-oauth2-jwt-secret") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-oauth2-jwt-secret" "secretval" .homey_gitea_oauth2_jwt_secret) $) }} ---- -apiVersion: v1 -kind: Secret -metadata: - name: gitea-random-internal-token - annotations: - "helm.sh/resource-policy": "keep" -type: Opaque -data: -{{- $secretObj := (lookup "v1" "Secret" .Release.Namespace "gitea-random-internal-token") | default dict -}} -{{- $secretData := (get $secretObj "data") | default dict -}} -{{- $pass := (get $secretData "password") | default (randAlphaNum 100 | b64enc) -}} -{{- $_ := set $ "homey_gitea_random_internal_token" ($pass | b64dec) }} - password: {{ $pass | quote }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: gitea-conf -data: - app.ini: |- -{{ tpl (.Files.Get "files/gitea-app.ini" | indent 4) . }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: gitea -spec: - replicas: 1 - selector: - matchLabels: - app: gitea - template: - metadata: - labels: - app: gitea - spec: - containers: - - name: gitea - image: gitea/gitea:latest - ports: - - containerPort: 3000 - name: http - volumeMounts: - - name: gitea-persistent-storage - mountPath: /data - subPath: gitea/gitea/data - - name: gitea-conf - mountPath: /data/gitea/conf/app.ini - subPath: app.ini - readOnly: true - # startProbe: - # httpGet: - # path: / - # port: 3000 - # initialDelaySeconds: 15 - # lifecycle: - # postStart: - # exec: - # {{- $gitea_cmd := (printf "gitea admin auth add-ldap --name ldap --security-protocol unencrypted --host ldap --port 389 --user-search-base ou=users,%s --user-filter \\\"(&(objectClass=inetOrgPerson)(|(uid=%[1]s)(mail=kk[1]s)))\\\" --email-attribute mail --bind-dn=\\\"cn=readonly,%s\\\" --bind-password=\\\"%s\\\"" ( .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim) ( .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim) (.homey_openldap_ro | replace "\"" ""))}} - # command: ["/bin/sh", "-c", "{{$gitea_cmd}}"] - volumes: - - name: gitea-persistent-storage - persistentVolumeClaim: - claimName: gitea-pvc - - name: gitea-conf - configMap: - name: gitea-conf - items: - - key: app.ini - path: app.ini ---- -apiVersion: v1 -kind: Service -metadata: - name: gitea-svc -spec: - selector: - app: gitea - ports: - - name: http-port - protocol: TCP - port: 3000 - targetPort: http - selector: - app: gitea ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: gitea-ingress -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - git.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: git.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: gitea-svc - port: - number: 3000 ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: nextcloud-postgres-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 5Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: nextcloud-data-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Ti - storageClassName: longhorn ---- -apiVersion: v1 -kind: Secret -metadata: - name: nextcloud-postgres-pass - annotations: - "helm.sh/resource-policy": "keep" -type: Opaque -data: - {{- $secretObj := (lookup "v1" "Secret" .Release.Namespace "nextcloud-postgres-pass") | default dict }} - {{- $secretData := (get $secretObj "data") | default dict }} - {{- $pass := (get $secretData "password") | default (randAlphaNum 32 | b64enc) }} - password: {{ $pass | quote }} ---- -{{- $_ := set $ "homey_nextcloud_postgres_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "nextcloud-postgres-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "nextcloud-postgres-pass" "secretval" .homey_nextcloud_postgres_pass) $) }} ---- -{{- $_ := set $ "homey_nextcloud_admin_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "nextcloud-admin-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "nextcloud-admin-pass" "secretval" .homey_nextcloud_admin_pass) $) }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nextcloud-postgres-config - labels: - app: nextcloud-postgres -data: - POSTGRES_DB: nextcloud_db - POSTGRES_USER: postgres ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nextcloud-postgres - labels: - app: nextcloud-postgres -spec: - replicas: 1 - selector: - matchLabels: - app: nextcloud-postgres - template: - metadata: - labels: - app: nextcloud-postgres - name: nextcloud-postgres - spec: - containers: - - name: nextcloud-postgres - image: postgres - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - envFrom: - - configMapRef: - name: nextcloud-postgres-config - env: - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: nextcloud-postgres-pass - key: password - volumeMounts: - - mountPath: /var/lib/postgresql/data - subPath: nextcloud/db - name: nextcloud-postgredb - volumes: - - name: nextcloud-postgredb - persistentVolumeClaim: - claimName: nextcloud-postgres-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: nextcloud-postgres - labels: - app: nextcloud-postgres -spec: - ports: - - port: 5432 - selector: - app: nextcloud-postgres ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: nextcloud-configmap - labels: - app: nextcloud -data: - POSTGRES_HOST: nextcloud-postgres - OVERWRITEPROTOCOL: https - NEXTCLOUD_ADMIN_USER: admin - NEXTCLOUD_TRUSTED_DOMAINS: nextcloud.{{ .Values.homey.url }} nextcloud.admin.home ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nextcloud - labels: - app: nextcloud -spec: - replicas: 1 - selector: - matchLabels: - app: nextcloud - template: - metadata: - labels: - app: nextcloud - name: nextcloud - spec: - containers: - - name: nextcloud - image: nextcloud - imagePullPolicy: Always - volumeMounts: - - name: nextcloud-volume - mountPath: "/var/www/html" - subPath: html - envFrom: - - configMapRef: - name: nextcloud-postgres-config - - configMapRef: - name: nextcloud-configmap - env: - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: nextcloud-postgres-pass - key: password - - name: NEXTCLOUD_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: nextcloud-admin-pass - key: password - volumes: - - name: nextcloud-volume - persistentVolumeClaim: - claimName: nextcloud-data-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: nextcloud -spec: - selector: - app: nextcloud - ports: - - port: 80 - targetPort: 80 - name: nextcloud ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: nextcloud-ingress - annotations: - nginx.ingress.kubernetes.io/proxy-body-size: 5g - nginx.ingress.kubernetes.io/server-snippet: | - # Make a regex exception for `/.well-known` so that clients can still - # access it despite the existence of the regex rule - # `location ~ /(\.|autotest|...)` which would otherwise handle requests - # for `/.well-known`. - location = /.well-known/carddav { return 301 https://nextcloud.{{ .Values.homey.url }}/remote.php/dav/; } - location = /.well-known/caldav { return 301 https://nextcloud.{{ .Values.homey.url }}/remote.php/dav/; } -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - nextcloud.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: nextcloud.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: nextcloud - port: - number: 80 ---- -#START RADICALE -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: radicale-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: radicale-conf - labels: - app: radicale -data: - config: |- -{{ tpl (.Files.Get "files/radicale-configmap.ini" | indent 4) . }} ---- -{{- $_ := set $ "homey_radicale_basic_auth" (include "homey.lookuporgensecret" (merge (dict "secretname" "radicale-basic-auth") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "radicale-basic-auth" "secretval" .homey_radicale_basic_auth) $) }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: radicale - labels: - app.kubernetes.io/name: radicale -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: radicale - template: - metadata: - labels: - app.kubernetes.io/name: radicale - spec: - containers: - - name: radicale - image: tomsquest/docker-radicale - imagePullPolicy: IfNotPresent - ports: - - name: dav - containerPort: 5232 - protocol: TCP - volumeMounts: - - name: collections - mountPath: /data/collections - - name: config - mountPath: /config/config - subPath: config - readOnly: true - restartPolicy: Always - volumes: - - name: collections - persistentVolumeClaim: - claimName: radicale-pvc - - name: config - configMap: - name: radicale-conf ---- -apiVersion: v1 -kind: Service -metadata: - name: radicale - labels: - app.kubernetes.io/name: radicale -spec: - type: ClusterIP - ports: - - name: dav - port: 5232 - targetPort: dav - selector: - app.kubernetes.io/name: radicale ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: radicale-dav - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header X-Remote-User $user; - proxy_set_header X-Remote-Fullname $name; - proxy_set_header X-Remote-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - dav.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: dav.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: radicale - port: - number: 5232 diff --git a/templates/media.yaml b/templates/media.yaml deleted file mode 100644 index 79948a0..0000000 --- a/templates/media.yaml +++ /dev/null @@ -1,211 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: jellyfin-config-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: jellyfin-data-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 700Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: transmission-config-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jellyfin -spec: - replicas: 1 - selector: - matchLabels: - app: jellyfin - template: - metadata: - labels: - app: jellyfin - spec: - containers: - - name: jellyfin - image: jellyfin/jellyfin:10.11.6 - ports: - - containerPort: 8096 - name: http - env: - - name: PGUID - value: "1000" - - name: PUID - value: "1000" - - name: JELLYFIN_PublishedServerUrl - value: jellyfin.{{ .Values.homey.url }} - imagePullPolicy: "Always" - volumeMounts: - - name: jellyfin-volume-config - mountPath: "/config" - subPath: jellyfin/config - - name: jellyfin-volume-data - mountPath: "/data/movies" - subPath: downloads/movies - - name: jellyfin-volume-data - mountPath: "/data/tvshows" - subPath: downloads/tvshows - volumes: - - name: jellyfin-volume-config - persistentVolumeClaim: - claimName: jellyfin-config-pvc - - name: jellyfin-volume-data - persistentVolumeClaim: - claimName: jellyfin-data-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: jellyfin-web -spec: - selector: - app: jellyfin - ports: - - port: 80 - targetPort: 8096 - name: jellyfin-web ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: jellyfin-ingress -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - jellyfin.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: jellyfin.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jellyfin-web - port: - number: 80 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: transmission - labels: - app: transmission -spec: - replicas: 1 - selector: - matchLabels: - app: transmission - template: - metadata: - labels: - app: transmission - name: transmission - spec: - containers: - - name: transmission - image: linuxserver/transmission - imagePullPolicy: Always - volumeMounts: - - name: transmission-volume-config - mountPath: "/config" - subPath: transmission/config - - name: transmission-volume-data - mountPath: "/downloads/movies" - subPath: downloads/movies - - name: transmission-volume-data - mountPath: "/downloads/tvshows" - subPath: downloads/tvshows - - name: transmission-volume-data - mountPath: "/downloads/general" - subPath: downloads/general - - name: transmission-volume-data - mountPath: "/downloads/complete" - subPath: downloads/complete - volumes: - - name: transmission-volume-config - persistentVolumeClaim: - claimName: transmission-config-pvc - - name: transmission-volume-data - persistentVolumeClaim: - claimName: jellyfin-data-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: transmission-web -spec: - selector: - app: transmission - ports: - - port: 80 - targetPort: 9091 - name: transmission-web ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: torrent - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header X-Webauth-User $user; - proxy_set_header X-Webauth-Fullname $name; - proxy_set_header X-Webauth-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - torrent.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: torrent.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: transmission-web - port: - number: 80 diff --git a/templates/phpldapadmin.yaml b/templates/phpldapadmin.yaml deleted file mode 100644 index 108983f..0000000 --- a/templates/phpldapadmin.yaml +++ /dev/null @@ -1,80 +0,0 @@ ---- -#_PHPADMIN________ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: phpldapadmin - labels: - app: phpldapadmin -spec: - replicas: 1 - selector: - matchLabels: - app: phpldapadmin - template: - metadata: - labels: - app: phpldapadmin - spec: - containers: - - env: - - name: PHPLDAPADMIN_HTTPS - value: "false" - - name: PHPLDAPADMIN_LDAP_HOSTS - value: ldap://openldap:389 - image: osixia/phpldapadmin - name: phpldapadmin - ports: - - containerPort: 80 - name: http - restartPolicy: Always ---- -apiVersion: v1 -kind: Service -metadata: - name: phpldapadmin -spec: - ports: - - port: 80 - targetPort: 80 - name: http - selector: - app: phpldapadmin ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: phpldapadmin - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header X-Webauth-User $user; - proxy_set_header X-Webauth-Fullname $name; - proxy_set_header X-Webauth-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - ldapadmin.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: ldapadmin.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: phpldapadmin - port: - number: 80 diff --git a/unused/auth-templates.yaml b/unused/auth-templates.yaml deleted file mode 100644 index a67a65b..0000000 --- a/unused/auth-templates.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -{{- define "homey.auth.ingress.annotations" }} - # nginx.ingress.kubernetes.io/auth-signin: "https://auth.zakobar.com" - nginx.ingress.kubernetes.io/auth-url: "http://ldap-auth-internal.{{ .Release.Namespace }}.svc.cluster.local:80" - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Email - nginx.ingress.kubernetes.io/location-snippets: |- - auth_request /auth - nginx.ingress.kubernetes.io/configuration-snippet: |- - location /auth { - # proxy_pass http://ldap-auth-internal; - proxy_pass_request_body off; - #THIS NEEDS TO BE SET BY ACTUAL SOMETHING LOGIN SHIT - # proxy_set_header X-Target http://ldap-auth-internal.{{ .Release.Namespace }}.svc.cluster.local:80; - proxy_set_header X-Ldap-URL "ldap://openldap"; - proxy_set_header X-Ldap-BaseDN "ou=users,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}"; - proxy_set_header X-Ldap-BindDN "cn=readonly,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}"; - proxy_set_header X-Ldap-BindPass {{ (get (get (lookup "v1" "Secret" .Release.Namespace "openldap-ro") "data") "password") | b64dec | quote}}; - proxy_set_header X-CookieName "homey.auth.cookie"; - proxy_set_header Cookie $cookie_homey.auth.cookie; - proxy_set_header X-Remote-User $remote_user; - proxy_set_header X-Forwarded-Method $request_method; - proxy_set_header X-Ldap-Template "(uid=%(username)s)"; - } -{{- end }} diff --git a/unused/baikal.yaml b/unused/baikal.yaml deleted file mode 100644 index 6f1366c..0000000 --- a/unused/baikal.yaml +++ /dev/null @@ -1,117 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: baikal-data-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: baikal-config-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: baikal - labels: - app: baikal -spec: - replicas: 1 - selector: - matchLabels: - app: baikal - template: - metadata: - labels: - app: baikal - spec: - containers: - - name: baikal - image: ckulka/baikal - imagePullPolicy: IfNotPresent - ports: - - containerPort: 80 - name: dav - volumeMounts: - - name: config - mountPath: /var/www/baikal/config - subPath: config - - name: data - mountPath: /var/www/baikal/Specific - subPath: Specific - restartPolicy: Always - volumes: - - name: data - persistentVolumeClaim: - claimName: baikal-data-pvc - - name: config - persistentVolumeClaim: - claimName: baikal-config-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: baikal -spec: - selector: - app: baikal - ports: - - name: dav - protocol: TCP - port: 80 - targetPort: 80 - selector: - app: baikal ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: baikal - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header X-Remote-User $user; - proxy_set_header X-Remote-Fullname $name; - proxy_set_header X-Remote-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - dav.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: dav.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: baikal - port: - number: 80 ---- diff --git a/unused/dav.yaml b/unused/dav.yaml deleted file mode 100644 index 566a3c3..0000000 --- a/unused/dav.yaml +++ /dev/null @@ -1,71 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: baikal - labels: - app: baikal -spec: - replicas: 1 - selector: - matchLabels: - app: baikal - template: - metadata: - labels: - app: baikal - spec: - containers: - - name: baikal - image: ckulka/baikal - ports: - - name: dav - containerPort: 80 - protocol: TCP - volumeMounts: - - name: baikal-volume - mountPath: /var/www/baikal/Specific - subPath: baikal/data - - name: baikal-volume - mountPath: /var/www/baikal/config - subPath: baikal/config - restartPolicy: Always - volumes: - - name: baikal-volume - persistentVolumeClaim: - claimName: homey-pvc-longhorn ---- -apiVersion: v1 -kind: Service -metadata: - name: baikal -spec: - ports: - - name: dav - targetPort: 80 - port: 80 - selector: - app: baikal ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: baikal -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - dav.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: dav.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: baikal - port: - name: dav ---- diff --git a/unused/davical.yaml b/unused/davical.yaml deleted file mode 100644 index e0affd2..0000000 --- a/unused/davical.yaml +++ /dev/null @@ -1,213 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: davical-postgres-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn - ---- -{{- $_ := set $ "homey_davical_postgres_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "davical-postgres-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "davical-postgres-pass" "secretval" .homey_davical_postgres_pass) $) }} ---- -# apiVersion: extensions/v1beta1 -apiVersion: v1 -kind: ConfigMap -metadata: - name: davical-postgres-config - labels: - app: davical-postgres -data: - POSTGRES_DB: postgres - POSTGRES_USER: postgres ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: davical-postgres - labels: - app: davical-postgres -spec: - replicas: 1 - selector: - matchLabels: - app: davical-postgres - template: - metadata: - labels: - app: davical-postgres - name: davical-postgres - spec: - containers: - - name: davical-postgres - image: postgres - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - envFrom: - - configMapRef: - name: davical-postgres-config - env: - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: davical-postgres-pass - key: password - volumeMounts: - - mountPath: /var/lib/postgresql/data - subPath: data - name: davical-postgredb - volumes: - - name: davical-postgredb - persistentVolumeClaim: - claimName: davical-postgres-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: davical-postgres - labels: - app: davical-postgres -spec: - ports: - - port: 5432 - selector: - app: davical-postgres ---- -{{- $_ := set $ "homey_davical_admin_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "davical-admin-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "davical-admin-pass" "secretval" .homey_davical_admin_pass) $) }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: davical-conf -data: - config.php: |- -{{ tpl (.Files.Get "files/davical-config.php" | indent 4) . }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: davical - labels: - app: davical -spec: - replicas: 1 - selector: - matchLabels: - app: davical - template: - metadata: - labels: - app: davical - spec: - containers: - - name: davical - image: anerisgreat/davical-multiarch-docker:latest - imagePullPolicy: "Always" - ports: - - containerPort: 80 - name: dav - env: - - name: PGHOST - value: "davical-postgres" - - name: PGUSER - value: "postgres" - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: davical-postgres-pass - key: password - - name: PGDATABASE - value: "davical" - - name: PGPORT - value: "5432" - - name: HOST_NAME - value: - "dav.{{ .Values.homey.url }}" - - name: DAVICAL_ADMIN_PASS - valueFrom: - secretKeyRef: - name: davical-admin-pass - key: password - - name: ROOT_PGUSER - value: "postgres" - - name: ROOT_PGPASSWORD - valueFrom: - secretKeyRef: - name: davical-postgres-pass - key: password - - name: RUN_MIGRATIONS_AT_STARTUP - value: "true" - volumeMounts: - - name: davical-conf - mountPath: /etc/davical/config.php - subPath: config.php - readOnly: true - volumes: - - name: davical-conf - configMap: - name: davical-conf - items: - - key: config.php - path: config.php ---- -apiVersion: v1 -kind: Service -metadata: - name: davical -spec: - selector: - app: davical - ports: - - name: dav - protocol: TCP - port: 80 - targetPort: 80 - selector: - app: davical ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: davical - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header Remote-User $user; - proxy_set_header Remote-Fullname $name; - proxy_set_header Remote-Email $email; - proxy_set_header Redirect-Remote-User $user; - proxy_set_header Redirect-Remote-Fullname $name; - proxy_set_header Redirect-Remote-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - dav.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: dav.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: davical - port: - number: 80 diff --git a/unused/gitea.yaml b/unused/gitea.yaml deleted file mode 100644 index bdd658f..0000000 --- a/unused/gitea.yaml +++ /dev/null @@ -1,131 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: gitea-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 5Gi - storageClassName: longhorn ---- -{{- $_ := set $ "homey_gitea_admin_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-admin-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-admin-pass" "secretval" .homey_gitea_admin_pass) $) }} ---- -{{- $_ := set $ "homey_gitea_lfs_jwt_secret" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-lfs-jwt-secret") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-lfs-jwt-secret" "secretval" .homey_gitea_lfs_jwt_secret) $) }} ---- -{{- $_ := set $ "homey_gitea_oauth2_jwt_secret" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-oauth2-jwt-secret") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-oauth2-jwt-secret" "secretval" .homey_gitea_oauth2_jwt_secret) $) }} - ---- -apiVersion: v1 -kind: Secret -metadata: - name: gitea-random-internal-token - annotations: - "helm.sh/resource-policy": "keep" -type: Opaque -data: -{{- $secretObj := (lookup "v1" "Secret" .Release.Namespace "gitea-random-internal-token") | default dict -}} -{{- $secretData := (get $secretObj "data") | default dict -}} -{{- $pass := (get $secretData "password") | default (randAlphaNum 100 | b64enc) -}} -{{- $_ := set $ "homey_gitea_random_internal_token" ($pass | b64dec) }} - password: {{ $pass | quote }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: gitea-conf -data: - app.ini: |- -{{ tpl (.Files.Get "files/gitea-app.ini" | indent 4) . }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: gitea -spec: - replicas: 1 - selector: - matchLabels: - app: gitea - template: - metadata: - labels: - app: gitea - spec: - containers: - - name: gitea - image: gitea/gitea:latest - ports: - - containerPort: 3000 - name: http - volumeMounts: - - name: gitea-persistent-storage - mountPath: /data - subPath: gitea/gitea/data - - name: gitea-conf - mountPath: /data/gitea/conf/app.ini - subPath: app.ini - readOnly: true - # startProbe: - # httpGet: - # path: / - # port: 3000 - # initialDelaySeconds: 15 - # lifecycle: - # postStart: - # exec: - # {{- set $gitea-cmd (printf "gitea admin auth add-ldap --name ldap --security-protocol unencrypted --host ldap --port 389 --user-search-base ou=users,%s --user-filter \\\"(&(objectClass=inetOrgPerson)(|(uid=\%[1]s)(mail=\%[1]s)))\\\" --email-attribute mail --bind-dn=\\\"cn=readonly,%s\\\" --bind-password=\\\"%s\\\"" ( .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim) () (.homey_openldap_ro | replace "\"" ""))}} - # command: ["/bin/sh", "-c", "{{cmd}}"] - volumes: - - name: gitea-persistent-storage - persistentVolumeClaim: - claimName: gitea-pvc - - name: gitea-conf - configMap: - name: gitea-conf - items: - - key: app.ini - path: app.ini ---- -apiVersion: v1 -kind: Service -metadata: - name: gitea-svc -spec: - selector: - app: gitea - ports: - - name: http-port - protocol: TCP - port: 3000 - targetPort: http - selector: - app: gitea ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: gitea-ingress -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - git.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: git.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: gitea-svc - port: - number: 3000 ---- diff --git a/unused/jellyfin.yaml b/unused/jellyfin.yaml deleted file mode 100644 index 53d231a..0000000 --- a/unused/jellyfin.yaml +++ /dev/null @@ -1,92 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: jellyfin-config-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 100Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: jellyfin-data-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 700Gi - storageClassName: longhorn ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: jellyfin -spec: - replicas: 1 - selector: - matchLabels: - app: jellyfin - template: - metadata: - labels: - app: jellyfin - spec: - containers: - - name: jellyfin - image: docker.io/jellyfin/jellyfin - volumeMounts: - - name: jellyfin-volume-config - mountPath: "/config" - subPath: jellyfin/config - - name: jellyfin-volume-data - mountPath: "/data/movies" - subPath: downloads/movies - - name: jellyfin-volume-data - mountPath: "/data/tvshows" - subPath: downloads/tvshows - - env: - - name: JELLYFIN_PublishedServerUrl - value: jellyfin.{{ .Values.homey.url }} - volumes: - - name: jellyfin-volume-config - persistentVolumeClaim: - claimName: jellyfin-config-pvc - - name: jellyfin-volume-data - persistentVolumeClaim: - claimName: jellyfin-data-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: jellyfin-web - namespace: homecenter -spec: - selector: - app: jellyfin - ports: - - port: 80 - targetPort: 8096 - name: jellyfin-web ---- -ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - jellyfin.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: jellyfin.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jellyfin-web - port: - number: 80 ---- diff --git a/unused/ldap-auth.yaml b/unused/ldap-auth.yaml deleted file mode 100644 index e9adf30..0000000 --- a/unused/ldap-auth.yaml +++ /dev/null @@ -1,70 +0,0 @@ ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ldap-auth - labels: - app: ldap-auth -spec: - replicas: 1 - selector: - matchLabels: - app: ldap-auth - template: - metadata: - labels: - app: ldap-auth - name: ldap-auth - spec: - containers: - - name: ldap-auth - image: linuxserver/ldap-auth - imagePullPolicy: Always ---- -#https://stackoverflow.com/questions/51149921/how-to-authenticate-nginx-with-ldap -apiVersion: v1 -kind: Service -metadata: - name: ldap-auth -spec: - selector: - app: ldap-auth - ports: - - port: 80 - targetPort: 9000 ---- -apiVersion: v1 -kind: Service -metadata: - name: ldap-auth-internal -spec: - selector: - app: ldap-auth - ports: - - port: 80 - targetPort: 8888 ---- -# apiVersion: networking.k8s.io/v1 -# kind: Ingress -# metadata: -# name: ldap-auth-ingress -# annotations: -# spec: -# ingressClassName: {{ .Values.homey.ingress_class }} -# tls: -# - hosts: -# - auth.{{ .Values.homey.url }} -# secretName: {{ .Values.homey.certname }} -# rules: -# - host: auth.{{ .Values.homey.url }} -# http: -# paths: -# - path: / -# pathType: Prefix -# backend: -# service: -# name: ldap-auth -# port: -# number: 80 -# --- - diff --git a/unused/nextcloud.yaml b/unused/nextcloud.yaml deleted file mode 100644 index d35f048..0000000 --- a/unused/nextcloud.yaml +++ /dev/null @@ -1,206 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: nextcloud-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 30Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: nextcloud-postgres-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 5Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: nextcloud-data-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 30Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: Secret -metadata: - name: nextcloud-postgres-pass - annotations: - "helm.sh/resource-policy": "keep" -type: Opaque -data: - {{- $secretObj := (lookup "v1" "Secret" .Release.Namespace "nextcloud-postgres-pass") | default dict }} - {{- $secretData := (get $secretObj "data") | default dict }} - {{- $pass := (get $secretData "password") | default (randAlphaNum 32 | b64enc) }} - password: {{ $pass | quote }} ---- -# apiVersion: extensions/v1beta1 -apiVersion: v1 -kind: ConfigMap -metadata: - name: nextcloud-postgres-config - labels: - app: nextcloud-postgres -data: - POSTGRES_DB: nextcloud_db - POSTGRES_USER: postgres ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nextcloud-postgres - labels: - app: nextcloud-postgres -spec: - replicas: 1 - selector: - matchLabels: - app: nextcloud-postgres - template: - metadata: - labels: - app: nextcloud-postgres - name: nextcloud-postgres - spec: - containers: - - name: nextcloud-postgres - image: postgres - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - envFrom: - - configMapRef: - name: nextcloud-postgres-config - env: - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: nextcloud-postgres-pass - key: password - volumeMounts: - - mountPath: /var/lib/postgresql/data - subPath: nextcloud/db - name: nextcloud-postgredb - volumes: - - name: nextcloud-postgredb - persistentVolumeClaim: - claimName: nextcloud-postgres-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: nextcloud-postgres - labels: - app: nextcloud-postgres -spec: - ports: - - port: 5432 - selector: - app: nextcloud-postgres ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nextcloud - labels: - app: nextcloud -spec: - replicas: 1 - selector: - matchLabels: - app: nextcloud - template: - metadata: - labels: - app: nextcloud - name: nextcloud - spec: - containers: - - name: nextcloud - image: nextcloud - imagePullPolicy: Always - volumeMounts: - - name: nextcloud-volume - mountPath: "/var/www/html" - subPath: nextcloud/html - - name: nextcloud-media - mountPath: "/var/www/html/data" - subPath: nextcloud/html/data - envFrom: - - configMapRef: - name: nextcloud-postgres-config - env: - - name: POSTGRES_HOST - value: "nextcloud-postgres" - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: nextcloud-postgres-pass - key: password - - name: OVERWRITEPROTOCOL - value: "https" - volumes: - - name: nextcloud-volume - persistentVolumeClaim: - claimName: nextcloud-pvc - - name: nextcloud-media - persistentVolumeClaim: - claimName: nextcloud-data-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: nextcloud -spec: - selector: - app: nextcloud - ports: - - port: 80 - targetPort: 80 - name: nextcloud ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: nextcloud-ingress - annotations: - nginx.ingress.kubernetes.io/proxy-body-size: 5g - nginx.ingress.kubernetes.io/server-snippet: | - # Make a regex exception for `/.well-known` so that clients can still - # access it despite the existence of the regex rule - # `location ~ /(\.|autotest|...)` which would otherwise handle requests - # for `/.well-known`. - location = /.well-known/carddav { return 301 https://nextcloud.zakobar.com/remote.php/dav/; } - location = /.well-known/caldav { return 301 https://nextcloud.zakobar.com/remote.php/dav/; } -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - nextcloud.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: nextcloud.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: nextcloud - port: - number: 80 ---- diff --git a/unused/paperless.yaml b/unused/paperless.yaml deleted file mode 100644 index 281a77a..0000000 --- a/unused/paperless.yaml +++ /dev/null @@ -1,230 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: paperless-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 50Gi - storageClassName: longhorn ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: paperless-redis -spec: - replicas: 1 - selector: - matchLabels: - app: paperless-redis - template: - metadata: - labels: - app: paperless-redis - spec: - containers: - - name: paperless - image: redis - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 6379 - name: redis - volumeMounts: - - name: paperless-volume - mountPath: "/data" - subPath: paperless/redis-data - volumes: - - name: paperless-volume - persistentVolumeClaim: - claimName: paperless-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: paperless-redis -spec: - selector: - app: paperless-redis - ports: - - port: 80 - targetPort: 8000 - name: paperless-web - ---- -{{- $_ := set $ "homey_paperless_postgres_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "paperless-postgres-pass") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "paperless-postgres-pass" "secretval" .homey_paperless_postgres_pass) $) }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: paperless-postgres-config - labels: - app: paperless-postgres -data: - POSTGRES_DB: paperless - POSTGRES_USER: paperless - POSTGRES_PASSWORD: paperless ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: paperless-postgres - labels: - app: paperless-postgres -spec: - replicas: 1 - selector: - matchLabels: - app: paperless-postgres - template: - metadata: - labels: - app: paperless-postgres - name: paperless-postgres - spec: - containers: - - name: paperless-postgres - image: postgres - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - envFrom: - - configMapRef: - name: paperless-postgres-config - volumeMounts: - - mountPath: /var/lib/postgresql/data - subPath: paperless/db - name: paperless-volume - volumes: - - name: paperless-volume - persistentVolumeClaim: - claimName: paperless-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: paperless-postgres - labels: - app: paperless-postgres -spec: - ports: - - port: 5432 - selector: - app: paperless-postgres ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: paperless -spec: - replicas: 1 - selector: - matchLabels: - app: paperless - template: - metadata: - labels: - app: paperless - spec: - containers: - - name: paperless - image: ghcr.io/paperless-ngx/paperless-ngx:latest - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 8000 - name: paperless-web - volumeMounts: - - name: paperless-volume - mountPath: "/usr/src/paperless/data" - subPath: paperless/data - - name: paperless-volume - mountPath: "/usr/src/paperless/media" - subPath: paperless/media - - name: paperless-volume - mountPath: "/usr/src/paperless/export" - subPath: paperless/export - - name: paperless-volume - mountPath: "/usr/src/paperless/consume" - subPath: paperless/consume - env: - - name: PAPERLESS_REDIS - value: redis://paperless-redis:6379 - - name: PAPERLESS_DBHOST - value: paperless-postgres - - name: PAPERLESS_DEBUG - value: "true" - - name: PAPERLESS_ENABLE_HTTP_REMOTE_USER - value: "true" - - name: PAPERLESS_ENABLE_HTTP_REMOTE_USER_API - value: "true" - - name: PAPERLESS_DISABLE_REGULAR_LOGIN - value: "true" - - name: PAPERLESS_LOGOUT_REDIRECT_URL - value: "https://auth.{{ .Values.homey.url }}/logout" - - name: PAPERLESS_URL - value: "https://paperless.{{ .Values.homey.url }}" - - name: PAPERLESS_DBPASSWORD - valueFrom: - secretKeyRef: - name: paperless-postgres-pass - key: password - volumes: - - name: paperless-volume - persistentVolumeClaim: - claimName: paperless-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: paperless-web - labels: - app: paperless-web -spec: - selector: - app: paperless - ports: - - port: 80 - targetPort: 8000 - name: paperless-web ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: paperless-ingress - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header REMOTE_USER $remote_user; - proxy_set_header REMOTE_EMAIL $email; - proxy_set_header REMOTE_NAME $name; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - paperless.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: paperless.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: paperless-web - port: - number: 80 ---- diff --git a/unused/radicale.yaml b/unused/radicale.yaml deleted file mode 100644 index ca5b27c..0000000 --- a/unused/radicale.yaml +++ /dev/null @@ -1,122 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: radicale-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: radicale-conf - labels: - app: radicale -data: - config: |- -{{ tpl (.Files.Get "files/radicale-configmap.ini" | indent 4) . }} ---- -{{- $_ := set $ "homey_radicale_basic_auth" (include "homey.lookuporgensecret" (merge (dict "secretname" "radicale-basic-auth") $))}} -{{ include "homey.randomsecret" (merge (dict "secretname" "radicale-basic-auth" "secretval" .homey_radicale_basic_auth) $) }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: radicale - labels: - app: radicale -spec: - replicas: 1 - selector: - matchLabels: - app: radicale - template: - metadata: - labels: - app: radicale - spec: - containers: - - name: radicale - image: tomsquest/docker-radicale - imagePullPolicy: IfNotPresent - ports: - - name: dav - containerPort: 5232 - protocol: TCP - volumeMounts: - - name: collections - mountPath: /data/collections - - name: config - mountPath: /config/config - subPath: config - readOnly: true - restartPolicy: Always - volumes: - - name: collections - persistentVolumeClaim: - claimName: radicale-pvc - - name: config - configMap: - name: radicale-conf ---- -apiVersion: v1 -kind: Service -metadata: - name: radicale - labels: - app.kubernetes.io/name: radicale -spec: - type: ClusterIP - ports: - - name: dav - port: 5232 - targetPort: 5232 - - name: http - port:80 - targetPort: 80 - selector: - app.kubernetes.io/name: radicale ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: radicale - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header X-Remote-User $user; - proxy_set_header X-Remote-Fullname $name; - proxy_set_header X-Remote-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - dav.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: dav.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: radicale - port: - number: 5232 ---- diff --git a/unused/sabre.yaml b/unused/sabre.yaml deleted file mode 100644 index 19d45aa..0000000 --- a/unused/sabre.yaml +++ /dev/null @@ -1,118 +0,0 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: baikal-data-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: baikal-config-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: baikal - labels: - app: baikal -spec: - replicas: 1 - selector: - matchLabels: - app: baikal - template: - metadata: - labels: - app: baikal - spec: - containers: - - name: baikal - image: ckulka/baikal-docker - imagePullPolicy: IfNotPresent - ports: - - name: dav - containerPort: 80 - protocol: TCP - volumeMounts: - - name: config - mountPath: /var/www/baikal/config - subPath: config - - name: data - mountPath: /var/www/baikal/Specific - subPath: Specific - restartPolicy: Always - volumes: - - name: data - persistentVolumeClaim: - claimName: baikal-data-pvc - - name: config - persistentVolumeClaim: - claimName: baikal-config-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: baikal - labels: - app.kubernetes.io/name: baikal -spec: - type: ClusterIP - ports: - - name: dav - port: 80 - targetPort: 80 - selector: - app.kubernetes.io/name: baikal ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: baikal - annotations: - kubernetes.io/ingress.allow-http: "false" - nginx.ingress.kubernetes.io/force-ssl-redirect: "true" - nginx.ingress.kubernetes.io/auth-method: GET - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email - nginx.ingress.kubernetes.io/auth-snippet: | - proxy_set_header X-Forwarded-Method $request_method; - auth_request_set $user $upstream_http_remote_user; - auth_request_set $groups $upstream_http_remote_groups; - auth_request_set $name $upstream_http_remote_name; - auth_request_set $email $upstream_http_remote_email; - proxy_set_header X-Remote-User $user; - proxy_set_header X-Remote-Fullname $name; - proxy_set_header X-Remote-Email $email; -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - dav.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: dav.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: baikal - port: - number: 80 ---- diff --git a/unused/sogo.yaml b/unused/sogo.yaml deleted file mode 100644 index e7162df..0000000 --- a/unused/sogo.yaml +++ /dev/null @@ -1,162 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: sogo-postgres-pvc -spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: longhorn ---- -apiVersion: v1 -kind: Secret -metadata: - name: sogo-db-pass -type: Opaque -data: - password: sogo ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sogo-postgres-config - labels: - app: sogo-postgres -data: - POSTGRES_DB: sogo - POSTGRES_USER: sogo - POSTGRES_PASSWORD: sogo ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sogo-postgres - labels: - app: sogo-postgres -spec: - replicas: 1 - selector: - matchLabels: - app: sogo-postgres - template: - metadata: - labels: - app: sogo-postgres - name: sogo-postgres - spec: - containers: - - name: postgres - image: postgres - imagePullPolicy: "IfNotPresent" - ports: - - containerPort: 5432 - envFrom: - - configMapRef: - name: sogo-postgres-config - volumeMounts: - - mountPath: /var/lib/postgresql/data - subPath: sogo/db/data - name: sogo-postgresdb - volumes: - - name: sogo-postgresdb - persistentVolumeClaim: - claimName: sogo-postgres-pvc ---- -apiVersion: v1 -kind: Service -metadata: - name: sogo-postgres - labels: - app: sogo-postgres -spec: - ports: - - port: 5432 - selector: - app: sogo-postgres ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sogo-conf -data: - sogo.conf: |- -{{ tpl (.Files.Get "files/sogo.conf" | indent 4) . }} ---- -apiVersion: v1 -kind: Service -metadata: - name: sogo - labels: - app: sogo -spec: - ports: - - port: 80 - targetPort: 80 - selector: - app: sogo ---- - -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sogo -spec: - # Stop old container before starting new one. - # No known upgrade policy know. Save to stop and start a new one. - strategy: - type: Recreate - rollingUpdate: null - selector: - matchLabels: - app: sogo - replicas: 1 - template: - metadata: - labels: - app: sogo - spec: - containers: - - name: sogo - image: mailcow/sogo:nightly-1.119 - resources: - requests: - cpu: 100m - memory: 400Mi - ports: - - containerPort: 80 - volumeMounts: - - mountPath: /etc/sogo/sogo.conf - name: sogo-conf - subPath: sogo.conf - readOnly: true - volumes: - - name: sogo-conf - configMap: - name: sogo-conf - optional: false ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: sogo-ingress -spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - git.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - rules: - - host: sogo.{{ .Values.homey.url }} - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: soo - port: - number: 80 - ---- diff --git a/values.yaml b/values.yaml deleted file mode 100644 index 4106407..0000000 --- a/values.yaml +++ /dev/null @@ -1,65 +0,0 @@ -replicaCount: 1 - -homeyNamespace: homey - -imagePullSecrets: [] -nameOverride: "homey-app" -fullnameOverride: "homey-chart" - -serviceAccount: - # Specifies whether a service account should be created - create: true - # Annotations to add to the service account - annotations: {} - # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template - name: "homey" - -podAnnotations: {} - -podSecurityContext: {} - # fsGroup: 2000 - -securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - -service: - type: ClusterIP - port: 80 - -resources: {} # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # limits: - # cpu: 100m - # memory: 128Mi - # requests: - # cpu: 100m - # memory: 128Mi - -autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - # targetMemoryUtilizationPercentage: 80 - -nodeSelector: {} - -tolerations: [] - -affinity: {} - -homey: - organization: "Zakobar Home Server" - url: zakobar.com - ip: 192.168.1.100 - certname: zakobarcert - ingress_class: nginx - From e2ff0eb4281ee30a2a3b611a25e61b9efcbd253d Mon Sep 17 00:00:00 2001 From: Aner Zakobar Date: Wed, 15 Apr 2026 17:20:35 +0300 Subject: [PATCH 2/4] Update AGENTS.md for NixOS port branch --- AGENTS.md | 460 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 236 insertions(+), 224 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05d04c7..b3fd80c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,255 +1,267 @@ # AGENTS.md -This is a Helm chart for deploying a self-hosted home environment (Homey) on Kubernetes. +Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed +entirely through NixOS. Services run as podman containers under systemd. +Remote access is via Cloudflare Tunnel; local access goes through Caddy +with Let's Encrypt TLS (DNS-01, Cloudflare API). -## Project Overview +The original Kubernetes/Helm setup is preserved on the `main` branch. +This branch (`nixos-port`) is the active NixOS port. -- **Type**: Helm Chart (Kubernetes package manager) -- **Language**: YAML + Go template syntax (Helm templating) -- **Key Files**: - - `Chart.yaml` - Chart metadata - - `values.yaml` - Default configuration values - - `templates/` - Kubernetes manifest templates (auth.yaml, media.yaml, phpldapadmin.yaml, _definitions.yaml) - - `files/` - Configuration file templates (processed by Helm with `tpl` function) +--- -## Build/Lint/Test Commands +## Project Structure -### Helm Validation -```bash -# Lint the Helm chart for errors -helm lint . - -# Template rendering (dry-run install) -helm template test-release . --debug - -# Install/upgrade in cluster -helm upgrade --install homey . -n homey - -# Verify chart against Kubernetes API -helm kubeval . - -# Check schema validation of values.yaml -helm schema generate +``` +flake.nix # Entry point — defines all hosts +modules/ + common.nix # Shared system config (nix, podman, sops, SSH) + 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 + services/ + openldap.nix # OpenLDAP — central identity provider + authelia.nix # Authelia — SSO gateway + gitea.nix # Gitea — Git server + nextcloud.nix # Nextcloud + PostgreSQL + phpldapadmin.nix # phpLDAPadmin — LDAP web UI + jellyfin.nix # Jellyfin — media server (disabled by default) + transmission.nix # Transmission — torrent client (disabled by default) +hosts/ + pi-main/ + default.nix # Service selection + host-specific overrides + hardware.nix # Pi 4 boot, SD card labels, ARM platform +secrets/ + .sops.yaml # Age key configuration + secrets.yaml # sops-encrypted secrets (commit only after encrypting) +PORTING.md # Step-by-step migration guide from the old Helm setup ``` -### Manual Template Testing -```bash -# Render templates locally with custom values -helm template homey . -f values.yaml --set homey.url=example.com +## Services and URLs -# Template with debug output -helm template homey . --debug 2>&1 | less +All services live under `home.zakobar.com`. + +| Service | URL | Auth | +|---------|-----|------| +| Authelia | `auth.home.zakobar.com` | Public (it is the auth portal) | +| Gitea | `git.home.zakobar.com` | Authelia one_factor | +| Nextcloud | `nextcloud.home.zakobar.com` | Nextcloud-native | +| phpLDAPadmin | `ldapadmin.home.zakobar.com` | Authelia two_factor, admins only | +| Jellyfin | `jellyfin.home.zakobar.com` | Authelia one_factor | +| Transmission | `torrent.home.zakobar.com` | Authelia two_factor, admins only | + +Internal ports (all bound to `127.0.0.1`): + +| Container | Port | +|-----------|------| +| openldap | 389 | +| authelia | 9091 | +| gitea | 3000 | +| nextcloud | 8080 | +| nextcloud-postgres | 5432 | +| phpldapadmin | 8081 | +| jellyfin | 8096 | +| transmission | 9092 (not 9091 — avoids clash with authelia) | + +## Storage Layout + +All persistent data lives on the external HD at `/mnt/data/`: + +``` +/mnt/data/ + openldap/ + etc-ldap-slapd.d/ → /etc/ldap/slapd.d in container + var-lib-ldap/ → /var/lib/ldap in container + authelia/config/ → /config + gitea/data/ → /data + nextcloud/ + html/ → /var/www/html + db/ → /var/lib/postgresql/data + db-dump/ → pg_dump output (pre-backup) + jellyfin/config/ → /config + media/movies|tvshows|... → shared media (read-only to jellyfin) + transmission/config/ → /config + restic-cache/ → restic local cache ``` -### Kubectl Validation -```bash -# Dry-run apply to validate manifests -kubectl apply -f templates/auth.yaml --dry-run=server +The drive device path is set per-host in `hosts//default.nix` via +`homey.storage.device`. Use a `/dev/disk/by-id/` path for stability. -# Get rendered template directly -helm template homey . | kubectl apply --dry-run=server -f - +## Build / Validate Commands + +```bash +# Check flake structure and evaluate all hosts (no build) +nix flake check + +# Dry-run: show what would change without applying +sudo nixos-rebuild dry-activate --flake .#pi-main + +# Apply configuration +sudo nixos-rebuild switch --flake .#pi-main + +# Build without switching (e.g. cross-compile on workstation) +nix build .#nixosConfigurations.pi-main.config.system.build.toplevel + +# Show diff between running system and new config +nvd diff /run/current-system $(nix build --no-link --print-out-paths .#nixosConfigurations.pi-main.config.system.build.toplevel) ``` +## Secret Management + +Secrets are managed with [sops-nix](https://github.com/Mic92/sops-nix) and +age keys. The encrypted `secrets/secrets.yaml` is committed to the repo; the +age private key lives on the Pi at `/var/lib/sops-nix/key.txt`. + +```bash +# Edit secrets (decrypts, opens $EDITOR, re-encrypts on save) +sops secrets/secrets.yaml + +# Encrypt a plaintext secrets.yaml for the first time +sops --encrypt --in-place secrets/secrets.yaml + +# Add a new host key (after generating it on the new machine) +# 1. Add the public key to secrets/.sops.yaml +# 2. Run: +sops updatekeys secrets/secrets.yaml + +# Generate a new age key on a host +age-keygen -o /var/lib/sops-nix/key.txt +age-keygen -y /var/lib/sops-nix/key.txt # print public key +``` + +Secrets that must come from the old deployment (see `PORTING.md` for how to +extract them from the old k8s cluster): + +- `openldap/admin_password`, `openldap/config_password`, `openldap/ro_password` +- `gitea/admin_password` +- `nextcloud/admin_password`, `nextcloud/postgres_password` + +Everything else (authelia JWT/session/encryption keys, gitea JWT tokens, +restic password, Cloudflare tokens) can be generated fresh. + ## Code Style Guidelines -### YAML Structure +### Nix -1. **Document Separators**: Use `---` at the start of each YAML document - ```yaml - --- - apiVersion: v1 - kind: ConfigMap +1. **Module pattern** — every service is an opt-in module with an `enable` option: + ```nix + options.homey.myservice.enable = lib.mkEnableOption "My service"; + config = lib.mkIf config.homey.myservice.enable { ... }; ``` -2. **Indentation**: Use 2 spaces (not tabs) - ```yaml - spec: - containers: - - name: app - image: nginx - ``` +2. **`homeyConfig` specialArgs** — top-level site config (domain, org name, + timezone) is passed via `specialArgs` in `flake.nix` and accessed as + `homeyConfig` in every module. Do not read domain/org from hardcoded strings. -3. **Trailing Commas**: Optional but preferred for multi-line lists - ```yaml - accessModes: - - ReadWriteMany - - ReadOnlyMany - ``` +3. **No secrets in the Nix store** — secrets are always read from sops-managed + files at runtime, never embedded in the built config. Use + `config.sops.secrets."key".path` to get the runtime path of a secret file. -4. **Quotes**: Use quotes for strings that might be interpreted as other types - - Always quote: `.Values.homey.url | quote` - - Optional for simple strings like names +4. **Secret injection pattern** — because `oci-containers` `environmentFiles` + is limited, use a `systemd ExecStartPre` script to write an ephemeral env + file at `/run/-secrets.env` and reference it via `EnvironmentFile`. + Clean it up in `postStop`. -### Kubernetes Resources +5. **`--network=host`** — all containers use host networking for simplicity on + a single-node setup. Services communicate via `127.0.0.1:`. -1. **Labels**: Use Kubernetes recommended labels - ```yaml - labels: - app.kubernetes.io/name: openldap - app.kubernetes.io/component: auth - ``` - -2. **Naming**: Use kebab-case for resource names - ```yaml - name: openldap-admin - name: nextcloud-postgres - ``` - -3. **Storage**: Always specify `storageClassName: longhorn` - ```yaml - spec: - storageClassName: longhorn - ``` - -### Helm Template Syntax - -1. **Variable Assignment**: Use `$_ := set` for complex assignments - ```yaml - {{- $_ := set $ "varname" (include "homey.lookuporgensecret" (merge (dict "secretname" "secret-name") $)) }} - ``` - -2. **Include with Merge**: Always pass `$` as the last argument - ```yaml - {{ include "homey.randomsecret" (merge (dict "secretname" "secret-name" "secretval" $secretval) $) }} - ``` - -3. **Quote Values from .Values**: Use `quote` filter - ```yaml - value: {{ .Values.homey.url | quote }} - ``` - -4. **Template Definitions**: Define reusable templates in `_definitions.yaml` - - `homey.lookuporgensecret` - Look up existing secrets or generate random - - `homey.randomsecret` - Generate a random secret - - `homey.randHex` - Generate random hex string - -5. **Template Spacing**: Use whitespace control to avoid extra newlines - ```yaml - {{- "leading minus" -}} # No newline before - {{ "trailing minus" -}} # No newline after - ``` - -### Secret Management - -1. **Annotations**: Always annotate managed secrets to prevent deletion - ```yaml - annotations: - "helm.sh/resource-policy": "keep" - ``` - -2. **Secret Generation Pattern**: - ```yaml - # Check for existing secret, create if not exists - {{- $secretObj := (lookup "v1" "Secret" .Release.Namespace "secret-name") | default dict -}} - {{- $secretData := (get $secretObj "data") | default dict -}} - {{- $pass := (get $secretData "password") | default (randAlphaNum 32 | b64enc) -}} - ``` - -3. **Never hardcode secrets** - Use the secret lookup pattern above - -### Config Files (files/ directory) - -1. **Go Templates in Configs**: Use `tpl` function to process config files - ```yaml - data: - config.yml: |- - {{ tpl (.Files.Get "files/authelia-config.yaml" | indent 4) . }} - ``` - -2. **Accessing Variables**: Config files can access `.Values.*` and custom variables set in templates - -### Ingress Configuration - -1. **TLS**: Always specify TLS with proper hosts and secret - ```yaml - spec: - ingressClassName: {{ .Values.homey.ingress_class }} - tls: - - hosts: - - auth.{{ .Values.homey.url }} - secretName: {{ .Values.homey.certname }} - ``` - -2. **Authelia Integration**: Use auth snippets for protected ingresses - ```yaml - annotations: - nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify - nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method - ``` - -### Resource Organization - -1. **File Structure**: - - `templates/_definitions.yaml` - Helper templates (secrets, utilities) - - `templates/auth.yaml` - Authentication services (OpenLDAP, Authelia, Gitea, Nextcloud, Radicale) - - `templates/media.yaml` - Media services (Jellyfin, Transmission) - - `templates/phpldapadmin.yaml` - LDAP admin interface - -2. **Manifest Order** (within a file): - - PersistentVolumeClaim - - Secrets - - ConfigMaps - - Deployments - - Services - - Ingress - -3. **Unused Resources**: Keep deprecated manifests in `unused/` directory - -### Environment Variables - -1. **Naming**: Use uppercase with underscores - ```yaml - - name: LDAP_ORGANISATION - value: {{ .Values.homey.organization }} - ``` - -2. **Value Sources**: Prefer `valueFrom.secretKeyRef` over inline values - ```yaml - - name: PASSWORD - valueFrom: - secretKeyRef: - name: secret-name - key: password - ``` - -### Volume Mounts - -1. **subPath**: Use `subPath` for shared PVCs - ```yaml - volumeMounts: - - mountPath: /data - subPath: service-name/data - ``` - -2. **Read-only ConfigMaps**: Mark config mounts as read-only - ```yaml - readOnly: true - ``` - -## Common Operations +6. **Systemd ordering** — always express `after`/`requires` dependencies + explicitly. The external HD mount unit is `mnt-data.mount`; containers that + need storage must depend on it. ### Adding a New Service -1. Add values to `values.yaml` -2. Create/extend template in `templates/` -3. Add PVC if persistent storage needed -4. Add Ingress with appropriate annotations -5. Test with `helm template .` +1. Create `modules/services/.nix` following the existing module pattern. +2. Add `homey..enable = false` as the default option. +3. Import the new module in `flake.nix` (in the `modules` list inside `mkHost`). +4. Enable it in `hosts/pi-main/default.nix`. +5. Add a Caddy virtual host block in `modules/caddy.nix`. +6. Add the service data directory to `modules/storage.nix` `tmpfiles.rules`. +7. Add the data path to the `paths` list in `modules/backup.nix`. +8. Add any new secrets to `secrets/secrets.yaml` (plaintext) and document them. -### Updating Secrets - -Secrets are generated on first install. To regenerate: -```bash -kubectl delete secret -n homey -helm upgrade --install homey . -n homey -``` - -### Debugging Templates +### Updating or Regenerating Secrets ```bash -# Show all template variables available -helm template . --show-only templates/_helpers.tpl +# Edit the encrypted file — sops opens $EDITOR +sops secrets/secrets.yaml -# Render single template -helm template . --show-only templates/auth.yaml +# Copy updated secrets to the Pi and rebuild +rsync secrets/secrets.yaml admin@pi-main:/path/to/homey/secrets/ +ssh admin@pi-main 'sudo nixos-rebuild switch --flake /path/to/homey#pi-main' ``` + +### Debugging Containers + +```bash +# List all running containers +podman ps + +# Follow logs for a service +journalctl -fu podman-authelia.service + +# Drop into a running container +podman exec -it authelia sh + +# Restart a single service +sudo systemctl restart podman-gitea.service + +# Check why a service failed to start +systemctl status podman-openldap.service +journalctl -u podman-openldap.service --since "5 min ago" +``` + +--- + +## Outstanding TODOs + +These items are known gaps that need to be addressed before the setup is +production-ready: + +- [ ] **`caddy.nix` — fix `vendorHash`**: The Caddy build with the Cloudflare + DNS plugin uses `lib.fakeHash` as a placeholder. After the first `nix build`, + replace it with the hash Nix reports in the error message. + +- [ ] **`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` + +- [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret + values (old passwords from k8s + freshly generated ones), 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). + +- [ ] **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. + +- [ ] **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. + Relevant settings: + - Host: `127.0.0.1`, Port: `389`, Security: Unencrypted + - Bind DN: `cn=readonly,dc=home,dc=zakobar,dc=com` + - User search base: `ou=users,dc=home,dc=zakobar,dc=com` + +- [ ] **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; + ``` From 05619d12fccee0d3350592cfad79bb8e41850860 Mon Sep 17 00:00:00 2001 From: Aner Zakobar Date: Mon, 20 Apr 2026 05:40:09 +0300 Subject: [PATCH 3/4] Changes to rpi setup --- AGENTS.md | 60 ++++++++++++++------- flake.nix | 106 ++++++++++++++++++++++++++++++++----- hosts/pi-main/default.nix | 34 ++++++++++-- hosts/pi-main/hardware.nix | 66 ++++++----------------- modules/backup.nix | 49 +++++++++++++++-- modules/caddy.nix | 49 +++++++++-------- modules/cloudflared.nix | 45 ++++++++++------ modules/common.nix | 16 ++++-- secrets/.sops.yaml | 8 +-- secrets/secrets.yaml | 106 +++++++++++++++++++------------------ 10 files changed, 353 insertions(+), 186 deletions(-) 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 From 0b73d493d8afd98e4614531a74d6624b988ed97f Mon Sep 17 00:00:00 2001 From: Aner Zakobar Date: Thu, 23 Apr 2026 14:46:21 +0300 Subject: [PATCH 4/4] Working NixOS port: all core services operational - Fix Caddy cfProxy helper for cloudflared http:// vhosts (X-Forwarded-Proto) - Fix Authelia LDAP bind (readonly user ACL + password sync) - Add gitea-admin-setup oneshot service to survive rebuilds - Update Authelia forward_auth with header_up X-Forwarded-Proto https - Update TODO.org with completed tasks and LDAP config details - Remove old Helm/k8s artifacts (Chart.yaml, templates/, values/, scripts) - Add result to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + secrets/.sops.yaml => .sops.yaml | 7 +- AGENTS.md | 18 +- PORTING.md | 38 ++-- README.org | 300 ++++++++++++++++++++++++++ TODO.org | 286 +++++++++++++++++++++++++ docs/caddy-cloudflare-tls.org | 321 ++++++++++++++++++++++++++++ flake.nix | 122 ++++------- hosts/pi-main-bootstrap/default.nix | 70 ++++++ hosts/pi-main/default.nix | 20 +- modules/caddy.nix | 100 +++++++-- modules/cloudflared.nix | 12 +- modules/common.nix | 3 - modules/services/authelia.nix | 4 +- modules/services/gitea.nix | 267 +++++++++++++---------- modules/services/jellyfin.nix | 2 +- modules/services/nextcloud.nix | 33 ++- modules/services/openldap.nix | 33 +-- modules/services/phpldapadmin.nix | 16 +- modules/services/transmission.nix | 7 +- modules/storage.nix | 4 +- secrets/secrets.yaml | 101 ++++----- 22 files changed, 1410 insertions(+), 355 deletions(-) rename secrets/.sops.yaml => .sops.yaml (81%) create mode 100644 README.org create mode 100644 TODO.org create mode 100644 docs/caddy-cloudflare-tls.org create mode 100644 hosts/pi-main-bootstrap/default.nix diff --git a/.gitignore b/.gitignore index 4d06034..d1ad11f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ charts *.lock .agent-shell +result diff --git a/secrets/.sops.yaml b/.sops.yaml similarity index 81% rename from secrets/.sops.yaml rename to .sops.yaml index 43a1a6f..f24d552 100644 --- a/secrets/.sops.yaml +++ b/.sops.yaml @@ -17,8 +17,7 @@ creation_rules: - path_regex: secrets/secrets\.yaml$ key_groups: - - pgp + - 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 + age: + - age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p diff --git a/AGENTS.md b/AGENTS.md index 4954fc3..c5e3955 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,16 +40,16 @@ PORTING.md # Step-by-step migration guide from the old Helm s ## Services and URLs -All services live under `home.zakobar.com`. +All services live under `zakobar.com`. | Service | URL | Auth | |---------|-----|------| -| Authelia | `auth.home.zakobar.com` | Public (it is the auth portal) | -| Gitea | `git.home.zakobar.com` | Authelia one_factor | -| Nextcloud | `nextcloud.home.zakobar.com` | Nextcloud-native | -| phpLDAPadmin | `ldapadmin.home.zakobar.com` | Authelia two_factor, admins only | -| Jellyfin | `jellyfin.home.zakobar.com` | Authelia one_factor | -| Transmission | `torrent.home.zakobar.com` | Authelia two_factor, admins only | +| Authelia | `auth.zakobar.com` | Public (it is the auth portal) | +| Gitea | `git.zakobar.com` | Gitea-native (LDAP) | +| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native | +| phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only | +| Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native | +| Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only | Internal ports (all bound to `127.0.0.1`): @@ -279,8 +279,8 @@ These items require the Pi to be built, flashed, and booted at least once. The old Helm chart had this commented out; it must be done manually once. Relevant settings: - Host: `127.0.0.1`, Port: `389`, Security: Unencrypted - - Bind DN: `cn=readonly,dc=home,dc=zakobar,dc=com` - - User search base: `ou=users,dc=home,dc=zakobar,dc=com` + - Bind DN: `cn=readonly,dc=zakobar,dc=com` + - User search base: `ou=users,dc=zakobar,dc=com` - [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify the LDAP Users and Contacts app is still configured correctly diff --git a/PORTING.md b/PORTING.md index cffd3b7..87b731e 100644 --- a/PORTING.md +++ b/PORTING.md @@ -260,21 +260,21 @@ In the tunnel's "Public Hostnames" tab, add: | Subdomain | Domain | Service | |-----------|--------|---------| -| `auth` | `home.zakobar.com` | `https://localhost:443` | -| `git` | `home.zakobar.com` | `https://localhost:443` | -| `nextcloud` | `home.zakobar.com` | `https://localhost:443` | -| `ldapadmin` | `home.zakobar.com` | `https://localhost:443` | -| `jellyfin` | `home.zakobar.com` | `https://localhost:443` | -| `torrent` | `home.zakobar.com` | `https://localhost:443` | +| `auth` | `zakobar.com` | `https://localhost:443` | +| `git` | `zakobar.com` | `https://localhost:443` | +| `nextcloud` | `zakobar.com` | `https://localhost:443` | +| `ldapadmin` | `zakobar.com` | `https://localhost:443` | +| `jellyfin` | `zakobar.com` | `https://localhost:443` | +| `torrent` | `zakobar.com` | `https://localhost:443` | For each entry, under "Additional settings" → TLS → **No TLS Verify: ON** (because cloudflared connects to `localhost` but the cert is for the real hostname). ### 3.3 Update DNS in Cloudflare -Add a CNAME for `home.zakobar.com` pointing to your tunnel's UUID (Cloudflare +Add a CNAME for `zakobar.com` pointing to your tunnel's UUID (Cloudflare creates this automatically when you add hostnames). You do not need to add -`home.zakobar.com` to your domain's A records — Cloudflare handles it. +`zakobar.com` to your domain's A records — Cloudflare handles it. --- @@ -294,19 +294,19 @@ sudo nixos-rebuild switch --flake /path/to/homey#pi-main systemctl list-units 'podman-*' --state=active # OpenLDAP responding? -ldapsearch -x -H ldap://127.0.0.1:389 -b dc=home,dc=zakobar,dc=com -D "cn=admin,dc=home,dc=zakobar,dc=com" -W +ldapsearch -x -H ldap://127.0.0.1:389 -b dc=zakobar,dc=com -D "cn=admin,dc=zakobar,dc=com" -W # Authelia health? curl -s http://localhost:9091/api/health | python3 -m json.tool # Caddy serving TLS? -curl -I https://auth.home.zakobar.com +curl -I https://auth.zakobar.com # Gitea login? -# Visit https://git.home.zakobar.com — should redirect to authelia if not logged in +# Visit https://git.zakobar.com — should redirect to authelia if not logged in # Nextcloud? -# Visit https://nextcloud.home.zakobar.com +# Visit https://nextcloud.zakobar.com # Cloudflare tunnel connected? systemctl status cloudflared-tunnel-pi-main @@ -320,13 +320,13 @@ To access services without going through Cloudflare on the LAN, add these records to your router's DNS or Pi-hole: ``` -192.168.1.100 home.zakobar.com -192.168.1.100 auth.home.zakobar.com -192.168.1.100 git.home.zakobar.com -192.168.1.100 nextcloud.home.zakobar.com -192.168.1.100 ldapadmin.home.zakobar.com -192.168.1.100 jellyfin.home.zakobar.com -192.168.1.100 torrent.home.zakobar.com +192.168.1.100 zakobar.com +192.168.1.100 auth.zakobar.com +192.168.1.100 git.zakobar.com +192.168.1.100 nextcloud.zakobar.com +192.168.1.100 ldapadmin.zakobar.com +192.168.1.100 jellyfin.zakobar.com +192.168.1.100 torrent.zakobar.com ``` Replace `192.168.1.100` with your Pi's actual LAN IP. diff --git a/README.org b/README.org new file mode 100644 index 0000000..2db05ea --- /dev/null +++ b/README.org @@ -0,0 +1,300 @@ +#+title: Homey + +A home environment for everyone! + +* NixOS Deployment (active branch: nixos-port) + +** Prerequisites + +Before building, make sure the following are set in the repo: + +- =hosts/pi-main/default.nix= — SSH public key, static IP, WiFi SSID +- =secrets/secrets.yaml= — all secrets populated and sops-encrypted +- WiFi password secret formatted as =wifi_psk=YourPassword= (see below) + +** Adding / updating secrets + +#+begin_src bash +sops secrets/secrets.yaml +#+end_src + +Opens your editor with the decrypted file. Save and quit to re-encrypt. + +The WiFi password entry must use the =wifi_psk== prefix so wpa_supplicant +can look up the value by name: + +#+begin_src yaml +wifi/psk: "wifi_psk=YourActualWifiPassword" +#+end_src + +** Phase 1 — Bootstrap image (flash this first) + +The full =pi-main= config requires sops secrets, which require an age key +on the Pi — but the age key doesn't exist until after first boot. To +break the chicken-and-egg problem, flash a minimal bootstrap image first. + +Before building, fill in the WiFi password in =flake.nix= in the +=pi-main-bootstrap= config (search for =WIFI_PASSWORD_HERE=): + +#+begin_src nix +networks."Zakobar".psk = "your-actual-wifi-password"; +#+end_src + +Build the bootstrap SD image (requires =aarch64-linux= build capability — +either =boot.binfmt.emulatedSystems = ["aarch64-linux"]= on your +workstation, or an aarch64 remote builder): + +#+begin_src bash +nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \ + --system aarch64-linux +#+end_src + +Find your SD card device, then flash (double-check =/dev/sdX=!): + +#+begin_src bash +lsblk + +zstdcat result/sd-image/nixos-sd-image-*.img.zst | \ + sudo dd of=/dev/sdX bs=4M status=progress conv=fsync +#+end_src + +The Pi will boot at =192.168.1.100=, connect to =Zakobar= WiFi, and accept +SSH connections with your key. No services run yet. + +** Phase 2 — Generate age key and re-encrypt secrets + +#+begin_src bash +# SSH into the Pi +ssh admin@192.168.1.100 + +# Generate the age key +sudo age-keygen -o /var/lib/sops-nix/key.txt + +# Print the public key — copy it +sudo age-keygen -y /var/lib/sops-nix/key.txt +#+end_src + +Back on your workstation, add the public key to =secrets/.sops.yaml= +alongside the existing PGP key: + +#+begin_src yaml +keys: + - &pi_main age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +creation_rules: + - path_regex: secrets/secrets.yaml$ + key_groups: + - pgp: + - 076AA297579A0064 + age: + - *pi_main +#+end_src + +Then re-encrypt so the Pi can decrypt its own secrets: + +#+begin_src bash +sops updatekeys secrets/secrets.yaml +#+end_src + +** Phase 3 — Deploy the full config + +#+begin_src bash +nixos-rebuild switch \ + --flake .#pi-main \ + --target-host admin@192.168.1.100 \ + --build-host admin@192.168.1.100 \ + --use-remote-sudo +#+end_src + +The Pi builds its own config natively (no cross-compilation). sops-nix +will now decrypt all secrets and start all services. + +** Caddy plugin hash + +The first deploy will fail at the Caddy build step because =lib.fakeHash= +is a placeholder. Copy the correct hash from the error output and replace +it in =modules/caddy.nix=: + +#+begin_src nix +caddyWithCloudflare = pkgs.caddy.withPlugins { + plugins = [ "github.com/caddy-dns/cloudflare@..." ]; + hash = "sha256-REPLACE_WITH_REAL_HASH="; # ← paste here +}; +#+end_src + +Then re-run the deploy command from Phase 3. + +** Ongoing deploys from workstation + +All future config changes follow the same pattern: + +1. Edit files on workstation +2. Run: + +#+begin_src bash +nixos-rebuild switch \ + --flake .#pi-main \ + --target-host admin@192.168.1.100 \ + --build-host admin@192.168.1.100 \ + --use-remote-sudo +#+end_src + +NixOS activates the new config on the Pi immediately, with an automatic +rollback if activation fails. + +* Installation (legacy Helm) + +Install using + +#+begin_src bash +helm upgrade --install homey . -n homey +#+end_src + +* Backing up + +Backups use [[https://restic.net/][restic]] and run automatically via systemd on a daily schedule. + +** Strategy — two tiers + +1. *Primary (automatic)*: Daily backup to an S3-compatible bucket (Backblaze B2, + Wasabi, AWS S3, etc.). Restic deduplicates and encrypts before upload. + Retention: 7 daily, 4 weekly, 6 monthly snapshots. + +2. *Offload (manual)*: Run =scripts/offload-backup.sh --target /path/to/disk= + to clone snapshots from the S3 repo onto a local disk (USB plugged into the + Pi, or a disk on your workstation). Uses =restic copy= so deduplication is + preserved on the target. + +** What is backed up + +All service data under =/mnt/data/=: + +- =openldap/= — LDAP database and config +- =authelia/= — Authelia config and state +- =gitea/= — Gitea repositories and data +- =nextcloud/= — Nextcloud files + a =pg_dump= of the database +- =jellyfin/= — Jellyfin metadata (media files are excluded — re-downloadable) +- =transmission/= — Torrent client config + +Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before +each backup to ensure a consistent snapshot. + +** Configuration + +Repository URL and credentials are set per-host: + +#+begin_src nix +# hosts/pi-main/default.nix +homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket"; +#+end_src + +S3 credentials live in =secrets/secrets.yaml= as =restic/s3_access_key_id= and +=restic/s3_secret_access_key=. + +** Restore + +#+begin_src bash +# List snapshots +restic -r s3:https://... snapshots + +# Restore latest snapshot to /mnt/data +restic -r s3:https://... restore latest --target /mnt/data + +# Restore a single service +restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea +#+end_src + +* LDAP Configuration + +Logins are done to PHPLDAPADMIN + +DN is like: + +cn=admin,dc=,dc=io +get-secret-val.sh homey openldap-admin password + +First thing we do is create an organization unit called users + +To add a new user, we create a child entry to ou=users + +It has to be of type inetOrgPerson + +cn = Common Name, sn = Sur Name. +Select RDN = User Name (uid) (FROM DROP DOWN MENU) +UID = USERNAME, that is what is important. (In PHPLdapAdmin it is under User Name) + +Now we may continue! + +* GITEA + +Site Title: whatever + +SSH Server Domain: git. +SSH Server Port: 2222 +Gitea Base URL: http://git. + +Then add Administrator Account Settings: + +Administrator Username: gitea-admin +Password: from gitea-admin-pass +Email address must be populated + +That will work after a few minutes. + +Now we go into Authentication Sources + +Add a new LDAP Authentication source + +Authentication name: Home LDAP +Host: openldap +Port: 389 +Bind DN = cn=readonly,dc=,dc=io +Bind Password: openldap-ro password +User Search Base: ou=users,dc=,dc=io +user search filter = (uid=%s) +Admin filter (title=admin) +Username Attribute: uid +First Name Attribute: cn +Surname Attribute: sn +Email Attribute: mail + +* AUTHELIA + +https://github.com/authelia/authelia/blob/57d5fbd3f5c82e83296023dc1de6e4f5ff063c00/examples/compose/lite/authelia/configuration.yml +This fucking sucks +https://gist.github.com/james-d-elliott/5152d27c0781aee856a3383f1284998e + +* EVERYTHING +https://www.talkingquickly.co.uk/gitea-sso-with-keycloak-openldap-openid-connect + +* DRONE AND GITEA +? +https://dev.to/ruanbekker/self-hosted-cicd-with-gitea-and-drone-ci-200l + +* DAV + +https://gitlab.com/davical-project/davical/-/blob/master/config/example-config.php + +Line 800 ish for auth from reverse proxy + +* NEXTCLOUD + +I ran THIS command inside +su www-data -s /bin/bash -c php occ ldap:promote-group "admins" + +** When maintenence mode + +#+begin_example +kubectl exec --tty --stdin -n homey deploy/nextcloud -- su -l www-data -s /bin/bash +php /var/www/html/occ maintenance:mode --off +#+end_src + +* I UNDERSTAND + +I need to backup Chen's stuff +And... I need to Jellyfin + +* PAPERLESS + +https://github.com/paperless-ngx/paperless-ngx/blob/74c44fe418a91a526b5dab1a91fde4aaebd28bb1/docker/compose/docker-compose.postgres.yml + +For docker diff --git a/TODO.org b/TODO.org new file mode 100644 index 0000000..c9fef3f --- /dev/null +++ b/TODO.org @@ -0,0 +1,286 @@ +#+TITLE: Homey NixOS Port — Outstanding Tasks +#+DATE: 2026-04-15 +#+OPTIONS: toc:nil + +* Secrets Setup + +** DONE Configure sops recipients in =secrets/.sops.yaml= + GPG encryption subkey =076AA297579A0064= is already configured in + =.sops.yaml=. The Pi's age key will be added post-boot (see Deployment section). + +** DONE Populate =secrets/secrets.yaml= with real values + Every key in =secrets/secrets.yaml= needs a real value. Fill in each row below. + + *** Recovered from k8s backup + | Key | Source k8s secret | Value | + |----------------------------------+-------------------------+----------------------------------| + | openldap/admin_password | openldap-admin | lfQWQgBZporyJT4xFSJTnu4vQMC7UevW | + | openldap/config_password | openldap-config | ZxlWbDAeHLdHi5lgdxmyZOWzsG3qDgrT | + | openldap/ro_password | openldap-ro | CZ7JLn23vSzhVjNW7UHGZ2YLFJPDLGsF | + | gitea/admin_password | gitea-admin-pass | y5kCPeCP1e1sCzahd7QmLyJqdQvd37ek | + | nextcloud/postgres_password | nextcloud-postgres-pass | hEq4zt1B1VKYtVAoiKYDmswcUmTbknSP | + | authelia/jwt_secret | jwt-secret | YJZBnQCD4OmhJkgdr6kksmMCatrKLCl3 | + | authelia/storage_encryption_key | fek-secret | KYWRYApCWWIN60gpSi7jhLuj1Wcm5z9Q | + + *** Needs generation or manual creation + | Key | Action | + |----------------------------------+---------------------------------------------------| + | authelia/session_secret | Generate fresh (64 random chars) | + | gitea/lfs_jwt_secret | Generate fresh (43-char base64url) | + | gitea/oauth2_jwt_secret | Generate fresh (43-char base64url) | + | gitea/internal_token | Generate fresh (100-char alphanumeric) | + | restic/password | Generate fresh (passphrase) | + | nextcloud/admin_password | NOT in k8s backup; try old value or reset later | + | cloudflare/api_token | Create DNS Edit token in Cloudflare dashboard | + | cloudflare/tunnel_token | Create tunnel in Cloudflare Zero Trust dashboard | + | restic/s3_access_key_id | Needs S3 provider credentials | + | restic/s3_secret_access_key | Needs S3 provider credentials | + + Generate random secrets with: + #+begin_src bash + # 64-char hex string + openssl rand -hex 32 + + # base64url (for gitea tokens) + openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' + + # 100-char alphanumeric (gitea internal token) + openssl rand -base64 75 | tr -dc 'A-Za-z0-9' | head -c 100 + #+end_src + +** DONE Encrypt =secrets/secrets.yaml= with sops + Encrypted with PGP key =076AA297579A0064=. Safe to commit. + +* Pi Hardware Setup + +** DONE Fill in real values in =hosts/pi-main/default.nix= + - [X] =users.users.admin.openssh.authorizedKeys.keys= — SSH public key added + - [X] =homey.storage.device= — =/dev/disk/by-id/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1= + - [X] =homey.backup.repository= — =s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup= + +** DONE Integrate nixos-raspberrypi flake + Replaced =nixos-hardware= with =nixos-raspberrypi= for vendor kernel, firmware, + u-boot bootloader, and binary cache. Both =pi-main= and =pi-main-bootstrap= + now use =nixos-raspberrypi.lib.nixosSystem= and =raspberry-pi-4.base=. + =nix flake check= passes. + +** TODO Verify SD card partition labels in =hosts/pi-main/hardware.nix= + The config assumes labels =NIXOS_SD= (root) and =FIRMWARE= (boot). + After flashing, check with: + #+begin_src bash + lsblk -o NAME,LABEL + #+end_src + Update =fileSystems= entries in =hosts/pi-main/hardware.nix= if they differ. + +* Caddy Build + +** TODO Fix =vendorHash= in =modules/caddy.nix= + The Caddy build with the Cloudflare DNS plugin currently uses =lib.fakeHash= + as a placeholder. After the first =nix build= attempt it will fail with the + correct hash in the error message. Replace =lib.fakeHash= with that value. + +* Cloudflare Setup + +** DONE Create Cloudflare Tunnel + 1. Go to Cloudflare Zero Trust dashboard → Networks → Tunnels → Create tunnel + 2. Name it (e.g. =homey=) + 3. Copy the tunnel token into =secrets/secrets.yaml= under =cloudflare/tunnel_token= + 4. Configure public hostnames for each service (see service/URL table in AGENTS.md) + +** DONE Create Cloudflare DNS API token + 1. Cloudflare dashboard → My Profile → API Tokens → Create Token + 2. Use the "Edit zone DNS" template, scope to =zakobar.com= + 3. Copy into =secrets/secrets.yaml= under =cloudflare/api_token= + +* Deployment + +** TODO Phase 1 — Build and flash bootstrap SD card image + + The bootstrap image is a minimal NixOS with SSH + WiFi only (no sops, no + services). Its sole purpose is to boot the Pi so you can generate the age key + and then deploy the full config remotely. + + Build on workstation (cross-compiles for aarch64): + #+begin_src bash + # Accept the nixos-raspberrypi cache config so pre-built kernel/firmware + # are fetched instead of compiled. First build still takes ~10-20 min. + nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \ + --accept-flake-config + #+end_src + + Flash to SD card (replace =/dev/sdX= with your card's device): + #+begin_src bash + # Decompress and write in one step — avoids storing the raw image on disk + zstdcat result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync + sudo sync + #+end_src + + Insert the SD card into the Pi and power it on. + It will connect to WiFi (=Zakobar=) with static IP =192.168.1.100=. + + Verify SSH access (wait ~60 s for first boot): + #+begin_src bash + ssh admin@192.168.1.100 + #+end_src + +** TODO Phase 2 — Generate age key and add it to sops + + On the Pi (over SSH): + #+begin_src bash + sudo mkdir -p /var/lib/sops-nix + sudo age-keygen -o /var/lib/sops-nix/key.txt + # Print the public key — copy this output to your workstation clipboard + sudo age-keygen -y /var/lib/sops-nix/key.txt + #+end_src + + On the workstation — edit =secrets/.sops.yaml=, uncomment the age section + and replace the placeholder with the public key you just copied: + #+begin_src yaml + key_groups: + - pgp: + - 076AA297579A0064 + age: + - age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # paste here + #+end_src + + Re-encrypt =secrets/secrets.yaml= so the Pi's age key can decrypt it: + #+begin_src bash + sops updatekeys secrets/secrets.yaml + #+end_src + + Commit and push: + #+begin_src bash + git add secrets/.sops.yaml secrets/secrets.yaml + git commit -m "add Pi age key to sops recipients" + #+end_src + +** TODO Phase 3 — Fix Caddy vendorHash, then deploy full config + + The full =pi-main= config includes Caddy built with the Cloudflare DNS + plugin. The first build will fail with the correct hash in the error output. + + Attempt the build to get the hash: + #+begin_src bash + nix build .#nixosConfigurations.pi-main.config.system.build.toplevel \ + --accept-flake-config 2>&1 | grep 'got:' + #+end_src + + Copy the hash from the error message and replace =lib.fakeHash= in + =modules/caddy.nix=, then commit: + #+begin_src bash + git add modules/caddy.nix + git commit -m "fix caddy vendorHash" + #+end_src + + Deploy to the Pi: + #+begin_src bash + # Dry-run first — shows what will change without applying + nixos-rebuild dry-activate \ + --flake .#pi-main \ + --target-host admin@192.168.1.100 \ + --use-remote-sudo \ + --accept-flake-config + + # Apply when happy with the diff + nixos-rebuild switch \ + --flake .#pi-main \ + --target-host admin@192.168.1.100 \ + --use-remote-sudo \ + --accept-flake-config + #+end_src + + After a successful switch, subsequent deploys can use the hostname: + #+begin_src bash + nixos-rebuild switch --flake .#pi-main --target-host admin@pi-main --use-remote-sudo + #+end_src + +* Post-Deployment Manual Steps + +** DONE Configure Gitea LDAP authentication + Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN): + - Host: =127.0.0.1=, Port: =389=, Security: Unencrypted + - Bind DN: =cn=readonly,dc=zakobar,dc=com= + - Bind Password: see =openldap/ro_password= in sops + - User Search Base: =ou=users,dc=zakobar,dc=com= + - User Filter: =(&(objectClass=inetOrgPerson)(uid=%s))= + - Username attribute: =uid= + - First name attribute: =cn= + - Surname attribute: =sn= + - Email attribute: =mail= + +** TODO Verify Nextcloud LDAP app configuration + After restoring the Nextcloud volume, check: + Admin → LDAP/AD Integration — confirm the LDAP Users and Contacts app is configured. + If reconfiguring from scratch, use the same settings as Gitea above but with + Nextcloud's LDAP wizard: + - Server: =127.0.0.1=, Port: =389= + - Bind DN: =cn=readonly,dc=zakobar,dc=com= + - Bind Password: see =openldap/ro_password= in sops + - Base DN: =dc=zakobar,dc=com= + - Users: filter =objectClass=inetOrgPerson=, search base =ou=users= + - Login attribute: =uid= + - Email attribute: =mail= + +** TODO (Optional) Enable Jellyfin and Transmission + When ready, in =hosts/pi-main/default.nix=: + #+begin_src nix + homey.jellyfin.enable = true; + homey.transmission.enable = true; + #+end_src + +* Backup Strategy + +** TODO Configure S3-compatible automatic backup target + Update =homey.backup.repository= in =hosts/pi-main/default.nix= to point at + your S3-compatible bucket (Backblaze B2, Wasabi, AWS S3, etc.): + #+begin_src nix + homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name"; + # or for AWS: + # homey.backup.repository = "s3:s3.amazonaws.com/your-bucket-name"; + #+end_src + + Add the S3 credentials to =secrets/secrets.yaml=: + #+begin_src yaml + restic/s3_access_key_id: "YOUR_KEY_ID" + restic/s3_secret_access_key: "YOUR_SECRET_KEY" + #+end_src + + Then wire them into =modules/backup.nix= via environment variables: + =AWS_ACCESS_KEY_ID= and =AWS_SECRET_ACCESS_KEY= (restic reads these natively). + + The existing daily schedule + prune retention in =modules/backup.nix= will + handle the rest automatically. + +** TODO Write manual offload script (=scripts/offload-backup.sh=) + A standalone script for copying backup data to an external disk — either + plugged directly into the Pi or mounted on your workstation. + + Design: + - Accepts a =--target= argument: a local path to the mounted disk + (e.g. =/media/aner/backup-disk= or =/mnt/usb=) + - Uses =restic copy= to clone snapshots from the S3 repo into a local restic + repo on the target disk (deduplication is preserved, no double storage) + - Alternatively can use =rsync= for a plain directory copy if restic is not + available on the target machine + - Should be runnable from either the Pi or a workstation (with the Pi's data + disk mounted or accessible over SSH) + + Example invocation: + #+begin_src bash + # On the Pi, with USB disk mounted at /mnt/usb: + ./scripts/offload-backup.sh --target /mnt/usb/homey-backup + + # On workstation, with Pi data disk mounted locally: + ./scripts/offload-backup.sh --target /media/aner/backup-disk/homey-backup + #+end_src + + This script does not exist yet — needs to be written. + +* Future + +** TODO Add second machine (=pi-secondary=) + When ready: + 1. Create =hosts/pi-secondary/= directory with =default.nix= and =hardware.nix= + 2. Uncomment the =pi-secondary= entry in =flake.nix= + 3. Services communicating cross-machine should reference the primary Pi's LAN IP + instead of =127.0.0.1= diff --git a/docs/caddy-cloudflare-tls.org b/docs/caddy-cloudflare-tls.org new file mode 100644 index 0000000..50d77de --- /dev/null +++ b/docs/caddy-cloudflare-tls.org @@ -0,0 +1,321 @@ +#+TITLE: Caddy, Cloudflare Tunnel & TLS Setup +#+DATE: 2026-04-23 +#+AUTHOR: homey project +#+OPTIONS: toc:2 num:t + +* Overview + +This document describes the TLS and reverse-proxy architecture for the homey +self-hosted stack, the problems encountered while getting it working, and the +final configuration that resolved them. It is intended as a reference for +future debugging and for adding new services. + +** Traffic flow + +#+BEGIN_EXAMPLE +Browser + │ HTTPS (TLS terminated by Cloudflare edge, *.zakobar.com cert) + ▼ +Cloudflare edge (anycast IP) + │ QUIC/HTTP2 tunnel (outbound from Pi, no open inbound ports) + ▼ +cloudflared daemon on Pi (systemd: cloudflared-tunnel.service) + │ plain HTTP on loopback http://localhost:80 + ▼ +Caddy reverse proxy (systemd: caddy.service, port 80 + 443) + │ proxies to backend by Host header + ▼ +Service container (podman, port on 127.0.0.1) +#+END_EXAMPLE + +Key points: +- TLS to the browser is provided entirely by Cloudflare's Universal SSL cert + (~*.zakobar.com~), not by the Pi's Let's Encrypt cert. +- The Pi's Let's Encrypt cert (~*.zakobar.com~ via DNS-01) is used only for + direct LAN access (bypassing the tunnel). +- The tunnel leg (cloudflared → Caddy) is plain HTTP on loopback — this is + safe because both endpoints are the same machine. + +* Components + +** Caddy (~modules/caddy.nix~) + +Caddy runs as a NixOS service (~services.caddy~) using a custom build that +includes the ~caddy-dns/cloudflare~ plugin for DNS-01 ACME challenges. + +*** Custom build + +The nixpkgs ~caddy~ package does not include the Cloudflare DNS plugin by +default. It is built using the ~withPlugins~ passthru function (backed by +xcaddy): + +#+BEGIN_SRC nix +caddyWithCloudflare = pkgs.caddy.withPlugins { + plugins = [ + "github.com/caddy-dns/cloudflare@v0.2.4" + ]; + hash = "sha256-..."; +}; +#+END_SRC + +The ~hash~ is a fixed-output derivation hash that must be updated whenever +the plugin version changes. Use ~lib.fakeHash~ to trigger a build failure +that prints the correct hash, then substitute it. + +*** API token injection + +The Cloudflare API token is stored in sops (~cloudflare/api_token~) and +injected into the Caddy process via ~systemd LoadCredential~: + +#+BEGIN_SRC nix +serviceConfig.LoadCredential = + "cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}"; +ExecStart = lib.mkForce [ + "" + (pkgs.writeShellScript "caddy-start" '' + export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token") + exec caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile + '') +]; +#+END_SRC + +*** Virtual hosts — dual HTTP/HTTPS entries + +Each service has *two* Caddyfile vhost entries: + +| Entry | Purpose | +|---|---| +| ~git.zakobar.com~ | HTTPS — for direct LAN access; Caddy handles TLS | +| ~http://git.zakobar.com~ | HTTP — for cloudflared on loopback; no redirect | + +Caddy's default behaviour is to automatically redirect HTTP → HTTPS for any +hostname that has a matching HTTPS vhost. By explicitly defining an +~http://~ vhost, that redirect is suppressed and cloudflared gets a direct +200 response instead of a redirect loop. + +Without the ~http://~ vhost, accessing via the tunnel produces: +~ERR_TOO_MANY_REDIRECTS~ in the browser because cloudflared follows the 308 +back to HTTP indefinitely. + +*** Global config + +#+BEGIN_SRC caddyfile +{ + email admin@zakobar.com + acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} +} +#+END_SRC + +The ~acme_dns~ directive in the global block tells Caddy to use DNS-01 +challenges for *all* HTTPS vhosts. This allows wildcard and multi-level +subdomain certs to be issued without any inbound port 80 requirement. + +** Cloudflare Tunnel (~modules/cloudflared.nix~) + +cloudflared runs as a plain systemd service using the token-based tunnel +approach (~cloudflared tunnel run --token~). No local credentials file or +config file is needed — just the tunnel token from the Zero Trust dashboard. + +*** Tunnel configuration (Zero Trust dashboard) + +One wildcard public hostname entry covers all services: + +| Field | Value | +|---|---| +| Hostname | ~*.zakobar.com~ | +| Service | ~http://localhost:80~ | +| No TLS Verify | off (not needed for HTTP) | +| HTTP Host Header | (empty — cloudflared forwards the real Host header) | +| Origin Server Name | (empty — not needed for HTTP) | + +cloudflared automatically forwards the incoming ~Host~ header (e.g. +~git.zakobar.com~) to Caddy, which uses it to select the correct vhost and +backend. + +*** DNS records + +A single wildcard CNAME record in Cloudflare DNS covers all subdomains: + +#+BEGIN_EXAMPLE +*.zakobar.com CNAME .cfargotunnel.com (proxied, orange cloud) +#+END_EXAMPLE + +This means new services require no DNS changes — only a new Caddy vhost. + +*** Cloudflare SSL/TLS mode + +Set to *Full (strict)* in the Cloudflare dashboard (SSL/TLS → Overview). + +| Mode | Meaning | +|---|---| +| Off | No HTTPS to browser | +| Flexible | HTTPS to browser, HTTP to origin | +| Full | HTTPS to browser, HTTPS to origin (cert not validated) | +| Full (strict) | HTTPS to browser, HTTPS to origin (cert must be valid) | + +Full (strict) works here because Cloudflare terminates TLS at its own edge +using its Universal cert, and the origin (cloudflared → Caddy) uses plain +HTTP which Cloudflare does not validate in this tunnel architecture. + +* Problems Encountered & How They Were Resolved + +** 1. ~caddy-dns/cloudflare~ rejected ~cfut_~ token format + +*Symptom:* +#+BEGIN_EXAMPLE +provision dns.providers.cloudflare: API token 'cfut_...' appears invalid; +ensure it's correctly entered and not wrapped in braces nor quotes +#+END_EXAMPLE + +*Cause:* +Cloudflare introduced new token formats with a ~cfut_~ (user token) or +~cfat_~ (account token) prefix. These tokens are 54 characters long. The +~caddy-dns/cloudflare~ plugin had a validation regex ~{35,50}~ that rejected +tokens longer than 50 characters, failing before even making an API call. + +*Fix:* +The fix was merged into the plugin's master branch as commit ~a8737d0~ and +included in the ~v0.2.4~ tag (despite the tag previously being associated +with an older tree — the proxy confirmed ~v0.2.4~ resolves to ~a8737d0~). + +Updating the ~hash~ in ~caddy.nix~ to the value produced by ~lib.fakeHash~ +forced a fresh fetch of the corrected ~v0.2.4~ tree: + +#+BEGIN_SRC nix +plugins = [ "github.com/caddy-dns/cloudflare@v0.2.4" ]; +hash = lib.fakeHash; # replace with hash from build error output +#+END_SRC + +Run ~nix build .#nixosConfigurations.pi-main.config.system.build.toplevel~, +copy the ~got:~ hash from the error, substitute it, and rebuild. + +** 2. cloudflared ~tls: internal error~ (SNI mismatch) + +*Symptom:* +#+BEGIN_EXAMPLE +Unable to reach the origin service: remote error: tls: internal error +originService=https://localhost:443 +#+END_EXAMPLE + +*Cause:* +cloudflared connected to ~https://localhost:443~ without sending an SNI +(Server Name Indication) hostname in the TLS ClientHello. Caddy could not +match any vhost, had no certificate for ~localhost~, and aborted the +handshake with a TLS internal error. + +Setting the ~HTTP Host Header~ override in the dashboard fixes the HTTP +layer but does *not* affect the TLS SNI, which is negotiated before HTTP +headers are exchanged. + +Setting the ~Origin Server Name~ field does set the SNI, but for a wildcard +rule (~*.zakobar.com~) the dashboard only accepts a static value, not a +dynamic placeholder — so it cannot be used for a catch-all. + +*Fix:* +Switch the tunnel service from ~https://localhost:443~ to +~http://localhost:80~. The internal leg does not need TLS (loopback +interface, same machine). Caddy's HTTP vhosts handle the requests directly. + +** 3. Cloudflare edge TLS handshake failure (~*.home.zakobar.com~) + +*Symptom:* +#+BEGIN_EXAMPLE +TLS connect error: error:0A000410:SSL routines::ssl/tls alert handshake failure +#+END_EXAMPLE + +*Cause:* +The domain was originally configured as ~home.zakobar.com~ (base domain), +making all services two levels deep: ~git.home.zakobar.com~, +~auth.home.zakobar.com~, etc. Cloudflare's free Universal SSL certificate +covers only one level of wildcard: ~*.zakobar.com~. It does *not* cover +~*.home.zakobar.com~ (two levels). The Cloudflare edge had no certificate to +present to browsers for these hostnames, causing a TLS handshake failure +before the request ever reached the tunnel. + +*Fix:* +Move all services to single-level subdomains under ~zakobar.com~ +(~git.zakobar.com~, ~auth.zakobar.com~, etc.). In the NixOS config this +required only one line change — the ~domain~ field in ~flake.nix~: + +#+BEGIN_SRC nix +domain = "zakobar.com"; # was "home.zakobar.com" +#+END_SRC + +All modules reference ~homeyConfig.domain~ and updated automatically on +rebuild. Tunnel hostnames and DNS records in the Cloudflare dashboard were +updated to match. + +** 4. ~ERR_TOO_MANY_REDIRECTS~ via tunnel + +*Symptom:* +Browser shows ~ERR_TOO_MANY_REDIRECTS~ when accessing any service through +the Cloudflare tunnel. + +*Cause:* +cloudflared was talking to Caddy over plain HTTP (~http://localhost:80~). +Caddy's default behaviour is to issue a 308 permanent redirect from HTTP to +HTTPS for any hostname that has a matching HTTPS vhost. cloudflared followed +the redirect back to ~http://localhost:80~, which redirected again, +indefinitely. + +*Fix:* +Add explicit ~http://~ vhost entries in ~caddy.nix~ for every service. When +Caddy has an explicit HTTP vhost for a hostname, it serves it directly +without redirecting: + +#+BEGIN_SRC nix +"git.${domain}" = { + extraConfig = "reverse_proxy localhost:3000"; +}; +"http://git.${domain}" = { # ← suppresses HTTP→HTTPS redirect + extraConfig = "reverse_proxy localhost:3000"; +}; +#+END_SRC + +* Adding a New Service + +To expose a new service through the tunnel: + +1. Create ~modules/services/.nix~ following the module pattern. +2. Add both a plain and ~http://~ vhost in ~modules/caddy.nix~: + #+BEGIN_SRC nix + ".${domain}" = { + extraConfig = "reverse_proxy localhost:"; + }; + "http://.${domain}" = { + extraConfig = "reverse_proxy localhost:"; + }; + #+END_SRC +3. No DNS or tunnel changes needed — the wildcard CNAME and wildcard tunnel + rule (~*.zakobar.com~) cover new subdomains automatically. +4. Rebuild and switch: ~sudo nixos-rebuild switch --flake .#pi-main~ + +* Certificate Details + +** Let's Encrypt cert (LAN access) + +- Issued per-hostname by Caddy via DNS-01 ACME using the Cloudflare API. +- Covers each hostname individually (e.g. ~git.zakobar.com~). +- Stored in ~/var/lib/caddy/.local/share/caddy/certificates/~. +- Used only when accessing services directly on the LAN (bypassing tunnel). +- Auto-renewed by Caddy. + +** Cloudflare Universal SSL cert (tunnel / remote access) + +- Issued by Google Trust Services for ~*.zakobar.com~. +- Managed entirely by Cloudflare — no action required on the Pi. +- Covers all single-level subdomains (~git.zakobar.com~, ~auth.zakobar.com~, etc.). +- Does *not* cover two-level subdomains (~git.home.zakobar.com~) — this was + the root cause of problem #3 above. + +* Quick Reference: Debugging Checklist + +| Symptom | Where to look | Command | +|---|---|---| +| 502 Bad Gateway | cloudflared logs | ~journalctl -u cloudflared-tunnel -n 50~ | +| 502 Bad Gateway | Caddy → backend | ~curl http://localhost:/~ | +| TLS internal error | SNI / cert issue | ~curl -sv --resolve host:443:127.0.0.1 https://host/~ | +| Too many redirects | HTTP vhost missing | check ~http://~ entries in caddy.nix | +| Handshake failure at edge | Cloudflare cert scope | check SSL/TLS → Edge Certificates | +| Token appears invalid | plugin version | check ~caddy-dns/cloudflare~ version vs token format | +| Caddy won't start | token / config error | ~journalctl -u caddy --since "5 min ago"~ | diff --git a/flake.nix b/flake.nix index d03d987..9240958 100644 --- a/flake.nix +++ b/flake.nix @@ -1,18 +1,6 @@ { 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-25.05"; @@ -22,46 +10,53 @@ inputs.nixpkgs.follows = "nixpkgs"; }; - # 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"; + # nixos-hardware provides RPi4 wireless firmware. + # 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"; }; - outputs = { self, nixpkgs, sops-nix, nixos-raspberrypi, ... }@inputs: + outputs = { self, nixpkgs, sops-nix, nixos-hardware, ... }@inputs: let # Shared specialArgs passed to every host commonArgs = { - inherit inputs nixos-raspberrypi; + inherit inputs; # Top-level site config — override per-host if needed homeyConfig = { - domain = "home.zakobar.com"; # base domain for all services + domain = "zakobar.com"; # base domain for all services organization = "Zakobar Home Server"; timezone = "Asia/Jerusalem"; - # External HD mount point — set in hardware.nix per host - # dataDir is intentionally NOT set here; each host sets it }; }; - # 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). + # Minimal RPi4 hardware module for a headless server. + # Provides only: bootloader, initrd modules, wireless firmware, DTB filter. + # Deliberately excludes display, audio, bluetooth from the full nixos-hardware module. + rpi4Headless = { pkgs, ... }: { + boot.loader.grub.enable = false; + boot.loader.generic-extlinux-compatible.enable = true; + boot.initrd.availableKernelModules = [ + "pcie-brcmstb" # PCIe bus (USB3, NVMe) + "reset-raspberrypi" # required for vl805 firmware + "usb-storage" + "usbhid" + "vc4" # VideoCore (needed even headless for boot) + ]; + # sd-image-aarch64.nix lists modules for many SoCs (including sun4i-drm + # for Allwinner boards) that don't exist in linux_rpi4. Allow missing. + boot.initrd.includeDefaultModules = false; + hardware.deviceTree.filter = "bcm2711-rpi-*.dtb"; + hardware.firmware = [ + (pkgs.callPackage "${nixos-hardware}/raspberry-pi/common/raspberry-pi-wireless-firmware.nix" {}) + ]; + }; + mkHost = { hostPath, extraModules ? [] }: - nixos-raspberrypi.lib.nixosSystem { + nixpkgs.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" ]; - }) + rpi4Headless hostPath ./modules/common.nix ./modules/storage.nix @@ -81,62 +76,17 @@ 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 { + # Bootstrap image — flash this first, then deploy pi-main. + # See hosts/pi-main-bootstrap/default.nix for details. + pi-main-bootstrap = nixpkgs.lib.nixosSystem { specialArgs = commonArgs; modules = [ - nixos-raspberrypi.nixosModules.raspberry-pi-4.base + rpi4Headless ({ 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 ]; - }) + ./hosts/pi-main-bootstrap/default.nix ]; }; diff --git a/hosts/pi-main-bootstrap/default.nix b/hosts/pi-main-bootstrap/default.nix new file mode 100644 index 0000000..decf336 --- /dev/null +++ b/hosts/pi-main-bootstrap/default.nix @@ -0,0 +1,70 @@ +{ pkgs, lib, homeyConfig, ... }: + +# Bootstrap image for the primary Raspberry Pi 4. +# +# Flash this image first. Its only purpose is to boot the Pi so you can: +# 1. Generate the age key: sudo age-keygen -o /var/lib/sops-nix/key.txt +# 2. Print the pubkey: sudo age-keygen -y /var/lib/sops-nix/key.txt +# 3. Add the pubkey to .sops.yaml, re-encrypt secrets, then deploy pi-main. +# +# No sops, no services, no external HD — just SSH + WiFi. +# +# WiFi PSK: uncomment and fill in before building. Do not commit the password. +# networks."YourSSID".psk = "your-wifi-password"; + +{ + networking.hostName = "pi-main"; + time.timeZone = homeyConfig.timezone; + i18n.defaultLocale = "en_US.UTF-8"; + system.stateVersion = "25.05"; + + nix.settings = { + experimental-features = [ "nix-command" "flakes" ]; + substituters = [ + "https://cache.nixos.org" + "https://nix-community.cachix.org" + ]; + trusted-public-keys = [ + "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + "nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk=" + ]; + }; + nixpkgs.config.allowUnfree = true; + + # linux_rpi4 is pre-built in cache.nixos.org — fetched, not compiled. + boot.kernelPackages = pkgs.linuxKernel.packages.linux_rpi4; + + networking.wireless = { + enable = true; + # networks."Zakobar".psk = "your-wifi-password"; + }; + 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 ]; + + services.openssh = { + enable = true; + settings = { + PasswordAuthentication = false; + PermitRootLogin = "no"; + }; + }; + + users.mutableUsers = false; + users.users.admin = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + openssh.authorizedKeys.keys = [ + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfzDDO5juINctECmWlsYtGghEiX/RnTJ1cazLvOWSrPfsTyEd+B1+Ig8kFefNryjkpApfRXqj5KtLPNlpLfdVBrOIfhIveEp2MGqhgOGZFNVxQyXnZgii8Zdh4cqZ2O3pZpMsaAQBaJ9nH6dK0dJjicWT5f6TqwrVcInywRc5SuyizoSxoFmg7ch2rnlVi0j5XMVqdh8XLzHXZ7yWCzXy7+hWl/d7pwpyuzoK8dBw2EU9TauhgRDruom5Q9vWJTLStALC9pAIb0v9UFj9y+1zwx7pXsXp5F1g73EYrE4QR+QQ6z2LebuK280W0t+VA/fSCEB13DnkmofgqZQxX5MSCmrxZ5lTFp1FjW6yJo7As9FheF/GECowYkMRIx4IiQsjjHjZqlLRpLas11yAp6tGoZnw59hFo6Lu0Kva39jGVVmioYHtAeE5rD5w+v5kseJR4jlQ8aKB5yOjYUQOIz2AHQyoidgaeR2jPWqZUeRQbACI+/p3CHO45r3hrjATtGloBg0xF95Qws7Be3mjHVhbBLOoob8MdZ8nYAGnhlWrZphlkvXsHC6OUkuDJW00tmMjWXRlFwhFJ+nqUQCgLVjxVHQJ5rq9GeXBUuNXAeCm5BKBsdq+9qqVlt7D9iGyfr0lcZ7peKz/96KwPCWpG2En1Ur0/cVcbWnXEfG/xWO10tQ== cardno:24_758_470" + ]; + }; + security.sudo.wheelNeedsPassword = false; + + environment.systemPackages = [ pkgs.age pkgs.vim ]; +} diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 747b513..3ce3ca6 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -9,6 +9,10 @@ ./hardware.nix ]; + # linux_rpi4 is the Raspberry Pi Foundation's kernel, sourced from nixpkgs + # and pre-built in cache.nixos.org. Avoids a multi-hour native compilation. + boot.kernelPackages = pkgs.linuxKernel.packages.linux_rpi4; + # ------------------------------------------------------------------------- # Identity # ------------------------------------------------------------------------- @@ -50,7 +54,7 @@ extraGroups = [ "wheel" "podman" ]; # Paste your SSH public key here 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==" + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfzDDO5juINctECmWlsYtGghEiX/RnTJ1cazLvOWSrPfsTyEd+B1+Ig8kFefNryjkpApfRXqj5KtLPNlpLfdVBrOIfhIveEp2MGqhgOGZFNVxQyXnZgii8Zdh4cqZ2O3pZpMsaAQBaJ9nH6dK0dJjicWT5f6TqwrVcInywRc5SuyizoSxoFmg7ch2rnlVi0j5XMVqdh8XLzHXZ7yWCzXy7+hWl/d7pwpyuzoK8dBw2EU9TauhgRDruom5Q9vWJTLStALC9pAIb0v9UFj9y+1zwx7pXsXp5F1g73EYrE4QR+QQ6z2LebuK280W0t+VA/fSCEB13DnkmofgqZQxX5MSCmrxZ5lTFp1FjW6yJo7As9FheF/GECowYkMRIx4IiQsjjHjZqlLRpLas11yAp6tGoZnw59hFo6Lu0Kva39jGVVmioYHtAeE5rD5w+v5kseJR4jlQ8aKB5yOjYUQOIz2AHQyoidgaeR2jPWqZUeRQbACI+/p3CHO45r3hrjATtGloBg0xF95Qws7Be3mjHVhbBLOoob8MdZ8nYAGnhlWrZphlkvXsHC6OUkuDJW00tmMjWXRlFwhFJ+nqUQCgLVjxVHQJ5rq9GeXBUuNXAeCm5BKBsdq+9qqVlt7D9iGyfr0lcZ7peKz/96KwPCWpG2En1Ur0/cVcbWnXEfG/xWO10tQ== cardno:24_758_470" ]; }; @@ -62,7 +66,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/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1"; + device = "/dev/disk/by-label/homey-data"; mountPoint = "/mnt/data"; fsType = "ext4"; }; @@ -98,14 +102,14 @@ # ------------------------------------------------------------------------- # Local DNS overrides (optional — makes LAN clients hit the Pi directly - # instead of going through Cloudflare for *.home.zakobar.com) + # instead of going through Cloudflare for *.zakobar.com) # ------------------------------------------------------------------------- # If you run Pi-hole or Adguard, add these records there instead. # networking.extraHosts = '' - # 192.168.1.100 home.zakobar.com - # 192.168.1.100 auth.home.zakobar.com - # 192.168.1.100 git.home.zakobar.com - # 192.168.1.100 nextcloud.home.zakobar.com - # 192.168.1.100 ldapadmin.home.zakobar.com + # 192.168.1.100 zakobar.com + # 192.168.1.100 auth.zakobar.com + # 192.168.1.100 git.zakobar.com + # 192.168.1.100 nextcloud.zakobar.com + # 192.168.1.100 ldapadmin.zakobar.com # ''; } diff --git a/modules/caddy.nix b/modules/caddy.nix index 06eae27..bcdbb47 100644 --- a/modules/caddy.nix +++ b/modules/caddy.nix @@ -3,7 +3,7 @@ # Caddy reverse proxy. # # Features: -# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.home.zakobar.com +# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.zakobar.com # - forward_auth to Authelia for protected vhosts # - Plain reverse_proxy for public vhosts (authelia itself, nextcloud) # - Listens on :80 (redirect) and :443 (TLS) @@ -23,11 +23,23 @@ let # 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" + # v0.2.4 tag points to commit a8737d0 which includes the fix for + # cfut_/cfat_ token format validation (PR #123). + "github.com/caddy-dns/cloudflare@v0.2.4" ]; - hash = "sha256-2Fb2fgM7YhWk9kBnnNGb85MJkAkgzXiI1fb6eK3ykIE="; + hash = "sha256-pRrLBlYRaAyMYwPXeTy4WqWNRu/L9K6Mn2src11dGh8="; }; + # Reverse-proxy snippet for cloudflared http:// vhosts. + # Cloudflare terminates TLS; cloudflared connects to Caddy over plain HTTP. + # We must override X-Forwarded-Proto so upstream services (especially + # Authelia) know the client is actually on HTTPS. + cfProxy = port: '' + reverse_proxy localhost:${toString port} { + header_up X-Forwarded-Proto https + } + ''; + # Reusable Authelia forward_auth snippet # Returns a Caddyfile snippet block that applies forward_auth. # copy_headers makes Authelia's Remote-* headers available downstream. @@ -35,6 +47,9 @@ let forward_auth localhost:9091 { uri /api/verify?rd=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 { @@ -77,7 +92,15 @@ in acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} ''; - # Each virtual host + # Each virtual host. + # + # Each service gets two vhost entries: + # - "host" (no scheme) → Caddy handles HTTPS + auto cert (for LAN access) + # - "http://host" → plain HTTP for cloudflared on loopback (no redirect) + # + # Caddy auto-redirects HTTP→HTTPS only when no explicit http:// vhost exists. + # By defining http:// explicitly we suppress that redirect so cloudflared + # (which talks plain HTTP on port 80) gets a direct response. virtualHosts = { # ------------------------------------------------------------------ @@ -88,21 +111,25 @@ in reverse_proxy localhost:9091 ''; }; + "http://auth.${domain}" = { + extraConfig = cfProxy 9091; + }; # ------------------------------------------------------------------ - # Gitea — protected behind one_factor Authelia + # Gitea — no forward_auth; git HTTP clients can't handle SSO redirects. + # Access control is handled by Gitea itself (LDAP auth + private repos). # ------------------------------------------------------------------ "git.${domain}" = { extraConfig = '' - ${autheliaForwardAuth} reverse_proxy localhost:3000 ''; }; + "http://git.${domain}" = { + extraConfig = cfProxy 3000; + }; # ------------------------------------------------------------------ # Nextcloud — public auth (Nextcloud manages its own users + LDAP) - # Authelia is not gating nextcloud directly because NC has its own - # login flow. We still want HTTPS. # ------------------------------------------------------------------ "nextcloud.${domain}" = { extraConfig = '' @@ -118,6 +145,18 @@ in reverse_proxy localhost:8080 ''; }; + "http://nextcloud.${domain}" = { + extraConfig = '' + redir /.well-known/carddav /remote.php/dav/ 301 + redir /.well-known/caldav /remote.php/dav/ 301 + request_body { + max_size 5GB + } + reverse_proxy localhost:8080 { + header_up X-Forwarded-Proto https + } + ''; + }; # ------------------------------------------------------------------ # phpLDAPadmin — two_factor, admins only (enforced by authelia policy) @@ -128,16 +167,25 @@ in reverse_proxy localhost:8081 ''; }; + "http://ldapadmin.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + ${cfProxy 8081} + ''; + }; # ------------------------------------------------------------------ - # Jellyfin — one_factor (added when enabled) + # Jellyfin — no forward_auth; Jellyfin has its own login UI and + # native app clients can't handle SSO redirects. # ------------------------------------------------------------------ "jellyfin.${domain}" = { extraConfig = '' - ${autheliaForwardAuth} reverse_proxy localhost:8096 ''; }; + "http://jellyfin.${domain}" = { + extraConfig = cfProxy 8096; + }; # ------------------------------------------------------------------ # Transmission — two_factor, admins only (enforced by authelia policy) @@ -149,6 +197,12 @@ in ''; # NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091. }; + "http://torrent.${domain}" = { + extraConfig = '' + ${autheliaForwardAuth} + ${cfProxy 9092} + ''; + }; }; }; @@ -163,18 +217,20 @@ in # ----------------------------------------------------------------------- systemd.services.caddy = { serviceConfig = { - EnvironmentFile = "/run/caddy-secrets.env"; - ExecStartPre = [ - (pkgs.writeShellScript "caddy-inject-cf-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 + # LoadCredential stages the sops-decrypted secret into a + # per-invocation directory ($CREDENTIALS_DIRECTORY) before any + # Exec* step. ExecStart then reads the file contents and exports + # CLOUDFLARE_API_TOKEN before exec-ing caddy, so there is no + # intermediate env file and no ordering race with EnvironmentFile. + LoadCredential = "cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}"; + # Systemd requires clearing ExecStart= before setting a new value for + # non-oneshot services. The empty string resets the list; the second + # entry is the actual start command. + ExecStart = lib.mkForce [ + "" + (pkgs.writeShellScript "caddy-start" '' + export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token") + exec ${caddyWithCloudflare}/bin/caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile '') ]; }; diff --git a/modules/cloudflared.nix b/modules/cloudflared.nix index 577c1c7..dd2ddd0 100644 --- a/modules/cloudflared.nix +++ b/modules/cloudflared.nix @@ -14,12 +14,12 @@ # 2. Name it (e.g. "pi-main") # 3. Copy the tunnel token — add it to secrets.yaml as cloudflare/tunnel_token # 4. In the tunnel's "Public Hostnames" config, add routes: -# auth.home.zakobar.com → http://localhost:80 (or https://localhost:443) -# git.home.zakobar.com → https://localhost:443 -# nextcloud.home.zakobar.com → https://localhost:443 -# ldapadmin.home.zakobar.com → https://localhost:443 -# jellyfin.home.zakobar.com → https://localhost:443 -# torrent.home.zakobar.com → https://localhost:443 +# auth.zakobar.com → http://localhost:80 (or https://localhost:443) +# git.zakobar.com → https://localhost:443 +# nextcloud.zakobar.com → https://localhost:443 +# ldapadmin.zakobar.com → https://localhost:443 +# jellyfin.zakobar.com → https://localhost:443 +# torrent.zakobar.com → https://localhost:443 # Set "No TLS Verify" = true (Caddy's cert is from Let's Encrypt but # the hostname seen by cloudflared is localhost, so hostname verification # would fail without this flag). diff --git a/modules/common.nix b/modules/common.nix index 49cbefb..3f7129b 100644 --- a/modules/common.nix +++ b/modules/common.nix @@ -16,13 +16,10 @@ 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=" ]; }; gc = { diff --git a/modules/services/authelia.nix b/modules/services/authelia.nix index 4b1b453..5ed42a2 100644 --- a/modules/services/authelia.nix +++ b/modules/services/authelia.nix @@ -23,7 +23,7 @@ let dataDir = config.homey.storage.mountPoint; domain = homeyConfig.domain; - # LDAP base DN derived from domain: home.zakobar.com → dc=home,dc=zakobar,dc=com + # LDAP base DN derived from domain: zakobar.com → dc=zakobar,dc=com ldapBaseDN = lib.concatStringsSep "," (map (p: "dc=${p}") (lib.splitString "." domain)); @@ -162,7 +162,7 @@ in virtualisation.oci-containers.containers.authelia = { image = cfg.image; - ports = [ "127.0.0.1:${toString cfg.port}:9091" ]; + # No ports mapping — --network=host shares the host network stack directly. environment = { TZ = homeyConfig.timezone; diff --git a/modules/services/gitea.nix b/modules/services/gitea.nix index baaa635..059cc0b 100644 --- a/modules/services/gitea.nix +++ b/modules/services/gitea.nix @@ -9,7 +9,15 @@ # Volume layout: # /gitea/data/ → /data (repos, sqlite db, avatars, lfs, etc.) # -# The app.ini is rendered by Nix and bind-mounted read-only. +# Configuration strategy: all settings are passed as GITEA__
__ +# environment variables. Gitea writes its own app.ini into /data/gitea/conf/ +# on first start; the env vars override every key at runtime without touching +# that file. This avoids the bind-mount / read-only-fs problem where Gitea +# needs to rewrite its own config file on startup. +# +# Non-secret settings go in the `environment` block (they are fine in the +# Nix store). Secret settings go into /run/gitea-secrets.env via ExecStartPre +# (never in the store). # # Secrets consumed from sops: # gitea/admin_password @@ -21,98 +29,6 @@ let cfg = config.homey.gitea; dataDir = config.homey.storage.mountPoint; domain = homeyConfig.domain; - - # Gitea app.ini — generated at build time. - # Secrets that Gitea reads from env vars are referenced as env var names here. - # The actual values are injected by the ExecStartPre wrapper below. - giteaAppIni = '' - APP_NAME = ${homeyConfig.organization} - RUN_MODE = prod - RUN_USER = git - WORK_PATH = /data/gitea - - [repository] - ROOT = /data/git/repositories - - [repository.local] - LOCAL_COPY_PATH = /data/gitea/tmp/local-repo - - [repository.upload] - TEMP_PATH = /data/gitea/uploads - - [server] - APP_DATA_PATH = /data/gitea - DOMAIN = git.${domain} - HTTP_PORT = 3000 - ROOT_URL = https://git.${domain}/ - DISABLE_SSH = true - LFS_START_SERVER = true - ; LFS_JWT_SECRET injected at container start via env var / startup script - LFS_JWT_SECRET = __GITEA_LFS_JWT_SECRET__ - OFFLINE_MODE = false - - [lfs] - PATH = /data/git/lfs - - [database] - DB_TYPE = sqlite3 - PATH = /data/gitea/gitea.db - LOG_SQL = false - - [indexer] - ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve - - [session] - PROVIDER_CONFIG = /data/gitea/sessions - PROVIDER = file - - [picture] - AVATAR_UPLOAD_PATH = /data/gitea/avatars - REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars - DISABLE_GRAVATAR = false - - [attachment] - PATH = /data/gitea/attachments - - [log] - MODE = console - LEVEL = info - ROUTER = console - ROOT_PATH = /data/gitea/log - - [security] - INSTALL_LOCK = true - REVERSE_PROXY_LIMIT = 1 - REVERSE_PROXY_TRUSTED_PROXIES = * - ; INTERNAL_TOKEN injected at container start - INTERNAL_TOKEN = __GITEA_INTERNAL_TOKEN__ - - [service] - DISABLE_REGISTRATION = true - REQUIRE_SIGNIN_VIEW = false - REGISTER_EMAIL_CONFIRM = false - ENABLE_NOTIFY_MAIL = false - ALLOW_ONLY_EXTERNAL_REGISTRATION = true - ENABLE_CAPTCHA = false - DEFAULT_ALLOW_CREATE_ORGANIZATION = true - DEFAULT_ENABLE_TIMETRACKING = true - NO_REPLY_ADDRESS = noreply.localhost - ENABLE_REVERSE_PROXY_AUTHENTICATION = true - ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true - - [mailer] - ENABLED = false - - [openid] - ENABLE_OPENID_SIGNIN = false - ENABLE_OPENID_SIGNUP = false - - [oauth2] - ENABLE = false - ; JWT_SECRET injected at container start - JWT_SECRET = __GITEA_OAUTH2_JWT_SECRET__ - ''; - in { options.homey.gitea = { @@ -139,60 +55,175 @@ in sops.secrets."gitea/oauth2_jwt_secret" = { owner = "root"; }; sops.secrets."gitea/internal_token" = { owner = "root"; }; - # ----------------------------------------------------------------------- - # Write the app.ini template to /etc (will be processed by ExecStartPre) - # ----------------------------------------------------------------------- - environment.etc."gitea/app.ini.tpl" = { - text = giteaAppIni; - mode = "0444"; - }; - # ----------------------------------------------------------------------- # Container # ----------------------------------------------------------------------- virtualisation.oci-containers.containers.gitea = { image = cfg.image; - ports = [ "127.0.0.1:${toString cfg.port}:3000" ]; + # No ports mapping — --network=host means the container shares the host + # network stack directly. Gitea binds to 0.0.0.0:3000 on the host. + # All non-secret settings via GITEA__
__ env vars. + # These are safe to store in the Nix store. environment = { - USER_UID = "1000"; - USER_GID = "1000"; - # Tell gitea where to look for the config + USER_UID = "1000"; + USER_GID = "1000"; GITEA_CUSTOM = "/data/gitea"; + + # [DEFAULT] + GITEA____APP_NAME = homeyConfig.organization; + GITEA____RUN_MODE = "prod"; + + # [repository] + GITEA__repository__ROOT = "/data/git/repositories"; + + # [server] + GITEA__server__APP_DATA_PATH = "/data/gitea"; + GITEA__server__DOMAIN = "git.${domain}"; + GITEA__server__HTTP_PORT = toString cfg.port; + GITEA__server__ROOT_URL = "https://git.${domain}/"; + GITEA__server__DISABLE_SSH = "true"; + GITEA__server__START_SSH_SERVER = "false"; + GITEA__server__SSH_PORT = "2222"; + GITEA__server__SSH_LISTEN_PORT = "2222"; + GITEA__server__LFS_START_SERVER = "true"; + GITEA__server__OFFLINE_MODE = "false"; + + # [lfs] + GITEA__lfs__PATH = "/data/git/lfs"; + + # [database] + GITEA__database__DB_TYPE = "sqlite3"; + GITEA__database__PATH = "/data/gitea/gitea.db"; + GITEA__database__LOG_SQL = "false"; + + # [indexer] + GITEA__indexer__ISSUE_INDEXER_PATH = "/data/gitea/indexers/issues.bleve"; + + # [session] + GITEA__session__PROVIDER = "file"; + GITEA__session__PROVIDER_CONFIG = "/data/gitea/sessions"; + + # [picture] + GITEA__picture__AVATAR_UPLOAD_PATH = "/data/gitea/avatars"; + GITEA__picture__REPOSITORY_AVATAR_UPLOAD_PATH = "/data/gitea/repo-avatars"; + GITEA__picture__DISABLE_GRAVATAR = "false"; + + # [attachment] + GITEA__attachment__PATH = "/data/gitea/attachments"; + + # [log] + GITEA__log__MODE = "console"; + GITEA__log__LEVEL = "info"; + GITEA__log__ROOT_PATH = "/data/gitea/log"; + + # [security] + GITEA__security__INSTALL_LOCK = "true"; + GITEA__security__REVERSE_PROXY_LIMIT = "1"; + GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES = "*"; + + # [service] + GITEA__service__DISABLE_REGISTRATION = "true"; + GITEA__service__REQUIRE_SIGNIN_VIEW = "false"; + GITEA__service__REGISTER_EMAIL_CONFIRM = "false"; + GITEA__service__ENABLE_NOTIFY_MAIL = "false"; + GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION = "true"; + GITEA__service__ENABLE_CAPTCHA = "false"; + GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION = "true"; + GITEA__service__DEFAULT_ENABLE_TIMETRACKING = "true"; + GITEA__service__NO_REPLY_ADDRESS = "noreply.localhost"; + GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION = "true"; + GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = "true"; + + # [mailer] + GITEA__mailer__ENABLED = "false"; + + # [openid] + GITEA__openid__ENABLE_OPENID_SIGNIN = "false"; + GITEA__openid__ENABLE_OPENID_SIGNUP = "false"; + + # [oauth2] + GITEA__oauth2__ENABLED = "false"; }; + # Secret env vars written at runtime by ExecStartPre — never in store. + environmentFiles = [ "/run/gitea-secrets.env" ]; + volumes = [ "${dataDir}/gitea/data:/data" - # The processed app.ini is written by ExecStartPre into /run/gitea-conf/ - "/run/gitea-conf/app.ini:/data/gitea/conf/app.ini:ro" ]; extraOptions = [ "--network=host" ]; }; # ----------------------------------------------------------------------- - # ExecStartPre: substitute secret placeholders into the ini template + # ExecStartPre: write ephemeral secrets env file + # ExecStopPost: clean it up # ----------------------------------------------------------------------- systemd.services."podman-gitea" = { serviceConfig = { ExecStartPre = [ - (pkgs.writeShellScript "gitea-build-config" '' + (pkgs.writeShellScript "gitea-write-secrets" '' set -euo pipefail - install -d -m 700 /run/gitea-conf LFS=$(cat ${config.sops.secrets."gitea/lfs_jwt_secret".path}) OAUTH=$(cat ${config.sops.secrets."gitea/oauth2_jwt_secret".path}) TOKEN=$(cat ${config.sops.secrets."gitea/internal_token".path}) - sed \ - -e "s|__GITEA_LFS_JWT_SECRET__|$LFS|g" \ - -e "s|__GITEA_OAUTH2_JWT_SECRET__|$OAUTH|g" \ - -e "s|__GITEA_INTERNAL_TOKEN__|$TOKEN|g" \ - /etc/gitea/app.ini.tpl > /run/gitea-conf/app.ini - chmod 444 /run/gitea-conf/app.ini + printf '%s\n' \ + "GITEA__server__LFS_JWT_SECRET=$LFS" \ + "GITEA__security__INTERNAL_TOKEN=$TOKEN" \ + "GITEA__oauth2__JWT_SECRET=$OAUTH" \ + > /run/gitea-secrets.env + chmod 600 /run/gitea-secrets.env + '') + ]; + ExecStopPost = [ + (pkgs.writeShellScript "gitea-cleanup-secrets" '' + rm -f /run/gitea-secrets.env '') ]; }; - after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ]; - requires = lib.mkAfter [ "mnt-data.mount" ]; + after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" ]; + }; + + # ----------------------------------------------------------------------- + # Ensure the Gitea admin user exists with the correct password after start. + # Runs as a oneshot after podman-gitea; idempotent (create or update). + # ----------------------------------------------------------------------- + systemd.services."gitea-admin-setup" = { + description = "Ensure Gitea admin user exists with correct password"; + wantedBy = [ "multi-user.target" ]; + after = [ "podman-gitea.service" ]; + requires = [ "podman-gitea.service" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + + script = '' + set -euo pipefail + PASS=$(cat ${config.sops.secrets."gitea/admin_password".path}) + + # Wait until Gitea's HTTP endpoint is up (max 60 s) + for i in $(seq 1 60); do + if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/ -o /dev/null; then + break + fi + sleep 1 + done + + # Sync password if admin exists; create if not. + if ! ${pkgs.podman}/bin/podman exec -u 1000 gitea \ + gitea admin user change-password --username admin --password "$PASS" 2>/dev/null; then + ${pkgs.podman}/bin/podman exec -u 1000 gitea \ + gitea admin user create \ + --username admin \ + --password "$PASS" \ + --email "admin@${domain}" \ + --admin + fi + ''; }; }; } diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index e3babaa..ff2a006 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -30,7 +30,7 @@ in config = lib.mkIf cfg.enable { virtualisation.oci-containers.containers.jellyfin = { image = cfg.image; - ports = [ "127.0.0.1:${toString cfg.port}:8096" ]; + # No ports mapping — --network=host shares the host network stack directly. environment = { JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}"; diff --git a/modules/services/nextcloud.nix b/modules/services/nextcloud.nix index 6e86423..6b69a16 100644 --- a/modules/services/nextcloud.nix +++ b/modules/services/nextcloud.nix @@ -58,7 +58,7 @@ in # ----------------------------------------------------------------------- virtualisation.oci-containers.containers.nextcloud-postgres = { image = cfg.postgresImage; - ports = [ "127.0.0.1:${toString cfg.postgresPort}:5432" ]; + # No ports mapping — --network=host shares the host network stack directly. environment = { POSTGRES_DB = "nextcloud_db"; @@ -70,20 +70,25 @@ in "${dataDir}/nextcloud/db:/var/lib/postgresql/data" ]; - extraOptions = [ "--network=host" ]; + extraOptions = [ + "--network=host" + "--env-file=/run/nc-postgres-secrets.env" + ]; }; systemd.services."podman-nextcloud-postgres" = { serviceConfig = { + LoadCredential = [ + "nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}" + ]; ExecStartPre = [ (pkgs.writeShellScript "nc-postgres-secrets-env" '' set -euo pipefail install -m 600 /dev/null /run/nc-postgres-secrets.env - echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" \ + echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" \ >> /run/nc-postgres-secrets.env '') ]; - EnvironmentFile = "/run/nc-postgres-secrets.env"; }; postStop = "rm -f /run/nc-postgres-secrets.env"; after = lib.mkAfter [ "mnt-data.mount" ]; @@ -95,7 +100,7 @@ in # ----------------------------------------------------------------------- virtualisation.oci-containers.containers.nextcloud = { image = cfg.image; - ports = [ "127.0.0.1:${toString cfg.port}:80" ]; + # No ports mapping — --network=host shares the host network stack directly. environment = { POSTGRES_HOST = "127.0.0.1"; @@ -105,6 +110,10 @@ in NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}"; OVERWRITEPROTOCOL = "https"; OVERWRITECLIURL = "https://nextcloud.${domain}"; + # With --network=host, port mappings are ignored and the container's + # Apache binds directly on the host. Force it onto port 8080 so Caddy + # can own 80/443. + APACHE_HTTP_PORT_NUMBER = toString cfg.port; # Passwords injected via env file }; @@ -112,20 +121,26 @@ in "${dataDir}/nextcloud/html:/var/www/html" ]; - extraOptions = [ "--network=host" ]; + extraOptions = [ + "--network=host" + "--env-file=/run/nc-secrets.env" + ]; }; systemd.services."podman-nextcloud" = { serviceConfig = { + LoadCredential = [ + "nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}" + "nextcloud_admin_password:${config.sops.secrets."nextcloud/admin_password".path}" + ]; ExecStartPre = [ (pkgs.writeShellScript "nc-secrets-env" '' set -euo pipefail install -m 600 /dev/null /run/nc-secrets.env - echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" >> /run/nc-secrets.env - echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat ${config.sops.secrets."nextcloud/admin_password".path})" >> /run/nc-secrets.env + echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" >> /run/nc-secrets.env + echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_admin_password")" >> /run/nc-secrets.env '') ]; - EnvironmentFile = "/run/nc-secrets.env"; }; postStop = "rm -f /run/nc-secrets.env"; after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ]; diff --git a/modules/services/openldap.nix b/modules/services/openldap.nix index 95f4725..e1543e5 100644 --- a/modules/services/openldap.nix +++ b/modules/services/openldap.nix @@ -50,8 +50,10 @@ in virtualisation.oci-containers.containers.openldap = { image = cfg.image; - # Bind only to localhost — no external exposure - ports = [ "127.0.0.1:${toString cfg.port}:389" ]; + # No ports mapping — --network=host means the container shares the host + # network stack. OpenLDAP binds to 0.0.0.0:389, but the firewall + # (common.nix) only opens 22/80/443, so port 389 is unreachable from + # the LAN or internet. environment = { LDAP_ORGANISATION = homeyConfig.organization; @@ -76,8 +78,8 @@ in ]; extraOptions = [ - "--network=host" # simplest for single-host: services talk on 127.0.0.1 - "--hostname=openldap" + "--network=host" + "--env-file=/run/openldap-secrets.env" ]; }; @@ -88,18 +90,25 @@ in # podman-.service systemd.services."podman-openldap" = { serviceConfig = { - # Write an env file with secret values before the container starts, - # then pass it to podman run via EnvironmentFile. + # LoadCredential stages the sops secrets into a per-invocation + # credential directory before any Exec* step, so they are available + # when ExecStartPre runs. ExecStartPre writes the env file that + # podman --env-file reads; this avoids the EnvironmentFile ordering + # race (EnvironmentFile is evaluated before ExecStartPre). + LoadCredential = [ + "openldap_admin_password:${config.sops.secrets."openldap/admin_password".path}" + "openldap_config_password:${config.sops.secrets."openldap/config_password".path}" + "openldap_ro_password:${config.sops.secrets."openldap/ro_password".path}" + ]; ExecStartPre = [ (pkgs.writeShellScript "openldap-secrets-env" '' set -euo pipefail install -m 600 /dev/null /run/openldap-secrets.env - echo "LDAP_ADMIN_PASSWORD=$(cat ${config.sops.secrets."openldap/admin_password".path})" >> /run/openldap-secrets.env - echo "LDAP_CONFIG_PASSWORD=$(cat ${config.sops.secrets."openldap/config_password".path})" >> /run/openldap-secrets.env - echo "LDAP_READONLY_USER_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})" >> /run/openldap-secrets.env + echo "LDAP_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_admin_password")" >> /run/openldap-secrets.env + echo "LDAP_CONFIG_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_config_password")" >> /run/openldap-secrets.env + echo "LDAP_READONLY_USER_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_ro_password")" >> /run/openldap-secrets.env '') ]; - EnvironmentFile = "/run/openldap-secrets.env"; }; # Clean up the env file on stop postStop = "rm -f /run/openldap-secrets.env"; @@ -109,8 +118,8 @@ in }; # ----------------------------------------------------------------------- - # Firewall — openldap port is NOT opened externally (localhost only) + # Firewall — openldap port is NOT opened externally # ----------------------------------------------------------------------- - # No firewall rule needed; bound to 127.0.0.1. + # No firewall rule needed; common.nix only opens 22/80/443. }; } diff --git a/modules/services/phpldapadmin.nix b/modules/services/phpldapadmin.nix index 3e5a11a..296c317 100644 --- a/modules/services/phpldapadmin.nix +++ b/modules/services/phpldapadmin.nix @@ -5,6 +5,11 @@ # Stateless container (no persistent volumes needed). # Protected by Authelia two_factor, admins-only policy (defined in authelia.nix). # Bound to localhost:8081; Caddy reverse-proxies it. +# +# Networking: uses default bridge (podman) network with a port mapping +# 127.0.0.1:8081->80 so Caddy can reach it. OpenLDAP runs on the host +# network at 127.0.0.1:389; the container reaches it via the special +# host.containers.internal DNS name that podman injects automatically. let cfg = config.homey.phpldapadmin; @@ -28,14 +33,17 @@ in config = lib.mkIf cfg.enable { virtualisation.oci-containers.containers.phpldapadmin = { image = cfg.image; - ports = [ "127.0.0.1:${toString cfg.port}:80" ]; environment = { - PHPLDAPADMIN_HTTPS = "false"; - PHPLDAPADMIN_LDAP_HOSTS = "127.0.0.1"; # openldap on host network + PHPLDAPADMIN_HTTPS = "false"; + # host.containers.internal resolves to the host from inside a podman + # bridge container — reaches openldap which is on --network=host at :389 + PHPLDAPADMIN_LDAP_HOSTS = "host.containers.internal"; }; - extraOptions = [ "--network=host" ]; + # Bridge network (default) + port mapping: Apache binds inside the + # container on :80, podman maps it to 127.0.0.1:8081 on the host. + ports = [ "127.0.0.1:${toString cfg.port}:80" ]; }; systemd.services."podman-phpldapadmin" = { diff --git a/modules/services/transmission.nix b/modules/services/transmission.nix index 74b50c2..c984a5f 100644 --- a/modules/services/transmission.nix +++ b/modules/services/transmission.nix @@ -35,11 +35,16 @@ in config = lib.mkIf cfg.enable { virtualisation.oci-containers.containers.transmission = { image = cfg.image; - ports = [ "127.0.0.1:${toString cfg.port}:9091" ]; + # No ports mapping — --network=host shares the host network stack directly. environment = { PUID = "1000"; PGID = "1000"; + # With --network=host, port mappings are ignored; transmission binds + # directly on the host. Force it to cfg.port (9092) to avoid + # conflicting with Authelia on 9091. + TRANSMISSION_WEB_HOME = "/usr/share/transmission/web"; + WEBUI_PORT = toString cfg.port; }; volumes = [ diff --git a/modules/storage.nix b/modules/storage.nix index e276f5c..3b25644 100644 --- a/modules/storage.nix +++ b/modules/storage.nix @@ -85,8 +85,8 @@ in "d ${cfg.mountPoint}/openldap/var-lib-ldap 0750 root root -" "d ${cfg.mountPoint}/authelia 0750 root root -" "d ${cfg.mountPoint}/authelia/config 0750 root root -" - "d ${cfg.mountPoint}/gitea 0750 root root -" - "d ${cfg.mountPoint}/gitea/data 0750 root root -" + "d ${cfg.mountPoint}/gitea 0750 1000 1000 -" + "d ${cfg.mountPoint}/gitea/data 0750 1000 1000 -" "d ${cfg.mountPoint}/nextcloud 0750 root root -" "d ${cfg.mountPoint}/nextcloud/html 0750 root root -" "d ${cfg.mountPoint}/nextcloud/db 0750 root root -" diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index 291d4aa..fcceb6a 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -1,57 +1,60 @@ -#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] -# -#ENC[AES256_GCM,data:IT6BEo5CjYm+15aeWl+S8M3B+SSmjPnhBRvYToWzezIweTl3MGBXtalvkV3NWkxH0EaHpueOMe6r,iv:7BDTiljEa59F13Pephw6MM+sZgL4jbfQafJyt0UU3hY=,tag:ia+7WUAl/45jrYrv3Pylxg==,type:comment] -# -#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] +openldap: + admin_password: ENC[AES256_GCM,data:hg+Ly1bX4ao1AT4SDvQWXiT/KMzsz0wdnRauiB+FetE=,iv:TAX+NZCVUNiwMeBrW58IeI1OJX6rzzGAhWiQ+cZXreo=,tag:MrwYKKBb1Cg2JvADtQqYrQ==,type:str] + config_password: ENC[AES256_GCM,data:qKEurb0slGnr6nES7w7fTPDCy/DARns0BorDZMwpI/w=,iv:+p6Fh9a2g0eBueOxDk1J+hnM9fMgE6/NYwz+sAovGjE=,tag:kKZVsxdxdDACD9J0NAf4gQ==,type:str] + ro_password: ENC[AES256_GCM,data:82htWXdJ07tdZ81o7o9a4hizxcn39yQidD5e9PijVpo=,iv:fDT1chX4ZPIS02IMEW02haPa2IIlLFhgFOpUwD7KL50=,tag:9pSo/3vFikKQAe8jS+3Q6A==,type:str] +authelia: + jwt_secret: ENC[AES256_GCM,data:SwXd/mMsrgXItP8QZr4z9YaN1lgSSO4Cpdwl+XxFj6I=,iv:gAkyHKP5D5RGJ3X3hoh8oEJfYaFnvYxGAoKxe+G1N0I=,tag:NlfWC2pmjCYiiRn9prgl2w==,type:str] + session_secret: ENC[AES256_GCM,data:bbbgmxYLbtteuT628O+uSVeo7Gx3hI6uWVIV5l8AhtNSvXBHKb9i2NvQqJfVG8D2YR00+OW9XtreocpBuS7YJKp58cP/EgrF9x1im/8UaaifAYqD8YXZReUsBw+/PzIvqyA1K9tFd0coc8tSHJmwTwea4sf6Tc10N6j/nhNQfpQ=,iv:wrGE8XVsTINmf5505XVI7HHqA33w+SBh9lsJXD+YnwU=,tag:lvSoU+IRy8f+6VpYqzvacg==,type:str] + storage_encryption_key: ENC[AES256_GCM,data:8KnaWBTlSStdC/uI4GUOYP9DJygjfCTu62zWTU9eeVE=,iv:DNl0L2QgT1lNUDdPNm9bXcGvrLXLDtWdJ9pPgRH20C8=,tag:hVhcCl0Vrt0ZnaVGaUokSQ==,type:str] +gitea: + admin_password: ENC[AES256_GCM,data:WncwJlqb/3X5WZYgIXAu0niI0ISP0eHfmhsKDLeAvE4=,iv:JBZNZJRSHKG+cCoFNJBI+jS+/WcLueqJ/UN/7wXfK/0=,tag:f6+Lhfcxstq6pwS9xkVwqQ==,type:str] + lfs_jwt_secret: ENC[AES256_GCM,data:i05gr2ou03w0yu6/bhlJOW1huysAAPTidFEusWkhQfpDj4Pyh8LEKb09Og==,iv:aqkblyz0oIFHwzVCzlGDdQuCbsDPrfBaJMzgRTw+pYU=,tag:6gBSerOUK8Y3la/2Bg2AZQ==,type:str] + oauth2_jwt_secret: ENC[AES256_GCM,data:BVvQJCEfHPbemd1jz7MWpIRia1wfvPMGuLqoi/xUMSoQoN5RPefQnPR4Cg==,iv:JAZQUTxHZSnMEnl+BIZ1PXlznMwKuPtiPP/17rc6lSs=,tag:mUw5RuthZmZegXCtfsFNmQ==,type:str] + internal_token: ENC[AES256_GCM,data:gnOebJbRsh2Cues9WjGQp4rWa6OuE3xSnby9jc3Hk8ywvpL7CNWmlGW7zmmOyDAfIKfm8kf1FxotWLXtGZDretzdbMRM9c6gkwSJf5MCsdm27Er+IRKS/QFBuvLSTEH0,iv:aVgRvs3T3zCg+AV/BKUXQyZDKvunHvXsdfr9sqo2cI0=,tag:U9aP+N0CWyRQ/xJ27Vo2mw==,type:str] +nextcloud: + admin_password: ENC[AES256_GCM,data:iK6VoE94vFQmn3i4XQc5r/c03u3b0knDgBNK8d1qyns=,iv:P1wax2vAjn9iwBe9T7SN+pKrtrWcOYb5OWUyHF4hlVg=,tag:ET8KU4IKzhWqIDeRihwcag==,type:str] + postgres_password: ENC[AES256_GCM,data:ga4cwhYsAgEBvr+aDVwiRZXeT+TjXzeef1r3ud6uYHs=,iv:PMHCjO4wLW6PER4oGODEG9CHqrvVpAbgTGF7p49MCL0=,tag:mTNzsDhufqLlf1LFu7Rl1A==,type:str] +cloudflare: + api_token: ENC[AES256_GCM,data:Erzom4DKiam9SHGLdT3CQRkuT5kkhcuUaLwTbt2P5pPjr1V56p733KB1kHheO/PZ+TRsZg0=,iv:eO+ryffyoSkzAgUXe0MH+FKitgHCQ3ychLWEAShAd9o=,tag:2Z8Io0ylpAI9rws5NXCvIw==,type:str] + tunnel_token: ENC[AES256_GCM,data:AFlD990L8l1Rh9i8wdyXwwyolrlw2ln1uuyCTiT7k1FVc2JjTOrgc8HiBIpxM40eqFGEnzDMs87tgzh1Pl8UThwV7WcLFWHMvtYHNez/F2+THknnW+ZinJbZnNSngicrhRIoNFhjQgjaR1LaS4kcNbkGBi655bl2uqNXoUpNThUKGAlZ4KwByEzK4B7QtCgAkqxQEFehtjdj41p8r58ViJTogXQKXmDYLjbqo8nPmUGSaiR/VCY7Gw==,iv:q755yc6wTMCuqHLvfHOZzBf3KoG4vcw431stQA78Bjs=,tag:Zt/LPG41HxSg2gSQOTC/6A==,type:str] +restic: + password: ENC[AES256_GCM,data:X/pWmwakzQzRpSaY+T/kOqdrtXyvGPa3UQc/iIQFFAzUS1jHR9IvzW7KYdm/3IKXPBlZzPkWDsoRvVAChPwfQA==,iv:4+RD9UD5daMP04ixeagxbCNkTdOPx+BqfSOheh88OUU=,tag:SmrIz7jvbM74y5RBX0nCbA==,type:str] + s3_access_key_id: ENC[AES256_GCM,data:XxElPQF28ThfYuiF4jQu7BiS8sh+c4V4ng==,iv:aLUIYnRGqFLYwlP3nFwDY5uvy8pXtX5QMKLMfRTxdNk=,tag:CscNNWnpvLuxw1DPK92GyA==,type:str] + 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] 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] + age: + - recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSGpPdTBIaTZ0TER2NkNO + U2ZPKzNwelJHUEpyU2VBSmd5Yjd5bEtibFZzCjlZZTRFa2FHN1JtK2JUSm51a3By + QmFyV1ZZNWI0OGJVM1NNZERjd2hWcDAKLS0tIG9VSVFTSTJBMjk5ZzBSL0ZQV2Ev + QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE + wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-04-21T12:42:15Z" + mac: ENC[AES256_GCM,data:fNip/7A7iKCVZqP0EziyBG7K8SVfRJTBpn4RcDLOaciJHx5DkLLszE8we9MmzpKXQIiMcJl2BTj/uqJrgc5EHTSOHwRzNJ4s2NJfvQW+8QUDfTGzKOkP3L837RkEPzH4HZLqGlfYK7cNJU5qXRPbusKjAft7Fz3+ONXmodb/ONY=,iv:CdSs1a+74+MfzWyML2JQ/b2IKbktVdefFFYP5LOtUos=,tag:ikr9LwPnmdiPucOoBt3/Bw==,type:str] pgp: - - created_at: "2026-04-18T20:12:39Z" + - created_at: "2026-04-21T06:39:49Z" enc: |- -----BEGIN PGP MESSAGE----- - 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 + hQIMAwdqopdXmgBkAQ//TzlOz/QYwiYAc6NGo2O8YJi5ERkS1+0qNpptD51g2dLF + V4iUx7400tc6IEEhZ0N54R7AO5mSX55XCWJxVQDTRJXmLDHcOR+9vThb4H571XBa + 3mcmE8Dj3sN3a1K2RwajZJXl1o5d1oNvWJ83pVsCnrJegi92+GmvmOt4QZ1l5aCf + TGYgUXAz1RreqsGKjJsSXscZOvRnp+cslJ9xY8OXeKLbQvLg0Z3pSQG2QGgDmHPD + fRxYnlc2lKe32uoBlD2LXK+NoBnrRYEVrrwGf6P5GpTDpJbc0bR5BiRIYDhPxtqK + SiXWHaebg73+kbWdcm+2kiac6hW6xW/iJL4eFBT1v/NgZmNoQCnJOIA7v2vjv9vl + 81Y1FM5MpIfwNiTwkJjVsgM2tHkANlbixBHJdbjlnKpo9pTS7RuttWtdCmFdmXr0 + oiuKDDRPVGvykPqvHzvCLf/k5j1nYvqvb7Wn2Bycc5kIOjFYEDEeM0r37vOX9nDM + SW1HtaWoZuVceTJEit0WR63kmXYLZ/AHvXcmq6ucUw8Fmw79n+7brQiX2RMtCK1E + pfrNey3EEqvPs2RDd6XdF4/73CdMDN5s3xiFAIfLGeZ6h0Eq27fazSZNmdh4MGYb + Wzj81ur8dimoSP+W9eW1TjIfY4deH5FRnN19ldKPuHdazvikWWsdN05evNlSZsDS + XgHafkhKiNSNZLw/VVzf+1SDLhN1H5QoxZ2YsxCc+psd5CFxU1x3llIDg4hXScAR + OQvRR1VjQOLFCwdFErW7sd6nQlkS7LnAskgT/0ZJGsxfkh1gJO3YqDnEKF7+P9w= + =zKa+ -----END PGP MESSAGE----- fp: 076AA297579A0064 unencrypted_suffix: _unencrypted