Files
hortusfox-web-nixos/CLAUDE.md
T
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

19 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

A Nix flake that packages HortusFox (a self-hosted, PHP/Laravel-style collaborative plant-management app) from source, and provides a NixOS module to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers).

  • flake.nix — flake entry point: exposes packages.hortusfox-web, overlays.default, nixosModules.default, devShells.default, checks.<system>.{build,vm-test,vm-test-caddy}, and a throwaway nixosConfigurations.test VM for manual testing. Only packages/devShells/checks are produced per-system via utils.lib.eachSystem; overlays.default, nixosModules.default, and nixosConfigurations.test are merged in afterwards with // since those output types are keyed by name, not by system — don't move them back inside the eachSystem callback.
  • package.nix — the derivation. Uses php.buildComposerProject to fetch danielbrendel/hortusfox-web from GitHub and vendor its Composer deps. Installs to $out/share/php/hortusfox-web/. Both src.hash and vendorHash are fixed-output hashes pinned to a specific upstream version/rev — bumping the app version means updating both.
  • nixos-module/default.nix — the services.hortusfox-web NixOS module (see Architecture below).
  • tests/default.nix — a minimal NixOS system config used as the nixosConfigurations.test VM (built/booted via just build-vm/just boot-vm), plus a secrets file (tests/test.env) and an admin password file (tests/hortusfox_web_test_password, currently password) consumed by the module.
  • tests/vm-test.nix — { pkgs, nixosModule, proxyServer }: pkgs.testers.nixosTest { ... }, the actual automated boot test. flake.nix calls it once per proxy backend (checks.vm-test for nginx, checks.vm-test-caddy for caddy) rather than inlining the test twice.
  • justfile — VM dev-loop wrapper around nix build/QEMU.

There is no application source code in this repo — HortusFox itself is fetched from upstream at build time. This repo is purely packaging/deployment glue.

Commands

just build-vm     # nix build the test VM (nixosConfigurations.test), via `nom build`
just boot-vm      # run the built VM in-terminal (QEMU, serial console, host 127.0.0.1:8080 -> guest 80)
just clean        # kill this project's own qemu instance, remove ./result and ./nixos.qcow2

nix build .#hortusfox-web          # build just the HortusFox package
nix build                          # same (default package)
nix develop                        # dev shell: matching PHP+extensions build, composer, just, nix-output-monitor
nix flake show                     # list outputs
nix flake check                    # evaluate all outputs; builds the package + runs both nixosTest VM checks
nix flake check -L                 # same, with full build/VM logs streamed (slow: boots two real VMs)

justfile's QEMU networking is bound to 127.0.0.1 explicitly (hostfwd=tcp:127.0.0.1:8080-:80) — a bare hostfwd=tcp::8080-:80 (empty host address) binds 0.0.0.0, exposing this throwaway VM (root password toor, firewall disabled, seeded test credentials) to the whole network. Likewise just clean kills only this project's own qemu process (pkill -f "$(pwd)/nixos.qcow2"), not pkill qemu generally, which would kill any unrelated QEMU VM on the host.

checks.<system>.vm-test/vm-test-caddy (tests/vm-test.nix) boot the module with proxy.ssl = false (no ACME/internet in the sandbox), settings.APP_DEBUG = true (required for migrations, see below), and a real adminAccount.passwordFile, then assert: the backend's own unit + phpfpm + mysql all come up; port 80 responds; the response contains no "Asatru PHP - Exception" marker (Asatru renders uncaught exceptions as HTML without failing the HTTP status — see below); a real static asset (/img/pattern.jpg) is served with no Permission denied in the proxy's own log; and — the strongest assertion — a full cookie-jar login as the seeded admin actually lands away from /auth rather than bouncing back to it. Each of these exists because a weaker check was previously fooled by a real bug during development (see the architecture notes below); don't weaken or remove any of them without understanding why. There's no other test suite; there's no lint/format command configured. just --list shows the same recipes as above.

Architecture (NixOS module)

The module (nixos-module/default.nix, config.services.hortusfox-web) wires HortusFox into a full host:

  • Read-only store vs. writable state, reconciled at boot. The Composer-built package (cfg.package, defaulting to pkgs.callPackage ../package.nix { }) lives read-only in the Nix store (appRoot). A hortusfox-setup oneshot systemd service (before phpfpm starts) mirrors it into a runtime tree (runDir = /run/hortusfox/www, tmpfs) via a real, full cp -r — not cp -rs (per-file symlinks back into the store). This was a real, previously-shipped bug: PHP resolves __DIR__/__FILE__ through a symlink to its real target, so a symlinked file under runDir reports its own location as the read-only store, not runDir. HortusFox computes its whole app root (and from there, .env discovery) via __DIR__-relative paths, so every symlinked file silently looked for config in the store instead of runDir — breaking DB/session config on every real web request while looking fine on curl. Verified by a minimal local repro before fixing; don't reintroduce per-file symlinking here. The tradeoff is real: this duplicates the app into tmpfs on every boot instead of deduping via the store, which is an acceptable cost for a small PHP app.
  • 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-tests explicitly set settings.APP_DEBUG = true.
  • First-boot vs. upgrade migrations. stateDir/.installed is the marker: absent → php asatru migrate:fresh (destructive schema create) runs and then creates the marker; present → php asatru migrate:upgrade || true runs instead. This is the guard against re-running a destructive migration against real data — don't remove it without understanding why it's there.
  • 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 INSERTs 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-tests 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-tests 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.
  • 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-tests (full boot + login) before landing.
  • Cron. HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by curling 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

  • nix flake check passes cleanly for all outputs (packages, checks.build, checks.vm-test, checks.vm-test-caddy, overlays, nixosModules, nixosConfigurations.test), and the VM actually serves a working, loggable-into site end-to-end for both proxy backends (verified via the automated vm-tests and by booting nixosConfigurations.test manually and logging in over HTTP with tests/hortusfox_web_test_password). Note tests/default.nix carries a fileSystems."/"/boot.loader.grub.devices/security.acme stanza purely so config.system.build.toplevel (what nix flake check builds) evaluates — the VM is actually built and run via .config.system.build.vm (see justfile's build-vm/boot-vm), which doesn't use any of that.
  • Upstream's own templates still emit a PHP Deprecated: htmlspecialchars(): Passing null to parameter #1 notice on some pages (PHP 8.1+ deprecation for htmlspecialchars(null)). Harmless and not something this repo can fix — it's app code, not packaging.