blob: 30014c3434926064fec1b7eefe46f63359cb7d02 [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 {
26 type = lib.home-manager.hm.types.dagOf lib.types.str;
27 example = {
28 "minion" = "Skyler Grey";
29 "clicks" = "Clicks Codes";
30 "coded" = "Samuel Shuert";
31 };
32 description = "A mapping of file names to ledger titles. Titles will not override any existing ledgers, they will only be added to new ones";
33 };
34 addr = lib.mkOption {
35 type = lib.types.str;
36 description = "The host to listen on";
37 default = "127.0.0.1";
38 };
39 port = lib.mkOption {
40 type = lib.types.int;
41 description = "The port to listen on";
42 default = 1025;
43 };
44 domain = lib.mkOption {
45 type = lib.types.str;
46 description = "The domain that fava should be hosted on";
47 };
48 };
49
50 config = lib.mkIf cfg.enable {
51 warnings =
52 (if !(cfg.tailscaleAuth || cfg.readOnly || cfg.iWillSetUpAuthMyself)
53 then [ ("You have not made fava either restricted to a tailnet or read only. " +
54 "Unless you set up auth yourself or do not route traffic from the internet to this server, this is a really bad idea. " +
55 "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ]
56 else []) ++
57 (if cfg.addr == "0.0.0.0" && cfg.tailscaleAuth && !cfg.iWillSetUpAuthMyself
58 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 " +
59 "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. "
60 "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ]
61 else []);
62
63 clicks = {
64 services.nginx.enable = true;
65 services.nginx.hosts.${cfg.domain} = {
66 service = lib.clicks.nginx.http.reverseProxy cfg.addr cfg.port;
67 www = false;
68 authWith = if cfg.tailscaleAuth then "tailscale" else null;
69 };
70 networking.tailscale.enable = lib.mkIf cfg.tailscaleAuth true;
71
72 storage.impermanence.persist.directories = [
73 { directory = "/var/lib/private/fava"; mode = "0700"; defaultPerms.mode = "0700"; } # Set defaultperms.mode because systemd also needs /var/lib/private 0700
74 ];
75 };
76
77 systemd.services.fava = let
78 getFilePath = name: "/var/lib/private/fava/${name}.beancount";
79 # Cannot use $STATE_DIRECTORY here as:
80 # (1) it's not supported via fava environment variables
81 # (2) it is a symlink and fava does not like that
82 attrNamesToPaths = accounts: lib.attrsets.mapAttrs' (name: value: {
83 name = getFilePath name;
84 inherit value;
85 }) accounts;
86
87 sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result;
88
89 fileNames = lib.trivial.pipe sortedAccounts [
90 (map (account: account.name))
91 (map getFilePath)
92 (lib.strings.concatStringsSep " ")
93 ];
94
95 accountToCreationScript = { name, data }: let
96 path = getFilePath name;
97 in ''
98 ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${path}")"
99 if [ ! -f "${path}" ]; then
100 ${pkgs.coreutils}/bin/echo 'option "title" "${data}"' >> ${path}
101 fi
102 '';
103
104 fileCreateCommands = lib.trivial.pipe sortedAccounts [
105 (map accountToCreationScript)
106 (lib.strings.concatStringsSep "\n")
107 ];
108 in {
109 requires = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []);
110 after = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []);
111
112 environment = {
113 FAVA_HOST = cfg.addr;
114 FAVA_PORT = builtins.toString cfg.port;
115 };
116
117 serviceConfig = {
118 StateDirectory = "fava";
119 DynamicUser = true;
120 };
121
122 script = "${pkgs.fava}/bin/fava ${if cfg.readOnly then "--read-only" else ""} ${fileNames}";
123 preStart = fileCreateCommands;
124
125 wantedBy = ["multi-user.target"];
126 };
127 };
128}