diff --git a/AGENTS.md b/AGENTS.md index 6f24ff0..42fd1c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # AGENTS.md 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). +entirely through NixOS. Services run as podman containers or native NixOS +services under systemd. Remote access is via Cloudflare Tunnel; local access +goes through Caddy with Let's Encrypt TLS (DNS-01, Cloudflare API). The original Kubernetes/Helm setup is preserved on the `main` branch. This branch (`nixos-port`) is the active NixOS port. @@ -20,14 +20,21 @@ modules/ caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth) cloudflared.nix # Cloudflare Tunnel for remote access backup.nix # Restic daily backups (S3 primary + manual offload) + monitoring.nix # Prometheus + Grafana (native NixOS services) services/ openldap.nix # OpenLDAP — central identity provider - authelia.nix # Authelia — SSO gateway + authelia.nix # Authelia — SSO gateway + accessControlRules option gitea.nix # Gitea — Git server + gitea-runner.nix # Gitea Actions runner 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) + jellyfin.nix # Jellyfin — media server (disabled) + transmission.nix # Transmission — torrent client (disabled) + uptime-kuma.nix # Uptime Kuma + homey.monitoring.monitors option + ntfy.nix # Ntfy — push notification server (native NixOS) + mealie.nix # Mealie — recipe manager + paperless.nix # Paperless-ngx — document management + eurovote.nix # Eurovision Vote — Django voting app hosts/ pi-main/ default.nix # Service selection + host-specific overrides @@ -42,14 +49,20 @@ PORTING.md # Step-by-step migration guide from the old Helm s All services live under `zakobar.com`. -| Service | URL | Auth | -|---------|-----|------| -| 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 | +| Service | URL | Auth | Runtime | +|---------|-----|------|---------| +| Authelia | `auth.zakobar.com` | Public (it is the auth portal) | container | +| Gitea | `git.zakobar.com` | Gitea-native (LDAP) | container | +| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native | container | +| Mealie | `mealie.zakobar.com` | Mealie-native (LDAP) | container | +| Paperless | `paperless.zakobar.com` | Authelia one_factor (SSO) | container | +| phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only | container | +| Uptime Kuma | `uptime.zakobar.com` | Authelia two_factor, admins only | container | +| Grafana | `grafana.zakobar.com` | Authelia two_factor, admins only | NixOS | +| Ntfy | `ntfy.zakobar.com` | Bypass (ntfy token/password auth) | NixOS | +| Eurovision Vote | `eurovision-vote.zakobar.com` | Authelia one_factor (`/admin` two_factor) | NixOS | +| Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native | container (disabled) | +| Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only | container (disabled) | ## Networking @@ -63,7 +76,16 @@ All containers join a private podman network named **`homey`**, created by the - **Defence in depth** — even if the firewall were misconfigured, services are not bound to `0.0.0.0`. -Internal ports (all mapped to `127.0.0.1` on the host): +Native NixOS services (not containers) listen on `127.0.0.1` directly: + +| Service | Host port | +|---------|-----------| +| ntfy | 2586 | +| Eurovision Vote | 8007 | +| Prometheus | 9090 | +| Grafana | 3002 | + +Container host-port mappings (all bound to `127.0.0.1`): | Container | Host port | Container port | |-----------|-----------|----------------| @@ -73,6 +95,10 @@ Internal ports (all mapped to `127.0.0.1` on the host): | nextcloud | 8080 | 80 | | nextcloud-postgres | 5432 | 5432 | | phpldapadmin | 8081 | 80 | +| uptime-kuma | 3001 | 3001 | +| mealie | 9093 | 9000 | +| paperless | 8083 | 8000 | +| paperless-redis | (internal only) | 6379 | | jellyfin | 8096 | 8096 | | transmission | 9092 | 9091 | @@ -87,22 +113,38 @@ 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 + 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 + 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 + uptime-kuma/ → /app/data + mealie/data/ → /app/data + paperless/ + data/ → /usr/src/paperless/data (DB, index) + media/ → /usr/src/paperless/media (document files) + consume/ → /usr/src/paperless/consume (drop folder) + export/ → /usr/src/paperless/export + ntfy/ + auth.db → ntfy user/token database (host path) + cache.db → ntfy message cache (host path) + attachments/ → file attachments (host path) + restic-cache/ → restic local cache ``` +Grafana and Prometheus use system state dirs (`/var/lib/grafana`, +`/var/lib/prometheus2`) and are not backed up — dashboards are provisioned by +Nix and metrics are ephemeral. + 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. +`homey.storage.device`. Use a `/dev/disk/by-label/` or `/dev/disk/by-id/` +path for stability. ## Build / Validate Commands @@ -160,7 +202,8 @@ restic password, Cloudflare tokens) can be generated fresh. ### Nix -1. **Module pattern** — every service is an opt-in module with an `enable` option: +1. **Module pattern** — every service is an opt-in module with an `enable` option + (defaulting to `false` for optional services): ```nix options.homey.myservice.enable = lib.mkEnableOption "My service"; config = lib.mkIf config.homey.myservice.enable { ... }; @@ -168,7 +211,7 @@ restic password, Cloudflare tokens) can be generated fresh. 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. + `homeyConfig` in every module. Do not hardcode domain/org strings. 3. **No secrets in the Nix store** — secrets are always read from sops-managed files at runtime, never embedded in the built config. Use @@ -176,7 +219,7 @@ restic password, Cloudflare tokens) can be generated fresh. 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`. + file at `/run/-secrets.env` and reference it via `environmentFiles`. Clean it up in `postStop`. 5. **`--network=homey`** — all containers join the private `homey` podman @@ -187,16 +230,47 @@ restic password, Cloudflare tokens) can be generated fresh. explicitly. The external HD mount unit is `mnt-data.mount`; containers that need storage must depend on it. +### Module Contribution Options + +Several cross-cutting concerns are wired up via list options that any service +module can append to, rather than editing central files: + +| Option | Declared in | Purpose | +|--------|-------------|---------| +| `homey.caddy.virtualHosts` | `caddy.nix` | Add a reverse-proxy vhost | +| `homey.storage.extraDirs` | `storage.nix` | Create tmpfiles dirs on the HD | +| `homey.backup.extraPaths` | `backup.nix` | Include a path in restic backups | +| `homey.monitoring.monitors` | `uptime-kuma.nix` | Add an Uptime Kuma HTTP monitor | +| `homey.authelia.accessControlRules` | `authelia.nix` | Add Authelia access-control rules | + +Each service module declares its own entries. No central file edits needed. + +**`homey.authelia.accessControlRules`** — each rule has: +- `priority` (int) — lower = earlier in the list. Authelia stops at the first + match, so more-specific rules (e.g. `subject: group:admins`) must precede + their catch-all counterparts. Assigned priority ranges by category: + - `0` — auth bypass (Authelia itself) + - `10–19` — blanket bypasses (e.g. ntfy) + - `20–49` — admin-only two_factor + deny pairs + - `50–64` — open one_factor services + - `65–79` — per-path rules (resources + subject combinations) +- `domain` (list of strings) +- `policy` — `bypass` | `one_factor` | `two_factor` | `deny` +- `subject` (optional list) — e.g. `[ "group:admins" ]` +- `resources` (optional list) — URL path regexes + ### Adding a New Service 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. +2. Import it in `flake.nix` (in the `modules` list inside `mkHost`). +3. Enable it in `hosts/pi-main/default.nix`. +4. Inside the module's `config = lib.mkIf cfg.enable { ... }` block: + - **Caddy**: add `homey.caddy.virtualHosts = [{ subdomain = "…"; port = …; auth = true/false; }]` + - **Storage**: add `homey.storage.extraDirs = [{ path = "…"; }]` for each HD directory + - **Backup**: add `homey.backup.extraPaths = [ "${dataDir}/…" ]` + - **Authelia**: add `homey.authelia.accessControlRules = [{ priority = …; domain = […]; policy = "…"; }]` + - **Monitoring**: add `homey.monitoring.monitors = [{ name = "…"; url = "…"; interval = 60; }]` +5. Add any new secrets to `secrets/secrets.yaml` and document them. ### Updating or Regenerating Secrets @@ -240,45 +314,48 @@ production-ready: 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` — must be an S3-compatible - URL, e.g. `"s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name"` +- [ ] **`monitoring.nix` — Grafana dashboard hash**: The Node Exporter Full + dashboard `fetchurl` hash is a placeholder. Run: + ```bash + nix store prefetch-file --hash-type sha256 \ + https://grafana.com/api/dashboards/1860/revisions/37/download + ``` + and replace the hash in `modules/monitoring.nix`. - [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret - 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. - -- [x] **`secrets/.sops.yaml` — PGP key**: The encryption subkey - `076AA297579A0064` is already in `.sops.yaml`. + values, then run `sops --encrypt --in-place secrets/secrets.yaml` before + committing. Secrets needed: + - From old k8s deployment: openldap passwords, gitea/nextcloud passwords + - Fresh: authelia JWT/session/encryption keys, gitea JWT tokens + - New services: `uptime-kuma/admin_password`, `ntfy/admin_password`, + `grafana/secret_key`, `ntfy/web_push_private_key` + - Backup: `restic/s3_access_key_id`, `restic/s3_secret_access_key` + - WiFi: `wifi/psk` - [ ] **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. + copy the tunnel token into secrets, and configure public hostnames for all + enabled services. See `modules/cloudflared.nix` for details. + +- [ ] **Cloudflare Tunnel — add new services**: After the initial tunnel is set + up, add public hostnames for: `uptime`, `ntfy`, `grafana`, `mealie`, + `paperless`, `eurovision-vote`. - [ ] **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: +- [ ] **Jellyfin and Transmission**: Both modules exist but are 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. + manually copying snapshots to a local disk. Uses `restic copy` to clone from + the S3 repo into a local restic repo. See `TODO.org` for design notes. -### Post- Pi first boot +### Post-Pi first boot These items require the Pi to be built, flashed, and booted at least once. @@ -293,7 +370,6 @@ These items require the Pi to be built, flashed, and booted at least once. - [ ] **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=zakobar,dc=com` @@ -302,3 +378,19 @@ These items require the Pi to be built, flashed, and booted at least once. - [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify the LDAP Users and Contacts app is still configured correctly (Admin → LDAP/AD Integration). + +- [ ] **Ntfy VAPID keys**: Generate Web Push keys on the Pi: + ```bash + sudo ntfy webpush keys + ``` + Set `homey.ntfy.webPushPublicKey` in `default.nix` and add the private key + to sops as `ntfy/web_push_private_key`. + +- [ ] **Uptime Kuma monitors**: On first boot, `uptime-kuma-sync` will + automatically create all monitors declared via `homey.monitoring.monitors`. + Verify they appear correctly in the UI at `https://uptime.zakobar.com`. + +- [ ] **Paperless admin token (iOS Shortcut)**: After first start, generate a + dedicated API token in the Paperless web UI (Profile → API Auth Token) for + the iOS Shortcut upload flow. The `/api/documents/post_document/` path + bypasses Authelia — the token is the only auth. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/flake.nix b/flake.nix index 45a06e5..0ca5440 100644 --- a/flake.nix +++ b/flake.nix @@ -78,6 +78,7 @@ ./modules/services/transmission.nix ./modules/services/gitea-runner.nix ./modules/services/paperless.nix + ./modules/services/attic.nix ./modules/services/mealie.nix ./modules/services/uptime-kuma.nix ./modules/services/ntfy.nix diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 5d86ef7..85d0ff9 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -96,6 +96,13 @@ homey.caddy.enable = true; homey.cloudflared.enable = true; + # Nix binary cache + homey.attic.enable = true; + nix.settings = { + substituters = lib.mkAfter [ "https://attic.zakobar.com/main" ]; + trusted-public-keys = lib.mkAfter [ "main:9SZt/6plBU7jjQzz90J7O011I13hmJvOMYouxNqExNQ=" ]; + }; + # CI/CD homey.giteaRunner.enable = true; diff --git a/modules/monitoring.nix b/modules/monitoring.nix index ed06475..96ef54a 100644 --- a/modules/monitoring.nix +++ b/modules/monitoring.nix @@ -205,6 +205,14 @@ in mode = "0444"; }; + # ----------------------------------------------------------------------- + # Authelia access control — admins only, two_factor; all others denied. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 35; domain = [ "grafana.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; } + { priority = 36; domain = [ "grafana.${domain}" ]; policy = "deny"; } + ]; + # ----------------------------------------------------------------------- # Caddy virtual host — forward_auth; Caddy maps Remote-User → X-WEBAUTH-USER # so Grafana's proxy auth auto-signs the user in diff --git a/modules/services/attic-setup.md b/modules/services/attic-setup.md new file mode 100644 index 0000000..86ea73a --- /dev/null +++ b/modules/services/attic-setup.md @@ -0,0 +1,160 @@ +# Attic — Post-Deployment Setup + +Steps to run once after the first `nixos-rebuild switch` with `homey.attic.enable = true`. + +**Status as of 2026-05-30:** all steps complete. Cache `main` is live at +`https://attic.zakobar.com/main`. Lauretta is logged in and can push/pull. + +--- + +## Known values + +| Item | Value | +|------|-------| +| Server URL | `https://attic.zakobar.com` | +| Cache name | `main` | +| Binary cache endpoint | `https://attic.zakobar.com/main` | +| Public signing key | `main:9SZt/6plBU7jjQzz90J7O011I13hmJvOMYouxNqExNQ=` | +| Cache visibility | Private (token required to pull) | +| GC retention | 90 days | +| Attic login (lauretta) | `~/.config/attic/config.toml` → server `homey` | + +--- + +## Token reference + +Tokens are stateless signed JWTs — the server does not store them. If you lose +one, regenerate it with the same command; it will work identically to the original. + +### Admin token (full access) + +```bash +ssh admin@192.168.1.100 \ + "sudo podman exec attic atticadm -f /etc/attic/server.toml make-token \ + --sub admin \ + --validity '10y' \ + --pull '*' \ + --push '*' \ + --delete '*' \ + --create-cache '*' \ + --configure-cache '*' \ + --configure-cache-retention '*' \ + --destroy-cache '*'" +``` + +### Pull-only token (for non-admin clients) + +```bash +ssh admin@192.168.1.100 \ + "sudo podman exec attic atticadm -f /etc/attic/server.toml make-token \ + --sub nixos-client \ + --validity '10y' \ + --pull '*'" +``` + +### Push-only token (e.g. for CI) + +```bash +ssh admin@192.168.1.100 \ + "sudo podman exec attic atticadm -f /etc/attic/server.toml make-token \ + --sub ci \ + --validity '10y' \ + --push 'main'" +``` + +--- + +## Configuring a new client machine + +### 1. Add to `~/.config/nix/nix.conf` + +``` +extra-substituters = https://attic.zakobar.com/main +extra-trusted-public-keys = main:9SZt/6plBU7jjQzz90J7O011I13hmJvOMYouxNqExNQ= +``` + +### 2. Add pull token to `~/.netrc` + +Generate a pull-only token (see above), then append to `~/.netrc`: + +``` +machine attic.zakobar.com + login token + password +``` + +### 3. Log in for pushing (optional) + +```bash +nix run github:zhaofengli/attic -- login homey https://attic.zakobar.com +``` + +### 4. Verify + +```bash +nix store ping --store https://attic.zakobar.com/main +``` + +--- + +## Pushing builds + +```bash +# Push a specific path and its closure +nix run github:zhaofengli/attic -- push homey:main + +# Push the current system closure +nix run github:zhaofengli/attic -- push homey:main /run/current-system + +# Push after a nix build +nix build .#nixosConfigurations.pi-main.config.system.build.toplevel +nix run github:zhaofengli/attic -- push homey:main ./result + +# Watch the store and push all new paths as they are built +nix run github:zhaofengli/attic -- watch-store homey:main +``` + +Paths already signed by `cache.nixos.org` are skipped automatically. + +--- + +## Monitoring + +- **Uptime Kuma**: monitor configured automatically via the NixOS module (5 min interval) +- **Disk usage**: `ssh admin@192.168.1.100 "du -sh /mnt/data/attic/"` +- **Grafana**: node exporter tracks `/mnt/data` filesystem usage +- **Logs**: `ssh admin@192.168.1.100 "journalctl -u podman-attic -n 50"` + +### Manual GC + +```bash +ssh admin@192.168.1.100 \ + "sudo podman exec attic atticadm -f /etc/attic/server.toml run-gc" +``` + +--- + +## Signing key rotation + +If the signing key is ever compromised or needs rotating: + +```bash +nix run github:zhaofengli/attic -- cache configure homey:main --regenerate-keypair +nix run github:zhaofengli/attic -- cache info homey:main # get new public key +``` + +Then update `trusted-public-keys` in `hosts/pi-main/default.nix` and on all client machines. + +--- + +## Initial setup steps (completed 2026-05-30) + +For reference — these were run once during first deployment. + +1. Deployed NixOS config with `homey.attic.enable = true` +2. Added `attic.zakobar.com` to Cloudflare Tunnel dashboard +3. Generated admin token via `atticadm` inside container +4. Logged in: `attic login homey https://attic.zakobar.com ` +5. Created cache: `attic cache create homey:main` (Attic generates signing key server-side) +6. Added public key and substituter to `hosts/pi-main/default.nix` +7. Configured lauretta: `~/.config/nix/nix.conf` + `~/.netrc` diff --git a/modules/services/attic.nix b/modules/services/attic.nix new file mode 100644 index 0000000..15d906f --- /dev/null +++ b/modules/services/attic.nix @@ -0,0 +1,166 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Attic — self-hosted Nix binary cache (cachix alternative). +# +# Auth model: JWT token-based. No Authelia forward_auth — Attic manages its +# own token issuance and verification. Use `attic make-token` to create tokens. +# Push requires a write-scoped token; pull visibility is per-cache (public or +# token-gated, configurable via `attic cache configure` after first deploy). +# +# Volume layout: +# /attic/ → /data (SQLite DB) +# /attic/cache/ → /data/cache (content-addressed NAR store) +# +# NOT backed up: NAR content is fully reproducible from source. +# +# Secrets consumed from sops: +# attic/jwt_secret (base64-encoded HS256 secret for JWT token signing) +# attic/pull_token (JWT with pull:* scope — used by the local Nix daemon) +# +# See attic-setup.md for post-deploy steps and token generation commands. + +let + cfg = config.homey.attic; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; +in +{ + options.homey.attic = { + enable = lib.mkEnableOption "Attic Nix binary cache"; + + image = lib.mkOption { + type = lib.types.str; + default = "ghcr.io/zhaofengli/attic:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8200; + description = "Host port Attic listens on (bound to 127.0.0.1)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Secrets + # ----------------------------------------------------------------------- + sops.secrets."attic/jwt_secret" = { owner = "root"; }; + sops.secrets."attic/pull_token" = { owner = "root"; }; + + # ----------------------------------------------------------------------- + # Container + # If the container fails to start, check the expected config path with: + # podman inspect ghcr.io/zhaofengli/attic:latest | jq '.[].Config.Cmd' + # and adjust `cmd` below accordingly. + # ----------------------------------------------------------------------- + virtualisation.oci-containers.containers.attic = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:8080" ]; + + cmd = [ "--config" "/etc/attic/server.toml" ]; + + volumes = [ + "${dataDir}/attic:/data" + "/run/attic-config.toml:/etc/attic/server.toml:ro" + ]; + + extraOptions = [ "--network=homey" ]; + }; + + # ----------------------------------------------------------------------- + # ExecStartPre: write ephemeral TOML config with JWT secret interpolated + # ----------------------------------------------------------------------- + systemd.services."podman-attic" = { + serviceConfig = { + ExecStartPre = [ + (pkgs.writeShellScript "attic-write-config" '' + set -euo pipefail + JWT=$(cat ${config.sops.secrets."attic/jwt_secret".path}) + install -m 600 /dev/null /run/attic-config.toml + printf '%s\n' \ + 'listen = "0.0.0.0:8080"' \ + "" \ + '[jwt.signing]' \ + "token-hs256-secret-base64 = \"$JWT\"" \ + "" \ + '[database]' \ + 'url = "sqlite:///data/server.db?mode=rwc"' \ + "" \ + '[storage]' \ + 'type = "local"' \ + 'path = "/data/cache"' \ + "" \ + '[chunking]' \ + 'nar-size-threshold = 65536' \ + 'min-size = 16384' \ + 'avg-size = 65536' \ + 'max-size = 262144' \ + "" \ + '[garbage-collection]' \ + 'default-retention-period = "90 days"' \ + "" \ + '[compression]' \ + 'type = "zstd"' \ + 'level = 8' \ + >> /run/attic-config.toml + '') + ]; + }; + postStop = "rm -f /run/attic-config.toml"; + after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + }; + + # ----------------------------------------------------------------------- + # Caddy virtual host — no forward_auth; Attic handles its own auth + # ----------------------------------------------------------------------- + homey.caddy.virtualHosts = [{ + subdomain = "attic"; + port = cfg.port; + auth = false; + }]; + + # ----------------------------------------------------------------------- + # Storage directories (not backed up — no backup.extraPaths entry) + # ----------------------------------------------------------------------- + homey.storage.extraDirs = [ + { path = "attic"; } + { path = "attic/cache"; mode = "0755"; } + ]; + + # ----------------------------------------------------------------------- + # Nix daemon pull auth + # Writes a netrc file from the pull token so the system Nix daemon (and + # anything using it, e.g. the Gitea runner) can fetch from the private cache. + # ----------------------------------------------------------------------- + systemd.services.attic-nix-netrc = { + description = "Write Attic pull token to netrc for Nix daemon"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "attic-write-netrc" '' + set -euo pipefail + TOKEN=$(cat ${config.sops.secrets."attic/pull_token".path}) + install -m 600 /dev/null /run/attic-netrc + printf 'machine attic.${domain}\n login token\n password %s\n' "$TOKEN" \ + > /run/attic-netrc + ''; + }; + postStop = "rm -f /run/attic-netrc"; + }; + + nix.extraOptions = '' + netrc-file = /run/attic-netrc + ''; + + # ----------------------------------------------------------------------- + # Uptime Kuma monitor + # ----------------------------------------------------------------------- + homey.monitoring.monitors = [{ + name = "Attic"; + url = "https://attic.${domain}"; + interval = 300; + }]; + }; +} diff --git a/modules/services/authelia.nix b/modules/services/authelia.nix index ed5cafc..7b6cd7a 100644 --- a/modules/services/authelia.nix +++ b/modules/services/authelia.nix @@ -17,6 +17,10 @@ # authelia/session_secret # authelia/storage_encryption_key # openldap/ro_password (shared with openldap module) +# +# Access control rules are NOT declared here. Each service module contributes +# its own rules via homey.authelia.accessControlRules, which are sorted by +# priority and merged into the final config at build time. let cfg = config.homey.authelia; @@ -27,9 +31,29 @@ let ldapBaseDN = lib.concatStringsSep "," (map (p: "dc=${p}") (lib.splitString "." domain)); + # Render a single access_control rule attrset to a YAML list item. + # Indented for insertion into the access_control.rules block (4 spaces + # before "- domain:", matching the 2-space indent of "rules:"). + renderRule = rule: + let + domainLines = lib.concatMapStringsSep "\n" (d: " - \"${d}\"") rule.domain; + subjectBlock = lib.optionalString (rule.subject != []) ( + "\n subject:\n" + + lib.concatMapStringsSep "\n" (s: " - \"${s}\"") rule.subject + ); + resourcesBlock = lib.optionalString (rule.resources != []) ( + "\n resources:\n" + + lib.concatMapStringsSep "\n" (r: " - \"${r}\"") rule.resources + ); + in + " - domain:\n${domainLines}${subjectBlock}${resourcesBlock}\n policy: \"${rule.policy}\"\n"; + + sortedRules = lib.sort (a: b: a.priority < b.priority) cfg.accessControlRules; + rulesYaml = lib.concatStrings (map renderRule sortedRules); + # 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). + # runtime via environment variables. autheliaConfig = '' ############################################################### # Authelia configuration # @@ -79,75 +103,7 @@ let 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" - - domain: - - "uptime.${domain}" - subject: - - "group:admins" - policy: "two_factor" - - domain: - - "uptime.${domain}" - policy: "deny" - - domain: - - "grafana.${domain}" - subject: - - "group:admins" - policy: "two_factor" - - domain: - - "grafana.${domain}" - policy: "deny" - # ntfy: bypass — ntfy enforces its own token/password auth; - # the mobile app must be able to connect without Authelia SSO. - - domain: - - "ntfy.${domain}" - policy: "bypass" - # Eurovision Vote: /admin/* for admins only; all others one_factor - - domain: - - "eurovision-vote.${domain}" - resources: - - "^/admin.*$" - subject: - - "group:admins" - policy: "two_factor" - - domain: - - "eurovision-vote.${domain}" - resources: - - "^/admin.*$" - policy: "deny" - - domain: - - "eurovision-vote.${domain}" - policy: "one_factor" - - domain: - - "paperless.${domain}" - policy: "one_factor" - + ${rulesYaml} notifier: filesystem: filename: "/config/emails.txt" @@ -163,6 +119,40 @@ let in { options.homey.authelia = { + # Declared unconditionally so any service module can contribute rules + # even when Authelia itself is disabled. + accessControlRules = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + priority = lib.mkOption { + type = lib.types.int; + default = 100; + description = "Order within access_control.rules — lower values appear first. Authelia evaluates rules top-to-bottom and stops at the first match."; + }; + domain = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = "Domain glob(s) this rule matches."; + }; + policy = lib.mkOption { + type = lib.types.enum [ "bypass" "one_factor" "two_factor" "deny" ]; + description = "Authelia policy applied when the rule matches."; + }; + subject = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Optional subject constraints (e.g. \"group:admins\")."; + }; + resources = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Optional URL path regex constraints."; + }; + }; + }); + default = []; + description = "Access control rules contributed by service modules. Merged and sorted by priority at build time."; + }; + enable = lib.mkEnableOption "Authelia SSO gateway" // { default = true; }; image = lib.mkOption { @@ -178,6 +168,15 @@ in }; config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Authelia's own bypass rule — must be first so the login UI is reachable. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 0; + domain = [ "auth.${domain}" ]; + policy = "bypass"; + }]; + # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- diff --git a/modules/services/eurovote.nix b/modules/services/eurovote.nix index 200b931..07294af 100644 --- a/modules/services/eurovote.nix +++ b/modules/services/eurovote.nix @@ -12,7 +12,7 @@ # Authentication: Caddy forward_auth → Authelia; the app reads the # X-Remote-User header set by Caddy (from Authelia's Remote-User). # All authenticated users get app access; /admin/* is restricted to -# group:admins by Authelia's access_control rules (see authelia.nix). +# group:admins by Authelia's access_control rules (defined in this file). # # Secrets consumed from sops: # eurovote/secret_key @@ -48,6 +48,16 @@ in logoutRedirectUrl = "https://auth.${domain}/logout"; }; + # ----------------------------------------------------------------------- + # Authelia access control — /admin/* requires two_factor + admins group; + # all other paths require one_factor. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 65; domain = [ "eurovision-vote.${domain}" ]; resources = [ "^/admin.*$" ]; subject = [ "group:admins" ]; policy = "two_factor"; } + { priority = 66; domain = [ "eurovision-vote.${domain}" ]; resources = [ "^/admin.*$" ]; policy = "deny"; } + { priority = 67; domain = [ "eurovision-vote.${domain}" ]; policy = "one_factor"; } + ]; + # ----------------------------------------------------------------------- # Caddy virtual host — forward_auth; X-Remote-User passed to Django's # RemoteUserMiddleware for automatic SSO login diff --git a/modules/services/gitea.nix b/modules/services/gitea.nix index 57b0054..c15fcbd 100644 --- a/modules/services/gitea.nix +++ b/modules/services/gitea.nix @@ -188,6 +188,17 @@ in requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; }; + # ----------------------------------------------------------------------- + # Authelia access control — one_factor for all authenticated users. + # Caddy does not apply forward_auth (git clients can't handle SSO redirects) + # but the rule is here for completeness/Cloudflare Tunnel path. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 50; + domain = [ "git.${domain}" ]; + policy = "one_factor"; + }]; + # ----------------------------------------------------------------------- # Caddy virtual host — no forward_auth; git clients can't handle SSO redirects # ----------------------------------------------------------------------- diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index acd3270..7d27152 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -52,6 +52,15 @@ in requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; }; + # ----------------------------------------------------------------------- + # Authelia access control — one_factor; Jellyfin has its own login UI. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 60; + domain = [ "jellyfin.${domain}" ]; + policy = "one_factor"; + }]; + # ----------------------------------------------------------------------- # Caddy virtual host — no forward_auth; Jellyfin has its own login UI # ----------------------------------------------------------------------- diff --git a/modules/services/mealie.nix b/modules/services/mealie.nix index c4ab44b..53013eb 100644 --- a/modules/services/mealie.nix +++ b/modules/services/mealie.nix @@ -11,6 +11,7 @@ # # Secrets consumed from sops: # mealie/secret_key +# openldap/ro_password (shared with openldap module — used as LDAP_QUERY_PASSWORD) let cfg = config.homey.mealie; @@ -41,7 +42,8 @@ in # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- - sops.secrets."mealie/secret_key" = { owner = "root"; }; + sops.secrets."mealie/secret_key" = { owner = "root"; }; + sops.secrets."openldap/ro_password" = { owner = "root"; }; # ----------------------------------------------------------------------- # Container @@ -55,12 +57,14 @@ in ALLOW_SIGNUP = "false"; TZ = homeyConfig.timezone; - # LDAP auth — users log in with their LDAP uid and password. - # Mealie binds directly as the user (no service account needed). + # LDAP auth — Mealie binds as the readonly service account to search, + # then re-binds as the user to verify the password. + # LDAP_QUERY_PASSWORD is injected via the secrets env file. LDAP_AUTH_ENABLED = "true"; LDAP_SERVER_URL = "ldap://openldap:389"; LDAP_ENABLE_STARTTLS = "false"; LDAP_BASE_DN = "ou=users,${ldapBaseDn}"; + LDAP_QUERY_BIND = "cn=readonly,${ldapBaseDn}"; LDAP_BIND_TEMPLATE = "uid={username},ou=users,${ldapBaseDn}"; LDAP_ID_ATTRIBUTE = "uid"; LDAP_NAME_ATTRIBUTE = "cn"; @@ -87,6 +91,7 @@ in install -m 600 /dev/null /run/mealie-secrets.env printf '%s\n' \ "SECRET_KEY=$(cat ${config.sops.secrets."mealie/secret_key".path})" \ + "LDAP_QUERY_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})" \ >> /run/mealie-secrets.env '') ]; diff --git a/modules/services/nextcloud.nix b/modules/services/nextcloud.nix index d9df1b6..c398a6e 100644 --- a/modules/services/nextcloud.nix +++ b/modules/services/nextcloud.nix @@ -166,6 +166,15 @@ in ]; }; + # ----------------------------------------------------------------------- + # Authelia access control — one_factor; Nextcloud manages its own login UI. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 55; + domain = [ "nextcloud.${domain}" ]; + policy = "one_factor"; + }]; + # ----------------------------------------------------------------------- # Caddy virtual host — no forward_auth; Nextcloud manages its own auth # ----------------------------------------------------------------------- diff --git a/modules/services/ntfy.nix b/modules/services/ntfy.nix index 3c938c1..172fa5b 100644 --- a/modules/services/ntfy.nix +++ b/modules/services/ntfy.nix @@ -176,6 +176,16 @@ in }; }; + # ----------------------------------------------------------------------- + # Authelia access control — bypass so the mobile app can connect without + # an Authelia session; ntfy enforces its own token/password auth. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 10; + domain = [ "ntfy.${domain}" ]; + policy = "bypass"; + }]; + # ----------------------------------------------------------------------- # Caddy virtual host — no forward_auth; ntfy uses its own token auth # ----------------------------------------------------------------------- diff --git a/modules/services/paperless.nix b/modules/services/paperless.nix index a1b9cd4..addba6f 100644 --- a/modules/services/paperless.nix +++ b/modules/services/paperless.nix @@ -12,6 +12,12 @@ # # Requires a Redis sidecar for Celery task workers. # +# iOS Shortcut upload: POST /api/documents/post_document/ with +# Authorization: Token . Generate a dedicated token in the Paperless +# web UI (Profile → API Auth Token) and use it only for the Shortcut so it +# can be revoked independently. The /api/documents/post_document/ path bypasses +# Authelia (see accessControlRules below) — all other paths remain behind one_factor. +# # Volume layout: # /paperless/data/ → /usr/src/paperless/data (DB, index) # /paperless/media/ → /usr/src/paperless/media (document files) @@ -124,6 +130,16 @@ in requires = lib.mkAfter [ "mnt-data.mount" "podman-paperless-redis.service" "podman-homey-network.service" ]; }; + # ----------------------------------------------------------------------- + # Authelia access control — bypass the upload API so token-authenticated + # clients (e.g. iOS Shortcut) can POST without an Authelia session; + # all other paths require one_factor. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 70; domain = [ "paperless.${domain}" ]; resources = [ "^/api/documents/post_document/$" ]; policy = "bypass"; } + { priority = 71; domain = [ "paperless.${domain}" ]; policy = "one_factor"; } + ]; + # ----------------------------------------------------------------------- # Caddy virtual host — forward_auth; Remote-User passed to Paperless for SSO # ----------------------------------------------------------------------- diff --git a/modules/services/phpldapadmin.nix b/modules/services/phpldapadmin.nix index 61f1b90..0ab3ab1 100644 --- a/modules/services/phpldapadmin.nix +++ b/modules/services/phpldapadmin.nix @@ -3,7 +3,7 @@ # phpLDAPadmin — web UI for OpenLDAP management. # # Stateless container (no persistent volumes needed). -# Protected by Authelia two_factor, admins-only policy (defined in authelia.nix). +# Protected by Authelia two_factor, admins-only policy. # Bound to localhost:8081; Caddy reverse-proxies it. # # Networking: uses default bridge (podman) network with a port mapping @@ -12,7 +12,8 @@ # host.containers.internal DNS name that podman injects automatically. let - cfg = config.homey.phpldapadmin; + cfg = config.homey.phpldapadmin; + domain = homeyConfig.domain; in { options.homey.phpldapadmin = { @@ -50,6 +51,14 @@ in wants = lib.mkAfter [ "podman-openldap.service" "podman-homey-network.service" ]; }; + # ----------------------------------------------------------------------- + # Authelia access control — admins only, two_factor; all others denied. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 20; domain = [ "ldapadmin.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; } + { priority = 21; domain = [ "ldapadmin.${domain}" ]; policy = "deny"; } + ]; + # ----------------------------------------------------------------------- # Caddy virtual host — forward_auth + reverse_proxy # ----------------------------------------------------------------------- diff --git a/modules/services/transmission.nix b/modules/services/transmission.nix index d51a6c7..cecc725 100644 --- a/modules/services/transmission.nix +++ b/modules/services/transmission.nix @@ -15,6 +15,7 @@ let cfg = config.homey.transmission; dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; in { options.homey.transmission = { @@ -61,6 +62,14 @@ in requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; }; + # ----------------------------------------------------------------------- + # Authelia access control — admins only, two_factor; all others denied. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 30; domain = [ "torrent.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; } + { priority = 31; domain = [ "torrent.${domain}" ]; policy = "deny"; } + ]; + # ----------------------------------------------------------------------- # Caddy virtual host — forward_auth, admins only # ----------------------------------------------------------------------- diff --git a/modules/services/uptime-kuma.nix b/modules/services/uptime-kuma.nix index b36644a..20ec519 100644 --- a/modules/services/uptime-kuma.nix +++ b/modules/services/uptime-kuma.nix @@ -285,6 +285,14 @@ in }; }; + # ----------------------------------------------------------------------- + # Authelia access control — admins only, two_factor; all others denied. + # ----------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 25; domain = [ "uptime.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; } + { priority = 26; domain = [ "uptime.${domain}" ]; policy = "deny"; } + ]; + # ----------------------------------------------------------------------- # Caddy virtual host — forward_auth, admins only # ----------------------------------------------------------------------- diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index 6d114e7..e487fdf 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -37,6 +37,9 @@ paperless: secret_key: ENC[AES256_GCM,data:jHbyLh4Yn0v7huw9oJiytMJ5KjifmEFsWh3u+YyOTlnm/M313dAigZItcX860oFVtZ8zZcuelUVAjcmIcl1LYw==,iv:PJhyXWa4r99dIXuKrEF+2wF9O8GEHIK8ereNQiXzO3Q=,tag:qDcPs3ulzjdQ2EUibo1Nlw==,type:str] mealie: secret_key: ENC[AES256_GCM,data:AmtyMMK2RMOy//o9G974wn5IcgZaqAn97OyNaY1AlMc5cCoydZhdAXymQ4RR8opWd+Oelx7vRcSscGJ0hTGakg==,iv:QH+iIbMoD33MAUraMTyuGghaWdjRBhypP9UEcEr9bL4=,tag:uHGW9OLqrDhRy+mnlfRmQA==,type:str] +attic: + jwt_secret: ENC[AES256_GCM,data:6g1wDau2rEqrmirzamrE6q0Sf38tosCp7EM0EtMLHXANoEfUdK8aL2Jo6z+tWL5bhNTkHwOl55j2mbyUWDlFN3I9vtI9uPKjlP+SgGbSJoKv++UYIhBmcg==,iv:DBgrMPQG/V9g0vG6Ax/fb1xCpvTYSfvAhqojH84wgn8=,tag:9WJjMFuo9kSfxRI9DVpdlg==,type:str] + pull_token: ENC[AES256_GCM,data:FDMRf8El1APXdE1+CraGDKBk9PvAnLFNL9YqvDA++5keV/M7ynAdvAhzJV1dkQ2PcRJKalAkWY0zkoQsXzmWRdY/30WzhHa60GPRRfdX4Bc1N2DqK9mFfO4eWFSBRF5EgZkqWJ+XcijiKHTr3W6MNt8oD+YQ6XkKLvRs6tOep085g2ZdK9jmaQnWTsFMhYmUt//THscDPBq8Jh81Uh2WcLJYB4hEGxxIZZtsbdK6AsRjlMsxkzr+W4kwVKs8aGjqJ5LvUOCHPGY9DvdGtWMMvMs9aw20b05ViuKzemMfDd0=,iv:CzzhhbYtJhtrAIMkERGim+j0pvC5anHVwguV//VrJRQ=,tag:6uGz1f1w76Bk8bbZItYzDg==,type:str] sops: age: - recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p @@ -48,8 +51,8 @@ sops: QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-05-20T19:56:00Z" - mac: ENC[AES256_GCM,data:i/uXzipvkadRGHxj7sk593SyALHVdv8wjH74xBduCI3y1cgsMYhAzH2+zY2N6BZ2ymrrcEI1+bVr2JAsCRASZ62dKEc3+m9H0+6ydpb5hl9kK7fLpuxy2nntMhTPnHznquysF4cRZoZeUJ0bDudo8mnog7GYoDI8LUvqEXZR/M8=,iv:X5WugE4STQEZHhaq6OzJLpXUgk+imbZNLZnXl5J1jUw=,tag:IDmsFOJEikpQKqi97ZKngg==,type:str] + lastmodified: "2026-05-30T09:31:03Z" + mac: ENC[AES256_GCM,data:Mnu3wtu6gfGWtU+03KyTKa9n0uWsRCISRZJcZaF2n9wCD/GDikqUX6QFFZcHHoablXEqN6yu5u0wc7efX80PCnDlkr8C0gQF3i9+p9Kj+i+pfguG47sfqP3ITXIjJpwwZwiFlbCJ/Hj3bpIpUCwr3gb6KQjQZ2bm7SGDlNeV9Ys=,iv:SbFCyuMKaYA3yKvh/DcslA98/cBXTBI7sn3TJ3RZ+y4=,tag:Eh9kMKe4pT8H9O1UZWaTRA==,type:str] pgp: - created_at: "2026-04-21T06:39:49Z" enc: |- diff --git a/shells/defaultShell.nix b/shells/defaultShell.nix index e07d0d4..461ee04 100644 --- a/shells/defaultShell.nix +++ b/shells/defaultShell.nix @@ -3,6 +3,7 @@ pkgs.mkShell { buildInputs = with pkgs; [ alejandra sops + openssl (pkgs.writeShellScriptBin "homey-deploy-rpi-main" '' nixos-rebuild switch \ --flake .#pi-main \