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:
@@ -23,6 +23,13 @@ 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"
|
||||
@@ -152,6 +159,20 @@ let
|
||||
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
|
||||
@@ -538,6 +559,11 @@ in {
|
||||
# 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
|
||||
@@ -557,6 +583,15 @@ in {
|
||||
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
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -582,11 +617,10 @@ in {
|
||||
serviceConfig = commonHardening // {
|
||||
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}";
|
||||
# A single outbound curl to the app's own loopback address --
|
||||
# doesn't touch the filesystem at all, so lock it down fully.
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -92,5 +92,12 @@ 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")
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user