Compare commits
5
Commits
2b1797770f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b478f1403 | ||
|
|
407f335bac | ||
|
|
de7a4b7733 | ||
|
|
88ed96550a | ||
|
|
0c49938796 |
Generated
+4
-4
@@ -2,16 +2,16 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786599213,
|
"lastModified": 1791031588,
|
||||||
"narHash": "sha256-yNJd40f11EzXBjSByCB7IPpeFFAdeoSKKM67dGkfFoU=",
|
"narHash": "sha256-/FGDr01siZ8txrVzkOz6CYjiz0XR27JuVJP+aWchi4s=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0e251e24a4f24e036a084b6b4b2d2491af4167f4",
|
"rev": "825e2028c29b702a4a5f085f08095d12099784f2",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-unstable",
|
"ref": "nixos-26.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
description = "HortusFox — self-hosted plant manager, from source";
|
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.utils.url = "github:numtide/flake-utils";
|
||||||
inputs.systems.url = "github:nix-systems/default";
|
inputs.systems.url = "github:nix-systems/default";
|
||||||
|
|
||||||
|
|||||||
+101
-8
@@ -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"
|
||||||
@@ -91,8 +98,8 @@ let
|
|||||||
APP_CONTACT = "dbrendel1988@gmail.com";
|
APP_CONTACT = "dbrendel1988@gmail.com";
|
||||||
APP_DEBUG = false;
|
APP_DEBUG = false;
|
||||||
APP_BASEDIR = "";
|
APP_BASEDIR = "";
|
||||||
APP_LANG = "en";
|
APP_LANG = cfg.language;
|
||||||
APP_WORKSPACE = "My workspace";
|
APP_WORKSPACE = cfg.workspace;
|
||||||
APP_OVERDUETASK_HOURS = 10;
|
APP_OVERDUETASK_HOURS = 10;
|
||||||
APP_CRONJOB_MAILLIMIT = 5;
|
APP_CRONJOB_MAILLIMIT = 5;
|
||||||
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
|
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
|
||||||
@@ -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
|
||||||
@@ -256,6 +277,23 @@ in {
|
|||||||
|
|
||||||
timezone = lib.mkOption { type = lib.types.str; default = "UTC"; };
|
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"; };
|
database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
|
||||||
|
|
||||||
secretFile = lib.mkOption {
|
secretFile = lib.mkOption {
|
||||||
@@ -492,7 +530,43 @@ in {
|
|||||||
if [ ! -e "${stateDir}/.installed" ]; then
|
if [ ! -e "${stateDir}/.installed" ]; then
|
||||||
php asatru migrate:fresh
|
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
|
# user rows at all, and the app's only "create user" flow
|
||||||
# lives behind the admin panel -- without this, a fresh
|
# lives behind the admin panel -- without this, a fresh
|
||||||
# deployment has no way to log in, ever. Insert directly
|
# deployment has no way to log in, ever. Insert directly
|
||||||
@@ -509,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
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -534,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";
|
||||||
};
|
};
|
||||||
@@ -559,6 +641,17 @@ in {
|
|||||||
user = cfg.user;
|
user = cfg.user;
|
||||||
group = cfg.group;
|
group = cfg.group;
|
||||||
phpPackage = phpPackage;
|
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 = {
|
settings = {
|
||||||
"listen.owner" = if cfg.proxy.server == "caddy" then config.services.caddy.user else config.services.nginx.user;
|
"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;
|
"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, (
|
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")
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user