diff --git a/CLAUDE.md b/CLAUDE.md index 98ef6cb..0ddb120 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` 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_.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`. diff --git a/README.md b/README.md index 39a81c6..466cc42 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ and a `services.hortusfox-web` NixOS module that wires the built package into a | `timezone` | string | `"UTC"` | App timezone (`APP_TIMEZONE`). | | `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. | +| `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_.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. | | `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. | diff --git a/nixos-module/default.nix b/nixos-module/default.nix index ffd1187..5c73dba 100644 --- a/nixos-module/default.nix +++ b/nixos-module/default.nix @@ -29,6 +29,23 @@ let "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_.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 # their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a # missing parent when processing a nested `d` line, but does so with @@ -216,6 +233,22 @@ in { ''; }; + 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_.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). + ''; + }; + adminAccount.passwordFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; @@ -328,7 +361,7 @@ in { systemd.tmpfiles.rules = [ "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} - -") 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 # cron-generated services all live under systemd.services, and a