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