From c7888c3f9df8f888582f8fc6b7c35fb9d11a794e Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Sun, 27 Sep 2026 17:05:11 -0400 Subject: [PATCH] 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 --- CLAUDE.md | 1 + nixos-module/default.nix | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 401f75c..a085e3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ The module (`nixos-module/default.nix`, `config.services.hortusfox-web`) wires H - **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 `admin@`, 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. - **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-` 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 diff --git a/nixos-module/default.nix b/nixos-module/default.nix index 95f2ce7..ec8dc3f 100644 --- a/nixos-module/default.nix +++ b/nixos-module/default.nix @@ -439,6 +439,14 @@ in { include ${config.services.nginx.package}/conf/fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; + ${lib.optionalString (cfg.port != null) '' + # Asatru's own url()/asset() helpers (vendor/danielbrendel/asatru-php-framework/src/helper.php) + # build every absolute URL straight from $_SERVER['SERVER_PORT'], never from APP_URL. That + # default fastcgi_params' $server_port is the port nginx itself listens on -- e.g. the QEMU + # test VM's unforwarded guest port 80 -- not the externally-reachable/forwarded one (8080), + # so every asset/redirect URL silently drops the real port. Override it to match cfg.port. + fastcgi_param SERVER_PORT "${toString cfg.port}"; + ''} ''; # Don't let anyone fetch the rendered .env or app internals. @@ -471,7 +479,15 @@ in { # php_fastcgi already covers the Asatru front-controller rewrite # (try real file, then index.php) and static file serving. - php_fastcgi unix/${config.services.phpfpm.pools.hortusfox.socket} + php_fastcgi unix/${config.services.phpfpm.pools.hortusfox.socket} { + ${lib.optionalString (cfg.port != null) '' + # See the matching nginx fastcgi_param SERVER_PORT override above: + # Asatru's url()/asset() helpers build absolute URLs from + # $_SERVER['SERVER_PORT'] alone, which Caddy otherwise sets to the + # port it's actually listening on rather than an external/forwarded one. + env SERVER_PORT ${toString cfg.port} + ''} + } file_server ''; };