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