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 { |
| 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 | } |