blob: fb707fda25acee2049b5fbe303616ff61bceff35 [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 };
25 accounts = lib.mkOption {
Skyler Grey5e8bba22024-07-12 14:31:49 +000026 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 Grey0a0912a2024-07-04 00:13:40 +0000103 example = {
Skyler Grey5e8bba22024-07-12 14:31:49 +0000104 "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 Grey0a0912a2024-07-04 00:13:40 +0000116 };
Skyler Grey5e8bba22024-07-12 14:31:49 +0000117 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 +0000118 };
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 Grey0a0912a2024-07-04 00:13:40 +0000163 sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result;
164
165 fileNames = lib.trivial.pipe sortedAccounts [
Skyler Grey5e8bba22024-07-12 14:31:49 +0000166 (map (account: account.data.configFile))
Skyler Grey0a0912a2024-07-04 00:13:40 +0000167 (lib.strings.concatStringsSep " ")
168 ];
169
Skyler Grey5e8bba22024-07-12 14:31:49 +0000170 accountToCreationScript = { data, ... }: let
Skyler Grey0a0912a2024-07-04 00:13:40 +0000171 in ''
Skyler Grey5e8bba22024-07-12 14:31:49 +0000172 ${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 Grey0a0912a2024-07-04 00:13:40 +0000175 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}