{ config, lib, pkgs, ... }: let cfg = config.services.finance-duck; address = if lib.hasInfix ":" cfg.listenAddress then "[${cfg.listenAddress}]" else cfg.listenAddress; validOrigin = builtins.match "https?://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|[[][0-9A-Fa-f:]+[]])(:[0-9]+)?" cfg.publicURL != null; in { options.services.finance-duck = { enable = lib.mkEnableOption "Finance Duck, a private financial dashboard without application authentication"; package = lib.mkOption { type = lib.types.package; description = "Finance Duck package, including the embedded dashboard."; }; publicURL = lib.mkOption { type = lib.types.str; description = "Exact HTTP(S) browser origin, including any non-default port, without a trailing slash or path."; }; listenAddress = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = "HTTP listen address. Non-loopback listeners must be isolated by a firewall or VPN."; }; port = lib.mkOption { type = lib.types.port; default = 8080; description = "HTTP listen port; the native module does not open the firewall."; }; environmentFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Absolute runtime environment-file path, never a Nix store path. Optional startup fallbacks; both providers can be configured in Settings. When configured, the file must exist. For banking, set ENABLEBANKING_APP_ID, ENABLEBANKING_KEY_FILE and ENABLEBANKING_REDIRECT_URL together. OPENROUTER_API_KEY is the optional AI fallback. For each provider, saved UI configuration or explicit removal overrides its environment. The environment file may be root:root 0600; the referenced private key must be readable by finance-duck (for example root:finance-duck 0640). ''; }; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = validOrigin; message = "services.finance-duck.publicURL must be an exact HTTP(S) origin, without userinfo, path, query or fragment."; } { assertion = cfg.listenAddress != "" && builtins.match "[A-Za-z0-9.:_-]+" cfg.listenAddress != null; message = "services.finance-duck.listenAddress must be a nonempty address without brackets or a port."; } { assertion = cfg.environmentFile == null || ( lib.hasPrefix "/" cfg.environmentFile && !lib.hasPrefix "/nix/store/" cfg.environmentFile && builtins.match "/[A-Za-z0-9_./-]+" cfg.environmentFile != null && !(lib.elem ".." (lib.splitString "/" cfg.environmentFile)) ); message = "services.finance-duck.environmentFile must be an absolute runtime path outside /nix/store, with no parent-directory traversal."; } ]; users.groups.finance-duck = { }; users.users.finance-duck = { isSystemUser = true; group = "finance-duck"; home = "/var/lib/finance-duck"; }; systemd.services.finance-duck = { description = "Finance Duck private financial dashboard"; wantedBy = [ "multi-user.target" ]; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; environment.HOME = "/var/lib/finance-duck"; serviceConfig = { ExecStart = lib.escapeShellArgs [ "${cfg.package}/bin/finance-duck" "-data" "/var/lib/finance-duck" "-listen" "${address}:${toString cfg.port}" "-public-url" cfg.publicURL ]; User = "finance-duck"; Group = "finance-duck"; StateDirectory = "finance-duck"; StateDirectoryMode = "0700"; WorkingDirectory = "/var/lib/finance-duck"; UMask = "0077"; Restart = "on-failure"; RestartSec = "5s"; TimeoutStopSec = "30s"; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; PrivateDevices = true; CapabilityBoundingSet = ""; } // lib.optionalAttrs (cfg.environmentFile != null) { EnvironmentFile = cfg.environmentFile; }; }; }; }