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>
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: exposespackages.hortusfox-web,overlays.default,nixosModules.default,devShells.default,checks.<system>.{build,vm-test,vm-test-caddy}, and a throwawaynixosConfigurations.testVM for manual testing. Onlypackages/devShells/checksare produced per-system viautils.lib.eachSystem;overlays.default,nixosModules.default, andnixosConfigurations.testare merged in afterwards with//since those output types are keyed by name, not by system — don't move them back inside theeachSystemcallback.package.nix— the derivation. Usesphp.buildComposerProjectto fetchdanielbrendel/hortusfox-webfrom GitHub and vendor its Composer deps. Installs to$out/share/php/hortusfox-web/. Bothsrc.hashandvendorHashare fixed-output hashes pinned to a specific upstreamversion/rev— bumping the app version means updating both.nixos-module/default.nix— theservices.hortusfox-webNixOS module (see Architecture below).tests/default.nix— a minimal NixOS system config used as thenixosConfigurations.testVM (built/booted viajust build-vm/just boot-vm), plus a secrets file (tests/test.env) and an admin password file (tests/hortusfox_web_test_password, currentlypassword) consumed by the module.tests/vm-test.nix—{ pkgs, nixosModule, proxyServer }: pkgs.testers.nixosTest { ... }, the actual automated boot test.flake.nixcalls it once per proxy backend (checks.vm-testfor nginx,checks.vm-test-caddyfor caddy) rather than inlining the test twice.justfile— VM dev-loop wrapper aroundnix 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 topkgs.callPackage ../package.nix { }) lives read-only in the Nix store (appRoot). Ahortusfox-setuponeshot systemd service (before phpfpm starts) mirrors it into a runtime tree (runDir = /run/hortusfox/www, tmpfs) via a real, fullcp -r— notcp -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 underrunDirreports its own location as the read-only store, notrunDir. HortusFox computes its whole app root (and from there,.envdiscovery) via__DIR__-relative paths, so every symlinked file silently looked for config in the store instead ofrunDir— breaking DB/session config on every real web request while looking fine oncurl. 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. cpcarries the store's read-only mode onto the copy. Every directorycp -rcreates inherits the store's555(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 canrm/relink anything under them.- Writable subdirs are seeded, not just replaced.
cache,app/logs,public/img,public/attachments,public/backup,public/themesget 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 intostateDir/<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.rulesdeclares adline per entry inwritableDirs, butapp/logsand the fourpublic/*entries are nested underapp/public, which have no rule of their own.systemd-tmpfilesauto-vivifies a missing parent for a nesteddline, but does so with defaultroot:root 0755ownership and does not reliably go on to create the requested leaf — soapp/logs(and all ofpublic/*) silently never got created, and the hortusfox-setup service's ownmkdir -p "${stateDir}/${d}"would fail withPermission deniedtrying to create the leaf inside a root-owned, not-writable-by-cfg.userparent. Fixed by computingwritableDirParents(the unique set of intermediate dirs, e.g.app,public) and giving each its own explicit tmpfilesdrule, ordered before the leaves. If you add a new nested entry towritableDirs, make sure its parent is covered by this too. public/backupgets auto-cleaned; nothing else does. HortusFox's own export/auto-backup feature (BackupModule/BackupController) writes topublic/backup/hf_backup_<timestamp>.zip— a fully predictable filename — and never deletes it itself (the auto-backup cron only does so when an externalbackup_pathis also configured in the admin panel; the manual "Export" flow never does). Sincepublic/backupis 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, butwritableDirAgesgivespublic/backup's tmpfilesdrule anagefield driven by thebackupMaxAgeoption (default"1d",nulldisables), sosystemd-tmpfiles-clean.timerprunes old exports and bounds the exposure window. Every otherwritableDirsentry keepsage = "-"(never auto-cleaned) viawritableDirAges.${d} or "-".RuntimeDirectoryModeandstateDir's tmpfiles rules must both stay world-readable (0755). nginx/caddy run as their own user, notcfg.user, and need tostat/read everything underrunDirand intostateDir's writable subdirs (public/imgetc., reached through symlinks fromrunDir) to servepublic/directly. At0750the proxy can't even traverse the directory,tryFiles/file_serverstats fail withPermission denied, and — this is the trap — the site can still appear to work, because PHP-FPM (running ascfg.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 plaincurlon/won't catch it; the VM tests fetch a real static asset and grep the proxy's own log forPermission deniedspecifically because of it. Nothing secret is exposed by the world-readable mode:.envis a symlink tostateDir/.env, which stays600independently..envrendering. A base attrset of env vars (envVars) is merged with the freeformcfg.settingsattrset (envVars // cfg.settings, sosettingsalways wins) and serialized to KEY=VALUE lines viapkgs.writeText.envVarsisn'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 noisset()guard (APP_BASEDIRin particular), so an omitted key produces aPHP Warning: Undefined array keyon every single request, not just a missing feature.APP_VERSIONderives frompackage.versionrather than being duplicated by hand, so it can't drift frompackage.nixon a version bump. Secrets are handled separately: ifcfg.secretFileis set, its contents arecat'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 instateDir/.env, then is symlinked intorunDir.settings.APP_DEBUGmatters more than it looks. Upstream'sasatruCLI (app root/asatrufile) refuses to run at all unlessAPP_DEBUGis true — it just prints a warning and exits 0. That means thehortusfox-setupservice'sphp asatru migrate:fresh/migrate:upgradecalls are silent no-ops unlesssettings.APP_DEBUG = true(or it's set viasecretFile). The module's own default isAPP_DEBUG = false, so migrations do not run out of the box — this is whytests/default.nixand bothvm-tests explicitly setsettings.APP_DEBUG = true.- First-boot vs. upgrade migrations.
stateDir/.installedis the marker: absent →php asatru migrate:fresh(destructive schema create) runs and then creates the marker; present →php asatru migrate:upgrade || trueruns 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.passwordFileactually 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:freshcreates 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.installedguard as migrations), if this is set, the setup script bcrypt-hashes the file's contents viaphp -r '...password_hash(...)...'(matchingUserModel's own hashing) andINSERTs a row directly into theUserModeltable (nameadminAccount.username, default"admin", purely a display name since HortusFox logs in by email; emailadminAccount.email— defaults toadmin@<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. Bothvm-tests exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from/authand checking the final URL isn't/authagain — 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.serverselects the reverse-proxy backend:"nginx"(default) or"caddy", each gated bylib.mkIf (cfg.proxy.server == "...").proxy.ssl(defaulttrue) toggles HTTPS/ACME on the vhost — nginx viaforceSSL/enableACME, caddy by prefixing the site address withhttp://(which disables its automatic HTTPS for that vhost specifically) — set it tofalsefor local/test deployments with no real domain or internet access to reach Let's Encrypt (bothvm-tests andtests/default.nixdo this).services.phpfpm.pools.hortusfoxruns ascfg.user/cfg.groupregardless of proxy backend, but its fastcgi socket'slisten.owner/listen.groupmust match whichever proxy is actually enabled (config.services.caddy.user/.groupvsconfig.services.nginx.user/.group) — this was hardcoded to nginx before caddy support existed. nginx uses explicittryFiles/location blocks (an Asatru-framework-style rewrite-to-index.phprule, mirroring upstream's.htaccess); caddy'sphp_fastcgidirective covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. Whenproxy.ssl = true, the deploying host config must set nginx'ssecurity.acme.{acceptTerms,defaults.email}or caddy'sservices.caddy.emailglobally (see comment at the bottom of the module). portalso has to overrideSERVER_PORTat the fastcgi layer, not justAPP_URL. Asatru's ownurl()/asset()helpers (vendor/danielbrendel/asatru-php-framework/src/helper.php) never readAPP_URLat all — they build every absolute URL straight from$_SERVER['SERVER_NAME']/SERVER_PORT. Nginx's/Caddy's default fastcgi env populatesSERVER_PORTfrom 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 onlyAPP_URLfixed, 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_REFUSEDin the browser, since nothing listens on host port 80). Fixed by explicitly overriding theSERVER_PORTfastcgi param/env var tocfg.portwhenever it's set — nginx via an extrafastcgi_param SERVER_PORT "...";line afterinclude fastcgi_params(later same-namefastcgi_paramwins), caddy viaphp_fastcgi'senv SERVER_PORT ...sub-directive. Verified by bootingnixosConfigurations.testmanually and confirming assets load over the forwarded port.- Systemd sandboxing, and the umask trap.
hortusfox-setupand the cron services share acommonHardeningserviceConfig attrset (NoNewPrivileges, Protect*, Restrict*, LockPersonality, RemoveIPC, RestrictAddressFamilies =AF_UNIX/AF_INET/AF_INET6for the mysql socket and SMTP/cron's loopback curl);phpfpm-hortusfoxinstead tightensservices.phpfpm's own baseline (ProtectSystem"full" → "strict" vialib.mkForce, since the phpfpm module sets it as a plain assignment) plusReadWritePaths = [ stateDir ].hortusfox-setup'sProtectSystem = "strict"needs no extraReadWritePathssince its declaredStateDirectory/RuntimeDirectoryare 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 modecp -r/mkdirproduce for files hortusfox-setup writes torunDir/stateDirand phpfpm writes to (e.g. uploadedpublic/attachments) — from world-readable 0755/0644 down to 0750/0640 — and the laterfind ... chmod u+wonly 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) andSystemCallFilter(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 bothvm-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 samesystemd.servicesattrset ashortusfox-setup/phpfpm-hortusfox(merged in with//, generated vialib.mapAttrs'overcronjobs) — keep it that way; splitting the cron services into a second top-levelsystemd.services = ...;binding is a Nix duplicate-attribute error (partial-path assignments likesystemd.services.hortusfox-setup = ...can't coexist with a later fullsystemd.services = ...reassignment in the same module).
Current state / known issues
nix flake checkpasses 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 automatedvm-tests and by bootingnixosConfigurations.testmanually and logging in over HTTP withtests/hortusfox_web_test_password). Notetests/default.nixcarries afileSystems."/"/boot.loader.grub.devices/security.acmestanza purely soconfig.system.build.toplevel(whatnix flake checkbuilds) evaluates — the VM is actually built and run via.config.system.build.vm(seejustfile'sbuild-vm/boot-vm), which doesn't use any of that.- Upstream's own templates still emit a
PHP Deprecated: htmlspecialchars(): Passing null to parameter #1notice on some pages (PHP 8.1+ deprecation forhtmlspecialchars(null)). Harmless and not something this repo can fix — it's app code, not packaging.