| |
| # SPDX-FileCopyrightText: 2024 Clicks Codes |
| # |
| # SPDX-License-Identifier: GPL-3.0-only |
| |
| { |
| lib, |
| config, |
| pkgs, |
| ... |
| }: |
| let |
| cfg = config.clicks.services.fava; |
| in |
| { |
| options.clicks.services.fava = { |
| enable = lib.mkEnableOption "The fava web beancount accounting server"; |
| tailscaleAuth = lib.mkEnableOption "Lock fava to only be accessible on your tailnet - recommended as fava does not contain any authentication"; |
| readOnly = lib.mkEnableOption "Make fava unable to edit the files, only read them"; |
| iWillSetUpAuthMyself = lib.mkOption { |
| type = lib.types.bool; |
| internal = true; |
| description = "Disable warnings about disrecommended or possibly unsafe authentication configurations"; |
| }; |
| accounts = lib.mkOption { |
| type = lib.home-manager.hm.types.dagOf (lib.types.submodule (submodule: let |
| filename = let |
| optionPath = submodule.options._module.args.loc; |
| len = lib.lists.length optionPath; |
| index = len - 4; # Ends ["data" "_module" "args"] in a DAG |
| in lib.lists.elemAt optionPath index; |
| in { |
| options = { |
| name = lib.mkOption { |
| type = lib.types.str; |
| description = "The friendly name of the ledger"; |
| example = "Clicks Codes"; |
| }; |
| favaExtraOptions = lib.mkOption { |
| type = lib.types.attrsOf (lib.types.nullOr lib.types.str); |
| description = "Extra options to set on this ledger for fava. See https://fava.pythonanywhere.com/example-beancount-file/help/options/ for a list of valid options. These mostly control fava UI, etc. Setting an option to null means that it will be given without a value"; |
| example = { |
| invert-income-liabilities-equity = true; |
| }; |
| default = {}; |
| }; |
| beancountExtraOptions = lib.mkOption { |
| type = lib.types.attrsOf lib.types.str; |
| description = "Extra options to set on this ledger for beancount. See https://beancount.github.io/docs/beancount_options_reference.html for a list of valid options. These mostly control the way beancount interprets numbers, account names, etc."; |
| example = { |
| long_string_maxlines = "128"; |
| }; |
| }; |
| extraConfig = lib.mkOption { |
| type = lib.types.str; |
| description = "Extra lines to be added to the end of the beancount config file verbatim. Probably you want these to contain configuration, but it's not required - for example it would be valid to open accounts here to force those accounts to be opened"; |
| example = '' |
| ; lines to be added to the beancount config file directly... |
| ''; |
| default = ""; |
| }; |
| mainFile = lib.mkOption { |
| type = lib.types.str; |
| readOnly = true; |
| internal = true; |
| description = "The file that you will write your transactions, etc. in"; |
| default = "/var/lib/private/fava/${filename}.beancount"; |
| }; |
| configFile = lib.mkOption { |
| type = lib.types.str; |
| readOnly = true; |
| internal = true; |
| description = "The file with all of the beancount/fava/etc. options compiled"; |
| }; |
| }; |
| config = { |
| beancountExtraOptions.title = lib.mkDefault submodule.config.name; |
| |
| configFile = builtins.toString (let |
| generateFavaOption = name: value: |
| if value == null |
| then ''1970-01-01 custom "fava-option" "${name}"'' |
| else ''1970-01-01 custom "fava-option" "${name}" "${value}"''; |
| favaOptions = lib.pipe submodule.config.favaExtraOptions [ |
| (lib.attrsets.mapAttrsToList generateFavaOption) |
| (lib.strings.concatStringsSep "\n") |
| ]; |
| |
| generateBeancountOption = name: value: ''option "${name}" "${value}"''; |
| beancountOptions = lib.pipe submodule.config.beancountExtraOptions [ |
| (lib.attrsets.mapAttrsToList generateBeancountOption) |
| (lib.strings.concatStringsSep "\n") |
| ]; |
| in pkgs.writeText "${filename}-config" '' |
| ${favaOptions} |
| ${beancountOptions} |
| ${submodule.config.extraConfig} |
| |
| include "${submodule.config.mainFile}" |
| ''); |
| }; |
| })); |
| example = { |
| "clicks" = lib.home-manager.hm.dag.entryAnywhere { |
| name = "Clicks Codes"; |
| favaExtraOptions = { |
| invert-income-liabilities-equity = true; |
| }; |
| beancountExtraOptions = { |
| long_string_maxlines = "128"; |
| }; |
| extraConfig = '' |
| ; lines to be added to the beancount config file directly... |
| ''; |
| }; |
| }; |
| description = "A mapping of file names to ledger settings. Ledger settings will not be written to ledgers that are already created, although accompanying files will still be written"; |
| }; |
| addr = lib.mkOption { |
| type = lib.types.str; |
| description = "The host to listen on"; |
| default = "127.0.0.1"; |
| }; |
| port = lib.mkOption { |
| type = lib.types.int; |
| description = "The port to listen on"; |
| default = 1025; |
| }; |
| domain = lib.mkOption { |
| type = lib.types.str; |
| description = "The domain that fava should be hosted on"; |
| }; |
| }; |
| |
| config = lib.mkIf cfg.enable { |
| warnings = |
| (if !(cfg.tailscaleAuth || cfg.readOnly || cfg.iWillSetUpAuthMyself) |
| then [ ("You have not made fava either restricted to a tailnet or read only. " + |
| "Unless you set up auth yourself or do not route traffic from the internet to this server, this is a really bad idea. " + |
| "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ] |
| else []) ++ |
| (if cfg.addr == "0.0.0.0" && cfg.tailscaleAuth && !cfg.iWillSetUpAuthMyself |
| then [ ("You have started hosting fava on '0.0.0.0' but additionally enabled tailnet-only access. We won't be able to control access " + |
| "that doesn't come through nginx, so any traffic that is allowed to the machine on port ${cfg.port} will still get to fava, tailnet or not. " |
| "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ] |
| else []); |
| |
| clicks = { |
| services.nginx.enable = true; |
| services.nginx.hosts.${cfg.domain} = { |
| service = lib.clicks.nginx.http.reverseProxy cfg.addr cfg.port; |
| www = false; |
| authWith = if cfg.tailscaleAuth then "tailscale" else null; |
| }; |
| networking.tailscale.enable = lib.mkIf cfg.tailscaleAuth true; |
| |
| storage.impermanence.persist.directories = [ |
| { directory = "/var/lib/private/fava"; mode = "0700"; defaultPerms.mode = "0700"; } # Set defaultperms.mode because systemd also needs /var/lib/private 0700 |
| ]; |
| }; |
| |
| systemd.services.fava = let |
| sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result; |
| |
| fileNames = lib.trivial.pipe sortedAccounts [ |
| (map (account: account.data.configFile)) |
| (lib.strings.concatStringsSep " ") |
| ]; |
| |
| accountToCreationScript = { data, ... }: let |
| in '' |
| ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${data.mainFile}")" |
| if [ ! -f "${data.mainFile}" ]; then |
| ${pkgs.coreutils}/bin/echo '1970-01-01 custom "fava-option" "default-file"' >> ${data.mainFile} |
| fi |
| ''; |
| |
| fileCreateCommands = lib.trivial.pipe sortedAccounts [ |
| (map accountToCreationScript) |
| (lib.strings.concatStringsSep "\n") |
| ]; |
| in { |
| requires = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []); |
| after = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []); |
| |
| environment = { |
| FAVA_HOST = cfg.addr; |
| FAVA_PORT = builtins.toString cfg.port; |
| }; |
| |
| serviceConfig = { |
| StateDirectory = "fava"; |
| DynamicUser = true; |
| }; |
| |
| script = "${pkgs.fava}/bin/fava ${if cfg.readOnly then "--read-only" else ""} ${fileNames}"; |
| preStart = fileCreateCommands; |
| |
| wantedBy = ["multi-user.target"]; |
| }; |
| }; |
| } |