blob: c489c1d65f5e0f5b7ddb49a39894306a0e43681e [file] [log] [blame]
Skyler Grey0a0912a2024-07-04 00:13:40 +00001
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}:
12let
13 cfg = config.clicks.services.fava;
14in
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 Grey2c9c3e72024-07-17 22:00:16 +000025 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 Grey0a0912a2024-07-04 00:13:40 +000038 accounts = lib.mkOption {
Skyler Grey5e8bba22024-07-12 14:31:49 +000039 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 Grey5e8bba22024-07-12 14:31:49 +000058 };
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 Greyfc03d442024-07-17 22:00:16 +000090 favaExtraOptions.default-file = lib.mkDefault submodule.config.mainFile;
Skyler Grey5e8bba22024-07-12 14:31:49 +000091
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 Grey0a0912a2024-07-04 00:13:40 +0000116 example = {
Skyler Grey5e8bba22024-07-12 14:31:49 +0000117 "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 Grey0a0912a2024-07-04 00:13:40 +0000129 };
Skyler Grey5e8bba22024-07-12 14:31:49 +0000130 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 Grey0a0912a2024-07-04 00:13:40 +0000131 };
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 Grey0a0912a2024-07-04 00:13:40 +0000176 sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result;
177
178 fileNames = lib.trivial.pipe sortedAccounts [
Skyler Grey5e8bba22024-07-12 14:31:49 +0000179 (map (account: account.data.configFile))
Skyler Grey0a0912a2024-07-04 00:13:40 +0000180 (lib.strings.concatStringsSep " ")
181 ];
182
Skyler Grey5e8bba22024-07-12 14:31:49 +0000183 accountToCreationScript = { data, ... }: let
Skyler Grey0a0912a2024-07-04 00:13:40 +0000184 in ''
Skyler Grey5e8bba22024-07-12 14:31:49 +0000185 ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${data.mainFile}")"
186 if [ ! -f "${data.mainFile}" ]; then
Skyler Greyfc03d442024-07-17 22:00:16 +0000187 touch ${data.mainFile}
Skyler Grey0a0912a2024-07-04 00:13:40 +0000188 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 Grey2c9c3e72024-07-17 22:00:16 +0000204 serviceConfig = let
205 credentialToStr = name: path: "${name}:${path}";
206 LoadCredential = lib.attrsets.mapAttrsToList credentialToStr cfg.credentials;
207 in {
Skyler Grey0a0912a2024-07-04 00:13:40 +0000208 StateDirectory = "fava";
209 DynamicUser = true;
Skyler Grey2c9c3e72024-07-17 22:00:16 +0000210 LoadCredential = LoadCredential;
Skyler Grey0a0912a2024-07-04 00:13:40 +0000211 };
212
Skyler Grey2c9c3e72024-07-17 22:00:16 +0000213 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 Grey0a0912a2024-07-04 00:13:40 +0000218 preStart = fileCreateCommands;
219
220 wantedBy = ["multi-user.target"];
221 };
222 };
223}