Compare commits

..
3 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 2b1797770f Add systemd sandboxing to hortusfox-setup, phpfpm, and cron services
hortusfox-setup and the cron jobs previously ran with no systemd
hardening at all; phpfpm-hortusfox only had whatever services.phpfpm
sets by default (ProtectSystem = "full"). Add a shared conservative
baseline (NoNewPrivileges, Protect*, Restrict*, LockPersonality,
RemoveIPC, RestrictAddressFamilies scoped to AF_UNIX/AF_INET/AF_INET6)
to hortusfox-setup and the cron services, and tighten phpfpm's own
ProtectSystem to "strict" plus an explicit ReadWritePaths for stateDir.

Deliberately no UMask override: a tighter umask silently reintroduces
the "Permission denied" static-asset bug already fixed once, since it
changes the mode cp/mkdir produce for files nginx/caddy (a different
user) need to read directly. Also no MemoryDenyWriteExecute or
SystemCallFilter for now -- broader surface, easy to break something
subtle in migrations/mysql/zip handling without much more testing.

Verified against both vm-tests (full boot + cookie-jar login) for both
proxy backends before landing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 16:52:43 -04:00
awkawbandClaude Sonnet 5 e210e344c8 Document that secretFile/adminAccount.passwordFile are read as cfg.user
hortusfox-setup reads both files as cfg.user, not root. Pointing either
at an agenix/sops-nix secret without setting its owner/group to match
cfg.user/cfg.group fails the service with a permission error rather
than anything obviously related -- note it on both options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:56:51 -04:00
awkawbandClaude Sonnet 5 6f16735747 Auto-expire public/backup exports via a configurable tmpfiles age
HortusFox writes export/auto-backup zips to public/backup using a
fully predictable hf_backup_<timestamp>.zip name, serves that
directory as real static files (ahead of its own auth-gated routes),
and never deletes them itself unless an external backup_path is
separately configured. That leaves full data exports downloadable by
anyone who knows or guesses the timestamp, indefinitely. This can't be
fixed from packaging alone, but giving public/backup's tmpfiles rule
an age (new backupMaxAge option, default "1d", null to disable) lets
systemd-tmpfiles-clean.timer prune old exports and bound the exposure
window. Every other writableDirs entry is unaffected (age stays "-").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:54:07 -04:00
3 changed files with 106 additions and 7 deletions
+2
View File
@@ -42,6 +42,7 @@ The module (`nixos-module/default.nix`, `config.services.hortusfox-web`) wires H
- **`cp` carries the store's read-only mode onto the copy.** Every directory `cp -r` creates inherits the store's `555` (no write) mode, so the script reopens them for writing (`find "${runDir}" -type d -exec chmod u+w {} +`, directories only, never individual files) before it can `rm`/relink anything under them. - **`cp` carries the store's read-only mode onto the copy.** Every directory `cp -r` creates inherits the store's `555` (no write) mode, so the script reopens them for writing (`find "${runDir}" -type d -exec chmod u+w {} +`, directories only, never individual files) before it can `rm`/relink anything under them.
- **Writable subdirs are seeded, not just replaced.** `cache`, `app/logs`, `public/img`, `public/attachments`, `public/backup`, `public/themes` get swapped for symlinks into persistent state (`stateDir = /var/lib/hortusfox`). Two of these (`public/img`, `public/themes`) ship real default assets in the package itself (login background, default theme) — naively replacing them with an empty persistent dir on first boot silently deletes those defaults and breaks page rendering (`Exception: Resource "img/pattern.jpg" not found`). The fix: before symlinking, `cp -rn` (no-clobber) the package's copy of each writable dir into `stateDir/<dir>` first, so shipped defaults get seeded in (and any *new* ones added by a future upstream bump get picked up too) without ever touching a file the user/app has already written. - **Writable subdirs are seeded, not just replaced.** `cache`, `app/logs`, `public/img`, `public/attachments`, `public/backup`, `public/themes` get swapped for symlinks into persistent state (`stateDir = /var/lib/hortusfox`). Two of these (`public/img`, `public/themes`) ship real default assets in the package itself (login background, default theme) — naively replacing them with an empty persistent dir on first boot silently deletes those defaults and breaks page rendering (`Exception: Resource "img/pattern.jpg" not found`). The fix: before symlinking, `cp -rn` (no-clobber) the package's copy of each writable dir into `stateDir/<dir>` first, so shipped defaults get seeded in (and any *new* ones added by a future upstream bump get picked up too) without ever touching a file the user/app has already written.
- **Nested writable dirs need their own tmpfiles rule for the parent, too.** `systemd.tmpfiles.rules` declares a `d` line per entry in `writableDirs`, but `app/logs` and the four `public/*` entries are nested under `app`/`public`, which have no rule of their own. `systemd-tmpfiles` auto-vivifies a missing parent for a nested `d` line, but does so with default `root:root 0755` ownership and does **not** reliably go on to create the requested leaf — so `app/logs` (and all of `public/*`) silently never got created, and the hortusfox-setup service's own `mkdir -p "${stateDir}/${d}"` would fail with `Permission denied` trying to create the leaf inside a root-owned, not-writable-by-`cfg.user` parent. Fixed by computing `writableDirParents` (the unique set of intermediate dirs, e.g. `app`, `public`) and giving each its own explicit tmpfiles `d` rule, ordered before the leaves. If you add a new *nested* entry to `writableDirs`, make sure its parent is covered by this too. - **Nested writable dirs need their own tmpfiles rule for the parent, too.** `systemd.tmpfiles.rules` declares a `d` line per entry in `writableDirs`, but `app/logs` and the four `public/*` entries are nested under `app`/`public`, which have no rule of their own. `systemd-tmpfiles` auto-vivifies a missing parent for a nested `d` line, but does so with default `root:root 0755` ownership and does **not** reliably go on to create the requested leaf — so `app/logs` (and all of `public/*`) silently never got created, and the hortusfox-setup service's own `mkdir -p "${stateDir}/${d}"` would fail with `Permission denied` trying to create the leaf inside a root-owned, not-writable-by-`cfg.user` parent. Fixed by computing `writableDirParents` (the unique set of intermediate dirs, e.g. `app`, `public`) and giving each its own explicit tmpfiles `d` rule, ordered before the leaves. If you add a new *nested* entry to `writableDirs`, make sure its parent is covered by this too.
- **`public/backup` gets auto-cleaned; nothing else does.** HortusFox's own export/auto-backup feature (`BackupModule`/`BackupController`) writes to `public/backup/hf_backup_<timestamp>.zip` — a fully predictable filename — and never deletes it itself (the auto-backup cron only does so when an external `backup_path` is also configured in the admin panel; the manual "Export" flow never does). Since `public/backup` is served as a real static file by nginx/caddy before ever reaching the app's own auth-gated routes, that zip — a full data export — is downloadable by anyone who knows or guesses the timestamp, indefinitely. This is upstream's own design and can't be fixed from here, but `writableDirAges` gives `public/backup`'s tmpfiles `d` rule an `age` field driven by the `backupMaxAge` option (default `"1d"`, `null` disables), so `systemd-tmpfiles-clean.timer` prunes old exports and bounds the exposure window. Every other `writableDirs` entry keeps `age = "-"` (never auto-cleaned) via `writableDirAges.${d} or "-"`.
- **`RuntimeDirectoryMode` and `stateDir`'s tmpfiles rules must both stay world-readable (`0755`).** nginx/caddy run as their own user, not `cfg.user`, and need to `stat`/read everything under `runDir` *and* into `stateDir`'s writable subdirs (`public/img` etc., reached through symlinks from `runDir`) to serve `public/` directly. At `0750` the proxy can't even traverse the directory, `tryFiles`/`file_server` stats fail with `Permission denied`, and — this is the trap — the site can still *appear* to work, because PHP-FPM (running as `cfg.user`) serves pages fine over fastcgi regardless of the proxy's own filesystem access; only requests for *static* files (not routed through PHP) actually exercise this path. A plain `curl` on `/` won't catch it; the VM tests fetch a real static asset and grep the proxy's own log for `Permission denied` specifically because of it. Nothing secret is exposed by the world-readable mode: `.env` is a symlink to `stateDir/.env`, which stays `600` independently. - **`RuntimeDirectoryMode` and `stateDir`'s tmpfiles rules must both stay world-readable (`0755`).** nginx/caddy run as their own user, not `cfg.user`, and need to `stat`/read everything under `runDir` *and* into `stateDir`'s writable subdirs (`public/img` etc., reached through symlinks from `runDir`) to serve `public/` directly. At `0750` the proxy can't even traverse the directory, `tryFiles`/`file_server` stats fail with `Permission denied`, and — this is the trap — the site can still *appear* to work, because PHP-FPM (running as `cfg.user`) serves pages fine over fastcgi regardless of the proxy's own filesystem access; only requests for *static* files (not routed through PHP) actually exercise this path. A plain `curl` on `/` won't catch it; the VM tests fetch a real static asset and grep the proxy's own log for `Permission denied` specifically because of it. Nothing secret is exposed by the world-readable mode: `.env` is a symlink to `stateDir/.env`, which stays `600` independently.
- **`.env` rendering.** A base attrset of env vars (`envVars`) is merged with the freeform `cfg.settings` attrset (`envVars // cfg.settings`, so `settings` always wins) and serialized to KEY=VALUE lines via `pkgs.writeText`. `envVars` isn't just our curated options (`domain`/`timezone`/`database.name`/`smtp.*`) — it mirrors the *entire* default set from upstream's `.env.example` (app metadata, session duration, photo-resize factors, `SMTP_AUTH`, `APP_BASEDIR`, ...). Leaving any of these out isn't cosmetic: several app code paths read `$_ENV[...]` directly with no `isset()` guard (`APP_BASEDIR` in particular), so an omitted key produces a `PHP Warning: Undefined array key` on *every single request*, not just a missing feature. `APP_VERSION` derives from `package.version` rather than being duplicated by hand, so it can't drift from `package.nix` on a version bump. Secrets are handled separately: if `cfg.secretFile` is set, its contents are `cat`'d verbatim onto the end of the rendered file at runtime — it is *not* parsed or filtered, so it can carry arbitrary keys (`APP_KEY`, `SMTP_PASSWORD`, custom API keys, …). The rendered file lands in `stateDir/.env`, then is symlinked into `runDir`. - **`.env` rendering.** A base attrset of env vars (`envVars`) is merged with the freeform `cfg.settings` attrset (`envVars // cfg.settings`, so `settings` always wins) and serialized to KEY=VALUE lines via `pkgs.writeText`. `envVars` isn't just our curated options (`domain`/`timezone`/`database.name`/`smtp.*`) — it mirrors the *entire* default set from upstream's `.env.example` (app metadata, session duration, photo-resize factors, `SMTP_AUTH`, `APP_BASEDIR`, ...). Leaving any of these out isn't cosmetic: several app code paths read `$_ENV[...]` directly with no `isset()` guard (`APP_BASEDIR` in particular), so an omitted key produces a `PHP Warning: Undefined array key` on *every single request*, not just a missing feature. `APP_VERSION` derives from `package.version` rather than being duplicated by hand, so it can't drift from `package.nix` on a version bump. Secrets are handled separately: if `cfg.secretFile` is set, its contents are `cat`'d verbatim onto the end of the rendered file at runtime — it is *not* parsed or filtered, so it can carry arbitrary keys (`APP_KEY`, `SMTP_PASSWORD`, custom API keys, …). The rendered file lands in `stateDir/.env`, then is symlinked into `runDir`.
- **`settings.APP_DEBUG` matters more than it looks.** Upstream's `asatru` CLI (`app root`/`asatru` file) refuses to run at all unless `APP_DEBUG` is true — it just prints a warning and exits 0. That means the `hortusfox-setup` service's `php asatru migrate:fresh`/`migrate:upgrade` calls are silent no-ops unless `settings.APP_DEBUG = true` (or it's set via `secretFile`). The module's own default is `APP_DEBUG = false`, so **migrations do not run out of the box** — this is why `tests/default.nix` and both `vm-test`s explicitly set `settings.APP_DEBUG = true`. - **`settings.APP_DEBUG` matters more than it looks.** Upstream's `asatru` CLI (`app root`/`asatru` file) refuses to run at all unless `APP_DEBUG` is true — it just prints a warning and exits 0. That means the `hortusfox-setup` service's `php asatru migrate:fresh`/`migrate:upgrade` calls are silent no-ops unless `settings.APP_DEBUG = true` (or it's set via `secretFile`). The module's own default is `APP_DEBUG = false`, so **migrations do not run out of the box** — this is why `tests/default.nix` and both `vm-test`s explicitly set `settings.APP_DEBUG = true`.
@@ -49,6 +50,7 @@ The module (`nixos-module/default.nix`, `config.services.hortusfox-web`) wires H
- **`adminAccount.passwordFile` actually seeds an admin account.** This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all. `migrate:fresh` creates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same `.installed` guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via `php -r '...password_hash(...)...'` (matching `UserModel`'s own hashing) and `INSERT`s a row directly into the `UserModel` table (name `adminAccount.username`, default `"admin"`, purely a display name since HortusFox logs in by email; email `adminAccount.email` — defaults to `admin@<domain>` but is independently overridable, since that default isn't necessarily a mailbox anyone controls and HortusFox's own "Restore password" flow emails it — admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both `vm-test`s exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from `/auth` and checking the final URL isn't `/auth` again — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check. - **`adminAccount.passwordFile` actually seeds an admin account.** This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all. `migrate:fresh` creates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same `.installed` guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via `php -r '...password_hash(...)...'` (matching `UserModel`'s own hashing) and `INSERT`s a row directly into the `UserModel` table (name `adminAccount.username`, default `"admin"`, purely a display name since HortusFox logs in by email; email `adminAccount.email` — defaults to `admin@<domain>` but is independently overridable, since that default isn't necessarily a mailbox anyone controls and HortusFox's own "Restore password" flow emails it — admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both `vm-test`s exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from `/auth` and checking the final URL isn't `/auth` again — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check.
- **Serving.** `proxy.server` selects the reverse-proxy backend: `"nginx"` (default) or `"caddy"`, each gated by `lib.mkIf (cfg.proxy.server == "...")`. `proxy.ssl` (default `true`) toggles HTTPS/ACME on the vhost — nginx via `forceSSL`/`enableACME`, caddy by prefixing the site address with `http://` (which disables its automatic HTTPS for that vhost specifically) — set it to `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt (both `vm-test`s and `tests/default.nix` do this). `services.phpfpm.pools.hortusfox` runs as `cfg.user`/`cfg.group` regardless of proxy backend, but its fastcgi socket's `listen.owner`/`listen.group` must match whichever proxy is actually enabled (`config.services.caddy.user`/`.group` vs `config.services.nginx.user`/`.group`) — this was hardcoded to nginx before caddy support existed. nginx uses explicit `tryFiles`/location blocks (an Asatru-framework-style rewrite-to-`index.php` rule, mirroring upstream's `.htaccess`); caddy's `php_fastcgi` directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When `proxy.ssl = true`, the deploying host config must set nginx's `security.acme.{acceptTerms,defaults.email}` or caddy's `services.caddy.email` globally (see comment at the bottom of the module). - **Serving.** `proxy.server` selects the reverse-proxy backend: `"nginx"` (default) or `"caddy"`, each gated by `lib.mkIf (cfg.proxy.server == "...")`. `proxy.ssl` (default `true`) toggles HTTPS/ACME on the vhost — nginx via `forceSSL`/`enableACME`, caddy by prefixing the site address with `http://` (which disables its automatic HTTPS for that vhost specifically) — set it to `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt (both `vm-test`s and `tests/default.nix` do this). `services.phpfpm.pools.hortusfox` runs as `cfg.user`/`cfg.group` regardless of proxy backend, but its fastcgi socket's `listen.owner`/`listen.group` must match whichever proxy is actually enabled (`config.services.caddy.user`/`.group` vs `config.services.nginx.user`/`.group`) — this was hardcoded to nginx before caddy support existed. nginx uses explicit `tryFiles`/location blocks (an Asatru-framework-style rewrite-to-`index.php` rule, mirroring upstream's `.htaccess`); caddy's `php_fastcgi` directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When `proxy.ssl = true`, the deploying host config must set nginx's `security.acme.{acceptTerms,defaults.email}` or caddy's `services.caddy.email` globally (see comment at the bottom of the module).
- **`port` also has to override `SERVER_PORT` at the fastcgi layer, not just `APP_URL`.** Asatru's own `url()`/`asset()` helpers (`vendor/danielbrendel/asatru-php-framework/src/helper.php`) never read `APP_URL` at all — they build every absolute URL straight from `$_SERVER['SERVER_NAME']`/`SERVER_PORT`. Nginx's/Caddy's default fastcgi env populates `SERVER_PORT` from the port the proxy itself is listening on (e.g. the QEMU test VM's unforwarded guest port 80), not an externally-reachable/forwarded one (e.g. the hostfwd'd 8080) — so with only `APP_URL` fixed, page HTML still loaded fine (that request itself hits the forwarded port directly) but every asset/CSS/JS URL the app *generates* silently dropped the port, breaking every static asset (`net::ERR_CONNECTION_REFUSED` in the browser, since nothing listens on host port 80). Fixed by explicitly overriding the `SERVER_PORT` fastcgi param/env var to `cfg.port` whenever it's set — nginx via an extra `fastcgi_param SERVER_PORT "...";` line after `include fastcgi_params` (later same-name `fastcgi_param` wins), caddy via `php_fastcgi`'s `env SERVER_PORT ...` sub-directive. Verified by booting `nixosConfigurations.test` manually and confirming assets load over the forwarded port. - **`port` also has to override `SERVER_PORT` at the fastcgi layer, not just `APP_URL`.** Asatru's own `url()`/`asset()` helpers (`vendor/danielbrendel/asatru-php-framework/src/helper.php`) never read `APP_URL` at all — they build every absolute URL straight from `$_SERVER['SERVER_NAME']`/`SERVER_PORT`. Nginx's/Caddy's default fastcgi env populates `SERVER_PORT` from the port the proxy itself is listening on (e.g. the QEMU test VM's unforwarded guest port 80), not an externally-reachable/forwarded one (e.g. the hostfwd'd 8080) — so with only `APP_URL` fixed, page HTML still loaded fine (that request itself hits the forwarded port directly) but every asset/CSS/JS URL the app *generates* silently dropped the port, breaking every static asset (`net::ERR_CONNECTION_REFUSED` in the browser, since nothing listens on host port 80). Fixed by explicitly overriding the `SERVER_PORT` fastcgi param/env var to `cfg.port` whenever it's set — nginx via an extra `fastcgi_param SERVER_PORT "...";` line after `include fastcgi_params` (later same-name `fastcgi_param` wins), caddy via `php_fastcgi`'s `env SERVER_PORT ...` sub-directive. Verified by booting `nixosConfigurations.test` manually and confirming assets load over the forwarded port.
- **Systemd sandboxing, and the umask trap.** `hortusfox-setup` and the cron services share a `commonHardening` serviceConfig attrset (NoNewPrivileges, Protect*, Restrict*, LockPersonality, RemoveIPC, RestrictAddressFamilies = `AF_UNIX`/`AF_INET`/`AF_INET6` for the mysql socket and SMTP/cron's loopback curl); `phpfpm-hortusfox` instead tightens `services.phpfpm`'s own baseline (`ProtectSystem` "full" → "strict" via `lib.mkForce`, since the phpfpm module sets it as a plain assignment) plus `ReadWritePaths = [ stateDir ]`. `hortusfox-setup`'s `ProtectSystem = "strict"` needs no extra `ReadWritePaths` since its declared `StateDirectory`/`RuntimeDirectory` are auto-exempted. Deliberately excluded: `UMask` — a tighter umask (e.g. `0027`) looks like obvious hardening, but silently reintroduces the exact "Permission denied" static-asset bug fixed above, since it changes what mode `cp -r`/`mkdir` produce for files hortusfox-setup writes to `runDir`/`stateDir` and phpfpm writes to (e.g. uploaded `public/attachments`) — from world-readable 0755/0644 down to 0750/0640 — and the later `find ... chmod u+w` only adds the owner-write bit back, it can't restore group/other bits umask already stripped at creation time. Also excluded: `MemoryDenyWriteExecute` (PHP/Zend may want executable heap for opcache) and `SystemCallFilter` (broad surface, easy to break something subtle in migrations/mysql/zip handling) — this is a first pass at blast-radius reduction, not maximal hardening, and was verified against both `vm-test`s (full boot + login) before landing.
- **Cron.** HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by `curl`ing the app's own HTTP cron routes from systemd timers (`hortusfox-cron-<name>` service+timer pairs), rather than an in-process scheduler. These live in the *same* `systemd.services` attrset as `hortusfox-setup`/`phpfpm-hortusfox` (merged in with `//`, generated via `lib.mapAttrs'` over `cronjobs`) — keep it that way; splitting the cron services into a second top-level `systemd.services = ...;` binding is a Nix duplicate-attribute error (partial-path assignments like `systemd.services.hortusfox-setup = ...` can't coexist with a later full `systemd.services = ...` reassignment in the same module). - **Cron.** HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by `curl`ing the app's own HTTP cron routes from systemd timers (`hortusfox-cron-<name>` service+timer pairs), rather than an in-process scheduler. These live in the *same* `systemd.services` attrset as `hortusfox-setup`/`phpfpm-hortusfox` (merged in with `//`, generated via `lib.mapAttrs'` over `cronjobs`) — keep it that way; splitting the cron services into a second top-level `systemd.services = ...;` binding is a Nix duplicate-attribute error (partial-path assignments like `systemd.services.hortusfox-setup = ...` can't coexist with a later full `systemd.services = ...` reassignment in the same module).
## Current state / known issues ## Current state / known issues
+3 -2
View File
@@ -67,8 +67,9 @@ and a `services.hortusfox-web` NixOS module that wires the built package into a
| `user` / `group` | string | `"hortusfox"` | System user/group the app and its phpfpm pool run as. | | `user` / `group` | string | `"hortusfox"` | System user/group the app and its phpfpm pool run as. |
| `timezone` | string | `"UTC"` | App timezone (`APP_TIMEZONE`). | | `timezone` | string | `"UTC"` | App timezone (`APP_TIMEZONE`). |
| `database.name` | string | `"hortusfox"` | MariaDB database name. | | `database.name` | string | `"hortusfox"` | MariaDB database name. |
| `secretFile` | null or path | `null` | File of `KEY=VALUE` lines (`APP_KEY`, `SMTP_PASSWORD`, API keys, ...) appended verbatim onto the generated `.env` at runtime — not parsed, so it can carry arbitrary keys. Keep it out of the Nix store in production (agenix/sops-nix); a store path is fine for throwaway test configs. | | `secretFile` | null or path | `null` | File of `KEY=VALUE` lines (`APP_KEY`, `SMTP_PASSWORD`, API keys, ...) appended verbatim onto the generated `.env` at runtime — not parsed, so it can carry arbitrary keys. Keep it out of the Nix store in production (agenix/sops-nix); a store path is fine for throwaway test configs. Read by `hortusfox-setup`, which runs as the module's system user, not root — make sure its owner/group lets that user read it. |
| `adminAccount.passwordFile` | null or path | `null` | Plaintext password file for a seeded initial admin account. HortusFox has no CLI/seed hook of its own and its only "create user" flow lives behind the admin panel, so without this a fresh deployment has no way to log in at all. Only takes effect on first boot; changing it later has no effect on an existing account. | | `backupMaxAge` | null or string | `"1d"` | How long HortusFox's own export/auto-backup zips (`public/backup`, systemd-tmpfiles "age" syntax, e.g. `"1d"`, `"12h"`) are kept before auto-deletion. HortusFox names these with a fully predictable `hf_backup_<timestamp>.zip` pattern and serves them from the public webroot without ever cleaning them up itself, so anyone who knows or guesses the timestamp can otherwise download a full data export indefinitely. `null` disables cleanup entirely (not recommended for internet-facing deployments). |
| `adminAccount.passwordFile` | null or path | `null` | Plaintext password file for a seeded initial admin account. HortusFox has no CLI/seed hook of its own and its only "create user" flow lives behind the admin panel, so without this a fresh deployment has no way to log in at all. Only takes effect on first boot; changing it later has no effect on an existing account. Read by `hortusfox-setup` under the same non-root user as `secretFile` above. |
| `adminAccount.username` | string | `"admin"` | Name for the seeded admin account — purely a display name, since HortusFox logs in by email rather than this field. | | `adminAccount.username` | string | `"admin"` | Name for the seeded admin account — purely a display name, since HortusFox logs in by email rather than this field. |
| `adminAccount.email` | string | `"admin@${domain}"` | Email for the seeded admin account. The default is just a guess at a real mailbox — set it to an address you actually control if you intend to use HortusFox's "Restore password" flow, which emails it. | | `adminAccount.email` | string | `"admin@${domain}"` | Email for the seeded admin account. The default is just a guess at a real mailbox — set it to an address you actually control if you intend to use HortusFox's "Restore password" flow, which emails it. |
| `settings` | attrs of str/int/bool | `{ }` | Extra/overriding `.env` entries, merged over the module's generated defaults (`settings` always wins). Note `php asatru` (used for migrations) only runs when `APP_DEBUG` is true, so migrations silently no-op unless it's set here or via `secretFile`. | | `settings` | attrs of str/int/bool | `{ }` | Extra/overriding `.env` entries, merged over the module's generated defaults (`settings` always wins). Note `php asatru` (used for migrations) only runs when `APP_DEBUG` is true, so migrations silently no-op unless it's set here or via `secretFile`. |
+101 -5
View File
@@ -29,6 +29,23 @@ let
"public/img" "public/attachments" "public/backup" "public/themes" "public/img" "public/attachments" "public/backup" "public/themes"
]; ];
# systemd-tmpfiles "age" overrides for specific writableDirs (default: "-",
# never auto-cleaned). public/backup is HortusFox's own export/auto-backup
# destination, named with a fully predictable `hf_backup_<timestamp>.zip`
# pattern (see BackupModule/BackupController upstream) and -- since it
# lives under public/, served as a real static file by nginx/caddy before
# ever reaching the app's own auth-gated routes -- effectively downloadable
# by anyone who knows or guesses that timestamp, indefinitely, since
# upstream never deletes it (the auto-backup cron only does so when an
# external backup_path is also configured in the admin panel; the manual
# "Export" flow never does). We can't fix upstream's public-webroot
# placement or predictable naming from here, but pruning anything older
# than backupMaxAge bounds how long a given export stays fetchable, via
# systemd's own periodic systemd-tmpfiles-clean.timer.
writableDirAges = {
"public/backup" = if cfg.backupMaxAge == null then "-" else cfg.backupMaxAge;
};
# Intermediate parent dirs of the above (e.g. "app", "public") that need # Intermediate parent dirs of the above (e.g. "app", "public") that need
# their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a # their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a
# missing parent when processing a nested `d` line, but does so with # missing parent when processing a nested `d` line, but does so with
@@ -135,6 +152,43 @@ let
cronPort = if cfg.proxy.ssl then 443 else 80; cronPort = if cfg.proxy.ssl then 443 else 80;
cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}"; cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}";
# Baseline systemd sandboxing shared by this module's own units
# (hortusfox-setup and the cron jobs; phpfpm-hortusfox gets its own,
# layered on top of what services.phpfpm already sets). Deliberately
# conservative: no MemoryDenyWriteExecute (PHP/Zend may want executable
# heap for opcache) and no SystemCallFilter (broad surface, easy to break
# something subtle in migrations/mysql/zip handling) -- this is a first
# pass at blast-radius reduction, not maximal hardening. RestrictAddress-
# Families keeps AF_INET/AF_INET6 (SMTP, cron's loopback curl) alongside
# AF_UNIX (the mysql socket). Deliberately no UMask override: this
# module's whole design (see the "world-readable 0755" notes elsewhere)
# depends on hortusfox-setup's and phpfpm's default umask producing
# world-readable files/dirs (0755/0644) for nginx/caddy, a different
# user, to traverse and serve statically -- a tighter umask (e.g. 0027)
# silently reintroduces the exact "Permission denied" static-asset bug
# that was already fixed once, since a later `chmod u+w` only adds the
# owner-write bit back and can't restore group/other bits umask stripped
# at creation time.
commonHardening = {
NoNewPrivileges = true;
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
ProtectClock = true;
ProtectHostname = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictSUIDSGID = true;
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
};
# curl'd cron endpoints -> (name, path, calendar spec) # curl'd cron endpoints -> (name, path, calendar spec)
cronjobs = { cronjobs = {
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; }; "tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
@@ -212,7 +266,27 @@ in {
File with secret KEY=VALUE lines (APP_KEY, SMTP_PASSWORD, API keys, File with secret KEY=VALUE lines (APP_KEY, SMTP_PASSWORD, API keys,
...) appended verbatim to the generated .env at runtime. Keep it out ...) appended verbatim to the generated .env at runtime. Keep it out
of the Nix store in production (e.g. agenix/sops-nix); a store path of the Nix store in production (e.g. agenix/sops-nix); a store path
is fine for throwaway test configs. is fine for throwaway test configs. Read by hortusfox-setup, which
runs as cfg.user rather than root -- if you point this at an
agenix/sops-nix secret, make sure its owner/group is set to
cfg.user/cfg.group (or make it world-readable), or the service fails
with a permission error.
'';
};
backupMaxAge = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "1d";
example = "7d";
description = ''
How long HortusFox's own export/auto-backup zips (written to
public/backup, using systemd-tmpfiles "age" syntax, e.g. "1d",
"12h") are kept before being auto-deleted. HortusFox names these
files with a fully predictable hf_backup_<timestamp>.zip pattern and
serves them from the public webroot without ever cleaning them up
itself, so anyone who knows or guesses the timestamp can download a
full data export indefinitely otherwise. Set to null to disable
cleanup entirely (not recommended for internet-facing deployments).
''; '';
}; };
@@ -228,7 +302,9 @@ in {
a fresh deployment has no way to log in at all. Only used on first a fresh deployment has no way to log in at all. Only used on first
boot (guarded by the same stateDir/.installed marker as boot (guarded by the same stateDir/.installed marker as
migrate:fresh); changing it afterwards has no effect on an migrate:fresh); changing it afterwards has no effect on an
existing account. existing account. Read by hortusfox-setup, which runs as cfg.user
rather than root -- as with secretFile, make sure this file's
owner/group lets cfg.user read it.
Seeds a user (name set via adminAccount.username, email via Seeds a user (name set via adminAccount.username, email via
adminAccount.email), bcrypt-hashed via adminAccount.email), bcrypt-hashed via
@@ -328,7 +404,7 @@ in {
systemd.tmpfiles.rules = systemd.tmpfiles.rules =
[ "d ${stateDir} 0755 ${cfg.user} ${cfg.group} - -" ] [ "d ${stateDir} 0755 ${cfg.user} ${cfg.group} - -" ]
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirParents ++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirParents
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirs; ++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} ${writableDirAges.${d} or "-"} -") writableDirs;
# Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the # Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the
# cron-generated services all live under systemd.services, and a # cron-generated services all live under systemd.services, and a
@@ -341,7 +417,7 @@ in {
requires = [ "mysql.service" ]; requires = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = commonHardening // {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
User = cfg.user; User = cfg.user;
@@ -353,6 +429,11 @@ in {
# which stays mode 600 regardless of this. # which stays mode 600 regardless of this.
RuntimeDirectory = "hortusfox"; RuntimeDirectory = "hortusfox";
RuntimeDirectoryMode = "0755"; RuntimeDirectoryMode = "0755";
# Whole filesystem read-only except /dev, /proc, /sys and the
# StateDirectory/RuntimeDirectory declared above, which systemd
# auto-exempts -- everything this script writes to (stateDir,
# runDir) is covered by those, so no extra ReadWritePaths needed.
ProtectSystem = "strict";
}; };
path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ]; path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ];
@@ -434,17 +515,32 @@ in {
phpfpm-hortusfox = { phpfpm-hortusfox = {
after = [ "hortusfox-setup.service" ]; after = [ "hortusfox-setup.service" ];
requires = [ "hortusfox-setup.service" ]; requires = [ "hortusfox-setup.service" ];
# services.phpfpm already sets a reasonable baseline (PrivateTmp,
# ProtectHome, ProtectSystem = "full", RestrictAddressFamilies).
# Tighten ProtectSystem to "strict" (whole filesystem read-only
# except /dev, /proc, /sys and explicit exceptions) -- stateDir is
# the only path phpfpm actually needs to write to, since every
# writable subdir under runDir is a symlink resolving there; runDir
# itself (the app code) only needs to be read, which ProtectSystem
# doesn't restrict. mkForce is needed since services.phpfpm sets
# ProtectSystem = "full" as a plain (non-mkDefault) assignment.
serviceConfig.ProtectSystem = lib.mkForce "strict";
serviceConfig.ReadWritePaths = [ stateDir ];
}; };
} // (lib.mapAttrs' (name: job: } // (lib.mapAttrs' (name: job:
lib.nameValuePair "hortusfox-cron-${name}" { lib.nameValuePair "hortusfox-cron-${name}" {
description = "HortusFox cronjob: ${name}"; description = "HortusFox cronjob: ${name}";
after = [ "${cfg.proxy.server}.service" ]; after = [ "${cfg.proxy.server}.service" ];
serviceConfig = { serviceConfig = commonHardening // {
Type = "oneshot"; Type = "oneshot";
User = cfg.user; User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 " ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 " + "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 "
+ "${cronUrl job.path}"; + "${cronUrl job.path}";
# A single outbound curl to the app's own loopback address --
# doesn't touch the filesystem at all, so lock it down fully.
ProtectSystem = "strict";
ProtectHome = "tmpfs";
}; };
}) cronjobs); }) cronjobs);