Compare commits

..
3 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 f21690377d 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:06:46 -04:00
awkawbandClaude Sonnet 5 4681e17130 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:05:11 -04:00
awkawbandClaude Sonnet 5 c6f246a74a 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:03:42 -04:00
7 changed files with 67 additions and 386 deletions
+2 -3
View File
@@ -42,18 +42,17 @@ 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 `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.
- **`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.
- **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
@@ -1,116 +0,0 @@
# 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
@@ -0,0 +1,44 @@
{
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": 1791031588,
"narHash": "sha256-/FGDr01siZ8txrVzkOz6CYjiz0XR27JuVJP+aWchi4s=",
"lastModified": 1786599213,
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "825e2028c29b702a4a5f085f08095d12099784f2",
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"ref": "nixos-unstable",
"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-26.05";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.utils.url = "github:numtide/flake-utils";
inputs.systems.url = "github:nix-systems/default";
+16 -255
View File
@@ -23,36 +23,12 @@ 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
@@ -65,15 +41,6 @@ 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:
@@ -98,8 +65,8 @@ let
APP_CONTACT = "dbrendel1988@gmail.com";
APP_DEBUG = false;
APP_BASEDIR = "";
APP_LANG = cfg.language;
APP_WORKSPACE = cfg.workspace;
APP_LANG = "en";
APP_WORKSPACE = "My workspace";
APP_OVERDUETASK_HOURS = 10;
APP_CRONJOB_MAILLIMIT = 5;
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
@@ -145,71 +112,6 @@ 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"; };
@@ -277,23 +179,6 @@ 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 {
@@ -304,27 +189,7 @@ 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. 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).
is fine for throwaway test configs.
'';
};
@@ -340,30 +205,16 @@ 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. 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.
existing account.
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
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
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}";
@@ -401,20 +252,6 @@ 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;
@@ -442,7 +279,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} ${writableDirAges.${d} or "-"} -") writableDirs;
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirs;
# Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the
# cron-generated services all live under systemd.services, and a
@@ -455,7 +292,7 @@ in {
requires = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = commonHardening // {
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = cfg.user;
@@ -467,11 +304,6 @@ 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 ];
@@ -530,43 +362,7 @@ in {
if [ ! -e "${stateDir}/.installed" ]; then
php asatru migrate:fresh
# 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
# 5. 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
@@ -576,53 +372,29 @@ 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 ('${sqlEscape cfg.adminAccount.username}', '${sqlEscape cfg.adminAccount.email}', '$admin_hash', 1);"
mariadb "${cfg.database.name}" -e "INSERT INTO UserModel (name, email, password, admin) VALUES ('admin', '${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 = [ "${cfg.proxy.server}.service" ];
serviceConfig = commonHardening // {
after = [ "nginx.service" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
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";
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
+ "${appUrl}${job.path}";
};
}) cronjobs);
@@ -641,17 +413,6 @@ 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,12 +92,5 @@ 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")
'';
}