diff --git a/hosts/muffin/default.nix b/hosts/muffin/default.nix index c0cd5ae..d5f15b9 100644 --- a/hosts/muffin/default.nix +++ b/hosts/muffin/default.nix @@ -54,6 +54,7 @@ # ../../services/llama-cpp.nix ../../services/trilium.nix ../../services/firefly-iii.nix + ../../services/firefly-iii-data-importer.nix ../../services/ups.nix diff --git a/hosts/muffin/service-configs.nix b/hosts/muffin/service-configs.nix index 2043d85..53a11d2 100644 --- a/hosts/muffin/service-configs.nix +++ b/hosts/muffin/service-configs.nix @@ -340,6 +340,15 @@ rec { domain = "firefly.${site_config.domain}"; }; + firefly_iii_data_importer = rec { + dataDir = services_dir + "/firefly-iii-data-importer"; + domain = "firefly-import.${site_config.domain}"; + # SimpleFIN config json the user uploads after first interactive setup. + # `JSON_CONFIGURATION_DIR` defaults to `/configurations` per the + # data-importer module's tmpfiles rules; we reuse that path. + simplefinConfigPath = "${dataDir}/storage/configurations/simplefin.json"; + }; + media = { moviesDir = torrents_path + "/media/movies"; tvDir = torrents_path + "/media/tv"; diff --git a/modules/server-age-secrets.nix b/modules/server-age-secrets.nix index 9eaec0f..0f09962 100644 --- a/modules/server-age-secrets.nix +++ b/modules/server-age-secrets.nix @@ -199,5 +199,23 @@ owner = "firefly-iii"; group = "caddy"; }; + + # Firefly III Data Importer Laravel APP_KEY (base64:<32 random bytes>) + firefly-iii-data-importer-app-key = { + file = ../secrets/server/firefly-iii-data-importer-app-key.age; + mode = "0400"; + owner = "firefly-iii-data-importer"; + group = "caddy"; + }; + + # Firefly III Personal Access Token used by the data importer (FIREFLY_III_ACCESS_TOKEN). + # First-deploy ciphertext is a placeholder string; rotate after creating + # the PAT in the Firefly UI (Profile → OAuth → Personal Access Tokens). + firefly-iii-fidi-token = { + file = ../secrets/server/firefly-iii-fidi-token.age; + mode = "0400"; + owner = "firefly-iii-data-importer"; + group = "caddy"; + }; }; } diff --git a/secrets/secrets.nix b/secrets/secrets.nix index afc5dd5..1b3cfb5 100644 Binary files a/secrets/secrets.nix and b/secrets/secrets.nix differ diff --git a/secrets/server/firefly-iii-data-importer-app-key.age b/secrets/server/firefly-iii-data-importer-app-key.age new file mode 100644 index 0000000..6e9f436 Binary files /dev/null and b/secrets/server/firefly-iii-data-importer-app-key.age differ diff --git a/secrets/server/firefly-iii-fidi-token.age b/secrets/server/firefly-iii-fidi-token.age new file mode 100644 index 0000000..ea496e7 Binary files /dev/null and b/secrets/server/firefly-iii-fidi-token.age differ diff --git a/services/firefly-iii-data-importer.nix b/services/firefly-iii-data-importer.nix new file mode 100644 index 0000000..25dcec4 --- /dev/null +++ b/services/firefly-iii-data-importer.nix @@ -0,0 +1,128 @@ +{ + config, + lib, + service_configs, + ... +}: +let + fidi = service_configs.firefly_iii_data_importer; +in +{ + imports = [ + # Same chicanery as services/firefly-iii.nix: the upstream module's + # actual systemd units are firefly-iii-data-importer-setup.service and + # phpfpm-firefly-iii-data-importer.service. Hook the zfs mount into the + # `-setup` unit; the upstream `requiredBy` chain pulls phpfpm forward. + (lib.serviceMountWithZpool "firefly-iii-data-importer-setup" service_configs.zpool_ssds [ + fidi.dataDir + ]) + ]; + + services.firefly-iii-data-importer = { + enable = true; + dataDir = fidi.dataDir; + # Same trick as firefly-iii: run as group caddy so caddy can read the + # php-fpm unix socket without us having to override `listen.group`. + group = "caddy"; + virtualHost = fidi.domain; + settings = { + APP_ENV = "production"; + APP_KEY_FILE = config.age.secrets.firefly-iii-data-importer-app-key.path; + LOG_CHANNEL = "syslog"; + LOG_LEVEL = "info"; + + # Importer → Firefly III. The /etc/hosts override below pins this hostname + # to 127.0.0.1 on muffin so the importer's outbound HTTPS lands on the + # local Caddy without hairpinning through the WAN gateway. The TLS cert + # is the same wildcard Caddy serves externally, so verification works. + FIREFLY_III_URL = "https://${service_configs.firefly_iii.domain}"; + FIREFLY_III_ACCESS_TOKEN_FILE = config.age.secrets.firefly-iii-fidi-token.path; + + TRUSTED_PROXIES = "127.0.0.1,::1"; + + # SimpleFIN polls the last 90d every run; without this, every daily run + # logs hundreds of "duplicate transaction" warnings as Firefly's server- + # side dedup catches them. Firefly still rejects the duplicates; we just + # don't see the noise. + IGNORE_DUPLICATE_ERRORS = true; + + # CLI-driven imports only. Leave the /autoimport HTTP endpoint disabled + # (default) — the systemd timer below uses `artisan importer:import` + # against a static config file, which avoids exposing another web- + # reachable surface that needs its own shared secret. + CAN_POST_AUTOIMPORT = false; + CAN_POST_FILES = false; + }; + }; + + # Pin the firefly-iii hostname to loopback on muffin so the importer's + # outbound API calls hit local Caddy directly. No effect on any other host. + networking.extraHosts = "127.0.0.1 ${service_configs.firefly_iii.domain}"; + + services.caddy.virtualHosts.${fidi.domain}.extraConfig = '' + encode zstd gzip + import ${config.age.secrets.caddy_auth.path} + + root * ${config.services.firefly-iii-data-importer.package}/public + php_fastcgi unix/${config.services.phpfpm.pools.firefly-iii-data-importer.socket} + file_server + ''; + + # Daily SimpleFIN import via the importer's CLI. The unit is gated by + # ConditionPathExists so it silently no-ops until the user has uploaded + # `simplefin.json` (per the post-deploy operational steps). + systemd.services.firefly-iii-data-importer-import = { + description = "Run scheduled Firefly III data importer config (SimpleFIN)"; + after = [ + "phpfpm-firefly-iii-data-importer.service" + "network-online.target" + ]; + wants = [ "network-online.target" ]; + unitConfig.ConditionPathExists = fidi.simplefinConfigPath; + serviceConfig = { + Type = "oneshot"; + User = config.services.firefly-iii-data-importer.user; + Group = config.services.firefly-iii-data-importer.group; + WorkingDirectory = config.services.firefly-iii-data-importer.package; + ExecStart = "${config.services.firefly-iii-data-importer.package}/artisan importer:import ${fidi.simplefinConfigPath}"; + # Same hardening posture as the upstream commonServiceConfig. + ReadWritePaths = [ fidi.dataDir ]; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = "tmpfs"; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + ProtectHostname = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + NoNewPrivileges = true; + RemoveIPC = true; + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service @resources" + "~@obsolete @privileged" + ]; + }; + }; + + systemd.timers.firefly-iii-data-importer-import = { + description = "Daily Firefly III SimpleFIN import"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "daily"; + RandomizedDelaySec = "1h"; + Persistent = true; + }; + }; +}