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>
This commit is contained in:
2026-10-07 18:48:41 -04:00
co-authored by Claude Sonnet 5
parent de7a4b7733
commit 407f335bac
2 changed files with 46 additions and 5 deletions
+39 -5
View File
@@ -23,6 +23,13 @@ let
runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs) runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs)
stateDir = "/var/lib/hortusfox"; # persistent writable data 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. # Writable subdirs that must survive reboots.
writableDirs = [ writableDirs = [
"cache" "app/logs" "cache" "app/logs"
@@ -152,6 +159,20 @@ let
cronPort = if cfg.proxy.ssl then 443 else 80; cronPort = if cfg.proxy.ssl then 443 else 80;
cronUrl = path: "${scheme}://${cfg.domain}:${toString cronPort}${path}"; 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 # Baseline systemd sandboxing shared by this module's own units
# (hortusfox-setup and the cron jobs; phpfpm-hortusfox gets its own, # (hortusfox-setup and the cron jobs; phpfpm-hortusfox gets its own,
# layered on top of what services.phpfpm already sets). Deliberately # layered on top of what services.phpfpm already sets). Deliberately
@@ -538,6 +559,11 @@ in {
# migration's own column default, same as upstream's # migration's own column default, same as upstream's
# installer does. # installer does.
cronjob_pw="$(php -r 'echo md5(random_bytes(55) . date("Y-m-d H:i:s"));')" 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);" 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 # 6. Seed the initial admin account. migrate:fresh creates no
@@ -557,6 +583,15 @@ in {
else else
php asatru migrate:upgrade || true php asatru migrate:upgrade || true
fi 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
''; '';
}; };
@@ -582,11 +617,10 @@ in {
serviceConfig = commonHardening // { serviceConfig = commonHardening // {
Type = "oneshot"; Type = "oneshot";
User = cfg.user; User = cfg.user;
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 " ExecStart = "${cronScript name job}";
+ "--resolve ${cfg.domain}:${toString cronPort}:127.0.0.1 " # Only touches the filesystem to read cronPwFile back -- stateDir
+ "${cronUrl job.path}"; # stays visible (read-only) under ProtectSystem=strict since it
# A single outbound curl to the app's own loopback address -- # isn't under /home, so no ReadWritePaths/ReadOnlyPaths needed.
# doesn't touch the filesystem at all, so lock it down fully.
ProtectSystem = "strict"; ProtectSystem = "strict";
ProtectHome = "tmpfs"; ProtectHome = "tmpfs";
}; };
+7
View File
@@ -92,5 +92,12 @@ pkgs.testers.nixosTest {
assert "/auth" not in final_url, ( assert "/auth" not in final_url, (
f"login didn't redirect away from /auth -- login failed (landed on {final_url}):\n{login_response}" 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")
''; '';
} }