Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 1 | |
| 2 | # SPDX-FileCopyrightText: 2024 Clicks Codes |
| 3 | # |
| 4 | # SPDX-License-Identifier: GPL-3.0-only |
| 5 | |
| 6 | { |
| 7 | lib, |
| 8 | config, |
| 9 | pkgs, |
| 10 | ... |
| 11 | }: |
| 12 | let |
| 13 | cfg = config.clicks.services.fava; |
| 14 | in |
| 15 | { |
| 16 | options.clicks.services.fava = { |
| 17 | enable = lib.mkEnableOption "The fava web beancount accounting server"; |
| 18 | tailscaleAuth = lib.mkEnableOption "Lock fava to only be accessible on your tailnet - recommended as fava does not contain any authentication"; |
| 19 | readOnly = lib.mkEnableOption "Make fava unable to edit the files, only read them"; |
| 20 | iWillSetUpAuthMyself = lib.mkOption { |
| 21 | type = lib.types.bool; |
| 22 | internal = true; |
| 23 | description = "Disable warnings about disrecommended or possibly unsafe authentication configurations"; |
| 24 | }; |
Skyler Grey | 2c9c3e7 | 2024-07-17 22:00:16 +0000 | [diff] [blame^] | 25 | extraPythonPackages = lib.mkOption { |
| 26 | type = lib.types.listOf lib.types.package; |
| 27 | description = "Extra python packages to include in the fava environment. You need to put all your plugins and importers in here"; |
| 28 | default = []; |
| 29 | }; |
| 30 | credentials = lib.mkOption { |
| 31 | type = lib.types.attrsOf lib.types.str; |
| 32 | description = "A mapping of systemd credential names to the paths you will find them at, see https://systemd.io/CREDENTIALS/ for more"; |
| 33 | example = { |
| 34 | truelayer_client_secret = "/run/agenix/truelayer_client_secret"; |
| 35 | }; |
| 36 | default = {}; |
| 37 | }; |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 38 | accounts = lib.mkOption { |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 39 | type = lib.home-manager.hm.types.dagOf (lib.types.submodule (submodule: let |
| 40 | filename = let |
| 41 | optionPath = submodule.options._module.args.loc; |
| 42 | len = lib.lists.length optionPath; |
| 43 | index = len - 4; # Ends ["data" "_module" "args"] in a DAG |
| 44 | in lib.lists.elemAt optionPath index; |
| 45 | in { |
| 46 | options = { |
| 47 | name = lib.mkOption { |
| 48 | type = lib.types.str; |
| 49 | description = "The friendly name of the ledger"; |
| 50 | example = "Clicks Codes"; |
| 51 | }; |
| 52 | favaExtraOptions = lib.mkOption { |
| 53 | type = lib.types.attrsOf (lib.types.nullOr lib.types.str); |
| 54 | 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"; |
| 55 | example = { |
| 56 | invert-income-liabilities-equity = true; |
| 57 | }; |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 58 | }; |
| 59 | beancountExtraOptions = lib.mkOption { |
| 60 | type = lib.types.attrsOf lib.types.str; |
| 61 | 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."; |
| 62 | example = { |
| 63 | long_string_maxlines = "128"; |
| 64 | }; |
| 65 | }; |
| 66 | extraConfig = lib.mkOption { |
| 67 | type = lib.types.str; |
| 68 | 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"; |
| 69 | example = '' |
| 70 | ; lines to be added to the beancount config file directly... |
| 71 | ''; |
| 72 | default = ""; |
| 73 | }; |
| 74 | mainFile = lib.mkOption { |
| 75 | type = lib.types.str; |
| 76 | readOnly = true; |
| 77 | internal = true; |
| 78 | description = "The file that you will write your transactions, etc. in"; |
| 79 | default = "/var/lib/private/fava/${filename}.beancount"; |
| 80 | }; |
| 81 | configFile = lib.mkOption { |
| 82 | type = lib.types.str; |
| 83 | readOnly = true; |
| 84 | internal = true; |
| 85 | description = "The file with all of the beancount/fava/etc. options compiled"; |
| 86 | }; |
| 87 | }; |
| 88 | config = { |
| 89 | beancountExtraOptions.title = lib.mkDefault submodule.config.name; |
Skyler Grey | fc03d44 | 2024-07-17 22:00:16 +0000 | [diff] [blame] | 90 | favaExtraOptions.default-file = lib.mkDefault submodule.config.mainFile; |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 91 | |
| 92 | configFile = builtins.toString (let |
| 93 | generateFavaOption = name: value: |
| 94 | if value == null |
| 95 | then ''1970-01-01 custom "fava-option" "${name}"'' |
| 96 | else ''1970-01-01 custom "fava-option" "${name}" "${value}"''; |
| 97 | favaOptions = lib.pipe submodule.config.favaExtraOptions [ |
| 98 | (lib.attrsets.mapAttrsToList generateFavaOption) |
| 99 | (lib.strings.concatStringsSep "\n") |
| 100 | ]; |
| 101 | |
| 102 | generateBeancountOption = name: value: ''option "${name}" "${value}"''; |
| 103 | beancountOptions = lib.pipe submodule.config.beancountExtraOptions [ |
| 104 | (lib.attrsets.mapAttrsToList generateBeancountOption) |
| 105 | (lib.strings.concatStringsSep "\n") |
| 106 | ]; |
| 107 | in pkgs.writeText "${filename}-config" '' |
| 108 | ${favaOptions} |
| 109 | ${beancountOptions} |
| 110 | ${submodule.config.extraConfig} |
| 111 | |
| 112 | include "${submodule.config.mainFile}" |
| 113 | ''); |
| 114 | }; |
| 115 | })); |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 116 | example = { |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 117 | "clicks" = lib.home-manager.hm.dag.entryAnywhere { |
| 118 | name = "Clicks Codes"; |
| 119 | favaExtraOptions = { |
| 120 | invert-income-liabilities-equity = true; |
| 121 | }; |
| 122 | beancountExtraOptions = { |
| 123 | long_string_maxlines = "128"; |
| 124 | }; |
| 125 | extraConfig = '' |
| 126 | ; lines to be added to the beancount config file directly... |
| 127 | ''; |
| 128 | }; |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 129 | }; |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 130 | 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"; |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 131 | }; |
| 132 | addr = lib.mkOption { |
| 133 | type = lib.types.str; |
| 134 | description = "The host to listen on"; |
| 135 | default = "127.0.0.1"; |
| 136 | }; |
| 137 | port = lib.mkOption { |
| 138 | type = lib.types.int; |
| 139 | description = "The port to listen on"; |
| 140 | default = 1025; |
| 141 | }; |
| 142 | domain = lib.mkOption { |
| 143 | type = lib.types.str; |
| 144 | description = "The domain that fava should be hosted on"; |
| 145 | }; |
| 146 | }; |
| 147 | |
| 148 | config = lib.mkIf cfg.enable { |
| 149 | warnings = |
| 150 | (if !(cfg.tailscaleAuth || cfg.readOnly || cfg.iWillSetUpAuthMyself) |
| 151 | then [ ("You have not made fava either restricted to a tailnet or read only. " + |
| 152 | "Unless you set up auth yourself or do not route traffic from the internet to this server, this is a really bad idea. " + |
| 153 | "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ] |
| 154 | else []) ++ |
| 155 | (if cfg.addr == "0.0.0.0" && cfg.tailscaleAuth && !cfg.iWillSetUpAuthMyself |
| 156 | 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 " + |
| 157 | "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. " |
| 158 | "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ] |
| 159 | else []); |
| 160 | |
| 161 | clicks = { |
| 162 | services.nginx.enable = true; |
| 163 | services.nginx.hosts.${cfg.domain} = { |
| 164 | service = lib.clicks.nginx.http.reverseProxy cfg.addr cfg.port; |
| 165 | www = false; |
| 166 | authWith = if cfg.tailscaleAuth then "tailscale" else null; |
| 167 | }; |
| 168 | networking.tailscale.enable = lib.mkIf cfg.tailscaleAuth true; |
| 169 | |
| 170 | storage.impermanence.persist.directories = [ |
| 171 | { directory = "/var/lib/private/fava"; mode = "0700"; defaultPerms.mode = "0700"; } # Set defaultperms.mode because systemd also needs /var/lib/private 0700 |
| 172 | ]; |
| 173 | }; |
| 174 | |
| 175 | systemd.services.fava = let |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 176 | sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result; |
| 177 | |
| 178 | fileNames = lib.trivial.pipe sortedAccounts [ |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 179 | (map (account: account.data.configFile)) |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 180 | (lib.strings.concatStringsSep " ") |
| 181 | ]; |
| 182 | |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 183 | accountToCreationScript = { data, ... }: let |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 184 | in '' |
Skyler Grey | 5e8bba2 | 2024-07-12 14:31:49 +0000 | [diff] [blame] | 185 | ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${data.mainFile}")" |
| 186 | if [ ! -f "${data.mainFile}" ]; then |
Skyler Grey | fc03d44 | 2024-07-17 22:00:16 +0000 | [diff] [blame] | 187 | touch ${data.mainFile} |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 188 | fi |
| 189 | ''; |
| 190 | |
| 191 | fileCreateCommands = lib.trivial.pipe sortedAccounts [ |
| 192 | (map accountToCreationScript) |
| 193 | (lib.strings.concatStringsSep "\n") |
| 194 | ]; |
| 195 | in { |
| 196 | requires = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []); |
| 197 | after = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []); |
| 198 | |
| 199 | environment = { |
| 200 | FAVA_HOST = cfg.addr; |
| 201 | FAVA_PORT = builtins.toString cfg.port; |
| 202 | }; |
| 203 | |
Skyler Grey | 2c9c3e7 | 2024-07-17 22:00:16 +0000 | [diff] [blame^] | 204 | serviceConfig = let |
| 205 | credentialToStr = name: path: "${name}:${path}"; |
| 206 | LoadCredential = lib.attrsets.mapAttrsToList credentialToStr cfg.credentials; |
| 207 | in { |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 208 | StateDirectory = "fava"; |
| 209 | DynamicUser = true; |
Skyler Grey | 2c9c3e7 | 2024-07-17 22:00:16 +0000 | [diff] [blame^] | 210 | LoadCredential = LoadCredential; |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 211 | }; |
| 212 | |
Skyler Grey | 2c9c3e7 | 2024-07-17 22:00:16 +0000 | [diff] [blame^] | 213 | script = let |
| 214 | fava = pkgs.fava.overrideAttrs (prevAttrs: { |
| 215 | propagatedBuildInputs = prevAttrs.propagatedBuildInputs ++ cfg.extraPythonPackages; |
| 216 | }); |
| 217 | in "${fava}/bin/fava ${if cfg.readOnly then "--read-only" else ""} ${fileNames}"; |
Skyler Grey | 0a0912a | 2024-07-04 00:13:40 +0000 | [diff] [blame] | 218 | preStart = fileCreateCommands; |
| 219 | |
| 220 | wantedBy = ["multi-user.target"]; |
| 221 | }; |
| 222 | }; |
| 223 | } |