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>
This commit is contained in:
@@ -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.
|
||||
- **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.
|
||||
- **`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.
|
||||
- **`.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`.
|
||||
|
||||
Reference in New Issue
Block a user