Compare commits
12 Commits
5e82ca5fe0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 261cf892dd | |||
| 08e8b5edbe | |||
| 171ff2f3bc | |||
| 42d91012c1 | |||
| d2793904f4 | |||
| 09052e8aec | |||
| af744e819c | |||
| 0e54760e34 | |||
| d6aa39ff04 | |||
| d49f0161ca | |||
| a7099e7d56 | |||
| 5e8d5f575a |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
charts
|
charts
|
||||||
*.lock
|
|
||||||
.agent-shell
|
.agent-shell
|
||||||
result
|
result
|
||||||
|
.direnv
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed
|
Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed
|
||||||
entirely through NixOS. Services run as podman containers under systemd.
|
entirely through NixOS. Services run as podman containers or native NixOS
|
||||||
Remote access is via Cloudflare Tunnel; local access goes through Caddy
|
services under systemd. Remote access is via Cloudflare Tunnel; local access
|
||||||
with Let's Encrypt TLS (DNS-01, Cloudflare API).
|
goes through Caddy with Let's Encrypt TLS (DNS-01, Cloudflare API).
|
||||||
|
|
||||||
The original Kubernetes/Helm setup is preserved on the `main` branch.
|
The original Kubernetes/Helm setup is preserved on the `main` branch.
|
||||||
This branch (`nixos-port`) is the active NixOS port.
|
This branch (`nixos-port`) is the active NixOS port.
|
||||||
@@ -20,14 +20,21 @@ modules/
|
|||||||
caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth)
|
caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth)
|
||||||
cloudflared.nix # Cloudflare Tunnel for remote access
|
cloudflared.nix # Cloudflare Tunnel for remote access
|
||||||
backup.nix # Restic daily backups (S3 primary + manual offload)
|
backup.nix # Restic daily backups (S3 primary + manual offload)
|
||||||
|
monitoring.nix # Prometheus + Grafana (native NixOS services)
|
||||||
services/
|
services/
|
||||||
openldap.nix # OpenLDAP — central identity provider
|
openldap.nix # OpenLDAP — central identity provider
|
||||||
authelia.nix # Authelia — SSO gateway
|
authelia.nix # Authelia — SSO gateway + accessControlRules option
|
||||||
gitea.nix # Gitea — Git server
|
gitea.nix # Gitea — Git server
|
||||||
|
gitea-runner.nix # Gitea Actions runner
|
||||||
nextcloud.nix # Nextcloud + PostgreSQL
|
nextcloud.nix # Nextcloud + PostgreSQL
|
||||||
phpldapadmin.nix # phpLDAPadmin — LDAP web UI
|
phpldapadmin.nix # phpLDAPadmin — LDAP web UI
|
||||||
jellyfin.nix # Jellyfin — media server (disabled by default)
|
jellyfin.nix # Jellyfin — media server (disabled)
|
||||||
transmission.nix # Transmission — torrent client (disabled by default)
|
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/
|
hosts/
|
||||||
pi-main/
|
pi-main/
|
||||||
default.nix # Service selection + host-specific overrides
|
default.nix # Service selection + host-specific overrides
|
||||||
@@ -42,27 +49,62 @@ PORTING.md # Step-by-step migration guide from the old Helm s
|
|||||||
|
|
||||||
All services live under `zakobar.com`.
|
All services live under `zakobar.com`.
|
||||||
|
|
||||||
| Service | URL | Auth |
|
| Service | URL | Auth | Runtime |
|
||||||
|---------|-----|------|
|
|---------|-----|------|---------|
|
||||||
| Authelia | `auth.zakobar.com` | Public (it is the auth portal) |
|
| Authelia | `auth.zakobar.com` | Public (it is the auth portal) | container |
|
||||||
| Gitea | `git.zakobar.com` | Gitea-native (LDAP) |
|
| Gitea | `git.zakobar.com` | Gitea-native (LDAP) | container |
|
||||||
| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native |
|
| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native | container |
|
||||||
| phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only |
|
| Mealie | `mealie.zakobar.com` | Mealie-native (LDAP) | container |
|
||||||
| Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native |
|
| Paperless | `paperless.zakobar.com` | Authelia one_factor (SSO) | container |
|
||||||
| Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only |
|
| 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) |
|
||||||
|
|
||||||
Internal ports (all bound to `127.0.0.1`):
|
## Networking
|
||||||
|
|
||||||
| Container | Port |
|
All containers join a private podman network named **`homey`**, created by the
|
||||||
|-----------|------|
|
`podman-homey-network` systemd service in `common.nix`. This provides:
|
||||||
| openldap | 389 |
|
|
||||||
| authelia | 9091 |
|
- **DNS isolation** — containers reach each other by name (e.g. `openldap`,
|
||||||
| gitea | 3000 |
|
`nextcloud-postgres`) without being exposed on the host network.
|
||||||
| nextcloud | 8080 |
|
- **No port conflicts** — Caddy owns host ports 80/443; service containers map
|
||||||
| nextcloud-postgres | 5432 |
|
only to `127.0.0.1:<port>`.
|
||||||
| phpldapadmin | 8081 |
|
- **Defence in depth** — even if the firewall were misconfigured, services are
|
||||||
| jellyfin | 8096 |
|
not bound to `0.0.0.0`.
|
||||||
| transmission | 9092 (not 9091 — avoids clash with authelia) |
|
|
||||||
|
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 |
|
||||||
|
|-----------|-----------|----------------|
|
||||||
|
| openldap | 389 | 389 |
|
||||||
|
| authelia | 9091 | 9091 |
|
||||||
|
| gitea | 3000 | 3000 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
Inter-container communication uses container names on the `homey` network
|
||||||
|
(e.g. authelia → `ldap://openldap:389`, nextcloud → `nextcloud-postgres:5432`).
|
||||||
|
Caddy (running on the host) proxies via `127.0.0.1:<host port>`.
|
||||||
|
|
||||||
## Storage Layout
|
## Storage Layout
|
||||||
|
|
||||||
@@ -82,11 +124,27 @@ All persistent data lives on the external HD at `/mnt/data/`:
|
|||||||
jellyfin/config/ → /config
|
jellyfin/config/ → /config
|
||||||
media/movies|tvshows|... → shared media (read-only to jellyfin)
|
media/movies|tvshows|... → shared media (read-only to jellyfin)
|
||||||
transmission/config/ → /config
|
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
|
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/<name>/default.nix` via
|
The drive device path is set per-host in `hosts/<name>/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
|
## Build / Validate Commands
|
||||||
|
|
||||||
@@ -144,7 +202,8 @@ restic password, Cloudflare tokens) can be generated fresh.
|
|||||||
|
|
||||||
### Nix
|
### 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
|
```nix
|
||||||
options.homey.myservice.enable = lib.mkEnableOption "My service";
|
options.homey.myservice.enable = lib.mkEnableOption "My service";
|
||||||
config = lib.mkIf config.homey.myservice.enable { ... };
|
config = lib.mkIf config.homey.myservice.enable { ... };
|
||||||
@@ -152,7 +211,7 @@ restic password, Cloudflare tokens) can be generated fresh.
|
|||||||
|
|
||||||
2. **`homeyConfig` specialArgs** — top-level site config (domain, org name,
|
2. **`homeyConfig` specialArgs** — top-level site config (domain, org name,
|
||||||
timezone) is passed via `specialArgs` in `flake.nix` and accessed as
|
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
|
3. **No secrets in the Nix store** — secrets are always read from sops-managed
|
||||||
files at runtime, never embedded in the built config. Use
|
files at runtime, never embedded in the built config. Use
|
||||||
@@ -160,26 +219,58 @@ restic password, Cloudflare tokens) can be generated fresh.
|
|||||||
|
|
||||||
4. **Secret injection pattern** — because `oci-containers` `environmentFiles`
|
4. **Secret injection pattern** — because `oci-containers` `environmentFiles`
|
||||||
is limited, use a `systemd ExecStartPre` script to write an ephemeral env
|
is limited, use a `systemd ExecStartPre` script to write an ephemeral env
|
||||||
file at `/run/<service>-secrets.env` and reference it via `EnvironmentFile`.
|
file at `/run/<service>-secrets.env` and reference it via `environmentFiles`.
|
||||||
Clean it up in `postStop`.
|
Clean it up in `postStop`.
|
||||||
|
|
||||||
5. **`--network=host`** — all containers use host networking for simplicity on
|
5. **`--network=homey`** — all containers join the private `homey` podman
|
||||||
a single-node setup. Services communicate via `127.0.0.1:<port>`.
|
network. Inter-container traffic uses container names as hostnames; host
|
||||||
|
access is via explicit `ports` mappings to `127.0.0.1:<port>`.
|
||||||
|
|
||||||
6. **Systemd ordering** — always express `after`/`requires` dependencies
|
6. **Systemd ordering** — always express `after`/`requires` dependencies
|
||||||
explicitly. The external HD mount unit is `mnt-data.mount`; containers that
|
explicitly. The external HD mount unit is `mnt-data.mount`; containers that
|
||||||
need storage must depend on it.
|
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
|
### Adding a New Service
|
||||||
|
|
||||||
1. Create `modules/services/<name>.nix` following the existing module pattern.
|
1. Create `modules/services/<name>.nix` following the existing module pattern.
|
||||||
2. Add `homey.<name>.enable = false` as the default option.
|
2. Import it in `flake.nix` (in the `modules` list inside `mkHost`).
|
||||||
3. Import the new module in `flake.nix` (in the `modules` list inside `mkHost`).
|
3. Enable it in `hosts/pi-main/default.nix`.
|
||||||
4. Enable it in `hosts/pi-main/default.nix`.
|
4. Inside the module's `config = lib.mkIf cfg.enable { ... }` block:
|
||||||
5. Add a Caddy virtual host block in `modules/caddy.nix`.
|
- **Caddy**: add `homey.caddy.virtualHosts = [{ subdomain = "…"; port = …; auth = true/false; }]`
|
||||||
6. Add the service data directory to `modules/storage.nix` `tmpfiles.rules`.
|
- **Storage**: add `homey.storage.extraDirs = [{ path = "…"; }]` for each HD directory
|
||||||
7. Add the data path to the `paths` list in `modules/backup.nix`.
|
- **Backup**: add `homey.backup.extraPaths = [ "${dataDir}/…" ]`
|
||||||
8. Add any new secrets to `secrets/secrets.yaml` (plaintext) and document them.
|
- **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
|
### Updating or Regenerating Secrets
|
||||||
|
|
||||||
@@ -223,45 +314,48 @@ production-ready:
|
|||||||
DNS plugin uses `lib.fakeHash` as a placeholder. After the first `nix build`,
|
DNS plugin uses `lib.fakeHash` as a placeholder. After the first `nix build`,
|
||||||
replace it with the hash Nix reports in the error message.
|
replace it with the hash Nix reports in the error message.
|
||||||
|
|
||||||
- [ ] **`hosts/pi-main/default.nix` — fill in real values**:
|
- [ ] **`monitoring.nix` — Grafana dashboard hash**: The Node Exporter Full
|
||||||
- SSH public key in `users.users.admin.openssh.authorizedKeys.keys`
|
dashboard `fetchurl` hash is a placeholder. Run:
|
||||||
- External HD device path in `homey.storage.device`
|
```bash
|
||||||
- Backup repository URL in `homey.backup.repository` — must be an S3-compatible
|
nix store prefetch-file --hash-type sha256 \
|
||||||
URL, e.g. `"s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name"`
|
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
|
- [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret
|
||||||
values (old passwords from k8s + freshly generated ones, including
|
values, then run `sops --encrypt --in-place secrets/secrets.yaml` before
|
||||||
`restic/s3_access_key_id` and `restic/s3_secret_access_key`), then run
|
committing. Secrets needed:
|
||||||
`sops --encrypt --in-place secrets/secrets.yaml` before committing.
|
- From old k8s deployment: openldap passwords, gitea/nextcloud passwords
|
||||||
|
- Fresh: authelia JWT/session/encryption keys, gitea JWT tokens
|
||||||
- [x] **`secrets/.sops.yaml` — PGP key**: The encryption subkey
|
- New services: `uptime-kuma/admin_password`, `ntfy/admin_password`,
|
||||||
`076AA297579A0064` is already in `.sops.yaml`.
|
`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,
|
- [ ] **Cloudflare Tunnel**: Create the tunnel in the Zero Trust dashboard,
|
||||||
copy the tunnel token into secrets, and configure public hostnames. See
|
copy the tunnel token into secrets, and configure public hostnames for all
|
||||||
`modules/cloudflared.nix` and Phase 3 of `PORTING.md` for details.
|
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
|
- [ ] **Second machine**: When ready, add `hosts/pi-secondary/` and uncomment
|
||||||
the `pi-secondary` entry in `flake.nix`. Services communicating cross-machine
|
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`.
|
should reference the primary Pi's LAN IP instead of `127.0.0.1`.
|
||||||
|
|
||||||
- [ ] **Jellyfin and Transmission**: Both modules are written and importable
|
- [ ] **Jellyfin and Transmission**: Both modules exist but are disabled.
|
||||||
but disabled. Enable in `hosts/pi-main/default.nix` when ready:
|
Enable in `hosts/pi-main/default.nix` when ready:
|
||||||
```nix
|
```nix
|
||||||
homey.jellyfin.enable = true;
|
homey.jellyfin.enable = true;
|
||||||
homey.transmission.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
|
- [ ] **Backup — offload script**: Write `scripts/offload-backup.sh` for
|
||||||
manually copying snapshots to a local disk (USB attached to Pi, or a disk
|
manually copying snapshots to a local disk. Uses `restic copy` to clone from
|
||||||
on your workstation). Uses `restic copy` to clone from the S3 repo into a
|
the S3 repo into a local restic repo. See `TODO.org` for design notes.
|
||||||
local restic repo on the target path. 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.
|
These items require the Pi to be built, flashed, and booted at least once.
|
||||||
|
|
||||||
@@ -276,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
|
- [ ] **Gitea LDAP auth**: After first start, configure LDAP authentication
|
||||||
in Gitea's admin panel (Admin → Authentication Sources → Add LDAP source).
|
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:
|
Relevant settings:
|
||||||
- Host: `127.0.0.1`, Port: `389`, Security: Unencrypted
|
- Host: `127.0.0.1`, Port: `389`, Security: Unencrypted
|
||||||
- Bind DN: `cn=readonly,dc=zakobar,dc=com`
|
- Bind DN: `cn=readonly,dc=zakobar,dc=com`
|
||||||
@@ -285,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
|
- [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify
|
||||||
the LDAP Users and Contacts app is still configured correctly
|
the LDAP Users and Contacts app is still configured correctly
|
||||||
(Admin → LDAP/AD Integration).
|
(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.
|
||||||
|
|||||||
+240
-102
@@ -108,21 +108,12 @@ nixos-rebuild switch \
|
|||||||
The Pi builds its own config natively (no cross-compilation). sops-nix
|
The Pi builds its own config natively (no cross-compilation). sops-nix
|
||||||
will now decrypt all secrets and start all services.
|
will now decrypt all secrets and start all services.
|
||||||
|
|
||||||
** Caddy plugin hash
|
You can also use the command:
|
||||||
|
|
||||||
The first deploy will fail at the Caddy build step because =lib.fakeHash=
|
#+begin_src bash
|
||||||
is a placeholder. Copy the correct hash from the error output and replace
|
homey-deploy-rpi-main
|
||||||
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
|
#+end_src
|
||||||
|
|
||||||
Then re-run the deploy command from Phase 3.
|
|
||||||
|
|
||||||
** Ongoing deploys from workstation
|
** Ongoing deploys from workstation
|
||||||
|
|
||||||
All future config changes follow the same pattern:
|
All future config changes follow the same pattern:
|
||||||
@@ -131,24 +122,94 @@ All future config changes follow the same pattern:
|
|||||||
2. Run:
|
2. Run:
|
||||||
|
|
||||||
#+begin_src bash
|
#+begin_src bash
|
||||||
nixos-rebuild switch \
|
homey-deploy-rpi-main
|
||||||
--flake .#pi-main \
|
|
||||||
--target-host admin@192.168.1.100 \
|
|
||||||
--build-host admin@192.168.1.100 \
|
|
||||||
--use-remote-sudo
|
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
NixOS activates the new config on the Pi immediately, with an automatic
|
NixOS activates the new config on the Pi immediately, with an automatic
|
||||||
rollback if activation fails.
|
rollback if activation fails.
|
||||||
|
|
||||||
* Installation (legacy Helm)
|
* Post-deploy setup
|
||||||
|
|
||||||
Install using
|
Some services require manual one-time configuration after the first deploy.
|
||||||
|
|
||||||
|
** Nix build directory
|
||||||
|
|
||||||
|
The nix daemon is configured to use =/mnt/data/nix-build= for sandbox
|
||||||
|
builds instead of the default =/tmp= (which is a small RAM-backed tmpfs).
|
||||||
|
This directory must be created manually once — =systemd-tmpfiles= will
|
||||||
|
maintain it on subsequent boots but cannot create it on the very first deploy
|
||||||
|
because the nix build itself needs the directory to already exist.
|
||||||
|
|
||||||
#+begin_src bash
|
#+begin_src bash
|
||||||
helm upgrade --install homey . -n homey
|
sudo mkdir -p /mnt/data/nix-build
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
|
** Ntfy — push notifications
|
||||||
|
|
||||||
|
Ntfy's admin user is created automatically from sops on first start.
|
||||||
|
|
||||||
|
*** Step 1 — Generate VAPID keys (Web Push)
|
||||||
|
|
||||||
|
Run on the Pi *before* the first full deploy:
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
ssh admin@192.168.1.100 'sudo ntfy webpush keys'
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
This prints a public key and a private key.
|
||||||
|
|
||||||
|
- Copy the *public key* into =hosts/pi-main/default.nix=:
|
||||||
|
#+begin_src nix
|
||||||
|
homey.ntfy.webPushPublicKey = "<public-key>";
|
||||||
|
homey.ntfy.webPushEmail = "mailto:you@zakobar.com";
|
||||||
|
#+end_src
|
||||||
|
- Add the *private key* to sops:
|
||||||
|
#+begin_src bash
|
||||||
|
sops secrets/secrets.yaml
|
||||||
|
# add: ntfy/web_push_private_key: <private-key>
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
The private key is injected at boot and never lands in the nix store.
|
||||||
|
|
||||||
|
*** Step 2 — Subscribe via Safari PWA (recommended for iOS)
|
||||||
|
|
||||||
|
1. Visit =https://ntfy.zakobar.com= in Safari and log in with the admin
|
||||||
|
password (=ntfy/admin_password= in =secrets/secrets.yaml=).
|
||||||
|
2. Go to *Account → Access Tokens → Create token* — give it a name and
|
||||||
|
copy the value.
|
||||||
|
3. Log in with the token, then tap *Share → Add to Home Screen*.
|
||||||
|
4. Open the app from the Home Screen (must be launched from there, not
|
||||||
|
Safari, to get push permission).
|
||||||
|
5. Subscribe to the =alerts= topic and grant notification permission when
|
||||||
|
prompted.
|
||||||
|
|
||||||
|
Web Push via the PWA uses Apple's APNs directly and is more reliable on
|
||||||
|
iOS than the native ntfy app's upstream relay.
|
||||||
|
|
||||||
|
** Uptime Kuma — notifications (two-deploy process)
|
||||||
|
|
||||||
|
Uptime Kuma monitors are created automatically by the sync script on first
|
||||||
|
deploy, but notification channels must be configured in the UI before they
|
||||||
|
can be attached to monitors. This requires two deploys:
|
||||||
|
|
||||||
|
*Deploy 1* — services are up, monitors exist, but no notifications assigned yet.
|
||||||
|
|
||||||
|
Then, in the Uptime Kuma UI (=https://uptime.zakobar.com=):
|
||||||
|
|
||||||
|
1. Go to *Settings → Notifications → Add Notification*.
|
||||||
|
2. Choose *ntfy* as the type and fill in:
|
||||||
|
- *Server URL*: =https://ntfy.zakobar.com=
|
||||||
|
- *Topic*: =alerts=
|
||||||
|
- *Token*: use the admin token (or create a dedicated one in ntfy)
|
||||||
|
3. Save — you do *not* need to manually assign it to any monitor.
|
||||||
|
|
||||||
|
*Deploy 2* — run =homey-deploy-rpi-main= again. The sync script will detect
|
||||||
|
the newly configured notification channel and attach it to every monitor
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
Any notifications added to Uptime Kuma in the future will also be picked up
|
||||||
|
on the next deploy.
|
||||||
|
|
||||||
* Backing up
|
* Backing up
|
||||||
|
|
||||||
Backups use [[https://restic.net/][restic]] and run automatically via systemd on a daily schedule.
|
Backups use [[https://restic.net/][restic]] and run automatically via systemd on a daily schedule.
|
||||||
@@ -178,6 +239,30 @@ All service data under =/mnt/data/=:
|
|||||||
Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before
|
Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before
|
||||||
each backup to ensure a consistent snapshot.
|
each backup to ensure a consistent snapshot.
|
||||||
|
|
||||||
|
** First-time setup — initialize the repository
|
||||||
|
|
||||||
|
Restic requires a one-time =init= before the first backup can run. The
|
||||||
|
automated job will fail with "repository does not exist" until this is done.
|
||||||
|
|
||||||
|
Run on the Pi after the first deploy:
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
# Note: use single quotes around the remote script to prevent local shell expansion
|
||||||
|
ssh admin@192.168.1.100 'sudo bash -c '"'"'
|
||||||
|
export AWS_ACCESS_KEY_ID=$(cat /run/secrets/restic/s3_access_key_id)
|
||||||
|
export AWS_SECRET_ACCESS_KEY=$(cat /run/secrets/restic/s3_secret_access_key)
|
||||||
|
export RESTIC_PASSWORD=$(cat /run/secrets/restic/password)
|
||||||
|
restic -r s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup init
|
||||||
|
'"'"''
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
You only need to do this once. After =init= succeeds, the daily timer will
|
||||||
|
run normally. To trigger a backup immediately without waiting for 03:00:
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
ssh admin@192.168.1.100 "sudo systemctl start restic-backups-homey.service"
|
||||||
|
#+end_src
|
||||||
|
|
||||||
** Configuration
|
** Configuration
|
||||||
|
|
||||||
Repository URL and credentials are set per-host:
|
Repository URL and credentials are set per-host:
|
||||||
@@ -203,98 +288,151 @@ restic -r s3:https://... restore latest --target /mnt/data
|
|||||||
restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea
|
restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
* LDAP Configuration
|
* Disaster Recovery
|
||||||
|
|
||||||
Logins are done to PHPLDAPADMIN
|
Full recovery from total host failure (dead Pi, dead SD card), assuming this
|
||||||
|
git repo and your workstation PGP key (=076AA297579A0064=) survive.
|
||||||
|
|
||||||
DN is like:
|
** Step 1 — Flash and boot a new Pi
|
||||||
|
|
||||||
cn=admin,dc=,dc=io
|
Follow Phase 1 above to build and flash a fresh bootstrap image, then SSH in.
|
||||||
get-secret-val.sh homey openldap-admin password
|
|
||||||
|
|
||||||
First thing we do is create an organization unit called users
|
** Step 2 — Regenerate the age key and re-encrypt secrets
|
||||||
|
|
||||||
To add a new user, we create a child entry to ou=users
|
The old Pi's age key is gone with the dead machine. Your workstation PGP key
|
||||||
|
is the fallback and can still decrypt =secrets/secrets.yaml=.
|
||||||
|
|
||||||
It has to be of type inetOrgPerson
|
On the Pi:
|
||||||
|
|
||||||
cn = Common Name, sn = Sur Name.
|
#+begin_src bash
|
||||||
Select RDN = User Name (uid) (FROM DROP DOWN MENU)
|
sudo age-keygen -o /var/lib/sops-nix/key.txt
|
||||||
UID = USERNAME, that is what is important. (In PHPLdapAdmin it is under User Name)
|
sudo age-keygen -y /var/lib/sops-nix/key.txt # copy this public key
|
||||||
|
|
||||||
Now we may continue!
|
|
||||||
|
|
||||||
* GITEA
|
|
||||||
|
|
||||||
Site Title: whatever
|
|
||||||
|
|
||||||
SSH Server Domain: git.<YOUR URL>
|
|
||||||
SSH Server Port: 2222
|
|
||||||
Gitea Base URL: http://git.<YOUR URL>
|
|
||||||
|
|
||||||
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
|
#+end_src
|
||||||
|
|
||||||
* I UNDERSTAND
|
On the workstation — replace the old age key in =secrets/.sops.yaml= with the
|
||||||
|
new public key, then re-encrypt:
|
||||||
|
|
||||||
I need to backup Chen's stuff
|
#+begin_src bash
|
||||||
And... I need to Jellyfin
|
sops updatekeys secrets/secrets.yaml
|
||||||
|
git add secrets/.sops.yaml secrets/secrets.yaml
|
||||||
|
git commit -m "replace Pi age key after host failure"
|
||||||
|
#+end_src
|
||||||
|
|
||||||
* PAPERLESS
|
** Step 3 — Deploy the full NixOS config
|
||||||
|
|
||||||
https://github.com/paperless-ngx/paperless-ngx/blob/74c44fe418a91a526b5dab1a91fde4aaebd28bb1/docker/compose/docker-compose.postgres.yml
|
#+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
|
||||||
|
|
||||||
|
This brings up the OS and mounts =/mnt/data=. Services will fail to start
|
||||||
|
until data is restored — that is expected.
|
||||||
|
|
||||||
|
** Step 4 — Restore data from restic
|
||||||
|
|
||||||
|
Credentials are in =secrets/secrets.yaml= (=restic/password=,
|
||||||
|
=restic/s3_access_key_id=, =restic/s3_secret_access_key=).
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
ssh admin@192.168.1.100
|
||||||
|
|
||||||
|
export RESTIC_REPOSITORY="s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup"
|
||||||
|
export RESTIC_PASSWORD="..." # restic/password from secrets
|
||||||
|
export AWS_ACCESS_KEY_ID="..." # restic/s3_access_key_id
|
||||||
|
export AWS_SECRET_ACCESS_KEY="..." # restic/s3_secret_access_key
|
||||||
|
|
||||||
|
restic snapshots # verify repo is reachable
|
||||||
|
sudo restic restore latest --target /mnt/data
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
If restoring from a USB offload disk instead of S3:
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
sudo restic -r /mnt/usb/homey-backup restore latest --target /mnt/data
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
** Step 5 — Restore the Nextcloud database
|
||||||
|
|
||||||
|
The raw Postgres data dir is excluded from restic; only the =pg_dump= SQL file
|
||||||
|
is backed up. After the data restore you will have
|
||||||
|
=/mnt/data/nextcloud/db-dump/nextcloud.sql= but an empty database. Import it:
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
sudo systemctl start podman-nextcloud-postgres
|
||||||
|
# Wait ~10 s for Postgres to be ready, then:
|
||||||
|
podman exec -i nextcloud-postgres \
|
||||||
|
psql -U postgres nextcloud_db \
|
||||||
|
< /mnt/data/nextcloud/db-dump/nextcloud.sql
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
** Step 6 — Start services and verify
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
sudo systemctl start podman-openldap podman-authelia podman-gitea podman-nextcloud
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
Manual checks after restart:
|
||||||
|
|
||||||
|
- *Gitea*: Admin → Authentication Sources — verify the LDAP source is present.
|
||||||
|
It lives in Gitea's database (restored from restic) so it should survive
|
||||||
|
automatically. Confirm by logging in with an LDAP user.
|
||||||
|
- *Nextcloud*: Admin → LDAP/AD Integration — confirm the LDAP app is still
|
||||||
|
configured. If not, re-enter the settings from the LDAP Configuration
|
||||||
|
section of this file.
|
||||||
|
|
||||||
|
** Key risks
|
||||||
|
|
||||||
|
| Risk | Consequence |
|
||||||
|
|------+-------------|
|
||||||
|
| External HD also fails | Restore all data from restic — Nextcloud files may be large |
|
||||||
|
| Workstation PGP key lost | Cannot decrypt =secrets/secrets.yaml= — passwords must be reset manually per service |
|
||||||
|
| USB offload not yet implemented | =scripts/offload-backup.sh= does not exist yet; S3 is the only working backup tier |
|
||||||
|
|
||||||
|
* Running commands in containers
|
||||||
|
|
||||||
|
All services run as podman containers. Use =podman exec= to run commands
|
||||||
|
inside them.
|
||||||
|
|
||||||
|
** General pattern
|
||||||
|
|
||||||
|
Containers are started by systemd as root, so they live in root's podman
|
||||||
|
context. All =podman= commands must be run with =sudo=.
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
# List running containers
|
||||||
|
sudo podman ps
|
||||||
|
|
||||||
|
# Run a command in a container
|
||||||
|
sudo podman exec <container-name> <command>
|
||||||
|
|
||||||
|
# Run as a specific user
|
||||||
|
sudo podman exec -u <user> <container-name> <command>
|
||||||
|
|
||||||
|
# Interactive shell
|
||||||
|
sudo podman exec -it <container-name> sh
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
Container names match the service: =openldap=, =authelia=, =gitea=,
|
||||||
|
=nextcloud=, =nextcloud-postgres=, =jellyfin=, =transmission=.
|
||||||
|
|
||||||
|
** Nextcloud — running occ commands
|
||||||
|
|
||||||
|
=occ= must run as =www-data= inside the =nextcloud= container.
|
||||||
|
|
||||||
|
#+begin_src bash
|
||||||
|
# General form
|
||||||
|
sudo podman exec -u www-data nextcloud php occ <command>
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
sudo podman exec -u www-data nextcloud php occ status
|
||||||
|
sudo podman exec -u www-data nextcloud php occ maintenance:mode --off
|
||||||
|
sudo podman exec -u www-data nextcloud php occ preview:generate-all -vvv
|
||||||
|
sudo podman exec -u www-data nextcloud php occ ldap:promote-group "admins"
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
Running without =-u www-data= will create files owned by root inside the
|
||||||
|
container, which breaks Nextcloud's file access.
|
||||||
|
|
||||||
For docker
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
now use =nixos-raspberrypi.lib.nixosSystem= and =raspberry-pi-4.base=.
|
now use =nixos-raspberrypi.lib.nixosSystem= and =raspberry-pi-4.base=.
|
||||||
=nix flake check= passes.
|
=nix flake check= passes.
|
||||||
|
|
||||||
** TODO Verify SD card partition labels in =hosts/pi-main/hardware.nix=
|
** DONE Verify SD card partition labels in =hosts/pi-main/hardware.nix=
|
||||||
The config assumes labels =NIXOS_SD= (root) and =FIRMWARE= (boot).
|
The config assumes labels =NIXOS_SD= (root) and =FIRMWARE= (boot).
|
||||||
After flashing, check with:
|
After flashing, check with:
|
||||||
#+begin_src bash
|
#+begin_src bash
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
* Caddy Build
|
* Caddy Build
|
||||||
|
|
||||||
** TODO Fix =vendorHash= in =modules/caddy.nix=
|
** DONE Fix =vendorHash= in =modules/caddy.nix=
|
||||||
The Caddy build with the Cloudflare DNS plugin currently uses =lib.fakeHash=
|
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
|
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.
|
correct hash in the error message. Replace =lib.fakeHash= with that value.
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
* Deployment
|
* Deployment
|
||||||
|
|
||||||
** TODO Phase 1 — Build and flash bootstrap SD card image
|
** DONE Phase 1 — Build and flash bootstrap SD card image
|
||||||
|
|
||||||
The bootstrap image is a minimal NixOS with SSH + WiFi only (no sops, no
|
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
|
services). Its sole purpose is to boot the Pi so you can generate the age key
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
ssh admin@192.168.1.100
|
ssh admin@192.168.1.100
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
** TODO Phase 2 — Generate age key and add it to sops
|
** DONE Phase 2 — Generate age key and add it to sops
|
||||||
|
|
||||||
On the Pi (over SSH):
|
On the Pi (over SSH):
|
||||||
#+begin_src bash
|
#+begin_src bash
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
git commit -m "add Pi age key to sops recipients"
|
git commit -m "add Pi age key to sops recipients"
|
||||||
#+end_src
|
#+end_src
|
||||||
|
|
||||||
** TODO Phase 3 — Fix Caddy vendorHash, then deploy full config
|
** DONE Phase 3 — Fix Caddy vendorHash, then deploy full config
|
||||||
|
|
||||||
The full =pi-main= config includes Caddy built with the Cloudflare DNS
|
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.
|
plugin. The first build will fail with the correct hash in the error output.
|
||||||
@@ -198,7 +198,8 @@
|
|||||||
|
|
||||||
** DONE Configure Gitea LDAP authentication
|
** DONE Configure Gitea LDAP authentication
|
||||||
Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN):
|
Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN):
|
||||||
- Host: =127.0.0.1=, Port: =389=, Security: Unencrypted
|
- Host: =openldap=, Port: =389=, Security: Unencrypted
|
||||||
|
(containers talk via the =homey= podman network — use container name, not =127.0.0.1=)
|
||||||
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
|
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
|
||||||
- Bind Password: see =openldap/ro_password= in sops
|
- Bind Password: see =openldap/ro_password= in sops
|
||||||
- User Search Base: =ou=users,dc=zakobar,dc=com=
|
- User Search Base: =ou=users,dc=zakobar,dc=com=
|
||||||
@@ -208,12 +209,13 @@
|
|||||||
- Surname attribute: =sn=
|
- Surname attribute: =sn=
|
||||||
- Email attribute: =mail=
|
- Email attribute: =mail=
|
||||||
|
|
||||||
** TODO Verify Nextcloud LDAP app configuration
|
** DONE Verify Nextcloud LDAP app configuration
|
||||||
After restoring the Nextcloud volume, check:
|
After restoring the Nextcloud volume, check:
|
||||||
Admin → LDAP/AD Integration — confirm the LDAP Users and Contacts app is configured.
|
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
|
If reconfiguring from scratch, use the same settings as Gitea above but with
|
||||||
Nextcloud's LDAP wizard:
|
Nextcloud's LDAP wizard:
|
||||||
- Server: =127.0.0.1=, Port: =389=
|
- Server: =openldap=, Port: =389=
|
||||||
|
(container name on the =homey= network — not =127.0.0.1=)
|
||||||
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
|
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
|
||||||
- Bind Password: see =openldap/ro_password= in sops
|
- Bind Password: see =openldap/ro_password= in sops
|
||||||
- Base DN: =dc=zakobar,dc=com=
|
- Base DN: =dc=zakobar,dc=com=
|
||||||
@@ -230,7 +232,7 @@
|
|||||||
|
|
||||||
* Backup Strategy
|
* Backup Strategy
|
||||||
|
|
||||||
** TODO Configure S3-compatible automatic backup target
|
** DONE Configure S3-compatible automatic backup target
|
||||||
Update =homey.backup.repository= in =hosts/pi-main/default.nix= to point at
|
Update =homey.backup.repository= in =hosts/pi-main/default.nix= to point at
|
||||||
your S3-compatible bucket (Backblaze B2, Wasabi, AWS S3, etc.):
|
your S3-compatible bucket (Backblaze B2, Wasabi, AWS S3, etc.):
|
||||||
#+begin_src nix
|
#+begin_src nix
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
#+TITLE: Gitea Actions Runner — Workflows & Usage Guide
|
||||||
|
#+DATE: 2026-05-04
|
||||||
|
#+AUTHOR: homey project
|
||||||
|
#+OPTIONS: toc:2 num:t
|
||||||
|
|
||||||
|
* Overview
|
||||||
|
|
||||||
|
This document covers the Gitea Actions runner setup on pi-main, how the runner
|
||||||
|
works, the label system, and example workflows for both host-based ("ubuntu")
|
||||||
|
and Nix-native jobs.
|
||||||
|
|
||||||
|
** Architecture
|
||||||
|
|
||||||
|
The runner is configured in =modules/services/gitea-runner.nix= and uses the
|
||||||
|
NixOS native =services.gitea-actions-runner= module. Jobs run with the *host*
|
||||||
|
executor: each step executes directly on the Pi 4 as the =gitea-runner-pi-main=
|
||||||
|
system user. There is no container isolation per job.
|
||||||
|
|
||||||
|
#+BEGIN_EXAMPLE
|
||||||
|
Gitea (podman container)
|
||||||
|
│ HTTPS → Cloudflare tunnel → Caddy → git.zakobar.com
|
||||||
|
│ (runner connects outbound via HTTPS, same path as a browser)
|
||||||
|
▼
|
||||||
|
gitea-actions-runner (systemd service)
|
||||||
|
│ host executor
|
||||||
|
▼
|
||||||
|
Jobs run as: gitea-runner-pi-main (unprivileged system user)
|
||||||
|
PATH includes: nix, git, bash + system packages
|
||||||
|
#+END_EXAMPLE
|
||||||
|
|
||||||
|
** Runner labels
|
||||||
|
|
||||||
|
Labels are advertised to Gitea and matched against =runs-on:= in workflow
|
||||||
|
files. The default labels configured in this project are:
|
||||||
|
|
||||||
|
| Label | Executor | Notes |
|
||||||
|
|---------------+----------+--------------------------------------------|
|
||||||
|
| =native:host= | host | Canonical label for "run on this machine" |
|
||||||
|
| =ubuntu-latest= | host | Matches common GitHub Actions workflows |
|
||||||
|
| =debian-latest= | host | Alternative for Debian-targeting workflows |
|
||||||
|
| =nix:host= | host | Explicit label for Nix-native jobs |
|
||||||
|
|
||||||
|
All four labels route to the same runner process and the same host environment.
|
||||||
|
The difference is purely semantic — pick the label that makes your workflow's
|
||||||
|
intent clear.
|
||||||
|
|
||||||
|
** Nix daemon trust
|
||||||
|
|
||||||
|
The runner user is added to =nix.settings.trusted-users=, which means it can:
|
||||||
|
- Evaluate flakes (=nix flake check=, =nix build=)
|
||||||
|
- Write derivation outputs to the Nix store
|
||||||
|
- Pass =--extra-experimental-features= flags to the daemon
|
||||||
|
- Use =nix copy= to push/pull store paths to a remote cache
|
||||||
|
|
||||||
|
It cannot modify NixOS system configuration or run privileged operations.
|
||||||
|
|
||||||
|
* Example workflows
|
||||||
|
|
||||||
|
Workflow files live in =.gitea/workflows/= inside each repository (or
|
||||||
|
=.github/workflows/= — Gitea Actions supports both paths).
|
||||||
|
|
||||||
|
** Minimal smoke test (host)
|
||||||
|
|
||||||
|
The simplest possible workflow — runs a shell command on the runner.
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
# .gitea/workflows/smoke.yaml
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
runs-on: native:host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- run: echo "Runner is alive on $(hostname)"
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Standard shell-based CI (ubuntu-latest label)
|
||||||
|
|
||||||
|
Use this for repos that want to stay compatible with GitHub Actions. The
|
||||||
|
workflow looks identical to what you'd push to GitHub; it just runs on your Pi.
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
# .gitea/workflows/ci.yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
# On the host executor, use nix-shell or system packages.
|
||||||
|
# apt/yum are NOT available — this is NixOS, not Ubuntu.
|
||||||
|
# Use nix-shell -p for one-off tools:
|
||||||
|
nix-shell -p nodejs --run "node --version"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
nix-shell -p nodejs --run "npm test"
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
*Important:* Despite the label =ubuntu-latest=, the host is NixOS. =apt=,
|
||||||
|
=yum=, and FHS paths like =/usr/bin/python3= are not available. Use
|
||||||
|
=nix-shell -p <pkg>= to bring in any tool you need.
|
||||||
|
|
||||||
|
** Nix flake check
|
||||||
|
|
||||||
|
Validate a flake on every push — the most common use case for this runner.
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
# .gitea/workflows/flake-check.yaml
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: nix:host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Check flake
|
||||||
|
run: nix flake check --no-build
|
||||||
|
|
||||||
|
- name: Build default package
|
||||||
|
run: nix build
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Nix build with caching
|
||||||
|
|
||||||
|
Build a derivation and push the result to a binary cache so subsequent builds
|
||||||
|
are fast. Requires a Cachix account or an S3-compatible cache configured in
|
||||||
|
=nix.settings=.
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
# .gitea/workflows/build-and-cache.yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: nix:host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: nix build --print-build-logs
|
||||||
|
|
||||||
|
- name: Push to cache
|
||||||
|
# nix copy requires the runner user to be in trusted-users (already set).
|
||||||
|
# Replace the URI with your actual cache.
|
||||||
|
run: |
|
||||||
|
nix copy --to "s3://your-cache-bucket?region=us-east-1" ./result
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** NixOS configuration check (this repo)
|
||||||
|
|
||||||
|
Check that the homey flake evaluates cleanly on every change. Add this to the
|
||||||
|
homey repo itself.
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
# .gitea/workflows/nixos-check.yaml
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
eval:
|
||||||
|
runs-on: nix:host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Evaluate NixOS configurations
|
||||||
|
run: |
|
||||||
|
nix flake check --no-build
|
||||||
|
# Optionally build a specific host config (slow on Pi):
|
||||||
|
# nix build .#nixosConfigurations.pi-main.config.system.build.toplevel
|
||||||
|
|
||||||
|
- name: Check formatting (optional)
|
||||||
|
run: |
|
||||||
|
nix-shell -p nixpkgs-fmt --run "nixpkgs-fmt --check ."
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Multi-step pipeline with artifacts
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
# .gitea/workflows/pipeline.yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: nix:host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build release
|
||||||
|
run: nix build --out-link result
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-binary
|
||||||
|
path: result/bin/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: native:host
|
||||||
|
steps:
|
||||||
|
- name: Download artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-binary
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: ./deploy.sh
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
* Caveats and gotchas
|
||||||
|
|
||||||
|
** No apt/brew/yum
|
||||||
|
|
||||||
|
The host is NixOS. Package managers from other distros do not work. Use
|
||||||
|
=nix-shell -p <pkg> --run "..."= for ad-hoc tools, or add a =shell.nix= /
|
||||||
|
=flake.nix= devShell to your repo and enter it with =nix develop=.
|
||||||
|
|
||||||
|
** No Docker/Podman per job
|
||||||
|
|
||||||
|
The host executor does not launch a fresh container per job. All jobs share the
|
||||||
|
same filesystem (under =/home/gitea-runner-pi-main/=) and the same running
|
||||||
|
system. This means:
|
||||||
|
|
||||||
|
- No isolation between concurrent jobs (though concurrency defaults to 1).
|
||||||
|
- Side effects (files written, packages installed with nix) persist between
|
||||||
|
runs unless you clean up explicitly.
|
||||||
|
- Use =nix build= output symlinks (=./result=) rather than writing to system
|
||||||
|
paths.
|
||||||
|
|
||||||
|
** actions/checkout and git
|
||||||
|
|
||||||
|
The =actions/checkout@v3= action works fine on the host executor. It clones
|
||||||
|
into the runner's working directory. Subsequent steps run in that directory by
|
||||||
|
default.
|
||||||
|
|
||||||
|
If you use =actions/checkout@v4=, note that it requires a newer Node.js. On
|
||||||
|
NixOS you can't rely on a system Node, so either pin to v3 or use:
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
- uses: actions/checkout@v3 # v3 bundles its own Node runtime
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Nix experimental features
|
||||||
|
|
||||||
|
Flake commands require =nix-command= and =flakes= experimental features. These
|
||||||
|
are typically enabled system-wide in =nix.settings.experimental-features= in
|
||||||
|
=modules/common.nix=. If a job fails with "experimental feature not enabled",
|
||||||
|
you can pass it inline:
|
||||||
|
|
||||||
|
#+BEGIN_SRC yaml
|
||||||
|
- run: nix --extra-experimental-features "nix-command flakes" flake check
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Or ensure =common.nix= has:
|
||||||
|
|
||||||
|
#+BEGIN_SRC nix
|
||||||
|
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Token rotation
|
||||||
|
|
||||||
|
The registration token in =gitea/runner_token= is consumed on first
|
||||||
|
registration. The runner then stores its own credentials in
|
||||||
|
=/var/lib/gitea-runner/pi-main/.runner=. If you need to re-register (e.g.
|
||||||
|
after wiping the state directory), generate a new token from Gitea's admin UI
|
||||||
|
and update the sops secret before restarting the service.
|
||||||
|
|
||||||
|
** Pi 4 performance
|
||||||
|
|
||||||
|
The Pi 4 is capable but not fast for heavy builds. Tips:
|
||||||
|
- Enable the Nix binary cache (=nixos-cache.nixos.org= is on by default) so
|
||||||
|
pre-built derivations are fetched instead of compiled.
|
||||||
|
- Set =nix.settings.max-jobs= to =4= to use all cores for parallel builds.
|
||||||
|
- Avoid building large packages (LLVM, Chromium) locally — push to a remote
|
||||||
|
builder or use Cachix.
|
||||||
|
|
||||||
|
* Debugging
|
||||||
|
|
||||||
|
** Check runner status
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
systemctl status gitea-runner-pi-main
|
||||||
|
journalctl -u gitea-runner-pi-main -f
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Runner registration state
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
cat /var/lib/gitea-runner/pi-main/.runner
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Force re-registration
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
# Stop, wipe state, restart (runner will re-register using the token file)
|
||||||
|
systemctl stop gitea-runner-pi-main
|
||||||
|
rm /var/lib/gitea-runner/pi-main/.runner
|
||||||
|
systemctl start gitea-runner-pi-main
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
** Test a workflow locally
|
||||||
|
|
||||||
|
Use =act= (the local runner) to test workflow files before pushing:
|
||||||
|
#+BEGIN_SRC sh
|
||||||
|
nix-shell -p act --run "act push"
|
||||||
|
#+END_SRC
|
||||||
|
|
||||||
|
Note: =act= spins up Docker containers for each job, so results may differ
|
||||||
|
slightly from the host-executor runner, but it is useful for syntax checking
|
||||||
|
and logic testing.
|
||||||
Generated
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"eurovote": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778959671,
|
||||||
|
"narHash": "sha256-MR70Q1lNOX7lqO7PwQUtJdB4+exZr8R10YPQanc5SwE=",
|
||||||
|
"owner": "anerisgreat",
|
||||||
|
"repo": "eurovote",
|
||||||
|
"rev": "245d9b1f3e182653e5cfa0d9689a97f263eb4354",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "anerisgreat",
|
||||||
|
"repo": "eurovote",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixos-hardware": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776983936,
|
||||||
|
"narHash": "sha256-ZOQyNqSvJ8UdrrqU1p7vaFcdL53idK+LOM8oRWEWh6o=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixos-hardware",
|
||||||
|
"rev": "2096f3f411ce46e88a79ae4eafcfc9df8ed41c61",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "master",
|
||||||
|
"repo": "nixos-hardware",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767313136,
|
||||||
|
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"eurovote": "eurovote",
|
||||||
|
"nixos-hardware": "nixos-hardware",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"sops-nix": "sops-nix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sops-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776771786,
|
||||||
|
"narHash": "sha256-DRFGPfFV6hbrfO9a1PH1FkCi7qR5FgjSqsQGGvk1rdI=",
|
||||||
|
"owner": "Mic92",
|
||||||
|
"repo": "sops-nix",
|
||||||
|
"rev": "bef289e2248991f7afeb95965c82fbcd8ff72598",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "Mic92",
|
||||||
|
"repo": "sops-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -14,9 +14,15 @@
|
|||||||
# We use only the minimal pieces needed for a headless server —
|
# We use only the minimal pieces needed for a headless server —
|
||||||
# no display, audio, or bluetooth modules.
|
# no display, audio, or bluetooth modules.
|
||||||
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
|
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
|
||||||
|
|
||||||
|
# Eurovision voting app
|
||||||
|
eurovote = {
|
||||||
|
url = "github:anerisgreat/eurovote";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, sops-nix, nixos-hardware, ... }@inputs:
|
outputs = { self, nixpkgs, sops-nix, nixos-hardware, eurovote, ... }@inputs:
|
||||||
let
|
let
|
||||||
# Shared specialArgs passed to every host
|
# Shared specialArgs passed to every host
|
||||||
commonArgs = {
|
commonArgs = {
|
||||||
@@ -70,6 +76,15 @@
|
|||||||
./modules/services/phpldapadmin.nix
|
./modules/services/phpldapadmin.nix
|
||||||
./modules/services/jellyfin.nix
|
./modules/services/jellyfin.nix
|
||||||
./modules/services/transmission.nix
|
./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
|
||||||
|
./modules/monitoring.nix
|
||||||
|
eurovote.nixosModules.default
|
||||||
|
./modules/services/eurovote.nix
|
||||||
] ++ extraModules;
|
] ++ extraModules;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,5 +116,9 @@
|
|||||||
# };
|
# };
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
devShells = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system:
|
||||||
|
(import ./shells) { pkgs = nixpkgs.legacyPackages.${system}; }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,10 +88,36 @@
|
|||||||
homey.jellyfin.enable = false;
|
homey.jellyfin.enable = false;
|
||||||
homey.transmission.enable = false;
|
homey.transmission.enable = false;
|
||||||
|
|
||||||
|
# Documents and recipes
|
||||||
|
homey.paperless.enable = true;
|
||||||
|
homey.mealie.enable = true;
|
||||||
|
|
||||||
# Reverse proxy + Cloudflare
|
# Reverse proxy + Cloudflare
|
||||||
homey.caddy.enable = true;
|
homey.caddy.enable = true;
|
||||||
homey.cloudflared.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;
|
||||||
|
|
||||||
|
# Eurovision voting app
|
||||||
|
homey.eurovote.enable = true;
|
||||||
|
|
||||||
|
# Monitoring stack
|
||||||
|
homey.uptimeKuma.enable = true;
|
||||||
|
homey.ntfy.enable = true;
|
||||||
|
# Generate with: ssh admin@192.168.1.100 'sudo ntfy webpush keys'
|
||||||
|
# Add private key to sops: ntfy/web_push_private_key
|
||||||
|
homey.ntfy.webPushPublicKey = "BE2qZVa3JEF741WTPtLevyhfP0I8bV0sD2a9-_y9NoyC40sgLpQi7bcoZesBwZEpRz8oiTVuoUFnHbckAsBQI5U";
|
||||||
|
homey.ntfy.webPushEmail = "aner@zakobar.com";
|
||||||
|
homey.monitoring.enable = true;
|
||||||
|
|
||||||
# Backups
|
# Backups
|
||||||
homey.backup.enable = true;
|
homey.backup.enable = true;
|
||||||
# Where to send restic backups — set to your backup destination:
|
# Where to send restic backups — set to your backup destination:
|
||||||
@@ -100,6 +126,92 @@
|
|||||||
# "rclone:remote:homey"
|
# "rclone:remote:homey"
|
||||||
homey.backup.repository = "s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup";
|
homey.backup.repository = "s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup";
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Reliability hardening
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Hardware watchdog — auto-reboot if the system hangs (e.g. blocked USB I/O).
|
||||||
|
# bcm2835_wdt exposes /dev/watchdog; systemd pets it every runtimeTime/2.
|
||||||
|
# If systemd itself stops responding, the hardware resets the Pi after 20s.
|
||||||
|
boot.kernelModules = [ "bcm2835_wdt" ];
|
||||||
|
systemd.watchdog = {
|
||||||
|
runtimeTime = "300s"; # 5 min — generous window for boot I/O storm on USB drive
|
||||||
|
rebootTime = "360s";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Disable WiFi power save — the brcmfmac driver on RPi4 lets the chip sleep,
|
||||||
|
# causing it to miss packets and drop the connection under low traffic.
|
||||||
|
# Run once when the wlan0 interface appears (and on every re-plug/reconnect).
|
||||||
|
systemd.services.wifi-disable-power-save = {
|
||||||
|
description = "Disable WiFi power management on wlan0";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "sys-subsystem-net-devices-wlan0.device" ];
|
||||||
|
bindsTo = [ "sys-subsystem-net-devices-wlan0.device" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
ExecStart = "${pkgs.iw}/bin/iw dev wlan0 set power_save off";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Network watchdog — if the LAN gateway becomes unreachable, restart
|
||||||
|
# wpa_supplicant to force a fresh association. If the link is still
|
||||||
|
# dead 30 s later, reboot so the hardware watchdog doesn't have to.
|
||||||
|
# Runs every 2 min starting 5 min after boot.
|
||||||
|
systemd.services.network-watchdog = {
|
||||||
|
description = "Network connectivity watchdog";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = pkgs.writeShellScript "network-watchdog" ''
|
||||||
|
gateway="192.168.1.1"
|
||||||
|
if ! ${pkgs.iputils}/bin/ping -c 3 -W 10 "$gateway" > /dev/null 2>&1; then
|
||||||
|
echo "Gateway $gateway unreachable — restarting wpa_supplicant"
|
||||||
|
systemctl restart wpa_supplicant.service
|
||||||
|
sleep 30
|
||||||
|
if ! ${pkgs.iputils}/bin/ping -c 3 -W 10 "$gateway" > /dev/null 2>&1; then
|
||||||
|
echo "Still unreachable after wpa_supplicant restart — rebooting"
|
||||||
|
systemctl reboot
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.network-watchdog = {
|
||||||
|
description = "Periodic network connectivity check";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnBootSec = "5min";
|
||||||
|
OnUnitActiveSec = "2min";
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Compressed in-RAM swap via zstd. Pages evicted from RAM are compressed
|
||||||
|
# (~3:1 ratio) and stored in a 25% RAM region (~2 GB) rather than written
|
||||||
|
# to disk. Gives the OOM killer breathing room under PHP upload spikes.
|
||||||
|
# CPU overhead is negligible during normal operation.
|
||||||
|
zramSwap = {
|
||||||
|
enable = true;
|
||||||
|
algorithm = "zstd";
|
||||||
|
memoryPercent = 25;
|
||||||
|
};
|
||||||
|
|
||||||
|
# hdparm -B udev rule removed: USB-SATA bridges often don't support APM
|
||||||
|
# commands and hdparm can hang indefinitely, causing boot-time crashes.
|
||||||
|
environment.systemPackages = [ pkgs.hdparm pkgs.tmux ];
|
||||||
|
|
||||||
|
systemd.services.nextcloud-generate-previews = {
|
||||||
|
description = "Generate missing Nextcloud preview thumbnails";
|
||||||
|
after = [ "podman-nextcloud.service" ];
|
||||||
|
requires = [ "podman-nextcloud.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${pkgs.podman}/bin/podman exec -u www-data nextcloud php occ preview:generate-all";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Local DNS overrides (optional — makes LAN clients hit the Pi directly
|
# Local DNS overrides (optional — makes LAN clients hit the Pi directly
|
||||||
# instead of going through Cloudflare for *.zakobar.com)
|
# instead of going through Cloudflare for *.zakobar.com)
|
||||||
|
|||||||
+41
-51
@@ -42,6 +42,18 @@ in
|
|||||||
options.homey.backup = {
|
options.homey.backup = {
|
||||||
enable = lib.mkEnableOption "Restic backup jobs";
|
enable = lib.mkEnableOption "Restic backup jobs";
|
||||||
|
|
||||||
|
extraPaths = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [];
|
||||||
|
description = "Paths to include in the restic backup. Each service module contributes its own entries.";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraExcludePaths = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [];
|
||||||
|
description = "Paths to exclude from the restic backup. Each service module contributes its own entries.";
|
||||||
|
};
|
||||||
|
|
||||||
repository = lib.mkOption {
|
repository = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
example = "sftp:user@nas.local:/backups/homey";
|
example = "sftp:user@nas.local:/backups/homey";
|
||||||
@@ -82,21 +94,34 @@ in
|
|||||||
# Pre-backup hook: pg_dump + nextcloud maintenance mode
|
# Pre-backup hook: pg_dump + nextcloud maintenance mode
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
systemd.services."homey-backup-pre" = {
|
systemd.services."homey-backup-pre" = {
|
||||||
description = "Pre-backup hooks (pg_dump, NC maintenance mode)";
|
description = "Pre-backup hooks (pg_dump, NC maintenance mode, secrets env)";
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
ExecStart = pkgs.writeShellScript "backup-pre" ''
|
ExecStart = pkgs.writeShellScript "backup-pre" ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
podman="${pkgs.podman}/bin/podman"
|
||||||
|
|
||||||
|
# Write S3 credentials env file now, before restic-backups-homey.service
|
||||||
|
# starts — systemd loads EnvironmentFile= before ExecStartPre runs, so
|
||||||
|
# the file must already exist when the restic unit activates.
|
||||||
|
install -m 0600 /dev/null /run/restic-homey-secrets.env
|
||||||
|
{
|
||||||
|
printf 'AWS_ACCESS_KEY_ID=%s\n' \
|
||||||
|
"$(cat ${config.sops.secrets."restic/s3_access_key_id".path})"
|
||||||
|
printf 'AWS_SECRET_ACCESS_KEY=%s\n' \
|
||||||
|
"$(cat ${config.sops.secrets."restic/s3_secret_access_key".path})"
|
||||||
|
printf 'RESTIC_CACHE_DIR=%s\n' "${dataDir}/restic-cache"
|
||||||
|
} >> /run/restic-homey-secrets.env
|
||||||
|
|
||||||
# Put Nextcloud into maintenance mode (if running)
|
# Put Nextcloud into maintenance mode (if running)
|
||||||
if systemctl is-active --quiet podman-nextcloud.service; then
|
if systemctl is-active --quiet podman-nextcloud.service; then
|
||||||
podman exec nextcloud php occ maintenance:mode --on || true
|
$podman exec nextcloud php occ maintenance:mode --on || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Dump postgres (if running)
|
# Dump postgres (if running)
|
||||||
if systemctl is-active --quiet podman-nextcloud-postgres.service; then
|
if systemctl is-active --quiet podman-nextcloud-postgres.service; then
|
||||||
install -d -m 700 ${dataDir}/nextcloud/db-dump
|
install -d -m 700 ${dataDir}/nextcloud/db-dump
|
||||||
podman exec nextcloud-postgres \
|
$podman exec nextcloud-postgres \
|
||||||
pg_dump -U postgres nextcloud_db \
|
pg_dump -U postgres nextcloud_db \
|
||||||
> ${dataDir}/nextcloud/db-dump/nextcloud.sql
|
> ${dataDir}/nextcloud/db-dump/nextcloud.sql
|
||||||
fi
|
fi
|
||||||
@@ -104,19 +129,6 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services."homey-backup-post" = {
|
|
||||||
description = "Post-backup hooks (take NC out of maintenance mode)";
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
ExecStart = pkgs.writeShellScript "backup-post" ''
|
|
||||||
set -euo pipefail
|
|
||||||
if systemctl is-active --quiet podman-nextcloud.service; then
|
|
||||||
podman exec nextcloud php occ maintenance:mode --off || true
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Restic backup service
|
# Restic backup service
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
@@ -124,25 +136,18 @@ in
|
|||||||
repository = cfg.repository;
|
repository = cfg.repository;
|
||||||
passwordFile = config.sops.secrets."restic/password".path;
|
passwordFile = config.sops.secrets."restic/password".path;
|
||||||
|
|
||||||
# Runtime env file written by ExecStartPre (see systemd override below)
|
# Runtime env file written by homey-backup-pre.service (which runs first)
|
||||||
environmentFile = "/run/restic-homey-secrets.env";
|
environmentFile = "/run/restic-homey-secrets.env";
|
||||||
|
|
||||||
paths = [
|
# Paths are contributed by individual service modules via homey.backup.extraPaths.
|
||||||
"${dataDir}/openldap"
|
paths = config.homey.backup.extraPaths;
|
||||||
"${dataDir}/authelia"
|
|
||||||
"${dataDir}/gitea"
|
|
||||||
"${dataDir}/nextcloud"
|
|
||||||
# media and transmission config included when those services are enabled:
|
|
||||||
"${dataDir}/jellyfin"
|
|
||||||
"${dataDir}/transmission"
|
|
||||||
# Deliberately excluded: media/* (large, can be re-downloaded)
|
|
||||||
];
|
|
||||||
|
|
||||||
# Exclude Nextcloud's raw DB directory in favour of the pg_dump file
|
|
||||||
exclude = [
|
exclude = [
|
||||||
"${dataDir}/nextcloud/db"
|
# restic's own local cache is never worth backing up
|
||||||
"${dataDir}/restic-cache"
|
"${dataDir}/restic-cache"
|
||||||
];
|
# media is large and can be re-downloaded; services exclude their own consume dirs
|
||||||
|
"${dataDir}/media"
|
||||||
|
] ++ config.homey.backup.extraExcludePaths;
|
||||||
|
|
||||||
timerConfig = {
|
timerConfig = {
|
||||||
OnCalendar = cfg.schedule;
|
OnCalendar = cfg.schedule;
|
||||||
@@ -156,36 +161,21 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Wire the pre/post hooks around the restic job and inject secrets
|
# Wire the pre/post hooks around the restic job
|
||||||
systemd.services."restic-backups-homey" = {
|
systemd.services."restic-backups-homey" = {
|
||||||
requires = [ "homey-backup-pre.service" ];
|
requires = [ "homey-backup-pre.service" ];
|
||||||
after = [ "homey-backup-pre.service" ];
|
after = [ "homey-backup-pre.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
# Write runtime env file with actual secret values (restic needs the
|
|
||||||
# raw values; it does not support _FILE suffix env vars).
|
|
||||||
ExecStartPre = [
|
|
||||||
(pkgs.writeShellScript "restic-inject-secrets" ''
|
|
||||||
install -m 0600 /dev/null /run/restic-homey-secrets.env
|
|
||||||
{
|
|
||||||
printf 'AWS_ACCESS_KEY_ID=%s\n' \
|
|
||||||
"$(cat ${config.sops.secrets."restic/s3_access_key_id".path})"
|
|
||||||
printf 'AWS_SECRET_ACCESS_KEY=%s\n' \
|
|
||||||
"$(cat ${config.sops.secrets."restic/s3_secret_access_key".path})"
|
|
||||||
printf 'RESTIC_CACHE_DIR=%s\n' "${dataDir}/restic-cache"
|
|
||||||
} >> /run/restic-homey-secrets.env
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
ExecStopPost = [
|
ExecStopPost = [
|
||||||
(pkgs.writeShellScript "restic-cleanup-secrets" ''
|
(pkgs.writeShellScript "restic-post-hooks" ''
|
||||||
|
# Always runs on stop, success or failure
|
||||||
rm -f /run/restic-homey-secrets.env
|
rm -f /run/restic-homey-secrets.env
|
||||||
|
if systemctl is-active --quiet podman-nextcloud.service; then
|
||||||
|
${pkgs.podman}/bin/podman exec nextcloud php occ maintenance:mode --off || true
|
||||||
|
fi
|
||||||
'')
|
'')
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services."homey-backup-post" = {
|
|
||||||
after = [ "restic-backups-homey.service" ];
|
|
||||||
wantedBy = [ "restic-backups-homey.service" ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-118
@@ -42,19 +42,16 @@ let
|
|||||||
|
|
||||||
# Reusable Authelia forward_auth snippet
|
# Reusable Authelia forward_auth snippet
|
||||||
# Returns a Caddyfile snippet block that applies forward_auth.
|
# Returns a Caddyfile snippet block that applies forward_auth.
|
||||||
|
# Uses the v4.38+ /api/authz/forward-auth endpoint which correctly honours
|
||||||
|
# one_factor policy without forcing TOTP enrollment on new users.
|
||||||
# copy_headers makes Authelia's Remote-* headers available downstream.
|
# copy_headers makes Authelia's Remote-* headers available downstream.
|
||||||
autheliaForwardAuth = ''
|
autheliaForwardAuth = ''
|
||||||
forward_auth localhost:9091 {
|
forward_auth localhost:9091 {
|
||||||
uri /api/verify?rd=https://auth.${domain}
|
uri /api/authz/forward-auth?authelia_url=https://auth.${domain}
|
||||||
copy_headers Remote-User Remote-Name Remote-Groups Remote-Email
|
copy_headers Remote-User Remote-Name Remote-Groups Remote-Email
|
||||||
# Always tell Authelia the scheme is https (cloudflared terminates TLS
|
# Always tell Authelia the scheme is https (cloudflared terminates TLS
|
||||||
# externally; Caddy's http:// vhosts are only for the tunnel loopback).
|
# externally; Caddy's http:// vhosts are only for the tunnel loopback).
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
# On auth failure, redirect to the authelia login page
|
|
||||||
@goauth status 401
|
|
||||||
handle_response @goauth {
|
|
||||||
redir https://auth.${domain}?rm={method} 302
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -68,6 +65,38 @@ in
|
|||||||
default = "admin@zakobar.com";
|
default = "admin@zakobar.com";
|
||||||
description = "Email for Let's Encrypt ACME registration.";
|
description = "Email for Let's Encrypt ACME registration.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
virtualHosts = lib.mkOption {
|
||||||
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
subdomain = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Subdomain under homeyConfig.domain (e.g. \"mealie\" → mealie.zakobar.com).";
|
||||||
|
};
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
description = "Host port to reverse-proxy to.";
|
||||||
|
};
|
||||||
|
auth = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Prepend Authelia forward_auth to this vhost.";
|
||||||
|
};
|
||||||
|
extraConfig = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Replaces the auto-generated 'reverse_proxy localhost:<port>' for HTTPS. Empty = use default.";
|
||||||
|
};
|
||||||
|
extraHttpConfig = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Replaces the auto-generated cfProxy for the HTTP loopback vhost. Empty = use default.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = [];
|
||||||
|
description = "Virtual hosts to generate. Each service module contributes its own entries.";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
@@ -92,119 +121,27 @@ in
|
|||||||
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Each virtual host.
|
# Each virtual host is generated from homey.caddy.virtualHosts entries.
|
||||||
|
# Each service module contributes its own entries to that list.
|
||||||
#
|
#
|
||||||
# Each service gets two vhost entries:
|
# Each entry produces two Caddy vhosts:
|
||||||
# - "host" (no scheme) → Caddy handles HTTPS + auto cert (for LAN access)
|
# - "subdomain.domain" → HTTPS (LAN access + Let's Encrypt cert)
|
||||||
# - "http://host" → plain HTTP for cloudflared on loopback (no redirect)
|
# - "http://subdomain.domain" → plain HTTP for cloudflared loopback
|
||||||
#
|
virtualHosts = lib.listToAttrs (
|
||||||
# Caddy auto-redirects HTTP→HTTPS only when no explicit http:// vhost exists.
|
lib.concatMap (vh:
|
||||||
# By defining http:// explicitly we suppress that redirect so cloudflared
|
let
|
||||||
# (which talks plain HTTP on port 80) gets a direct response.
|
d = "${vh.subdomain}.${domain}";
|
||||||
virtualHosts = {
|
authSnip = lib.optionalString vh.auth autheliaForwardAuth;
|
||||||
|
httpsBody = if vh.extraConfig != "" then vh.extraConfig
|
||||||
# ------------------------------------------------------------------
|
else "reverse_proxy localhost:${toString vh.port}\n";
|
||||||
# Authelia — public, no auth gate (it IS the auth gate)
|
httpBody = if vh.extraHttpConfig != "" then vh.extraHttpConfig
|
||||||
# ------------------------------------------------------------------
|
else cfProxy vh.port;
|
||||||
"auth.${domain}" = {
|
in [
|
||||||
extraConfig = ''
|
{ name = d; value.extraConfig = "${authSnip}${httpsBody}"; }
|
||||||
reverse_proxy localhost:9091
|
{ name = "http://${d}"; value.extraConfig = "${authSnip}${httpBody}"; }
|
||||||
'';
|
]
|
||||||
};
|
) cfg.virtualHosts
|
||||||
"http://auth.${domain}" = {
|
);
|
||||||
extraConfig = cfProxy 9091;
|
|
||||||
};
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 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 = ''
|
|
||||||
reverse_proxy localhost:3000
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
"http://git.${domain}" = {
|
|
||||||
extraConfig = cfProxy 3000;
|
|
||||||
};
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Nextcloud — public auth (Nextcloud manages its own users + LDAP)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
"nextcloud.${domain}" = {
|
|
||||||
extraConfig = ''
|
|
||||||
# Redirect CardDAV/CalDAV discovery
|
|
||||||
redir /.well-known/carddav /remote.php/dav/ 301
|
|
||||||
redir /.well-known/caldav /remote.php/dav/ 301
|
|
||||||
|
|
||||||
# Large uploads (5 GB)
|
|
||||||
request_body {
|
|
||||||
max_size 5GB
|
|
||||||
}
|
|
||||||
|
|
||||||
reverse_proxy localhost:8080
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
"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)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
"ldapadmin.${domain}" = {
|
|
||||||
extraConfig = ''
|
|
||||||
${autheliaForwardAuth}
|
|
||||||
reverse_proxy localhost:8081
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
"http://ldapadmin.${domain}" = {
|
|
||||||
extraConfig = ''
|
|
||||||
${autheliaForwardAuth}
|
|
||||||
${cfProxy 8081}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Jellyfin — no forward_auth; Jellyfin has its own login UI and
|
|
||||||
# native app clients can't handle SSO redirects.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
"jellyfin.${domain}" = {
|
|
||||||
extraConfig = ''
|
|
||||||
reverse_proxy localhost:8096
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
"http://jellyfin.${domain}" = {
|
|
||||||
extraConfig = cfProxy 8096;
|
|
||||||
};
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Transmission — two_factor, admins only (enforced by authelia policy)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
"torrent.${domain}" = {
|
|
||||||
extraConfig = ''
|
|
||||||
${autheliaForwardAuth}
|
|
||||||
reverse_proxy localhost:9092
|
|
||||||
'';
|
|
||||||
# NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091.
|
|
||||||
};
|
|
||||||
"http://torrent.${domain}" = {
|
|
||||||
extraConfig = ''
|
|
||||||
${autheliaForwardAuth}
|
|
||||||
${cfProxy 9092}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
# ldapadmin.zakobar.com → https://localhost:443
|
# ldapadmin.zakobar.com → https://localhost:443
|
||||||
# jellyfin.zakobar.com → https://localhost:443
|
# jellyfin.zakobar.com → https://localhost:443
|
||||||
# torrent.zakobar.com → https://localhost:443
|
# torrent.zakobar.com → https://localhost:443
|
||||||
|
# uptime.zakobar.com → https://localhost:443
|
||||||
|
# ntfy.zakobar.com → https://localhost:443
|
||||||
|
# grafana.zakobar.com → https://localhost:443
|
||||||
# Set "No TLS Verify" = true (Caddy's cert is from Let's Encrypt but
|
# Set "No TLS Verify" = true (Caddy's cert is from Let's Encrypt but
|
||||||
# the hostname seen by cloudflared is localhost, so hostname verification
|
# the hostname seen by cloudflared is localhost, so hostname verification
|
||||||
# would fail without this flag).
|
# would fail without this flag).
|
||||||
|
|||||||
@@ -21,6 +21,14 @@
|
|||||||
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
||||||
"nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk="
|
"nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk="
|
||||||
];
|
];
|
||||||
|
# Trigger GC automatically when free space drops below 2 GB;
|
||||||
|
# stop once 5 GB is free. Prevents CI builds from filling the disk
|
||||||
|
# between weekly GC runs.
|
||||||
|
min-free = 2147483648; # 2 GiB
|
||||||
|
max-free = 5368709120; # 5 GiB
|
||||||
|
# Use the external drive for sandbox builds — the default /tmp is a
|
||||||
|
# small RAM-backed tmpfs that fills up during large builds (e.g. wrangler).
|
||||||
|
build-dir = "/mnt/data/nix-build";
|
||||||
};
|
};
|
||||||
gc = {
|
gc = {
|
||||||
automatic = true;
|
automatic = true;
|
||||||
@@ -32,6 +40,10 @@
|
|||||||
# Allow unfree packages (e.g. cloudflared binary)
|
# Allow unfree packages (e.g. cloudflared binary)
|
||||||
nixpkgs.config.allowUnfree = true;
|
nixpkgs.config.allowUnfree = true;
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d /mnt/data/nix-build 0755 root root -"
|
||||||
|
];
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Boot — set in hardware.nix; this is just a safe default
|
# Boot — set in hardware.nix; this is just a safe default
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -80,6 +92,26 @@
|
|||||||
defaultNetwork.settings.dns_enabled = true;
|
defaultNetwork.settings.dns_enabled = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Create the shared "homey" podman network that all service containers join.
|
||||||
|
# DNS is enabled by default on netavark-backed networks, so containers can
|
||||||
|
# reach each other by container name (e.g. "openldap", "nextcloud-postgres").
|
||||||
|
systemd.services.podman-homey-network = {
|
||||||
|
description = "Create homey podman network";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
before = [ "podman-openldap.service" "podman-authelia.service"
|
||||||
|
"podman-gitea.service" "podman-nextcloud-postgres.service"
|
||||||
|
"podman-nextcloud.service" "podman-phpldapadmin.service"
|
||||||
|
"podman-jellyfin.service" "podman-transmission.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
ExecStart = pkgs.writeShellScript "create-homey-network" ''
|
||||||
|
${pkgs.podman}/bin/podman network exists homey \
|
||||||
|
|| ${pkgs.podman}/bin/podman network create homey
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Core packages available on every host
|
# Core packages available on every host
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Prometheus + Grafana — metrics collection and dashboarding.
|
||||||
|
#
|
||||||
|
# Uses native NixOS services (not containers) for tight integration with
|
||||||
|
# the host OS and declarative dashboard/datasource provisioning.
|
||||||
|
#
|
||||||
|
# Architecture:
|
||||||
|
# node_exporter → Prometheus ← systemd_exporter
|
||||||
|
# ↓
|
||||||
|
# Grafana (pre-provisioned dashboard: Node Exporter Full)
|
||||||
|
#
|
||||||
|
# Auth (Grafana):
|
||||||
|
# Authelia enforces two_factor + admins-only before any request reaches
|
||||||
|
# Grafana. Caddy then maps the Authelia Remote-User header to
|
||||||
|
# X-WEBAUTH-USER, and Grafana's proxy auth auto-signs the user in —
|
||||||
|
# no second login required.
|
||||||
|
#
|
||||||
|
# Prometheus is internal-only (127.0.0.1:9090); only Grafana reads it.
|
||||||
|
# Grafana is exposed at 127.0.0.1:3002 and reverse-proxied by Caddy.
|
||||||
|
#
|
||||||
|
# Data dirs:
|
||||||
|
# Prometheus: /var/lib/prometheus2 (system drive — metrics are ephemeral)
|
||||||
|
# Grafana: /var/lib/grafana (system drive — dashboards provisioned by Nix)
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# grafana/secret_key (session signing key)
|
||||||
|
# openldap/ro_password (for Grafana → LDAP auth, shared with other modules)
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.monitoring;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
|
||||||
|
# LDAP base DN derived from domain (e.g. zakobar.com → dc=zakobar,dc=com)
|
||||||
|
ldapBaseDN = lib.concatStringsSep ","
|
||||||
|
(map (p: "dc=${p}") (lib.splitString "." domain));
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.homey.monitoring = {
|
||||||
|
enable = lib.mkEnableOption "Prometheus + Grafana monitoring stack" // { default = true; };
|
||||||
|
|
||||||
|
prometheusPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 9090;
|
||||||
|
description = "Prometheus listen port (localhost only).";
|
||||||
|
};
|
||||||
|
|
||||||
|
grafanaPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 3002;
|
||||||
|
description = "Grafana listen port (localhost only, reverse-proxied by Caddy).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
sops.secrets."grafana/secret_key" = { owner = "grafana"; };
|
||||||
|
sops.secrets."openldap/ro_password" = { owner = "root"; };
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Prometheus
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
services.prometheus = {
|
||||||
|
enable = true;
|
||||||
|
listenAddress = "127.0.0.1";
|
||||||
|
port = cfg.prometheusPort;
|
||||||
|
|
||||||
|
globalConfig = {
|
||||||
|
scrape_interval = "30s";
|
||||||
|
evaluation_interval = "30s";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Scrape node and systemd metrics from local exporters
|
||||||
|
scrapeConfigs = [
|
||||||
|
{
|
||||||
|
job_name = "node";
|
||||||
|
static_configs = [{
|
||||||
|
targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.node.port}" ];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
job_name = "systemd";
|
||||||
|
static_configs = [{
|
||||||
|
targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.systemd.port}" ];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
exporters = {
|
||||||
|
node = {
|
||||||
|
enable = true;
|
||||||
|
port = 9100;
|
||||||
|
# Enable extra collectors beyond the defaults
|
||||||
|
enabledCollectors = [
|
||||||
|
"cpu"
|
||||||
|
"diskstats"
|
||||||
|
"filesystem"
|
||||||
|
"loadavg"
|
||||||
|
"meminfo"
|
||||||
|
"netdev"
|
||||||
|
"stat"
|
||||||
|
"time"
|
||||||
|
"uname"
|
||||||
|
"pressure" # CPU/memory/IO pressure stall info (Linux PSI)
|
||||||
|
"hwmon" # temperature sensors (RPi4 has a CPU temp sensor)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
enable = true;
|
||||||
|
port = 9558;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Grafana
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
services.grafana = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
server = {
|
||||||
|
http_addr = "127.0.0.1";
|
||||||
|
http_port = cfg.grafanaPort;
|
||||||
|
domain = "grafana.${domain}";
|
||||||
|
root_url = "https://grafana.${domain}";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Session signing key — read from sops at runtime via Grafana's
|
||||||
|
# $__file{} interpolation syntax.
|
||||||
|
security = {
|
||||||
|
secret_key = "$__file{${config.sops.secrets."grafana/secret_key".path}}";
|
||||||
|
# Disable Grafana's own login form — Authelia is the auth gate,
|
||||||
|
# and proxy auth auto-signs users in via the X-WEBAUTH-USER header.
|
||||||
|
disable_initial_admin_creation = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Proxy auth: trust the X-WEBAUTH-USER header set by Caddy after
|
||||||
|
# Authelia verifies the user's identity and TOTP.
|
||||||
|
"auth.proxy" = {
|
||||||
|
enabled = true;
|
||||||
|
header_name = "X-WEBAUTH-USER";
|
||||||
|
header_property = "username";
|
||||||
|
auto_sign_up = true;
|
||||||
|
# All users that reach Grafana are already confirmed admins
|
||||||
|
# (Authelia enforces the admins group + two_factor policy).
|
||||||
|
headers = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Disable Grafana's own login UI — all auth goes via Authelia.
|
||||||
|
# Set to false to keep a fallback login form (useful for recovery).
|
||||||
|
"auth" = {
|
||||||
|
disable_login_form = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Assign all proxy-auth users the Admin role automatically.
|
||||||
|
# Safe because Authelia already restricts access to the admins group.
|
||||||
|
users = {
|
||||||
|
auto_assign_org_role = "Admin";
|
||||||
|
};
|
||||||
|
|
||||||
|
analytics.reporting_enabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Provision Prometheus as a datasource
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
provision = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
datasources.settings.datasources = [{
|
||||||
|
name = "Prometheus";
|
||||||
|
type = "prometheus";
|
||||||
|
url = "http://127.0.0.1:${toString cfg.prometheusPort}";
|
||||||
|
isDefault = true;
|
||||||
|
access = "proxy";
|
||||||
|
}];
|
||||||
|
|
||||||
|
# Pre-load the Node Exporter Full community dashboard (ID 1860).
|
||||||
|
# The JSON is downloaded via Nix so it's available at build time.
|
||||||
|
dashboards.settings.providers = [{
|
||||||
|
name = "default";
|
||||||
|
options.path = "/etc/grafana/dashboards";
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Download the Node Exporter Full dashboard JSON at build time.
|
||||||
|
#
|
||||||
|
# If the hash is wrong, `nix build` will print the correct one.
|
||||||
|
# Run: nix store prefetch-file --hash-type sha256 \
|
||||||
|
# https://grafana.com/api/dashboards/1860/revisions/37/download
|
||||||
|
# and replace the hash below.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
environment.etc."grafana/dashboards/node-exporter-full.json" = {
|
||||||
|
source = pkgs.fetchurl {
|
||||||
|
url = "https://grafana.com/api/dashboards/1860/revisions/37/download";
|
||||||
|
hash = "sha256-1DE1aaanRHHeCOMWDGdOS1wBXxOF84UXAjJzT5Ek6mM=";
|
||||||
|
};
|
||||||
|
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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "grafana";
|
||||||
|
port = cfg.grafanaPort;
|
||||||
|
auth = true;
|
||||||
|
extraConfig = ''
|
||||||
|
reverse_proxy localhost:${toString cfg.grafanaPort} {
|
||||||
|
header_up X-WEBAUTH-USER {http.request.header.Remote-User}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
extraHttpConfig = ''
|
||||||
|
reverse_proxy localhost:${toString cfg.grafanaPort} {
|
||||||
|
header_up X-Forwarded-Proto https
|
||||||
|
header_up X-WEBAUTH-USER {http.request.header.Remote-User}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
}];
|
||||||
|
|
||||||
|
# Grafana and Prometheus use system state dirs (/var/lib/grafana,
|
||||||
|
# /var/lib/prometheus2) — no extraDirs or backup entries needed.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor for Grafana
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Grafana";
|
||||||
|
url = "https://grafana.${domain}";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 <pull-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Log in for pushing (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:zhaofengli/attic -- login homey https://attic.zakobar.com <admin-or-push-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <path>
|
||||||
|
|
||||||
|
# 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 <token>`
|
||||||
|
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`
|
||||||
@@ -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:
|
||||||
|
# <dataDir>/attic/ → /data (SQLite DB)
|
||||||
|
# <dataDir>/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;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
+110
-36
@@ -17,6 +17,10 @@
|
|||||||
# authelia/session_secret
|
# authelia/session_secret
|
||||||
# authelia/storage_encryption_key
|
# authelia/storage_encryption_key
|
||||||
# openldap/ro_password (shared with openldap module)
|
# 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
|
let
|
||||||
cfg = config.homey.authelia;
|
cfg = config.homey.authelia;
|
||||||
@@ -27,9 +31,29 @@ let
|
|||||||
ldapBaseDN = lib.concatStringsSep ","
|
ldapBaseDN = lib.concatStringsSep ","
|
||||||
(map (p: "dc=${p}") (lib.splitString "." domain));
|
(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
|
# The authelia config is written as a Nix string so all values are
|
||||||
# resolved at build time except for secrets, which are injected at
|
# 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 = ''
|
autheliaConfig = ''
|
||||||
###############################################################
|
###############################################################
|
||||||
# Authelia configuration #
|
# Authelia configuration #
|
||||||
@@ -43,7 +67,7 @@ let
|
|||||||
authentication_backend:
|
authentication_backend:
|
||||||
ldap:
|
ldap:
|
||||||
implementation: "custom"
|
implementation: "custom"
|
||||||
url: "ldap://127.0.0.1:389"
|
url: "ldap://openldap:389"
|
||||||
timeout: "5s"
|
timeout: "5s"
|
||||||
start_tls: false
|
start_tls: false
|
||||||
base_dn: "${ldapBaseDN}"
|
base_dn: "${ldapBaseDN}"
|
||||||
@@ -79,35 +103,7 @@ let
|
|||||||
access_control:
|
access_control:
|
||||||
default_policy: "deny"
|
default_policy: "deny"
|
||||||
rules:
|
rules:
|
||||||
- domain:
|
${rulesYaml}
|
||||||
- "auth.${domain}"
|
|
||||||
policy: "bypass"
|
|
||||||
- domain:
|
|
||||||
- "ldapadmin.${domain}"
|
|
||||||
subject:
|
|
||||||
- "group:admins"
|
|
||||||
policy: "two_factor"
|
|
||||||
- domain:
|
|
||||||
- "ldapadmin.${domain}"
|
|
||||||
policy: "deny"
|
|
||||||
- domain:
|
|
||||||
- "torrent.${domain}"
|
|
||||||
subject:
|
|
||||||
- "group:admins"
|
|
||||||
policy: "two_factor"
|
|
||||||
- domain:
|
|
||||||
- "torrent.${domain}"
|
|
||||||
policy: "deny"
|
|
||||||
- domain:
|
|
||||||
- "git.${domain}"
|
|
||||||
policy: "one_factor"
|
|
||||||
- domain:
|
|
||||||
- "nextcloud.${domain}"
|
|
||||||
policy: "one_factor"
|
|
||||||
- domain:
|
|
||||||
- "jellyfin.${domain}"
|
|
||||||
policy: "one_factor"
|
|
||||||
|
|
||||||
notifier:
|
notifier:
|
||||||
filesystem:
|
filesystem:
|
||||||
filename: "/config/emails.txt"
|
filename: "/config/emails.txt"
|
||||||
@@ -123,7 +119,41 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.authelia = {
|
options.homey.authelia = {
|
||||||
enable = lib.mkEnableOption "Authelia SSO gateway";
|
# 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 {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -138,6 +168,15 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
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
|
# Secrets
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
@@ -162,7 +201,7 @@ in
|
|||||||
virtualisation.oci-containers.containers.authelia = {
|
virtualisation.oci-containers.containers.authelia = {
|
||||||
image = cfg.image;
|
image = cfg.image;
|
||||||
|
|
||||||
# No ports mapping — --network=host shares the host network stack directly.
|
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
TZ = homeyConfig.timezone;
|
TZ = homeyConfig.timezone;
|
||||||
@@ -171,6 +210,10 @@ in
|
|||||||
AUTHELIA_SESSION_SECRET_FILE = "/run/secrets/session_secret";
|
AUTHELIA_SESSION_SECRET_FILE = "/run/secrets/session_secret";
|
||||||
AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE = "/run/secrets/storage_encryption_key";
|
AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE = "/run/secrets/storage_encryption_key";
|
||||||
AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = "/run/secrets/ldap_ro_password";
|
AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = "/run/secrets/ldap_ro_password";
|
||||||
|
# Changing this forces a container restart when the config changes.
|
||||||
|
# NixOS bind-mounts resolve symlinks at container start, so the running
|
||||||
|
# container would otherwise keep the old nix-store config until restarted.
|
||||||
|
NIXOS_CONFIG_HASH = builtins.hashString "sha256" autheliaConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
volumes = [
|
volumes = [
|
||||||
@@ -184,7 +227,7 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [
|
extraOptions = [
|
||||||
"--network=host"
|
"--network=homey"
|
||||||
"--hostname=authelia"
|
"--hostname=authelia"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -193,8 +236,39 @@ in
|
|||||||
# Systemd — wait for openldap and external HD
|
# Systemd — wait for openldap and external HD
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
systemd.services."podman-authelia" = {
|
systemd.services."podman-authelia" = {
|
||||||
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
|
requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Caddy virtual host — no forward_auth (Authelia IS the auth gateway)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "auth";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = false;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "authelia"; }
|
||||||
|
{ path = "authelia/config"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/authelia" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor for this service
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Authelia";
|
||||||
|
url = "https://auth.${domain}/api/health";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Eurovision Vote — Django app for ranking Eurovision performances.
|
||||||
|
#
|
||||||
|
# Uses the NixOS module from the eurovote flake (eurovote.nixosModules.default).
|
||||||
|
# This wrapper wires it into the homey module system: enable flag, sops secret,
|
||||||
|
# and uptime monitoring.
|
||||||
|
#
|
||||||
|
# The app uses DynamicUser + StateDirectory so systemd owns /var/lib/eurovote/;
|
||||||
|
# no tmpfiles.rules entry needed.
|
||||||
|
#
|
||||||
|
# Authentication: Caddy forward_auth → Authelia; the app reads the
|
||||||
|
# X-Remote-User header set by Caddy (from Authelia's Remote-User).
|
||||||
|
# All authenticated users get app access; /admin/* is restricted to
|
||||||
|
# group:admins by Authelia's access_control rules (defined in this file).
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# eurovote/secret_key
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.eurovote;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.homey.eurovote = {
|
||||||
|
enable = lib.mkEnableOption "Eurovision Vote app" // { default = true; };
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# mode 0444: the service runs as a DynamicUser (random UID) so it cannot
|
||||||
|
# read a root-owned 0400 file. /run/secrets/ itself is not world-listable
|
||||||
|
# (mode 0751), so world-readable here is acceptable on a home server.
|
||||||
|
sops.secrets."eurovote/secret_key" = { owner = "root"; mode = "0444"; };
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Service (options provided by eurovote.nixosModules.default)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
services.eurovote = {
|
||||||
|
enable = true;
|
||||||
|
port = 8007;
|
||||||
|
allowedHosts = "localhost 127.0.0.1 eurovision-vote.${domain}";
|
||||||
|
secretKeyFile = config.sops.secrets."eurovote/secret_key".path;
|
||||||
|
trustedOrigins = "https://eurovision-vote.${domain}";
|
||||||
|
# After SSO logout, send the user back to Authelia's logout page
|
||||||
|
logoutRedirectUrl = "https://auth.${domain}/logout";
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "eurovision-vote";
|
||||||
|
port = 8007;
|
||||||
|
auth = true;
|
||||||
|
extraConfig = ''
|
||||||
|
reverse_proxy localhost:8007 {
|
||||||
|
header_up X-Remote-User {http.request.header.Remote-User}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
extraHttpConfig = ''
|
||||||
|
reverse_proxy localhost:8007 {
|
||||||
|
header_up X-Forwarded-Proto https
|
||||||
|
header_up X-Remote-User {http.request.header.Remote-User}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
}];
|
||||||
|
|
||||||
|
# Eurovision Vote uses DynamicUser + /var/lib/eurovote — no extraDirs needed.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup — /var/lib/eurovote holds the SQLite DB with votes
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "/var/lib/eurovote" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Eurovision Vote";
|
||||||
|
url = "https://eurovision-vote.${domain}";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Gitea Actions Runner — executes CI/CD jobs triggered by Gitea Actions.
|
||||||
|
#
|
||||||
|
# Uses the NixOS native services.gitea-actions-runner module (act runner).
|
||||||
|
# Jobs run directly on the host ("host" executor) — no container isolation.
|
||||||
|
# This is appropriate for a trusted home server and avoids the overhead of
|
||||||
|
# nested containers on a Pi 4.
|
||||||
|
#
|
||||||
|
# The service uses DynamicUser=true so there is no persistent system user.
|
||||||
|
# Job step PATH is controlled by hostPackages (not the service PATH).
|
||||||
|
# nix is not in the NixOS module's default hostPackages and must be added.
|
||||||
|
#
|
||||||
|
# Setup (one-time):
|
||||||
|
# 1. In Gitea: Site Administration → Actions → Runners → Create Runner Token
|
||||||
|
# 2. Store the token in sops with KEY=VALUE format:
|
||||||
|
# gitea/runner_token: "TOKEN=<your-token-here>"
|
||||||
|
# 3. Enable homey.giteaRunner in the host config and deploy.
|
||||||
|
#
|
||||||
|
# After first start the runner registers itself and stores credentials in
|
||||||
|
# /var/lib/gitea-runner/<name>/.runner — the token file is only needed for
|
||||||
|
# (re-)registration.
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# gitea/runner_token (must contain: TOKEN=<value>)
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.giteaRunner;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.homey.giteaRunner = {
|
||||||
|
enable = lib.mkEnableOption "Gitea Actions runner" // { default = true; };
|
||||||
|
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = config.networking.hostName;
|
||||||
|
description = "Runner name as shown in Gitea's runner list.";
|
||||||
|
};
|
||||||
|
|
||||||
|
labels = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ "native:host" "ubuntu-latest:host" "debian-latest:host" "nix:host" ];
|
||||||
|
description = ''
|
||||||
|
Labels advertised to Gitea. The "host" executor runs jobs directly on
|
||||||
|
this machine. Workflow files targeting any of these labels will be
|
||||||
|
picked up by this runner.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# The NixOS module reads tokenFile as a systemd EnvironmentFile (root reads
|
||||||
|
# it before DynamicUser privilege drop), so owner=root / mode=0400 is correct.
|
||||||
|
# The file must contain: TOKEN=<registration-token>
|
||||||
|
sops.secrets."gitea/runner_token" = { owner = "root"; mode = "0400"; };
|
||||||
|
|
||||||
|
services.gitea-actions-runner.instances.${cfg.name} = {
|
||||||
|
enable = true;
|
||||||
|
url = "https://git.${domain}";
|
||||||
|
tokenFile = config.sops.secrets."gitea/runner_token".path;
|
||||||
|
name = cfg.name;
|
||||||
|
labels = cfg.labels;
|
||||||
|
# hostPackages controls the PATH available to job steps (host executor).
|
||||||
|
# nix is not in the default list so must be added explicitly.
|
||||||
|
hostPackages = with pkgs; [
|
||||||
|
bash coreutils curl gawk gitMinimal gnused nodejs wget
|
||||||
|
nix
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.gitea = {
|
options.homey.gitea = {
|
||||||
enable = lib.mkEnableOption "Gitea Git server";
|
enable = lib.mkEnableOption "Gitea Git server" // { default = true; };
|
||||||
|
|
||||||
image = lib.mkOption {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -60,8 +60,7 @@ in
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
virtualisation.oci-containers.containers.gitea = {
|
virtualisation.oci-containers.containers.gitea = {
|
||||||
image = cfg.image;
|
image = cfg.image;
|
||||||
# No ports mapping — --network=host means the container shares the host
|
ports = [ "127.0.0.1:${toString cfg.port}:3000" ];
|
||||||
# network stack directly. Gitea binds to 0.0.0.0:3000 on the host.
|
|
||||||
|
|
||||||
# All non-secret settings via GITEA__<SECTION>__<KEY> env vars.
|
# All non-secret settings via GITEA__<SECTION>__<KEY> env vars.
|
||||||
# These are safe to store in the Nix store.
|
# These are safe to store in the Nix store.
|
||||||
@@ -144,6 +143,9 @@ in
|
|||||||
|
|
||||||
# [oauth2]
|
# [oauth2]
|
||||||
GITEA__oauth2__ENABLED = "false";
|
GITEA__oauth2__ENABLED = "false";
|
||||||
|
|
||||||
|
# [actions]
|
||||||
|
GITEA__actions__ENABLED = "true";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Secret env vars written at runtime by ExecStartPre — never in store.
|
# Secret env vars written at runtime by ExecStartPre — never in store.
|
||||||
@@ -153,7 +155,7 @@ in
|
|||||||
"${dataDir}/gitea/data:/data"
|
"${dataDir}/gitea/data:/data"
|
||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [ "--network=host" ];
|
extraOptions = [ "--network=homey" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
@@ -182,10 +184,52 @@ in
|
|||||||
'')
|
'')
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "git";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = false;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories (UID 1000 = Gitea's internal user)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "gitea"; user = "1000"; group = "1000"; }
|
||||||
|
{ path = "gitea/data"; user = "1000"; group = "1000"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/gitea" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor for this service
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Gitea";
|
||||||
|
url = "https://git.${domain}";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Ensure the Gitea admin user exists with the correct password after start.
|
# Ensure the Gitea admin user exists with the correct password after start.
|
||||||
# Runs as a oneshot after podman-gitea; idempotent (create or update).
|
# Runs as a oneshot after podman-gitea; idempotent (create or update).
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.jellyfin = {
|
options.homey.jellyfin = {
|
||||||
enable = lib.mkEnableOption "Jellyfin media server";
|
enable = lib.mkEnableOption "Jellyfin media server" // { default = true; };
|
||||||
|
|
||||||
image = lib.mkOption {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -30,7 +30,7 @@ in
|
|||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
virtualisation.oci-containers.containers.jellyfin = {
|
virtualisation.oci-containers.containers.jellyfin = {
|
||||||
image = cfg.image;
|
image = cfg.image;
|
||||||
# No ports mapping — --network=host shares the host network stack directly.
|
ports = [ "127.0.0.1:${toString cfg.port}:8096" ];
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}";
|
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}";
|
||||||
@@ -44,12 +44,43 @@ in
|
|||||||
"${dataDir}/media/tvshows:/data/tvshows:ro"
|
"${dataDir}/media/tvshows:/data/tvshows:ro"
|
||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [ "--network=host" ];
|
extraOptions = [ "--network=homey" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services."podman-jellyfin" = {
|
systemd.services."podman-jellyfin" = {
|
||||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "jellyfin";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = false;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "jellyfin"; }
|
||||||
|
{ path = "jellyfin/config"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/jellyfin" ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Mealie — recipe manager and meal planner.
|
||||||
|
#
|
||||||
|
# Auth model: LDAP. Users log in with the same uid/password as the rest of
|
||||||
|
# the stack (OpenLDAP). No Authelia forward_auth — Mealie's own login page
|
||||||
|
# handles authentication via django-auth-ldap.
|
||||||
|
#
|
||||||
|
# Volume layout:
|
||||||
|
# <dataDir>/mealie/data/ → /app/data (SQLite DB, images, backups)
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# mealie/secret_key
|
||||||
|
# openldap/ro_password (shared with openldap module — used as LDAP_QUERY_PASSWORD)
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.mealie;
|
||||||
|
dataDir = config.homey.storage.mountPoint;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
|
||||||
|
# LDAP base DN derived from domain (zakobar.com → dc=zakobar,dc=com)
|
||||||
|
ldapBaseDn = lib.concatStringsSep ","
|
||||||
|
(map (p: "dc=${p}") (lib.splitString "." domain));
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.homey.mealie = {
|
||||||
|
enable = lib.mkEnableOption "Mealie recipe manager" // { default = true; };
|
||||||
|
|
||||||
|
image = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "ghcr.io/mealie-recipes/mealie:latest";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 9093;
|
||||||
|
description = "Host port Mealie listens on (bound to 127.0.0.1).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
sops.secrets."mealie/secret_key" = { owner = "root"; };
|
||||||
|
sops.secrets."openldap/ro_password" = { owner = "root"; };
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Container
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
virtualisation.oci-containers.containers.mealie = {
|
||||||
|
image = cfg.image;
|
||||||
|
ports = [ "127.0.0.1:${toString cfg.port}:9000" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
BASE_URL = "https://mealie.${domain}";
|
||||||
|
ALLOW_SIGNUP = "false";
|
||||||
|
TZ = homeyConfig.timezone;
|
||||||
|
|
||||||
|
# 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";
|
||||||
|
LDAP_MAIL_ATTRIBUTE = "mail";
|
||||||
|
};
|
||||||
|
|
||||||
|
environmentFiles = [ "/run/mealie-secrets.env" ];
|
||||||
|
|
||||||
|
volumes = [
|
||||||
|
"${dataDir}/mealie/data:/app/data"
|
||||||
|
];
|
||||||
|
|
||||||
|
extraOptions = [ "--network=homey" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# ExecStartPre: write ephemeral secrets env file
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
systemd.services."podman-mealie" = {
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStartPre = [
|
||||||
|
(pkgs.writeShellScript "mealie-write-secrets" ''
|
||||||
|
set -euo pipefail
|
||||||
|
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
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
};
|
||||||
|
postStop = "rm -f /run/mealie-secrets.env";
|
||||||
|
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
|
||||||
|
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Caddy virtual host — no forward_auth; Mealie uses LDAP login page
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "mealie";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = false;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "mealie"; }
|
||||||
|
{ path = "mealie/data"; mode = "0755"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/mealie" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Mealie";
|
||||||
|
url = "https://mealie.${domain}";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
+123
-14
@@ -18,10 +18,41 @@ let
|
|||||||
cfg = config.homey.nextcloud;
|
cfg = config.homey.nextcloud;
|
||||||
dataDir = config.homey.storage.mountPoint;
|
dataDir = config.homey.storage.mountPoint;
|
||||||
domain = homeyConfig.domain;
|
domain = homeyConfig.domain;
|
||||||
|
|
||||||
|
# Custom Nextcloud config mounted into the container as an extra config file.
|
||||||
|
# Nextcloud auto-loads all *.config.php files in /var/www/html/config/.
|
||||||
|
nextcloudCustomConfig = pkgs.writeText "zakobar.config.php" ''
|
||||||
|
<?php
|
||||||
|
$CONFIG = [
|
||||||
|
// Throttle preview generation during bulk uploads.
|
||||||
|
// Generating thumbnails re-reads every uploaded file and writes preview
|
||||||
|
// files, roughly doubling disk I/O. Limiting concurrency to 1 prevents
|
||||||
|
// the drive from being hit by simultaneous read+write storms.
|
||||||
|
'preview_concurrency_new' => 1,
|
||||||
|
'preview_concurrency_all' => 1,
|
||||||
|
// Cap preview dimensions to reduce per-preview write size.
|
||||||
|
'preview_max_x' => 1024,
|
||||||
|
'preview_max_y' => 1024,
|
||||||
|
'jpeg_quality' => 75,
|
||||||
|
];
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Limit Apache's prefork MPM so at most 4 PHP processes write to the USB
|
||||||
|
# drive simultaneously. Default is often 150, which causes an I/O storm
|
||||||
|
# on slow USB HDDs. Lower = fewer concurrent writers = more stable I/O.
|
||||||
|
apacheMpmConfig = pkgs.writeText "mpm_prefork.conf" ''
|
||||||
|
<IfModule mpm_prefork_module>
|
||||||
|
StartServers 2
|
||||||
|
MinSpareServers 1
|
||||||
|
MaxSpareServers 3
|
||||||
|
MaxRequestWorkers 4
|
||||||
|
MaxConnectionsPerChild 500
|
||||||
|
</IfModule>
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.nextcloud = {
|
options.homey.nextcloud = {
|
||||||
enable = lib.mkEnableOption "Nextcloud file server";
|
enable = lib.mkEnableOption "Nextcloud file server" // { default = true; };
|
||||||
|
|
||||||
image = lib.mkOption {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -58,7 +89,9 @@ in
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
virtualisation.oci-containers.containers.nextcloud-postgres = {
|
virtualisation.oci-containers.containers.nextcloud-postgres = {
|
||||||
image = cfg.postgresImage;
|
image = cfg.postgresImage;
|
||||||
# No ports mapping — --network=host shares the host network stack directly.
|
# Exposed on localhost for debugging; nextcloud reaches it via the
|
||||||
|
# container name "nextcloud-postgres" on the homey network.
|
||||||
|
ports = [ "127.0.0.1:${toString cfg.postgresPort}:5432" ];
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
POSTGRES_DB = "nextcloud_db";
|
POSTGRES_DB = "nextcloud_db";
|
||||||
@@ -71,7 +104,7 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [
|
extraOptions = [
|
||||||
"--network=host"
|
"--network=homey"
|
||||||
"--env-file=/run/nc-postgres-secrets.env"
|
"--env-file=/run/nc-postgres-secrets.env"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -91,8 +124,8 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
postStop = "rm -f /run/nc-postgres-secrets.env";
|
postStop = "rm -f /run/nc-postgres-secrets.env";
|
||||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
@@ -100,33 +133,109 @@ in
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
virtualisation.oci-containers.containers.nextcloud = {
|
virtualisation.oci-containers.containers.nextcloud = {
|
||||||
image = cfg.image;
|
image = cfg.image;
|
||||||
# No ports mapping — --network=host shares the host network stack directly.
|
# Apache inside the container listens on port 80; map it to cfg.port on
|
||||||
|
# the host so Caddy can reach it. Postgres is reachable by container name.
|
||||||
|
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
POSTGRES_HOST = "127.0.0.1";
|
POSTGRES_HOST = "nextcloud-postgres";
|
||||||
POSTGRES_DB = "nextcloud_db";
|
POSTGRES_DB = "nextcloud_db";
|
||||||
POSTGRES_USER = "postgres";
|
POSTGRES_USER = "postgres";
|
||||||
NEXTCLOUD_ADMIN_USER = "admin";
|
NEXTCLOUD_ADMIN_USER = "admin";
|
||||||
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
|
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
|
||||||
OVERWRITEPROTOCOL = "https";
|
OVERWRITEPROTOCOL = "https";
|
||||||
OVERWRITECLIURL = "https://nextcloud.${domain}";
|
OVERWRITECLIURL = "https://nextcloud.${domain}";
|
||||||
# With --network=host, port mappings are ignored and the container's
|
OVERWRITEHOST = "nextcloud.${domain}";
|
||||||
# Apache binds directly on the host. Force it onto port 8080 so Caddy
|
# Trust the reverse proxy (Caddy on the host reaches the container
|
||||||
# can own 80/443.
|
# via the podman bridge; cover all RFC-1918 ranges to be robust).
|
||||||
APACHE_HTTP_PORT_NUMBER = toString cfg.port;
|
TRUSTED_PROXIES = "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.1 ::1";
|
||||||
# Passwords injected via env file
|
# Passwords injected via env file
|
||||||
};
|
};
|
||||||
|
|
||||||
volumes = [
|
volumes = [
|
||||||
"${dataDir}/nextcloud/html:/var/www/html"
|
"${dataDir}/nextcloud/html:/var/www/html"
|
||||||
|
# Extra config auto-loaded by Nextcloud (throttles preview generation)
|
||||||
|
"${nextcloudCustomConfig}:/var/www/html/config/zakobar.config.php:ro"
|
||||||
|
# Apache MPM limits (caps concurrent PHP processes / disk writers)
|
||||||
|
"${apacheMpmConfig}:/etc/apache2/mods-available/mpm_prefork.conf:ro"
|
||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [
|
extraOptions = [
|
||||||
"--network=host"
|
"--network=homey"
|
||||||
"--env-file=/run/nc-secrets.env"
|
"--env-file=/run/nc-secrets.env"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "nextcloud";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = false;
|
||||||
|
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:${toString cfg.port} {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
extraHttpConfig = ''
|
||||||
|
redir /.well-known/carddav /remote.php/dav/ 301
|
||||||
|
redir /.well-known/caldav /remote.php/dav/ 301
|
||||||
|
request_body {
|
||||||
|
max_size 5GB
|
||||||
|
}
|
||||||
|
reverse_proxy localhost:${toString cfg.port} {
|
||||||
|
header_up X-Forwarded-Proto https
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# UID 33 = www-data in the Nextcloud container
|
||||||
|
# UID 999 = postgres — must own the db dir (creates files directly in it)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "nextcloud"; }
|
||||||
|
{ path = "nextcloud/html"; user = "33"; group = "33"; }
|
||||||
|
{ path = "nextcloud/db"; mode = "0700"; user = "999"; group = "999"; }
|
||||||
|
{ path = "nextcloud/db-dump"; mode = "0700"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup — exclude raw DB dir (pg_dump file in db-dump/ is used instead)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/nextcloud" ];
|
||||||
|
homey.backup.extraExcludePaths = [ "${dataDir}/nextcloud/db" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor for this service
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Nextcloud";
|
||||||
|
url = "https://nextcloud.${domain}/status.php";
|
||||||
|
interval = 60;
|
||||||
|
keyword = "\"maintenance\":false";
|
||||||
|
# Nightly maintenance is expected — only alert if stuck for 4+ hours.
|
||||||
|
# 240 retries × 60s = 4 hours of consecutive failures before notifying.
|
||||||
|
maxretries = 240;
|
||||||
|
}];
|
||||||
|
|
||||||
systemd.services."podman-nextcloud" = {
|
systemd.services."podman-nextcloud" = {
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
LoadCredential = [
|
LoadCredential = [
|
||||||
@@ -143,8 +252,8 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
postStop = "rm -f /run/nc-secrets.env";
|
postStop = "rm -f /run/nc-secrets.env";
|
||||||
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
|
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" "podman-homey-network.service" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Ntfy — self-hosted push notification server.
|
||||||
|
#
|
||||||
|
# Mobile app (Android/iOS) connects to https://ntfy.zakobar.com with a token
|
||||||
|
# and subscribes to the "alerts" topic. Uptime Kuma and Grafana send alerts
|
||||||
|
# to that topic when services go down.
|
||||||
|
#
|
||||||
|
# Auth model:
|
||||||
|
# - Web UI: public-facing but ntfy enforces its own auth (deny-all by default)
|
||||||
|
# - Caddy does NOT put forward_auth here; ntfy has native token/password auth
|
||||||
|
# so the mobile app can connect without Authelia SSO complications.
|
||||||
|
#
|
||||||
|
# Web Push (PWA via Safari "Add to Home Screen"):
|
||||||
|
# Generate VAPID keys on the Pi:
|
||||||
|
# sudo ntfy webpush keys
|
||||||
|
# Set homey.ntfy.webPushPublicKey and homey.ntfy.webPushEmail in default.nix.
|
||||||
|
# Add the private key to sops: ntfy/web_push_private_key
|
||||||
|
#
|
||||||
|
# Setup after first deploy:
|
||||||
|
# 1. Visit https://ntfy.zakobar.com — log in with the admin password from sops.
|
||||||
|
# 2. Create an access token for your phone (Admin → Users & Tokens).
|
||||||
|
# 3. PWA: open https://ntfy.zakobar.com in Safari → Share → Add to Home Screen,
|
||||||
|
# then open from Home Screen and subscribe to "alerts".
|
||||||
|
#
|
||||||
|
# Volume layout:
|
||||||
|
# <dataDir>/ntfy/auth.db ← user/token database
|
||||||
|
# <dataDir>/ntfy/cache.db ← message cache (for missed messages)
|
||||||
|
# <dataDir>/ntfy/attachments/ ← file attachments
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# ntfy/admin_password
|
||||||
|
# ntfy/web_push_private_key
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.ntfy;
|
||||||
|
dataDir = config.homey.storage.mountPoint;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
|
||||||
|
# All ntfy settings in one place. The private key is NOT here — it is
|
||||||
|
# injected at runtime via ExecStartPre so it never lands in the nix store.
|
||||||
|
ntfySettings = {
|
||||||
|
listen-http = "127.0.0.1:${toString cfg.port}";
|
||||||
|
base-url = "https://ntfy.${domain}";
|
||||||
|
auth-default-access = "deny-all";
|
||||||
|
auth-file = "${dataDir}/ntfy/auth.db";
|
||||||
|
cache-file = "${dataDir}/ntfy/cache.db";
|
||||||
|
attachment-root = "${dataDir}/ntfy/attachments";
|
||||||
|
upstream-base-url = "https://ntfy.sh";
|
||||||
|
cache-duration = "12h";
|
||||||
|
attachment-total-size-limit = "5G";
|
||||||
|
attachment-file-size-limit = "15M";
|
||||||
|
attachment-expiry-duration = "3h";
|
||||||
|
web-push-public-key = cfg.webPushPublicKey;
|
||||||
|
web-push-email-address = cfg.webPushEmail;
|
||||||
|
web-push-file = "${dataDir}/ntfy/webpush.db";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Build-time base config (no private key). ExecStartPre copies this to
|
||||||
|
# /run/ntfy-sh/server.yml and appends web-push-private-key from the credential.
|
||||||
|
baseConfigFile = (pkgs.formats.yaml {}).generate "ntfy-server-base.yml" ntfySettings;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.homey.ntfy = {
|
||||||
|
enable = lib.mkEnableOption "Ntfy push notification server" // { default = true; };
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 2586;
|
||||||
|
description = "Host port ntfy listens on (bound to 127.0.0.1).";
|
||||||
|
};
|
||||||
|
|
||||||
|
webPushPublicKey = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "VAPID public key for Web Push (generate with: sudo ntfy webpush keys).";
|
||||||
|
};
|
||||||
|
|
||||||
|
webPushEmail = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Contact e-mail sent in VAPID headers (e.g. mailto:you@example.com).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
sops.secrets."ntfy/admin_password" = { owner = "root"; };
|
||||||
|
sops.secrets."ntfy/web_push_private_key" = { owner = "root"; };
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# ntfy-sh native NixOS service
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
services.ntfy-sh = {
|
||||||
|
enable = true;
|
||||||
|
settings = ntfySettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Minimal config for the `ntfy user` CLI — the NixOS module puts its
|
||||||
|
# generated config in the nix store under an unpredictable path, so we
|
||||||
|
# write a separate file just containing the auth-file path. The server
|
||||||
|
# ignores this file (it uses the module-generated one via -c flag).
|
||||||
|
environment.etc."ntfy-sh/user-cli.yml" = {
|
||||||
|
text = "auth-file: ${dataDir}/ntfy/auth.db\n";
|
||||||
|
mode = "0444";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Ensure ntfy-sh starts after the HD is mounted and dirs are ready.
|
||||||
|
# Widen ReadWritePaths so ntfy-sh can write to the external HD.
|
||||||
|
# Inject the VAPID private key at runtime: ExecStartPre copies the
|
||||||
|
# build-time base config to /run/ntfy-sh/server.yml and appends the key,
|
||||||
|
# then we override ExecStart to use that runtime config file.
|
||||||
|
systemd.services.ntfy-sh = {
|
||||||
|
after = lib.mkAfter [ "mnt-data.mount" "systemd-tmpfiles-setup.service" ];
|
||||||
|
requires = lib.mkAfter [ "mnt-data.mount" ];
|
||||||
|
serviceConfig = {
|
||||||
|
ReadWritePaths = lib.mkAfter [ "${dataDir}/ntfy" ];
|
||||||
|
RuntimeDirectory = "ntfy-sh"; # creates /run/ntfy-sh, owned by ntfy-sh user
|
||||||
|
# Run as root (+) so the module's sandbox hardening can't block the write.
|
||||||
|
# Read the sops secret directly — no LoadCredential needed.
|
||||||
|
ExecStartPre = "+" + toString (pkgs.writeShellScript "ntfy-write-config" ''
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /run/ntfy-sh
|
||||||
|
cp ${baseConfigFile} /run/ntfy-sh/server.yml
|
||||||
|
printf 'web-push-private-key: %s\n' \
|
||||||
|
"$(cat ${config.sops.secrets."ntfy/web_push_private_key".path})" \
|
||||||
|
>> /run/ntfy-sh/server.yml
|
||||||
|
chown ntfy-sh:ntfy-sh /run/ntfy-sh/server.yml
|
||||||
|
chmod 600 /run/ntfy-sh/server.yml
|
||||||
|
'');
|
||||||
|
ExecStart = lib.mkForce "${pkgs.ntfy-sh}/bin/ntfy serve -c /run/ntfy-sh/server.yml";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Create the admin user on first start (idempotent)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
systemd.services.ntfy-sh-setup = {
|
||||||
|
description = "Create Ntfy admin user";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "ntfy-sh.service" "mnt-data.mount" ];
|
||||||
|
requires = [ "ntfy-sh.service" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
LoadCredential = "ntfy_admin_password:${config.sops.secrets."ntfy/admin_password".path}";
|
||||||
|
|
||||||
|
ExecStart = pkgs.writeShellScript "ntfy-create-admin" ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Wait until ntfy HTTP endpoint is ready (max 60 s)
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/v1/health > /dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
PASS=$(cat "$CREDENTIALS_DIRECTORY/ntfy_admin_password")
|
||||||
|
|
||||||
|
# Use the minimal CLI config (just has auth-file path).
|
||||||
|
NTFY="${pkgs.ntfy-sh}/bin/ntfy user --config /etc/ntfy-sh/user-cli.yml"
|
||||||
|
|
||||||
|
# ntfy user add reads password + confirmation from stdin (two lines).
|
||||||
|
# If the user already exists ntfy exits 1 with "already exists" — treat that as success.
|
||||||
|
if out=$(printf '%s\n%s\n' "$PASS" "$PASS" | $NTFY add --role=admin admin 2>&1); then
|
||||||
|
echo "ntfy-sh-setup: admin user created"
|
||||||
|
elif echo "$out" | grep -q "already exists"; then
|
||||||
|
echo "ntfy-sh-setup: admin user already exists (ok)"
|
||||||
|
else
|
||||||
|
echo "$out" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "ntfy";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = false;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories (owned by the ntfy-sh system user)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "ntfy"; user = "ntfy-sh"; group = "ntfy-sh"; }
|
||||||
|
{ path = "ntfy/attachments"; user = "ntfy-sh"; group = "ntfy-sh"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/ntfy" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor for this service
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Ntfy";
|
||||||
|
url = "https://ntfy.${domain}/v1/health";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.openldap = {
|
options.homey.openldap = {
|
||||||
enable = lib.mkEnableOption "OpenLDAP identity provider";
|
enable = lib.mkEnableOption "OpenLDAP identity provider" // { default = true; };
|
||||||
|
|
||||||
image = lib.mkOption {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -50,10 +50,7 @@ in
|
|||||||
virtualisation.oci-containers.containers.openldap = {
|
virtualisation.oci-containers.containers.openldap = {
|
||||||
image = cfg.image;
|
image = cfg.image;
|
||||||
|
|
||||||
# No ports mapping — --network=host means the container shares the host
|
ports = [ "127.0.0.1:${toString cfg.port}:389" ];
|
||||||
# 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 = {
|
environment = {
|
||||||
LDAP_ORGANISATION = homeyConfig.organization;
|
LDAP_ORGANISATION = homeyConfig.organization;
|
||||||
@@ -78,7 +75,7 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [
|
extraOptions = [
|
||||||
"--network=host"
|
"--network=homey"
|
||||||
"--env-file=/run/openldap-secrets.env"
|
"--env-file=/run/openldap-secrets.env"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -113,10 +110,24 @@ in
|
|||||||
# Clean up the env file on stop
|
# Clean up the env file on stop
|
||||||
postStop = "rm -f /run/openldap-secrets.env";
|
postStop = "rm -f /run/openldap-secrets.env";
|
||||||
# Wait for the external HD to be mounted before starting
|
# Wait for the external HD to be mounted before starting
|
||||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "openldap"; }
|
||||||
|
{ path = "openldap/etc-ldap-slapd.d"; }
|
||||||
|
{ path = "openldap/var-lib-ldap"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/openldap" ];
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Firewall — openldap port is NOT opened externally
|
# Firewall — openldap port is NOT opened externally
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Paperless-ngx — document management with OCR.
|
||||||
|
#
|
||||||
|
# Auth model: HTTP Remote User SSO. Authelia authenticates via Caddy
|
||||||
|
# forward_auth and sets the Remote-User header; Paperless trusts it and
|
||||||
|
# auto-creates/logs in the user. No separate Paperless login needed.
|
||||||
|
#
|
||||||
|
# The admin user (set via homey.paperless.adminUser) is created as a
|
||||||
|
# superuser on first start. Its password is randomly generated and never
|
||||||
|
# used — all logins go through Authelia.
|
||||||
|
#
|
||||||
|
# Requires a Redis sidecar for Celery task workers.
|
||||||
|
#
|
||||||
|
# iOS Shortcut upload: POST /api/documents/post_document/ with
|
||||||
|
# Authorization: Token <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:
|
||||||
|
# <dataDir>/paperless/data/ → /usr/src/paperless/data (DB, index)
|
||||||
|
# <dataDir>/paperless/media/ → /usr/src/paperless/media (document files)
|
||||||
|
# <dataDir>/paperless/consume/ → /usr/src/paperless/consume (drop folder)
|
||||||
|
# <dataDir>/paperless/export/ → /usr/src/paperless/export (export output)
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# paperless/secret_key
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.paperless;
|
||||||
|
dataDir = config.homey.storage.mountPoint;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.homey.paperless = {
|
||||||
|
enable = lib.mkEnableOption "Paperless-ngx document management" // { default = true; };
|
||||||
|
|
||||||
|
image = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "ghcr.io/paperless-ngx/paperless-ngx:latest";
|
||||||
|
};
|
||||||
|
|
||||||
|
redisImage = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "docker.io/redis:7-alpine";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8083;
|
||||||
|
description = "Host port Paperless listens on (bound to 127.0.0.1).";
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
sops.secrets."paperless/secret_key" = { owner = "root"; };
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Redis — Celery task queue, stateless (no persistent storage)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
virtualisation.oci-containers.containers.paperless-redis = {
|
||||||
|
image = cfg.redisImage;
|
||||||
|
extraOptions = [ "--network=homey" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services."podman-paperless-redis" = {
|
||||||
|
after = lib.mkAfter [ "podman-homey-network.service" ];
|
||||||
|
requires = lib.mkAfter [ "podman-homey-network.service" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Paperless container
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
virtualisation.oci-containers.containers.paperless = {
|
||||||
|
image = cfg.image;
|
||||||
|
ports = [ "127.0.0.1:${toString cfg.port}:8000" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
PAPERLESS_REDIS = "redis://paperless-redis:6379";
|
||||||
|
PAPERLESS_URL = "https://paperless.${domain}";
|
||||||
|
PAPERLESS_ALLOWED_HOSTS = "paperless.${domain}";
|
||||||
|
PAPERLESS_CORS_ALLOWED_HOSTS = "https://paperless.${domain}";
|
||||||
|
PAPERLESS_TIME_ZONE = homeyConfig.timezone;
|
||||||
|
PAPERLESS_OCR_LANGUAGE = "eng";
|
||||||
|
USERMAP_UID = "1000";
|
||||||
|
USERMAP_GID = "1000";
|
||||||
|
|
||||||
|
# SSO via Authelia: Caddy's forward_auth copies Remote-User from
|
||||||
|
# Authelia's response; Gunicorn/WSGI exposes it as HTTP_REMOTE_USER.
|
||||||
|
PAPERLESS_ENABLE_HTTP_REMOTE_USER = "true";
|
||||||
|
PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME = "HTTP_REMOTE_USER";
|
||||||
|
# Redirect to Authelia on logout so the SSO session is also cleared.
|
||||||
|
PAPERLESS_LOGOUT_REDIRECT_URL = "https://auth.${domain}";
|
||||||
|
};
|
||||||
|
|
||||||
|
environmentFiles = [ "/run/paperless-secrets.env" ];
|
||||||
|
|
||||||
|
volumes = [
|
||||||
|
"${dataDir}/paperless/data:/usr/src/paperless/data"
|
||||||
|
"${dataDir}/paperless/media:/usr/src/paperless/media"
|
||||||
|
"${dataDir}/paperless/consume:/usr/src/paperless/consume"
|
||||||
|
"${dataDir}/paperless/export:/usr/src/paperless/export"
|
||||||
|
];
|
||||||
|
|
||||||
|
extraOptions = [ "--network=homey" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# ExecStartPre: write ephemeral secrets env file
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
systemd.services."podman-paperless" = {
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStartPre = [
|
||||||
|
(pkgs.writeShellScript "paperless-write-secrets" ''
|
||||||
|
set -euo pipefail
|
||||||
|
install -m 600 /dev/null /run/paperless-secrets.env
|
||||||
|
printf '%s\n' \
|
||||||
|
"PAPERLESS_SECRET_KEY=$(cat ${config.sops.secrets."paperless/secret_key".path})" \
|
||||||
|
>> /run/paperless-secrets.env
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
};
|
||||||
|
postStop = "rm -f /run/paperless-secrets.env";
|
||||||
|
after = lib.mkAfter [ "mnt-data.mount" "podman-paperless-redis.service" "podman-homey-network.service" ];
|
||||||
|
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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "paperless";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = true;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories (UID 1000 = USERMAP_UID in container)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "paperless"; }
|
||||||
|
{ path = "paperless/data"; user = "1000"; group = "1000"; }
|
||||||
|
{ path = "paperless/media"; user = "1000"; group = "1000"; }
|
||||||
|
{ path = "paperless/consume"; user = "1000"; group = "1000"; }
|
||||||
|
{ path = "paperless/export"; user = "1000"; group = "1000"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup — exclude consume dir (unprocessed drops, usually empty)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/paperless" ];
|
||||||
|
homey.backup.extraExcludePaths = [ "${dataDir}/paperless/consume" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Paperless";
|
||||||
|
url = "https://paperless.${domain}";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# phpLDAPadmin — web UI for OpenLDAP management.
|
# phpLDAPadmin — web UI for OpenLDAP management.
|
||||||
#
|
#
|
||||||
# Stateless container (no persistent volumes needed).
|
# 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.
|
# Bound to localhost:8081; Caddy reverse-proxies it.
|
||||||
#
|
#
|
||||||
# Networking: uses default bridge (podman) network with a port mapping
|
# Networking: uses default bridge (podman) network with a port mapping
|
||||||
@@ -13,10 +13,11 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.homey.phpldapadmin;
|
cfg = config.homey.phpldapadmin;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.phpldapadmin = {
|
options.homey.phpldapadmin = {
|
||||||
enable = lib.mkEnableOption "phpLDAPadmin web interface";
|
enable = lib.mkEnableOption "phpLDAPadmin web interface" // { default = true; };
|
||||||
|
|
||||||
image = lib.mkOption {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -36,19 +37,46 @@ in
|
|||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
PHPLDAPADMIN_HTTPS = "false";
|
PHPLDAPADMIN_HTTPS = "false";
|
||||||
# host.containers.internal resolves to the host from inside a podman
|
# "openldap" resolves to the OpenLDAP container via homey network DNS.
|
||||||
# bridge container — reaches openldap which is on --network=host at :389
|
PHPLDAPADMIN_LDAP_HOSTS = "openldap";
|
||||||
PHPLDAPADMIN_LDAP_HOSTS = "host.containers.internal";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# 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" ];
|
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
|
||||||
|
|
||||||
|
extraOptions = [ "--network=homey" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services."podman-phpldapadmin" = {
|
systemd.services."podman-phpldapadmin" = {
|
||||||
after = lib.mkAfter [ "podman-openldap.service" ];
|
after = lib.mkAfter [ "podman-openldap.service" "podman-homey-network.service" ];
|
||||||
wants = lib.mkAfter [ "podman-openldap.service" ];
|
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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "ldapadmin";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = true;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# phpLDAPadmin is stateless (no persistent volumes) — no storage or backup entries needed.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma monitor for this service
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "phpLDAPadmin";
|
||||||
|
url = "http://phpldapadmin:80";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
let
|
let
|
||||||
cfg = config.homey.transmission;
|
cfg = config.homey.transmission;
|
||||||
dataDir = config.homey.storage.mountPoint;
|
dataDir = config.homey.storage.mountPoint;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.homey.transmission = {
|
options.homey.transmission = {
|
||||||
enable = lib.mkEnableOption "Transmission torrent client";
|
enable = lib.mkEnableOption "Transmission torrent client" // { default = true; };
|
||||||
|
|
||||||
image = lib.mkOption {
|
image = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
@@ -35,16 +36,14 @@ in
|
|||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
virtualisation.oci-containers.containers.transmission = {
|
virtualisation.oci-containers.containers.transmission = {
|
||||||
image = cfg.image;
|
image = cfg.image;
|
||||||
# No ports mapping — --network=host shares the host network stack directly.
|
# Map host cfg.port (9092) → container 9091 so Caddy can reach it
|
||||||
|
# without conflicting with Authelia's host port (also 9091).
|
||||||
|
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
PUID = "1000";
|
PUID = "1000";
|
||||||
PGID = "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";
|
TRANSMISSION_WEB_HOME = "/usr/share/transmission/web";
|
||||||
WEBUI_PORT = toString cfg.port;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
volumes = [
|
volumes = [
|
||||||
@@ -55,12 +54,42 @@ in
|
|||||||
"${dataDir}/media/complete:/downloads/complete"
|
"${dataDir}/media/complete:/downloads/complete"
|
||||||
];
|
];
|
||||||
|
|
||||||
extraOptions = [ "--network=host" ];
|
extraOptions = [ "--network=homey" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services."podman-transmission" = {
|
systemd.services."podman-transmission" = {
|
||||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "torrent";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = true;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "transmission"; }
|
||||||
|
{ path = "transmission/config"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/transmission" ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
{ config, lib, pkgs, homeyConfig, ... }:
|
||||||
|
|
||||||
|
# Uptime Kuma — endpoint uptime monitoring with a status-page UI.
|
||||||
|
#
|
||||||
|
# This module does two things:
|
||||||
|
#
|
||||||
|
# 1. Declares the shared homey.monitoring.monitors option that any service
|
||||||
|
# module can contribute to. Adding your service's URL there means it
|
||||||
|
# automatically appears in Uptime Kuma — no manual UI work needed.
|
||||||
|
#
|
||||||
|
# 2. Runs Uptime Kuma as an OCI container and syncs the monitor list via
|
||||||
|
# the Socket.IO API on startup using the uptime-kuma-api Python library.
|
||||||
|
#
|
||||||
|
# Example (in nextcloud.nix):
|
||||||
|
# homey.monitoring.monitors = [{
|
||||||
|
# name = "Nextcloud";
|
||||||
|
# url = "https://nextcloud.zakobar.com/status.php";
|
||||||
|
# interval = 60;
|
||||||
|
# }];
|
||||||
|
#
|
||||||
|
# Auth: Authelia two_factor, admins-only (enforced in authelia.nix + caddy.nix).
|
||||||
|
#
|
||||||
|
# Volume layout:
|
||||||
|
# <dataDir>/uptime-kuma/ → /app/data (SQLite DB, config)
|
||||||
|
#
|
||||||
|
# Secrets consumed from sops:
|
||||||
|
# uptime-kuma/admin_password
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.homey.uptimeKuma;
|
||||||
|
dataDir = config.homey.storage.mountPoint;
|
||||||
|
domain = homeyConfig.domain;
|
||||||
|
|
||||||
|
# Serialise the NixOS monitor list to JSON at build time.
|
||||||
|
# The setup script reads this at runtime to know what to create.
|
||||||
|
monitorsJson = pkgs.writeText "uptime-kuma-monitors.json"
|
||||||
|
(builtins.toJSON config.homey.monitoring.monitors);
|
||||||
|
|
||||||
|
# Python environment for the monitor-sync script.
|
||||||
|
# uptime-kuma-api's transitive deps (requests, socketio, websocket-client)
|
||||||
|
# are listed explicitly because withPackages doesn't always pull propagated
|
||||||
|
# deps transitively in all nixpkgs versions.
|
||||||
|
pythonEnv = pkgs.python3.withPackages (ps: [
|
||||||
|
ps."uptime-kuma-api"
|
||||||
|
ps.requests
|
||||||
|
ps."python-socketio"
|
||||||
|
ps."websocket-client"
|
||||||
|
]);
|
||||||
|
|
||||||
|
# Monitor-sync script: idempotent, hash-gated, uses Socket.IO API
|
||||||
|
syncScript = pkgs.writeText "uptime-kuma-sync.py" ''
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Sync monitors declared in /etc/uptime-kuma/monitors.json into Uptime Kuma.
|
||||||
|
|
||||||
|
Runs as a oneshot systemd service after podman-uptime-kuma.service.
|
||||||
|
Tracks a hash of the monitor list so it only re-syncs when the NixOS
|
||||||
|
config changes.
|
||||||
|
|
||||||
|
Uptime Kuma v1 has no REST API — everything is Socket.IO. Initial admin
|
||||||
|
creation uses api.setup() which raises if already done (we ignore that).
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MONITORS_PATH = "/etc/uptime-kuma/monitors.json"
|
||||||
|
HASH_PATH = "/var/lib/uptime-kuma-setup/last-hash"
|
||||||
|
KUMA_URL = "http://localhost:3001"
|
||||||
|
CREDS_DIR = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||||
|
|
||||||
|
def wait_for_kuma(timeout=120):
|
||||||
|
"""Wait until Uptime Kuma HTTP responds (any non-5xx — just checks it's up)."""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(KUMA_URL, timeout=5) as r:
|
||||||
|
if r.status < 500:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(3)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open(MONITORS_PATH) as f:
|
||||||
|
monitors = json.load(f)
|
||||||
|
|
||||||
|
config_hash = hashlib.sha256(
|
||||||
|
json.dumps(monitors, sort_keys=True).encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
# Skip sync if config hasn't changed
|
||||||
|
try:
|
||||||
|
with open(HASH_PATH) as f:
|
||||||
|
if f.read().strip() == config_hash:
|
||||||
|
print("uptime-kuma-sync: config unchanged, skipping")
|
||||||
|
return
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
password_file = os.path.join(CREDS_DIR, "uptime_kuma_password")
|
||||||
|
with open(password_file) as f:
|
||||||
|
password = f.read().strip()
|
||||||
|
|
||||||
|
print("uptime-kuma-sync: waiting for Uptime Kuma to be ready...")
|
||||||
|
if not wait_for_kuma():
|
||||||
|
print("uptime-kuma-sync: timed out waiting for Uptime Kuma", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from uptime_kuma_api import UptimeKumaApi, MonitorType
|
||||||
|
|
||||||
|
api = UptimeKumaApi(KUMA_URL)
|
||||||
|
|
||||||
|
# Initial admin setup via Socket.IO — idempotent (raises if already done, ignore it)
|
||||||
|
try:
|
||||||
|
api.setup("admin", password)
|
||||||
|
print("uptime-kuma-sync: initial admin user created")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"uptime-kuma-sync: setup skipped (already configured): {e}")
|
||||||
|
|
||||||
|
# Login
|
||||||
|
try:
|
||||||
|
api.login("admin", password)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"uptime-kuma-sync: login failed: {e}", file=sys.stderr)
|
||||||
|
api.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Sync monitors: add missing, update changed
|
||||||
|
try:
|
||||||
|
existing = {m["name"]: m for m in api.get_monitors()}
|
||||||
|
for m in monitors:
|
||||||
|
keyword = m.get("keyword")
|
||||||
|
maxretries = m.get("maxretries", 0)
|
||||||
|
monitor_type = MonitorType.KEYWORD if keyword else MonitorType.HTTP
|
||||||
|
extra = {"keyword": keyword} if keyword else {}
|
||||||
|
if m["name"] not in existing:
|
||||||
|
api.add_monitor(
|
||||||
|
type=monitor_type,
|
||||||
|
name=m["name"],
|
||||||
|
url=m["url"],
|
||||||
|
interval=m.get("interval", 60),
|
||||||
|
maxretries=maxretries,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
print(f"uptime-kuma-sync: created monitor: {m['name']}")
|
||||||
|
elif (existing[m["name"]].get("url") != m["url"]
|
||||||
|
or existing[m["name"]].get("keyword") != keyword
|
||||||
|
or existing[m["name"]].get("maxretries") != maxretries):
|
||||||
|
api.edit_monitor(
|
||||||
|
existing[m["name"]]["id"],
|
||||||
|
type=monitor_type,
|
||||||
|
url=m["url"],
|
||||||
|
interval=m.get("interval", 60),
|
||||||
|
maxretries=maxretries,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
print(f"uptime-kuma-sync: updated monitor: {m['name']}")
|
||||||
|
finally:
|
||||||
|
api.disconnect()
|
||||||
|
|
||||||
|
# Persist hash so we don't re-sync on every boot
|
||||||
|
os.makedirs(os.path.dirname(HASH_PATH), exist_ok=True)
|
||||||
|
with open(HASH_PATH, "w") as f:
|
||||||
|
f.write(config_hash)
|
||||||
|
print("uptime-kuma-sync: done")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
'';
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared monitor-list option — declared unconditionally so any service module
|
||||||
|
# can contribute monitors even when Uptime Kuma itself is disabled.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
options.homey.monitoring.monitors = lib.mkOption {
|
||||||
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Display name shown in Uptime Kuma.";
|
||||||
|
};
|
||||||
|
url = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "URL to check (HTTP/HTTPS).";
|
||||||
|
};
|
||||||
|
interval = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 60;
|
||||||
|
description = "Check interval in seconds.";
|
||||||
|
};
|
||||||
|
keyword = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "If set, use a keyword monitor that checks for this string in the response body.";
|
||||||
|
};
|
||||||
|
maxretries = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 0;
|
||||||
|
description = "Consecutive failures before a DOWN alert is sent. 0 = alert immediately.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = [];
|
||||||
|
description = ''
|
||||||
|
List of HTTP endpoints to monitor in Uptime Kuma.
|
||||||
|
Each service module contributes its own entries here.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.homey.uptimeKuma = {
|
||||||
|
enable = lib.mkEnableOption "Uptime Kuma uptime monitoring" // { default = true; };
|
||||||
|
|
||||||
|
image = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "docker.io/louislam/uptime-kuma:1";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 3001;
|
||||||
|
description = "Host port Uptime Kuma listens on (bound to 127.0.0.1).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
sops.secrets."uptime-kuma/admin_password" = { owner = "root"; };
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Write monitor list to /etc at build time
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
environment.etc."uptime-kuma/monitors.json" = {
|
||||||
|
source = monitorsJson;
|
||||||
|
mode = "0444";
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma container
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
virtualisation.oci-containers.containers.uptime-kuma = {
|
||||||
|
image = cfg.image;
|
||||||
|
ports = [ "127.0.0.1:${toString cfg.port}:3001" ];
|
||||||
|
|
||||||
|
volumes = [
|
||||||
|
"${dataDir}/uptime-kuma:/app/data"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Join the homey network so monitors can reach other containers by name
|
||||||
|
# (e.g. phpldapadmin:80) without going through the host loopback.
|
||||||
|
extraOptions = [ "--network=homey" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services."podman-uptime-kuma" = {
|
||||||
|
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
|
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Monitor-sync service: runs after Uptime Kuma is up, syncs monitors
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
systemd.services."uptime-kuma-sync" = {
|
||||||
|
description = "Sync Uptime Kuma monitors from NixOS config";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "podman-uptime-kuma.service" ];
|
||||||
|
requires = [ "podman-uptime-kuma.service" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
LoadCredential = "uptime_kuma_password:${config.sops.secrets."uptime-kuma/admin_password".path}";
|
||||||
|
|
||||||
|
ExecStart = pkgs.writeShellScript "uptime-kuma-sync-runner" ''
|
||||||
|
set -euo pipefail
|
||||||
|
exec ${pythonEnv}/bin/python3 ${syncScript}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.caddy.virtualHosts = [{
|
||||||
|
subdomain = "uptime";
|
||||||
|
port = cfg.port;
|
||||||
|
auth = true;
|
||||||
|
}];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Storage directories
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.storage.extraDirs = [
|
||||||
|
{ path = "uptime-kuma"; }
|
||||||
|
];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Backup
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.backup.extraPaths = [ "${dataDir}/uptime-kuma" ];
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Uptime Kuma self-monitor
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
homey.monitoring.monitors = [{
|
||||||
|
name = "Uptime Kuma";
|
||||||
|
url = "https://uptime.${domain}";
|
||||||
|
interval = 60;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
+28
-19
@@ -29,6 +29,11 @@
|
|||||||
# complete/
|
# complete/
|
||||||
# transmission/
|
# transmission/
|
||||||
# config/
|
# config/
|
||||||
|
# uptime-kuma/ ← /app/data in uptime-kuma container (SQLite DB, config)
|
||||||
|
# ntfy/
|
||||||
|
# auth.db ← user/token auth database
|
||||||
|
# cache.db ← message cache
|
||||||
|
# attachments/ ← file attachments
|
||||||
# restic-cache/ ← restic local cache (not the backup destination)
|
# restic-cache/ ← restic local cache (not the backup destination)
|
||||||
|
|
||||||
let
|
let
|
||||||
@@ -58,6 +63,22 @@ in
|
|||||||
default = "ext4";
|
default = "ext4";
|
||||||
description = "Filesystem type of the external drive.";
|
description = "Filesystem type of the external drive.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
extraDirs = lib.mkOption {
|
||||||
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
path = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Path relative to mountPoint (e.g. \"gitea/data\").";
|
||||||
|
};
|
||||||
|
mode = lib.mkOption { type = lib.types.str; default = "0750"; };
|
||||||
|
user = lib.mkOption { type = lib.types.str; default = "root"; };
|
||||||
|
group = lib.mkOption { type = lib.types.str; default = "root"; };
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = [];
|
||||||
|
description = "Per-service directories to create under mountPoint. Each service module contributes its own entries.";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf (cfg.device != "") {
|
config = lib.mkIf (cfg.device != "") {
|
||||||
@@ -74,32 +95,20 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Ensure the mount point directory exists
|
# Mount point root + shared infrastructure dirs (restic cache, shared media).
|
||||||
|
# Per-service dirs are contributed via homey.storage.extraDirs by each
|
||||||
|
# service module, so adding a new service only requires editing that module.
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d ${cfg.mountPoint} 0755 root root -"
|
"d ${cfg.mountPoint} 0755 root root -"
|
||||||
|
# Shared media directories used by both Jellyfin and Transmission.
|
||||||
# Service subdirectories — created on boot so containers can start
|
|
||||||
# even before any data is restored into them.
|
|
||||||
"d ${cfg.mountPoint}/openldap 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/openldap/etc-ldap-slapd.d 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/openldap/var-lib-ldap 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/authelia 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/authelia/config 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/gitea 0750 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 -"
|
|
||||||
"d ${cfg.mountPoint}/jellyfin 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/jellyfin/config 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/media 0755 root root -"
|
"d ${cfg.mountPoint}/media 0755 root root -"
|
||||||
"d ${cfg.mountPoint}/media/movies 0755 root root -"
|
"d ${cfg.mountPoint}/media/movies 0755 root root -"
|
||||||
"d ${cfg.mountPoint}/media/tvshows 0755 root root -"
|
"d ${cfg.mountPoint}/media/tvshows 0755 root root -"
|
||||||
"d ${cfg.mountPoint}/media/general 0755 root root -"
|
"d ${cfg.mountPoint}/media/general 0755 root root -"
|
||||||
"d ${cfg.mountPoint}/media/complete 0755 root root -"
|
"d ${cfg.mountPoint}/media/complete 0755 root root -"
|
||||||
"d ${cfg.mountPoint}/transmission 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/transmission/config 0750 root root -"
|
|
||||||
"d ${cfg.mountPoint}/restic-cache 0700 root root -"
|
"d ${cfg.mountPoint}/restic-cache 0700 root root -"
|
||||||
];
|
] ++ (map
|
||||||
|
(d: "d ${cfg.mountPoint}/${d.path} ${d.mode} ${d.user} ${d.group} -")
|
||||||
|
config.homey.storage.extraDirs);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# offload-backup.sh — back up /mnt/data directly to a USB drive using restic.
|
||||||
|
#
|
||||||
|
# Run on the Pi (see homey-offload-backup in the dev shell).
|
||||||
|
# Scans for plugged-in USB partitions, lets you pick one, mounts it if needed,
|
||||||
|
# initialises a restic repo on it, and runs a backup of all service data dirs.
|
||||||
|
#
|
||||||
|
# The restic password is read from the sops-managed secret at runtime;
|
||||||
|
# no S3 credentials are needed — this is a direct local backup.
|
||||||
|
#
|
||||||
|
# Usage: sudo bash offload-backup.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_NAME="homey-backup"
|
||||||
|
DATA_DIR="/mnt/data"
|
||||||
|
PASSWORD_FILE="/run/secrets/restic/password"
|
||||||
|
|
||||||
|
BACKUP_PATHS=(
|
||||||
|
"$DATA_DIR/openldap"
|
||||||
|
"$DATA_DIR/authelia"
|
||||||
|
"$DATA_DIR/gitea"
|
||||||
|
"$DATA_DIR/nextcloud"
|
||||||
|
"$DATA_DIR/jellyfin"
|
||||||
|
"$DATA_DIR/transmission"
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDE_ARGS=(
|
||||||
|
--exclude "$DATA_DIR/nextcloud/db"
|
||||||
|
--exclude "$DATA_DIR/restic-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Find USB partitions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "Scanning for USB drives..."
|
||||||
|
mapfile -t USB_PARTS < <(
|
||||||
|
lsblk -o NAME,SIZE,TRAN,LABEL,MOUNTPOINT -rn \
|
||||||
|
| awk '$3 == "usb" && $2 != "" {print $1, $2, $4, $5}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#USB_PARTS[@]}" -eq 0 ]; then
|
||||||
|
echo "No USB partitions found. Plug in a USB drive and try again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Available USB partitions:"
|
||||||
|
for i in "${!USB_PARTS[@]}"; do
|
||||||
|
read -r dev size label mount <<< "${USB_PARTS[$i]}"
|
||||||
|
label="${label:-(no label)}"
|
||||||
|
mount="${mount:-(not mounted)}"
|
||||||
|
printf " [%d] /dev/%s %s label=%s mount=%s\n" \
|
||||||
|
"$((i + 1))" "$dev" "$size" "$label" "$mount"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
read -rp "Select a partition [1-${#USB_PARTS[@]}]: " CHOICE
|
||||||
|
|
||||||
|
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] \
|
||||||
|
|| [ "$CHOICE" -lt 1 ] \
|
||||||
|
|| [ "$CHOICE" -gt "${#USB_PARTS[@]}" ]; then
|
||||||
|
echo "Invalid selection." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r SELECTED_DEV _ _ EXISTING_MOUNT <<< "${USB_PARTS[$((CHOICE - 1))]}"
|
||||||
|
SELECTED_DEV="/dev/$SELECTED_DEV"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mount if needed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MOUNTED_HERE=false
|
||||||
|
MOUNT_DIR=""
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_MOUNT" ]; then
|
||||||
|
MOUNT_DIR="$EXISTING_MOUNT"
|
||||||
|
echo "Using existing mount at $MOUNT_DIR"
|
||||||
|
else
|
||||||
|
MOUNT_DIR=$(mktemp -d)
|
||||||
|
echo "Mounting $SELECTED_DEV at $MOUNT_DIR..."
|
||||||
|
mount "$SELECTED_DEV" "$MOUNT_DIR"
|
||||||
|
MOUNTED_HERE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ "$MOUNTED_HERE" = true ] && [ -n "$MOUNT_DIR" ]; then
|
||||||
|
echo "Unmounting $MOUNT_DIR..."
|
||||||
|
umount "$MOUNT_DIR"
|
||||||
|
rmdir "$MOUNT_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Initialise restic repo if this is the first run
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
REPO="$MOUNT_DIR/$REPO_NAME"
|
||||||
|
|
||||||
|
if ! restic -r "$REPO" --password-file "$PASSWORD_FILE" snapshots &>/dev/null 2>&1; then
|
||||||
|
echo "Initialising restic repository at $REPO..."
|
||||||
|
restic -r "$REPO" --password-file "$PASSWORD_FILE" init
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Run the backup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "Backing up to $REPO..."
|
||||||
|
restic -r "$REPO" \
|
||||||
|
--password-file "$PASSWORD_FILE" \
|
||||||
|
--cache-dir "$DATA_DIR/restic-cache" \
|
||||||
|
backup \
|
||||||
|
"${BACKUP_PATHS[@]}" \
|
||||||
|
"${EXCLUDE_ARGS[@]}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Snapshots on this drive:"
|
||||||
|
restic -r "$REPO" --password-file "$PASSWORD_FILE" snapshots
|
||||||
+19
-2
@@ -1,3 +1,10 @@
|
|||||||
|
uptime-kuma:
|
||||||
|
admin_password: ENC[AES256_GCM,data:tPKWxWmxRcVJeywY3J4eXAWWnAinLwMn3X68TrV/4emonvRiuyPmiwhn2fjDxwB/kT78y/iDDmpdQY229yJrkQ==,iv:YSL40PDbRTgtSYZCwqHzfJTcEAiILIDbGRA2kfamiw8=,tag:pMM0AWkjkcS9XOaSHG1oUQ==,type:str]
|
||||||
|
ntfy:
|
||||||
|
admin_password: ENC[AES256_GCM,data:P5pjnt00lyeGVlrBvUlJWWeTi3evFZPJIxjcsndbo4LZOLk6hbbrh8RwCAGzr1ump0A5fRXqynByRFdaS6++wA==,iv:Uxeh0/mygR++4S//O/RO2bouH2J0qcSCYtjjyZNooNk=,tag:LGIDaq4RzBuzrWFqVDr8ow==,type:str]
|
||||||
|
web_push_private_key: ENC[AES256_GCM,data:BggPo7uYjda48iV3G8TaPk7mPZXHv+H6MW3BeMYFaxYCVAok0zT7Tzko7A==,iv:qPX8N4mzD4DWX2tWlsQCK09PD0R4ntrJMqYOqwwzGXg=,tag:pXIp3pAkYQpdbXG/PtsFag==,type:str]
|
||||||
|
grafana:
|
||||||
|
secret_key: ENC[AES256_GCM,data:/KNDMZZN5thoqsgJZS7fuNQULI1PAKVuihRu9WzO00Qw8js/V4KKJT0JOVOcqdHAnf44+szYZaCWt0xe02chGw==,iv:Y0FQ7h4SqZVtz0wLjPnVGGYyXmBIDi8nzaK2GFzDxqQ=,tag:w0z5/vI3Hfd8ry9DCHAvJw==,type:str]
|
||||||
openldap:
|
openldap:
|
||||||
admin_password: ENC[AES256_GCM,data:hg+Ly1bX4ao1AT4SDvQWXiT/KMzsz0wdnRauiB+FetE=,iv:TAX+NZCVUNiwMeBrW58IeI1OJX6rzzGAhWiQ+cZXreo=,tag:MrwYKKBb1Cg2JvADtQqYrQ==,type:str]
|
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]
|
config_password: ENC[AES256_GCM,data:qKEurb0slGnr6nES7w7fTPDCy/DARns0BorDZMwpI/w=,iv:+p6Fh9a2g0eBueOxDk1J+hnM9fMgE6/NYwz+sAovGjE=,tag:kKZVsxdxdDACD9J0NAf4gQ==,type:str]
|
||||||
@@ -11,6 +18,7 @@ gitea:
|
|||||||
lfs_jwt_secret: ENC[AES256_GCM,data:i05gr2ou03w0yu6/bhlJOW1huysAAPTidFEusWkhQfpDj4Pyh8LEKb09Og==,iv:aqkblyz0oIFHwzVCzlGDdQuCbsDPrfBaJMzgRTw+pYU=,tag:6gBSerOUK8Y3la/2Bg2AZQ==,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]
|
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]
|
internal_token: ENC[AES256_GCM,data:gnOebJbRsh2Cues9WjGQp4rWa6OuE3xSnby9jc3Hk8ywvpL7CNWmlGW7zmmOyDAfIKfm8kf1FxotWLXtGZDretzdbMRM9c6gkwSJf5MCsdm27Er+IRKS/QFBuvLSTEH0,iv:aVgRvs3T3zCg+AV/BKUXQyZDKvunHvXsdfr9sqo2cI0=,tag:U9aP+N0CWyRQ/xJ27Vo2mw==,type:str]
|
||||||
|
runner_token: ENC[AES256_GCM,data:fNiP3hIhBw16zYAt9dMuGu6C3n48R6H4O8en8JzRnNy0KGbbvv08w8qRceD6XQ==,iv:DJarsN6yYbdyesd5MoQEB0mDdS9O39VLKmJUIicTlG8=,tag:8+W6jYg8kSqy6FztaJnn9w==,type:str]
|
||||||
nextcloud:
|
nextcloud:
|
||||||
admin_password: ENC[AES256_GCM,data:iK6VoE94vFQmn3i4XQc5r/c03u3b0knDgBNK8d1qyns=,iv:P1wax2vAjn9iwBe9T7SN+pKrtrWcOYb5OWUyHF4hlVg=,tag:ET8KU4IKzhWqIDeRihwcag==,type:str]
|
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]
|
postgres_password: ENC[AES256_GCM,data:ga4cwhYsAgEBvr+aDVwiRZXeT+TjXzeef1r3ud6uYHs=,iv:PMHCjO4wLW6PER4oGODEG9CHqrvVpAbgTGF7p49MCL0=,tag:mTNzsDhufqLlf1LFu7Rl1A==,type:str]
|
||||||
@@ -23,6 +31,15 @@ restic:
|
|||||||
s3_secret_access_key: ENC[AES256_GCM,data:9ZWyhGJm4t2benDrLmnyQ9ZA5Jjl6l+pza1VmymTlw==,iv:xYsG6QlxXhQNO9szmsycxP6lT0cFF7lq3iNg6j+ED0E=,tag:wOJT4Vg3DuNFWTtx3QS9IQ==,type:str]
|
s3_secret_access_key: ENC[AES256_GCM,data:9ZWyhGJm4t2benDrLmnyQ9ZA5Jjl6l+pza1VmymTlw==,iv:xYsG6QlxXhQNO9szmsycxP6lT0cFF7lq3iNg6j+ED0E=,tag:wOJT4Vg3DuNFWTtx3QS9IQ==,type:str]
|
||||||
wifi:
|
wifi:
|
||||||
psk: ENC[AES256_GCM,data:znk9Wr+vsntzbJ3H0TORUrAiDw==,iv:wbl8fUuKlgTqhajwjlTgFS7ijaTwXBFPRW2AmtiTklg=,tag:IK4oe8cJcccPaQ0V0NlncQ==,type:str]
|
psk: ENC[AES256_GCM,data:znk9Wr+vsntzbJ3H0TORUrAiDw==,iv:wbl8fUuKlgTqhajwjlTgFS7ijaTwXBFPRW2AmtiTklg=,tag:IK4oe8cJcccPaQ0V0NlncQ==,type:str]
|
||||||
|
eurovote:
|
||||||
|
secret_key: ENC[AES256_GCM,data:Re9MTYA46ERXsxucT19K4Pj3rV5i74s8zQ/WYj6GlxeoN1r0Oit6PP0C3PY5Arp6Y6g=,iv:0BnuZ9Uv2RgDwlisrVSvg7ESmNZvd8trggQDSJ42ewM=,tag:SXW2hbprj2qSRzjKY3Aw3Q==,type:str]
|
||||||
|
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:
|
sops:
|
||||||
age:
|
age:
|
||||||
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
||||||
@@ -34,8 +51,8 @@ sops:
|
|||||||
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
|
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
|
||||||
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
|
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
lastmodified: "2026-04-21T12:42:15Z"
|
lastmodified: "2026-05-30T09:31:03Z"
|
||||||
mac: ENC[AES256_GCM,data:fNip/7A7iKCVZqP0EziyBG7K8SVfRJTBpn4RcDLOaciJHx5DkLLszE8we9MmzpKXQIiMcJl2BTj/uqJrgc5EHTSOHwRzNJ4s2NJfvQW+8QUDfTGzKOkP3L837RkEPzH4HZLqGlfYK7cNJU5qXRPbusKjAft7Fz3+ONXmodb/ONY=,iv:CdSs1a+74+MfzWyML2JQ/b2IKbktVdefFFYP5LOtUos=,tag:ikr9LwPnmdiPucOoBt3/Bw==,type:str]
|
mac: ENC[AES256_GCM,data:Mnu3wtu6gfGWtU+03KyTKa9n0uWsRCISRZJcZaF2n9wCD/GDikqUX6QFFZcHHoablXEqN6yu5u0wc7efX80PCnDlkr8C0gQF3i9+p9Kj+i+pfguG47sfqP3ITXIjJpwwZwiFlbCJ/Hj3bpIpUCwr3gb6KQjQZ2bm7SGDlNeV9Ys=,iv:SbFCyuMKaYA3yKvh/DcslA98/cBXTBI7sn3TJ3RZ+y4=,tag:Eh9kMKe4pT8H9O1UZWaTRA==,type:str]
|
||||||
pgp:
|
pgp:
|
||||||
- created_at: "2026-04-21T06:39:49Z"
|
- created_at: "2026-04-21T06:39:49Z"
|
||||||
enc: |-
|
enc: |-
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{pkgs} @ args: {
|
||||||
|
default = import ./defaultShell.nix args;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{pkgs} @ args:
|
||||||
|
pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
alejandra
|
||||||
|
sops
|
||||||
|
openssl
|
||||||
|
(pkgs.writeShellScriptBin "homey-deploy-rpi-main" ''
|
||||||
|
nixos-rebuild switch \
|
||||||
|
--flake .#pi-main \
|
||||||
|
--target-host admin@192.168.1.100 \
|
||||||
|
--build-host admin@192.168.1.100 \
|
||||||
|
--use-remote-sudo
|
||||||
|
'')
|
||||||
|
(pkgs.writeShellScriptBin "homey-build-rpi-main" ''
|
||||||
|
sudo nixos-rebuild switch \
|
||||||
|
--flake .#pi-main
|
||||||
|
'')
|
||||||
|
(pkgs.writeShellScriptBin "homey-offload-backup" ''
|
||||||
|
set -euo pipefail
|
||||||
|
scp scripts/offload-backup.sh admin@192.168.1.100:/tmp/homey-offload-backup.sh
|
||||||
|
ssh -t admin@192.168.1.100 'sudo bash /tmp/homey-offload-backup.sh; rm /tmp/homey-offload-backup.sh'
|
||||||
|
'')
|
||||||
|
(pkgs.writeShellScriptBin "homey-backup-status" ''
|
||||||
|
ssh admin@192.168.1.100 bash -s <<'ENDSSH'
|
||||||
|
echo "=== Backup timer ==="
|
||||||
|
systemctl status restic-backups-homey.timer --no-pager -l 2>&1 || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Last backup run (journal) ==="
|
||||||
|
journalctl -u restic-backups-homey.service -n 50 --no-pager 2>&1 || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Recent snapshots ==="
|
||||||
|
sudo bash -c '
|
||||||
|
export AWS_ACCESS_KEY_ID=$(cat /run/secrets/restic/s3_access_key_id)
|
||||||
|
export AWS_SECRET_ACCESS_KEY=$(cat /run/secrets/restic/s3_secret_access_key)
|
||||||
|
export RESTIC_CACHE_DIR=/mnt/data/restic-cache
|
||||||
|
restic \
|
||||||
|
-r "s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup" \
|
||||||
|
--password-file /run/secrets/restic/password \
|
||||||
|
snapshots --latest 5
|
||||||
|
' 2>&1
|
||||||
|
ENDSSH
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user