Compare commits

...
3 Commits
Author SHA1 Message Date
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
+41 -3
View File
@@ -41,6 +41,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:
@@ -112,6 +121,20 @@ 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}";
# curl'd cron endpoints -> (name, path, calendar spec)
cronjobs = {
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
@@ -264,6 +287,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;
@@ -384,7 +421,7 @@ 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 ('${cfg.adminAccount.username}', '${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"
@@ -401,12 +438,13 @@ in {
} // (lib.mapAttrs' (name: job:
lib.nameValuePair "hortusfox-cron-${name}" {
description = "HortusFox cronjob: ${name}";
after = [ "nginx.service" ];
after = [ "${cfg.proxy.server}.service" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
+ "${appUrl}${job.path}";
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 "
+ "${cronUrl job.path}";
};
}) cronjobs);