Compare commits
5
Commits
2b1797770f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b478f1403 | ||
|
|
407f335bac | ||
|
|
de7a4b7733 | ||
|
|
88ed96550a | ||
|
|
0c49938796 |
Generated
+4
-4
@@ -2,16 +2,16 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1786599213,
|
||||
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=",
|
||||
"lastModified": 1791031588,
|
||||
"narHash": "sha256-/FGDr01siZ8txrVzkOz6CYjiz0XR27JuVJP+aWchi4s=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
|
||||
"rev": "825e2028c29b702a4a5f085f08095d12099784f2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
description = "HortusFox — self-hosted plant manager, from source";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||
inputs.utils.url = "github:numtide/flake-utils";
|
||||
inputs.systems.url = "github:nix-systems/default";
|
||||
|
||||
|
||||
+101
-8
@@ -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"
|
||||
@@ -91,8 +98,8 @@ let
|
||||
APP_CONTACT = "dbrendel1988@gmail.com";
|
||||
APP_DEBUG = false;
|
||||
APP_BASEDIR = "";
|
||||
APP_LANG = "en";
|
||||
APP_WORKSPACE = "My workspace";
|
||||
APP_LANG = cfg.language;
|
||||
APP_WORKSPACE = cfg.workspace;
|
||||
APP_OVERDUETASK_HOURS = 10;
|
||||
APP_CRONJOB_MAILLIMIT = 5;
|
||||
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
|
||||
@@ -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
|
||||
@@ -256,6 +277,23 @@ in {
|
||||
|
||||
timezone = lib.mkOption { type = lib.types.str; default = "UTC"; };
|
||||
|
||||
language = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "en";
|
||||
example = "de";
|
||||
description = ''
|
||||
UI language (APP_LANG). Must match one of the language codes
|
||||
upstream ships translation files for under lang/.
|
||||
'';
|
||||
};
|
||||
|
||||
workspace = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "My workspace";
|
||||
example = "Greenhouse";
|
||||
description = "Workspace name shown in the UI (APP_WORKSPACE).";
|
||||
};
|
||||
|
||||
database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
|
||||
|
||||
secretFile = lib.mkOption {
|
||||
@@ -492,7 +530,43 @@ in {
|
||||
if [ ! -e "${stateDir}/.installed" ]; then
|
||||
php asatru migrate:fresh
|
||||
|
||||
# 5. Seed the initial admin account. migrate:fresh creates no
|
||||
# 5. Seed the AppModel settings row. migrate:fresh only creates
|
||||
# the (empty) table -- upstream's own web installer is what
|
||||
# inserts the single settings row that app()/AppModel's own
|
||||
# admin-panel features (the Environment tab's Language/
|
||||
# Timezone fields, SMTP toggle, etc.) read and write, and we
|
||||
# bypass that installer entirely. Without this row,
|
||||
# AppModel::updateSet()'s `UPDATE AppModel SET ...` -- no
|
||||
# WHERE clause, since it's a de-facto single-row table --
|
||||
# matches zero rows, so every "Save" in the admin Environment
|
||||
# tab reports success but silently changes nothing. This
|
||||
# includes Language/Timezone: those live here, not in .env
|
||||
# (.env's APP_LANG is only a fallback used when this row is
|
||||
# missing, and APP_TIMEZONE only sets PHP's global default
|
||||
# timezone -- see AppHelper.php/bootstrap.php upstream,
|
||||
# neither is what the admin UI's fields actually edit). It
|
||||
# also means app_mail_config() -- called at the top of every
|
||||
# single request/cronjob -- was overwriting $_ENV['SMTP_*']
|
||||
# with NULLs read from this nonexistent row on every request,
|
||||
# silently breaking outbound mail regardless of smtp.*/
|
||||
# secretFile; seeding smtp_* here fixes that too. smtp_password
|
||||
# is deliberately left NULL: it's secret-only (see
|
||||
# secretFile), not a plain Nix option, and leaving it NULL is
|
||||
# no regression versus the previous (rowless) behavior either
|
||||
# way. cronjob_pw is generated the same way AppModel's own
|
||||
# generateCronjobToken() does. Every other column either has
|
||||
# no corresponding Nix option yet or just mirrors the
|
||||
# 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
|
||||
# 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
|
||||
@@ -509,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
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -534,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";
|
||||
};
|
||||
@@ -559,6 +641,17 @@ in {
|
||||
user = cfg.user;
|
||||
group = cfg.group;
|
||||
phpPackage = phpPackage;
|
||||
# PHP's own stock defaults (upload_max_filesize=2M, post_max_size=8M)
|
||||
# are far below what both proxy configs below already allow through
|
||||
# (nginx's client_max_body_size / caddy's request_body.max_size, both
|
||||
# 64M) -- without this, PHP itself truncates/rejects any plant photo
|
||||
# upload past 2M (a single modern phone camera JPEG, easily) before
|
||||
# PlantsModel::editPlantPhoto ever sees it, surfacing as a bare
|
||||
# "Errorneous file" 500 with no indication it was ever a size limit.
|
||||
phpOptions = ''
|
||||
upload_max_filesize = 64M
|
||||
post_max_size = 64M
|
||||
'';
|
||||
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;
|
||||
|
||||
@@ -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