diff --git a/nixos-module/default.nix b/nixos-module/default.nix index 11d1afe..f2ca517 100644 --- a/nixos-module/default.nix +++ b/nixos-module/default.nix @@ -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"; }; diff --git a/tests/vm-test.nix b/tests/vm-test.nix index 19dfffb..51fc426 100644 --- a/tests/vm-test.nix +++ b/tests/vm-test.nix @@ -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") ''; }