Files
hortusfox-web-nixos/nixos-module/default.nix
T
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

566 lines
24 KiB
Nix

{ config, lib, pkgs, ... }:
let
cfg = config.services."hortusfox-web";
# The app's own canonical URL (used for login/session redirects and cron
# requests) must track proxy.ssl, or a plain-HTTP deployment (proxy.ssl =
# false, e.g. the test VM) redirects users to an https:// URL nothing is
# listening on.
scheme = if cfg.proxy.ssl then "https" else "http";
portSuffix = if cfg.port == null then "" else ":${toString cfg.port}";
appUrl = "${scheme}://${cfg.domain}${portSuffix}";
# PHP with the extensions HortusFox needs (GD for photo resize, pdo_mysql, etc.)
phpPackage = cfg.phpPackage.buildEnv {
extensions = ({ enabled, all }:
enabled ++ (with all; [ pdo_mysql mysqli gd exif fileinfo mbstring curl zip openssl ]));
};
package = cfg.package;
appRoot = "${package}/share/php/hortusfox-web";
runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs)
stateDir = "/var/lib/hortusfox"; # persistent writable data
# Writable subdirs that must survive reboots.
writableDirs = [
"cache" "app/logs"
"public/img" "public/attachments" "public/backup" "public/themes"
];
# 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
# default (root:root 0755) ownership and does NOT reliably go on to
# create the requested leaf itself -- so a nested writableDirs entry
# whose parent has no rule of its own silently never gets created.
writableDirParents = lib.unique (builtins.filter (p: p != null) (map
(d:
let parts = lib.splitString "/" d;
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:
let
rendered =
if builtins.isBool value then (if value then "true" else "false")
else if builtins.isInt value then toString value
else if builtins.isFloat value then toString value
else ''"${toString value}"'';
in
"${name}=${rendered}";
# Base .env content; `settings` can override or extend any of this. Beyond
# our own curated options (domain/timezone/db/smtp), this also seeds every
# other key from upstream's .env.example that app code reads directly from
# $_ENV without an isset() guard -- e.g. APP_BASEDIR, omitted here before,
# produced a "PHP Warning: Undefined array key" on every single request.
envVars = {
APP_NAME = "HortusFox";
APP_VERSION = package.version or "unknown";
APP_AUTHOR = "Daniel Brendel";
APP_CONTACT = "dbrendel1988@gmail.com";
APP_DEBUG = false;
APP_BASEDIR = "";
APP_LANG = "en";
APP_WORKSPACE = "My workspace";
APP_OVERDUETASK_HOURS = 10;
APP_CRONJOB_MAILLIMIT = 5;
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
APP_SERVICE_URL = "https://www.hortusfox.com";
APP_GITHUB_SPONSOR = "https://github.com/sponsors/danielbrendel";
APP_DONATION_KOFI = "https://ko-fi.com/danielbrendel";
APP_URL = appUrl;
APP_TIMEZONE = cfg.timezone;
SESSION_ENABLE = true;
SESSION_DURATION = 32532000;
# SESSION_NAME deliberately omitted: the app only uses it behind an
# isset() guard, so "unset" (fall back to PHP's own default) is safe.
PHOTO_RESIZE_FACTOR_DEFAULT = 1.0;
PHOTO_RESIZE_FACTOR_1 = 0.5;
PHOTO_RESIZE_FACTOR_2 = 0.4;
PHOTO_RESIZE_FACTOR_3 = 0.4;
PHOTO_RESIZE_FACTOR_4 = 0.3;
PHOTO_RESIZE_FACTOR_5 = 0.2;
DB_ENABLE = true;
DB_DRIVER = "mysql";
DB_HOST = "localhost";
DB_PORT = 3306;
DB_DATABASE = cfg.database.name;
DB_USER = cfg.user;
DB_PASSWORD = "";
DB_CHARSET = "utf8mb4";
SMTP_AUTH = true;
SMTP_FROMNAME = cfg.smtp.fromName;
SMTP_FROMADDRESS = cfg.smtp.fromAddress;
SMTP_HOST = cfg.smtp.host;
SMTP_PORT = cfg.smtp.port;
SMTP_USERNAME = cfg.smtp.username;
SMTP_ENCRYPTION = cfg.smtp.encryption;
LOG_ENABLE = true;
} // cfg.settings;
envFile = pkgs.writeText "hortusfox.env" (
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}";
# curl'd cron endpoints -> (name, path, calendar spec)
cronjobs = {
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
"tasks-tomorrow" = { path = "/cronjob/tasks/tomorrow"; when = "*-*-* 06,18:00:00"; };
"tasks-recurring" = { path = "/cronjob/tasks/recurring"; when = "hourly"; };
"calendar-reminder"= { path = "/cronjob/calendar/reminder";when = "*-*-* 07:00:00"; };
"backup-auto" = { path = "/cronjob/backup/auto"; when = "*-*-* 03:00:00"; };
};
in {
options.services."hortusfox-web" = {
enable = lib.mkEnableOption "HortusFox plant management system";
domain = lib.mkOption {
type = lib.types.str;
default = "localhost";
example = "plants.example.com";
description = "FQDN served over HTTPS (used for the nginx vhost + ACME).";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 8080;
description = ''
Port to append to the app's own canonical URL (APP_URL, used for
login/session redirects and cron requests). Leave unset for a normal
deployment reachable on the standard 80/443. Set this when the proxy
is only reachable through a non-standard or forwarded port -- e.g.
the QEMU test VM's hostfwd (see justfile) -- otherwise redirects
drop the port and land somewhere unreachable. This does not change
what port nginx/caddy actually listens on.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.callPackage ../package.nix { };
description = "HortusFox package to deploy.";
};
proxy.server = lib.mkOption {
type = lib.types.enum [ "nginx" "caddy" ];
default = "nginx";
description = "Reverse proxy/webserver to front phpfpm.";
};
proxy.ssl = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to force HTTPS and request an ACME certificate for the
vhost. Disable for local/test deployments that have no real domain
or internet access to reach Let's Encrypt.
'';
};
phpPackage = lib.mkOption {
type = lib.types.package;
default = pkgs.php83;
description = "Base PHP used to build the phpfpm environment.";
};
user = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
group = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
timezone = lib.mkOption { type = lib.types.str; default = "UTC"; };
database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
secretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/agenix/hortusfox.env";
description = ''
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.
'';
};
adminAccount.passwordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
File containing the initial admin account's password (plaintext,
no trailing newline needed). HortusFox has no CLI/seed hook of its
own for this -- its only "create user" flow lives in the admin
panel, which itself requires already being logged in as an admin,
and `migrate:fresh` creates no user rows at all. Without this set,
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.
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}";
example = "you@realmail.com";
description = ''
Email address for the seeded admin account (see
adminAccount.passwordFile). Defaults to admin@<domain>, but that's
just a guess -- it's not necessarily a mailbox anyone can actually
receive mail at, and HortusFox's own "Restore password" flow emails
this address. Set it to a real address you control if you intend to
use password reset.
'';
};
settings = lib.mkOption {
type = lib.types.attrsOf (lib.types.oneOf [ lib.types.str lib.types.int lib.types.bool ]);
default = { };
example = { APP_DEBUG = true; APP_OVERDUETASK_HOURS = 6; };
description = ''
Extra/overriding .env entries, merged over the generated defaults.
Note that the `php asatru` CLI (used here for migrations) only runs
when APP_DEBUG is true, so migrations silently no-op unless that's
set here or in secretFile.
'';
};
smtp = {
host = lib.mkOption { type = lib.types.str; default = ""; };
port = lib.mkOption { type = lib.types.port; default = 587; };
username = lib.mkOption { type = lib.types.str; default = ""; };
fromName = lib.mkOption { type = lib.types.str; default = "HortusFox"; };
fromAddress = lib.mkOption { type = lib.types.str; default = "hortusfox@localhost"; };
encryption = lib.mkOption { type = lib.types.enum [ "tls" "ssl" "" ]; default = "tls"; };
};
};
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;
home = stateDir;
};
users.groups.${cfg.group} = { };
# --- Database: unix-socket auth, no password needed ------------------
services.mysql = {
enable = true;
package = pkgs.mariadb_114;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [{
name = cfg.user;
ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
}];
};
# --- Persistent writable dirs ---------------------------------------
# World-readable (0755), same reasoning as RuntimeDirectoryMode above:
# some of these (public/*) are nginx's webroot, and nginx (a different
# user) needs to traverse them directly. Nothing secret lives at the
# directory level -- .env (the one real secret under stateDir) is a
# separate file installed at mode 600, unaffected by this.
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;
# Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the
# cron-generated services all live under systemd.services, and a
# separate full reassignment (as the cron block used to be) would
# conflict with these partial-path definitions.
systemd.services = {
hortusfox-setup = {
description = "Prepare HortusFox runtime tree and run migrations";
after = [ "mysql.service" "network.target" ];
requires = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = cfg.user;
Group = cfg.group;
StateDirectory = "hortusfox";
# World-readable: nginx (a different user) needs to stat/read
# everything under here to serve `public/` directly. Nothing
# secret lives in this tree -- .env is a symlink to stateDir/.env,
# which stays mode 600 regardless of this.
RuntimeDirectory = "hortusfox";
RuntimeDirectoryMode = "0755";
};
path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ];
script = ''
set -euo pipefail
# 1. Mirror the read-only store package into a writable-structured
# tree with a real (non-symlinked) copy of every file. This
# duplicates the app into tmpfs on every boot -- NOT `cp -rs`
# (per-file symlinks back into the store): PHP resolves
# __DIR__/__FILE__ through symlinks to their real target, so
# any symlinked file here would see itself as living in the
# read-only store rather than under runDir, breaking the
# app's own self-relative path lookups (its .env discovery
# included). `cp` carries the store's read-only directory
# mode (555) onto the new directories, so reopen them for
# writing -- only directories, since we never need to touch
# the copied files' own permissions.
rm -rf "${runDir}"
mkdir -p "${runDir}"
cp -r "${appRoot}/." "${runDir}/"
find "${runDir}" -type d -exec chmod u+w {} +
# 2. Swap the writable subdirs for persistent state dirs. Some of
# these (public/img, public/themes) ship real default assets
# (backgrounds, the default theme) in the package, not just
# user-generated content -- seed the persistent copy from the
# package on every boot, without ever clobbering what's
# already there, before replacing the dir with a symlink to
# it. Harmless no-op for dirs that ship nothing but the app's
# own .gitignore.
${lib.concatMapStringsSep "\n" (d: ''
mkdir -p "${stateDir}/${d}"
cp -rn "${appRoot}/${d}/." "${stateDir}/${d}/" || true
find "${stateDir}/${d}" -type d -exec chmod u+w {} +
rm -rf "${runDir}/${d}"
ln -sfn "${stateDir}/${d}" "${runDir}/${d}"
'') writableDirs}
# 3. Render .env: static body + secrets from secretFile (if any),
# appended verbatim.
install -m600 /dev/null "${stateDir}/.env"
cat ${envFile} > "${stateDir}/.env"
${lib.optionalString (cfg.secretFile != null) ''
{
echo ""
cat "${cfg.secretFile}"
} >> "${stateDir}/.env"
''}
ln -sfn "${stateDir}/.env" "${runDir}/.env"
# 4. Migrate. First boot builds the schema (destructive fresh); later
# boots only upgrade. The marker guards you from nuking your data.
cd "${runDir}"
if [ ! -e "${stateDir}/.installed" ]; then
php asatru migrate:fresh
# 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
# (name/email hardcoded, bypassing the app's normal
# create-then-promote-to-admin two-step flow) rather than
# going through any app-level API, since none exists yet at
# 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);"
''}
touch "${stateDir}/.installed"
else
php asatru migrate:upgrade || true
fi
'';
};
phpfpm-hortusfox = {
after = [ "hortusfox-setup.service" ];
requires = [ "hortusfox-setup.service" ];
};
} // (lib.mapAttrs' (name: job:
lib.nameValuePair "hortusfox-cron-${name}" {
description = "HortusFox cronjob: ${name}";
after = [ "${cfg.proxy.server}.service" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 "
+ "${cronUrl job.path}";
};
}) cronjobs);
systemd.timers = lib.mapAttrs' (name: job:
lib.nameValuePair "hortusfox-cron-${name}" {
description = "Timer for HortusFox cronjob: ${name}";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = job.when;
Persistent = true;
};
}) cronjobs;
# --- phpfpm pool ------------------------------------------------------
services.phpfpm.pools.hortusfox = {
user = cfg.user;
group = cfg.group;
phpPackage = phpPackage;
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;
"pm" = "dynamic";
"pm.max_children" = 16;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 4;
"catch_workers_output" = true;
};
};
# --- nginx + ACME ------------------------------------------------------
services.nginx = lib.mkIf (cfg.proxy.server == "nginx") {
enable = true;
recommendedTlsSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
virtualHosts.${cfg.domain} = {
forceSSL = cfg.proxy.ssl;
enableACME = cfg.proxy.ssl;
root = "${runDir}/public";
# Asatru front controller: everything that isn't a real file
# rewrites to index.php (mirrors the upstream .htaccess).
locations."/" = {
index = "index.php";
tryFiles = "$uri $uri/ /index.php?$query_string";
};
locations."~ \\.php$".extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.hortusfox.socket};
fastcgi_index index.php;
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.
locations."~ /\\.".extraConfig = "deny all;";
extraConfig = ''
client_max_body_size 64M;
'';
};
};
# --- caddy ---------------------------------------------------------
# `proxy.ssl = false` prefixes the site address with "http://", which
# disables Caddy's automatic HTTPS/ACME for this vhost specifically
# (rather than needing a separate flag like nginx's forceSSL/enableACME).
services.caddy = lib.mkIf (cfg.proxy.server == "caddy") {
enable = true;
virtualHosts.${if cfg.proxy.ssl then cfg.domain else "http://${cfg.domain}"}.extraConfig = ''
root * ${runDir}/public
encode gzip
# Don't let anyone fetch the rendered .env or app internals.
@dotfiles path /.*
respond @dotfiles 403
request_body {
max_size 64MB
}
# 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} {
${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
'';
};
# ACME needs an email; set this once globally in your host config:
# nginx: security.acme = { acceptTerms = true; defaults.email = "you@example.com"; };
# caddy: services.caddy.email = "you@example.com";
networking.firewall.allowedTCPPorts = [ 80 443 ];
};
}