Compare commits

..
16 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 3b478f1403 Raise phpfpm upload_max_filesize/post_max_size to match the proxy configs
Both nginx (client_max_body_size) and caddy (request_body.max_size) are
already configured for 64M uploads, but PHP's own stock defaults
(upload_max_filesize=2M, post_max_size=8M) were never raised to match --
so PHP itself truncated/rejected any plant photo upload past 2M (any
modern phone camera JPEG) before PlantsModel::editPlantPhoto ever saw
it, surfacing as a bare "Errorneous file" 500 with no indication it was
ever a size limit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 13:38:08 -04:00
awkawbandClaude Sonnet 5 407f335bac Fix cron jobs always 403ing: wire cronjob_pw through to the curl requests
CronjobsController requires a `cronpw` query param matching
AppModel.cronjob_pw, but cronjob_pw is generated at install time and
INSERTed straight into the DB -- nothing ever surfaced it to the cron
units' curl invocations, so every cron request has 403'd since this
service existed. Cache the value to a cfg.user-only-readable file at
generation time (backfilling it from the DB for already-installed
deployments), and have each cron unit read it back and append it as
`?cronpw=`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 18:48:41 -04:00
awkawbandClaude Sonnet 5 de7a4b7733 Track nixpkgs nixos-26.05 (stable) instead of nixos-25.05
Verified via nix flake check -L: package builds against PHP 8.4.26
with the existing vendorHash still valid, and both vm-test/vm-test-caddy
boot, migrate, and log in successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 11:10:23 -04:00
awkawb 88ed96550a Seed the AppModel settings row and expose language/workspace options
Upstream's own web installer inserts the single-row AppModel settings
table that the admin Environment tab (Language/Timezone/SMTP) reads
and writes via app()/AppModel::updateSet(). hortusfox-setup bypasses
that installer and never created this row, so AppModel::updateSet()'s
WHERE-less UPDATE matched zero rows: every admin "Save" reported
success but changed nothing, and app_mail_config() was nulling out
$_ENV['SMTP_*'] on every request from the same missing row, silently
breaking outbound mail. Seed the row on first boot, and add
language/workspace options (alongside the existing timezone) so
APP_LANG/APP_WORKSPACE and the seeded row both follow them.
2026-10-04 11:02:32 -04:00
awkawb 0c49938796 Track nixpkgs nixos-25.05 (stable) instead of nixos-unstable 2026-10-02 18:03:13 -04:00
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
awkawbandClaude Sonnet 5 8ec061bede Point cron jobs at the app's own loopback address, not its public appUrl
Cron previously curled appUrl (the externally-reachable domain, with
cfg.port appended when set). That meant every cron tick depended on
the host being able to route back to its own public domain (hairpin
NAT, not guaranteed), and whenever cfg.port was set to an
externally-forwarded port nginx/caddy don't themselves listen on (e.g.
the QEMU test VM's hostfwd), cron would curl a port nothing is
listening on at all and fail outright.

curl --resolve now pins the connection to 127.0.0.1 on the port
nginx/caddy actually listen on (80, or 443 under proxy.ssl), while
keeping the Host header/SNI as cfg.domain so vhost routing and cert
validation still work normally. Also fixes the cron services'
"after" dependency, which was hardcoded to nginx.service even when
proxy.server = "caddy".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:21:27 -04:00
awkawbandClaude Sonnet 5 3f5a120338 Assert against proxy.ssl with the default domain "localhost"
proxy.ssl = true (the default) requests a real ACME certificate for
services.hortusfox-web.domain. Left at its own default of "localhost",
that request can never succeed -- Let's Encrypt won't issue for it --
but previously this only surfaced as an ACME failure at activation
time. Catch it at eval time instead with a clear assertion message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:20:06 -04:00
awkawbandClaude Sonnet 5 e2ac57d9c6 Escape adminAccount.username/email before interpolating into the seed INSERT
Both fields were spliced directly into a single-quoted SQL string. An
ordinary value containing an apostrophe (e.g. adminAccount.username =
"O'Brien") broke the INSERT's SQL syntax outright, failing
hortusfox-setup on first boot under set -euo pipefail; a crafted value
could inject arbitrary SQL. Escape backslashes and quotes to match
MySQL/MariaDB's default string literal rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:19:24 -04:00
awkawbandClaude Sonnet 5 d88f6f312d Add adminAccount.username, decoupled from the seeded admin's hardcoded name
The seeded admin account's UserModel "name" was hardcoded to "admin".
Make it a separate, overridable option (still defaulting to "admin"
for compatibility) -- it's purely a display name, since HortusFox logs
in by email rather than this field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 12:52:33 -04:00
awkawbandClaude Sonnet 5 13667f5e47 Add README with a full services.hortusfox-web options reference
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:25:33 -04:00
awkawbandClaude Sonnet 5 150d307020 Add adminAccount.email, decoupled from the seeded admin's assumed domain email
The seeded admin account's email was hardcoded to admin@<domain>, which
isn't necessarily a mailbox the deployer can actually receive mail at --
notably breaking HortusFox's own "Restore password" flow, which emails
that address. Make it a separate, overridable option (still defaulting
to admin@<domain> for compatibility).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:14:22 -04:00
awkawbandClaude Sonnet 5 c7888c3f9d Fix asset/URL port mismatch when proxy.port differs from nginx/caddy's own listen port
Asatru's own url()/asset() helpers build absolute URLs from
$_SERVER['SERVER_PORT'] rather than APP_URL, and nginx/caddy populate
that from the port they're actually listening on -- not an externally
forwarded one (e.g. the QEMU test VM's hostfwd to 8080). That silently
dropped the port from every generated asset/redirect URL, breaking all
static assets when accessed through a forwarded port. Override
SERVER_PORT at the fastcgi layer to cfg.port when set, for both
backends.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:14:22 -04:00
awkawbandClaude Sonnet 5 38ec3c76f7 Initial import of HortusFox Nix flake packaging
Packages HortusFox from source via php.buildComposerProject and provides
a NixOS module (services.hortusfox-web) to deploy it as a full service
(mariadb + phpfpm + nginx/caddy + ACME + cron timers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:14:22 -04:00
7 changed files with 386 additions and 67 deletions
+3 -2
View File
@@ -42,17 +42,18 @@ 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`.
- **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 `INSERT`s a row directly into the `UserModel` table (name `admin`, 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).
- **`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).
## 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-test`s 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.
- `flake.nix.bk` is a stale, syntactically-broken (extra paren) prior draft of `flake.nix`, kept around for reference; it's not a flake input and isn't loaded by anything.
+116
View File
@@ -0,0 +1,116 @@
# hortusfox-web-nixos
A Nix flake that packages [HortusFox](https://github.com/danielbrendel/hortusfox-web) — a self-hosted,
PHP/Laravel-style collaborative plant-management app — from source, and provides a NixOS module to run it
as a full service: mariadb + phpfpm + nginx or caddy + ACME + the app's own cron endpoints on systemd
timers.
There is no HortusFox source code in this repo — the app itself is fetched from upstream at build time via
`php.buildComposerProject`. This repo is purely packaging and deployment glue: a `package.nix` derivation
and a `services.hortusfox-web` NixOS module that wires the built package into a real, stateful host.
## Flake outputs
| Output | What it is |
| --- | --- |
| `packages.hortusfox-web` / `packages.default` | The built HortusFox package. |
| `overlays.default` | Adds `hortusfox-web` to `pkgs`. |
| `nixosModules.default` | The `services.hortusfox-web` module. |
| `devShells.default` | PHP (matching the module's build) + extensions, composer, `just`, `nix-output-monitor`. |
| `checks.<system>.build` | Builds the package. |
| `checks.<system>.vm-test` / `vm-test-caddy` | Boots a full nixosTest VM per proxy backend and exercises a real login. |
| `nixosConfigurations.test` | A throwaway VM (`tests/`) for manual poking — not meant for real deployments. |
## Usage
```nix
{
inputs.hortusfox-web.url = "github:<you>/hortusfox-web-nixos";
outputs = { self, nixpkgs, hortusfox-web, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
hortusfox-web.nixosModules.default
{
services.hortusfox-web = {
enable = true;
domain = "plants.example.com";
adminAccount.passwordFile = "/run/secrets/hortusfox-admin-password";
adminAccount.email = "you@example.com";
secretFile = "/run/secrets/hortusfox.env"; # APP_KEY, SMTP_PASSWORD, ...
};
# proxy.ssl defaults to true (forceSSL + ACME); the host config
# must then set one of these globally:
security.acme = { acceptTerms = true; defaults.email = "you@example.com"; };
# or, if services.hortusfox-web.proxy.server = "caddy":
# services.caddy.email = "you@example.com";
}
];
};
};
}
```
## `services.hortusfox-web` options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enable` | bool | `false` | Enable the service. |
| `domain` | string | `"localhost"` | FQDN served over HTTPS; used for the nginx/caddy vhost and ACME. |
| `port` | null or port | `null` | Appended to the app's own canonical URL (`APP_URL`) and, when set, overrides the `SERVER_PORT` fastcgi param/env seen by PHP. Needed whenever the proxy is only reachable through a forwarded/non-standard port (e.g. the test VM's QEMU hostfwd) — Asatru's own `url()`/`asset()` helpers build every absolute URL from the *request's* `SERVER_PORT`, not from `APP_URL`, so without this every generated asset/redirect URL silently drops the real external port. Does not change what port nginx/caddy actually listen on. |
| `package` | package | built from `package.nix` | The HortusFox package to deploy. |
| `proxy.server` | `"nginx"` \| `"caddy"` | `"nginx"` | Reverse proxy/webserver fronting phpfpm. |
| `proxy.ssl` | bool | `true` | Force HTTPS + request an ACME cert for the vhost. Set `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt. |
| `phpPackage` | package | `pkgs.php83` | Base PHP the phpfpm environment is built from (extensions are added automatically). |
| `user` / `group` | string | `"hortusfox"` | System user/group the app and its phpfpm pool run as. |
| `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. Read by `hortusfox-setup`, which runs as the module's system user, not root — make sure its owner/group lets that user read it. |
| `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.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`. |
| `smtp.host` | string | `""` | SMTP server host. |
| `smtp.port` | port | `587` | SMTP server port. |
| `smtp.username` | string | `""` | SMTP username. |
| `smtp.fromName` | string | `"HortusFox"` | SMTP from-name. |
| `smtp.fromAddress` | string | `"hortusfox@localhost"` | SMTP from-address. |
| `smtp.encryption` | `"tls"` \| `"ssl"` \| `""` | `"tls"` | SMTP transport encryption. |
SMTP password isn't a module option — set `SMTP_PASSWORD` via `secretFile` instead, alongside `APP_KEY`.
## What the module actually does
- Copies the read-only Nix store package into a writable runtime tree (`/run/hortusfox/www`) on every boot,
then symlinks a handful of subdirectories (`cache`, `app/logs`, `public/img`, `public/attachments`,
`public/backup`, `public/themes`) into persistent state under `/var/lib/hortusfox`, seeding them from the
package's own shipped defaults first so nothing gets silently deleted on first boot.
- Renders `.env` from the module's curated + mirrored-upstream defaults, `settings` (always wins), and
`secretFile` (appended verbatim).
- Runs `php asatru migrate:fresh` on first boot (schema only) and `migrate:upgrade` on every boot after,
guarded by a `stateDir/.installed` marker so a destructive fresh migration never runs twice.
- Optionally seeds a single admin account directly into the database on first boot, since HortusFox has no
other way to create the very first user.
- Drives HortusFox's own HTTP cron endpoints (task/calendar reminders, auto-backup) via systemd timers
rather than an in-process scheduler.
See `nixos-module/default.nix` and `CLAUDE.md` for the full reasoning and the gotchas each of these design
choices worked around (several were real, previously-shipped bugs).
## Local development / testing
```bash
nix develop # dev shell: PHP+extensions, composer, just, nix-output-monitor
just build-vm # build the throwaway test VM (nixosConfigurations.test)
just boot-vm # boot it in-terminal (QEMU, serial console); http://127.0.0.1:8080
just clean # tear down local VM artifacts (this project's own qemu instance + build outputs)
nix flake check # evaluate all outputs; builds the package + boots both proxy-backend VM tests
nix flake check -L # same, with full build/VM logs streamed (slow: boots two real VMs)
```
The test VM's seeded admin account logs in with the password in `tests/hortusfox_web_test_password`
(currently `password`) — see `tests/default.nix` for its full config.
-44
View File
@@ -1,44 +0,0 @@
{
description = "HortusFox — self-hosted plant manager, from source";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forEachSystem = nixpkgs.lib.genAttrs systems;
pkgs = nixpkgs.legacyPackages.${system};
overlayList = [ self.overlays.default ];
pkgsBySystem = forEachSystem (
system:
import nixpkgs {
inherit system;
overlays = overlayList;
}
);
in {
nixosConfigurations.test = nixpkgs.lib.nixosSystem {
inherit system;
modules = [ ./tests ];
});
overlays.default = final: prev: { hortusfox-web = final.callPackage ./package.nix { }; };
packages = {
hortusfox-web = pkgs.${system}.hortusfox-web;
default = pkgs.${system}.hortusfox-web;
};
nixosModules.default = import ./nixos-module { overlays = overlayList; };
# Handy for `nix flake check` on a throwaway host
checks = forEachSystem (system: {
build = pkgsBySystem.${system}.hortusfox-web;
});
};
}
Generated
+4 -4
View File
@@ -2,16 +2,16 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1786599213,
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=",
"lastModified": 1791031588,
"narHash": "sha256-/FGDr01siZ8txrVzkOz6CYjiz0XR27JuVJP+aWchi4s=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
"rev": "825e2028c29b702a4a5f085f08095d12099784f2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
description = "HortusFox — self-hosted plant manager, from source";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
inputs.utils.url = "github:numtide/flake-utils";
inputs.systems.url = "github:nix-systems/default";
+255 -16
View File
@@ -23,12 +23,36 @@ let
runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs)
stateDir = "/var/lib/hortusfox"; # persistent writable data
# The app's CronjobsController requires a `?cronpw=` query param matching
# AppModel.cronjob_pw (see hortusfox-setup's step 5 below) -- cache that
# value here, readable only by cfg.user, so the cron units below can read
# it back at request time without ever going through Nix (cronjob_pw is
# DB-generated at install time, not a Nix-level secret).
cronPwFile = "${stateDir}/.cronjob-pw";
# Writable subdirs that must survive reboots.
writableDirs = [
"cache" "app/logs"
"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
# their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a
# missing parent when processing a nested `d` line, but does so with
@@ -41,6 +65,15 @@ let
in if builtins.length parts > 1 then lib.concatStringsSep "/" (lib.init parts) else null)
writableDirs));
# Escape a value for interpolation into a single-quoted SQL string literal
# (used for the admin-seed INSERT below). Doubling backslashes first, then
# quotes, matches MySQL/MariaDB's default (backslash-escapes-active)
# string literal rules -- without this, an ordinary value like "O'Brien"
# breaks the INSERT's SQL syntax outright (failing hortusfox-setup on
# first boot, since the script runs under `set -euo pipefail`), and a
# crafted value could inject arbitrary SQL.
sqlEscape = s: lib.replaceStrings [ "\\" "'" ] [ "\\\\" "''" ] s;
# Render one KEY=VALUE .env line. Booleans/ints/floats are left bare so
# Asatru's datatype auto-detection picks them up as such rather than string.
mkEnvLine = name: value:
@@ -65,8 +98,8 @@ let
APP_CONTACT = "dbrendel1988@gmail.com";
APP_DEBUG = false;
APP_BASEDIR = "";
APP_LANG = "en";
APP_WORKSPACE = "My workspace";
APP_LANG = cfg.language;
APP_WORKSPACE = cfg.workspace;
APP_OVERDUETASK_HOURS = 10;
APP_CRONJOB_MAILLIMIT = 5;
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
@@ -112,6 +145,71 @@ let
lib.concatStringsSep "\n" (lib.mapAttrsToList mkEnvLine envVars) + "\n"
);
# Cron jobs curl the app's own loopback address rather than its
# externally-reachable appUrl, on the port nginx/caddy actually listen on
# (80, or 443 when proxy.ssl -- never cfg.port, which per its own option
# doc doesn't change that). Otherwise cron would depend on the host being
# able to route back to its own public domain (hairpin NAT, not
# guaranteed) and, whenever cfg.port is set to an externally-forwarded
# port nginx/caddy don't themselves listen on (e.g. the QEMU test VM's
# hostfwd), would curl a port nothing is listening on at all. curl's
# --resolve pins the connection to 127.0.0.1 while keeping the
# Host header/SNI as cfg.domain, so vhost routing and cert validation
# still work normally.
cronPort = if cfg.proxy.ssl then 443 else 80;
cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}";
# The actual ExecStart for each cron unit: read the cached cronjob_pw
# (written by hortusfox-setup) and append it as the `?cronpw=` query param
# CronjobsController requires, or every cron request gets a bare 403.
# A wrapper script rather than a plain curl invocation because systemd's
# ExecStart doesn't run through a shell, so it can't itself do the
# `$(cat ...)` substitution.
cronScript = name: job: pkgs.writeShellScript "hortusfox-cron-${name}" ''
set -euo pipefail
cronpw="$(${pkgs.coreutils}/bin/cat "${cronPwFile}")"
exec ${pkgs.curl}/bin/curl -fsS --max-time 300 \
--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 \
"${cronUrl job.path}?cronpw=$cronpw"
'';
# 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)
cronjobs = {
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
@@ -179,6 +277,23 @@ in {
timezone = lib.mkOption { type = lib.types.str; default = "UTC"; };
language = lib.mkOption {
type = lib.types.str;
default = "en";
example = "de";
description = ''
UI language (APP_LANG). Must match one of the language codes
upstream ships translation files for under lang/.
'';
};
workspace = lib.mkOption {
type = lib.types.str;
default = "My workspace";
example = "Greenhouse";
description = "Workspace name shown in the UI (APP_WORKSPACE).";
};
database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
secretFile = lib.mkOption {
@@ -189,7 +304,27 @@ in {
File with secret KEY=VALUE lines (APP_KEY, SMTP_PASSWORD, API keys,
...) 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
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).
'';
};
@@ -205,16 +340,30 @@ in {
a fresh deployment has no way to log in at all. Only used on first
boot (guarded by the same stateDir/.installed marker as
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 named "admin" (email set via adminAccount.email),
bcrypt-hashed via `password_hash(..., PASSWORD_BCRYPT)` (matching
UserModel's own hashing) and inserted directly into the UserModel
table with the admin flag set, bypassing the app's normal two-step
Seeds a user (name set via adminAccount.username, email via
adminAccount.email), bcrypt-hashed via
`password_hash(..., PASSWORD_BCRYPT)` (matching UserModel's own
hashing) and inserted directly into the UserModel table with the
admin flag set, bypassing the app's normal two-step
create-then-promote admin-panel flow.
'';
};
adminAccount.username = lib.mkOption {
type = lib.types.str;
default = "admin";
example = "Jane Doe";
description = ''
Name for the seeded admin account (see adminAccount.passwordFile) --
the UserModel table's "name" column. HortusFox logs in by email, not
this field, so it's purely a display name shown in the app's UI.
'';
};
adminAccount.email = lib.mkOption {
type = lib.types.str;
default = "admin@${cfg.domain}";
@@ -252,6 +401,20 @@ in {
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !(cfg.proxy.ssl && cfg.domain == "localhost");
message = ''
services.hortusfox-web: proxy.ssl = true (the default) requests an
ACME certificate for services.hortusfox-web.domain, but domain is
left at its own default of "localhost" -- Let's Encrypt can never
issue a certificate for that, so this would only fail at runtime.
Set domain to a real, publicly resolvable FQDN, or set
proxy.ssl = false for a local/test deployment.
'';
}
];
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
@@ -279,7 +442,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
@@ -292,7 +455,7 @@ in {
requires = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
serviceConfig = commonHardening // {
Type = "oneshot";
RemainAfterExit = true;
User = cfg.user;
@@ -304,6 +467,11 @@ in {
# which stays mode 600 regardless of this.
RuntimeDirectory = "hortusfox";
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 ];
@@ -362,7 +530,43 @@ in {
if [ ! -e "${stateDir}/.installed" ]; then
php asatru migrate:fresh
# 5. Seed the initial admin account. migrate:fresh creates no
# 5. Seed the AppModel settings row. migrate:fresh only creates
# the (empty) table -- upstream's own web installer is what
# inserts the single settings row that app()/AppModel's own
# admin-panel features (the Environment tab's Language/
# Timezone fields, SMTP toggle, etc.) read and write, and we
# bypass that installer entirely. Without this row,
# AppModel::updateSet()'s `UPDATE AppModel SET ...` -- no
# WHERE clause, since it's a de-facto single-row table --
# matches zero rows, so every "Save" in the admin Environment
# tab reports success but silently changes nothing. This
# includes Language/Timezone: those live here, not in .env
# (.env's APP_LANG is only a fallback used when this row is
# missing, and APP_TIMEZONE only sets PHP's global default
# timezone -- see AppHelper.php/bootstrap.php upstream,
# neither is what the admin UI's fields actually edit). It
# also means app_mail_config() -- called at the top of every
# single request/cronjob -- was overwriting $_ENV['SMTP_*']
# with NULLs read from this nonexistent row on every request,
# silently breaking outbound mail regardless of smtp.*/
# secretFile; seeding smtp_* here fixes that too. smtp_password
# is deliberately left NULL: it's secret-only (see
# secretFile), not a plain Nix option, and leaving it NULL is
# no regression versus the previous (rowless) behavior either
# way. cronjob_pw is generated the same way AppModel's own
# generateCronjobToken() does. Every other column either has
# no corresponding Nix option yet or just mirrors the
# migration's own column default, same as upstream's
# installer does.
cronjob_pw="$(php -r 'echo md5(random_bytes(55) . date("Y-m-d H:i:s"));')"
# Cache it for the cron units (see cronScript above) -- this is
# the only place that ever learns the value in plaintext outside
# the DB itself.
printf '%s' "$cronjob_pw" > "${cronPwFile}"
chmod 600 "${cronPwFile}"
mariadb "${cfg.database.name}" -e "INSERT INTO AppModel (id, workspace, language, timezone, scroller, chat_enable, chat_timelimit, chat_showusers, chat_indicator, chat_system, history_enable, history_name, enable_media_share, cronjob_pw, overlay_alpha, smtp_enable_auth, smtp_fromname, smtp_fromaddress, smtp_host, smtp_port, smtp_username, smtp_password, smtp_encryption, pwa_enable, owm_enable, owm_api_key, owm_latitude, owm_longitude, owm_unittype, owm_cache) VALUES (NULL, '${sqlEscape envVars.APP_WORKSPACE}', '${sqlEscape cfg.language}', '${sqlEscape cfg.timezone}', 1, 1, 5, 1, 1, 1, 1, 'History', 0, '$cronjob_pw', NULL, 1, '${sqlEscape cfg.smtp.fromName}', '${sqlEscape cfg.smtp.fromAddress}', '${sqlEscape cfg.smtp.host}', ${toString cfg.smtp.port}, '${sqlEscape cfg.smtp.username}', NULL, '${sqlEscape cfg.smtp.encryption}', 0, 0, NULL, NULL, NULL, 'default', 300);"
# 6. Seed the initial admin account. migrate:fresh creates no
# user rows at all, and the app's only "create user" flow
# lives behind the admin panel -- without this, a fresh
# deployment has no way to log in, ever. Insert directly
@@ -372,29 +576,53 @@ in {
# this point.
${lib.optionalString (cfg.adminAccount.passwordFile != null) ''
admin_hash="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$(cat "${cfg.adminAccount.passwordFile}")")"
mariadb "${cfg.database.name}" -e "INSERT INTO UserModel (name, email, password, admin) VALUES ('admin', '${cfg.adminAccount.email}', '$admin_hash', 1);"
mariadb "${cfg.database.name}" -e "INSERT INTO UserModel (name, email, password, admin) VALUES ('${sqlEscape cfg.adminAccount.username}', '${sqlEscape cfg.adminAccount.email}', '$admin_hash', 1);"
''}
touch "${stateDir}/.installed"
else
php asatru migrate:upgrade || true
fi
# Backfill for deployments that installed before cronjob_pw was
# cached to disk (it's always been DB-only until now): pull the
# value AppModel already has back out, rather than regenerating
# one that wouldn't match it.
if [ ! -s "${cronPwFile}" ]; then
mariadb -N "${cfg.database.name}" -e "SELECT cronjob_pw FROM AppModel LIMIT 1" > "${cronPwFile}"
chmod 600 "${cronPwFile}"
fi
'';
};
phpfpm-hortusfox = {
after = [ "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.nameValuePair "hortusfox-cron-${name}" {
description = "HortusFox cronjob: ${name}";
after = [ "nginx.service" ];
serviceConfig = {
after = [ "${cfg.proxy.server}.service" ];
serviceConfig = commonHardening // {
Type = "oneshot";
User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
+ "${appUrl}${job.path}";
ExecStart = "${cronScript name job}";
# Only touches the filesystem to read cronPwFile back -- stateDir
# stays visible (read-only) under ProtectSystem=strict since it
# isn't under /home, so no ReadWritePaths/ReadOnlyPaths needed.
ProtectSystem = "strict";
ProtectHome = "tmpfs";
};
}) cronjobs);
@@ -413,6 +641,17 @@ in {
user = cfg.user;
group = cfg.group;
phpPackage = phpPackage;
# PHP's own stock defaults (upload_max_filesize=2M, post_max_size=8M)
# are far below what both proxy configs below already allow through
# (nginx's client_max_body_size / caddy's request_body.max_size, both
# 64M) -- without this, PHP itself truncates/rejects any plant photo
# upload past 2M (a single modern phone camera JPEG, easily) before
# PlantsModel::editPlantPhoto ever sees it, surfacing as a bare
# "Errorneous file" 500 with no indication it was ever a size limit.
phpOptions = ''
upload_max_filesize = 64M
post_max_size = 64M
'';
settings = {
"listen.owner" = if cfg.proxy.server == "caddy" then config.services.caddy.user else config.services.nginx.user;
"listen.group" = if cfg.proxy.server == "caddy" then config.services.caddy.group else config.services.nginx.group;
+7
View File
@@ -92,5 +92,12 @@ pkgs.testers.nixosTest {
assert "/auth" not in final_url, (
f"login didn't redirect away from /auth -- login failed (landed on {final_url}):\n{login_response}"
)
# The cron units curl the app's own cronjob endpoints with a `?cronpw=`
# query param matching AppModel.cronjob_pw -- exercise one directly
# rather than just trusting it, since this exact wiring broke silently
# in production (every cron request 403'd, forever, with nothing short
# of reading the journal ever surfacing it) before this assertion existed.
machine.succeed("systemctl start hortusfox-cron-tasks-recurring.service")
'';
}