feat(teal): Add fava-web

Fava-web is a simple solution for accounting based on 'beancount' (a
text-based accounting system).

Previously we attempted to use frappe, but found it was overly-complex,
so something with simpler concepts that was more geared towards a
smaller use-case was at the top of our goals.

The fact that I have used beancount before also helped.

We've secured this behind our tailnet, as generally we trust anyone who
can access teal through the tailnet.

Change-Id: I0db2ee0bd98cb7560ca26d303396e179fcb0f283
Reviewed-on: https://git.clicks.codes/c/Infra/NixFiles/+/787
Reviewed-by: Samuel Shuert <coded@clicks.codes>
Tested-by: Skyler Grey <minion@clicks.codes>
diff --git a/modules/nixos/clicks/services/fava/default.nix b/modules/nixos/clicks/services/fava/default.nix
new file mode 100644
index 0000000..30014c3
--- /dev/null
+++ b/modules/nixos/clicks/services/fava/default.nix
@@ -0,0 +1,128 @@
+
+# SPDX-FileCopyrightText: 2024 Clicks Codes
+#
+# SPDX-License-Identifier: GPL-3.0-only
+
+{
+  lib,
+  config,
+  pkgs,
+  ...
+}:
+let
+  cfg = config.clicks.services.fava;
+in
+{
+  options.clicks.services.fava = {
+    enable = lib.mkEnableOption "The fava web beancount accounting server";
+    tailscaleAuth = lib.mkEnableOption "Lock fava to only be accessible on your tailnet - recommended as fava does not contain any authentication";
+    readOnly = lib.mkEnableOption "Make fava unable to edit the files, only read them";
+    iWillSetUpAuthMyself = lib.mkOption {
+      type = lib.types.bool;
+      internal = true;
+      description = "Disable warnings about disrecommended or possibly unsafe authentication configurations";
+    };
+    accounts = lib.mkOption {
+      type = lib.home-manager.hm.types.dagOf lib.types.str;
+      example = {
+        "minion" = "Skyler Grey";
+        "clicks" = "Clicks Codes";
+        "coded" = "Samuel Shuert";
+      };
+      description = "A mapping of file names to ledger titles. Titles will not override any existing ledgers, they will only be added to new ones";
+    };
+    addr = lib.mkOption {
+      type = lib.types.str;
+      description = "The host to listen on";
+      default = "127.0.0.1";
+    };
+    port = lib.mkOption {
+      type = lib.types.int;
+      description = "The port to listen on";
+      default = 1025;
+    };
+    domain = lib.mkOption {
+      type = lib.types.str;
+      description = "The domain that fava should be hosted on";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    warnings =
+      (if !(cfg.tailscaleAuth || cfg.readOnly || cfg.iWillSetUpAuthMyself)
+      then [ ("You have not made fava either restricted to a tailnet or read only. " +
+              "Unless you set up auth yourself or do not route traffic from the internet to this server, this is a really bad idea. " +
+              "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ]
+       else []) ++
+      (if cfg.addr == "0.0.0.0" && cfg.tailscaleAuth && !cfg.iWillSetUpAuthMyself
+      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 " +
+              "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. "
+              "To confirm you know what you're doing, set 'clicks.services.fava.iWillSetUpAuthMyself = true' to turn off this warning") ]
+       else []);
+
+    clicks = {
+      services.nginx.enable = true;
+      services.nginx.hosts.${cfg.domain} = {
+        service = lib.clicks.nginx.http.reverseProxy cfg.addr cfg.port;
+        www = false;
+        authWith = if cfg.tailscaleAuth then "tailscale" else null;
+      };
+      networking.tailscale.enable = lib.mkIf cfg.tailscaleAuth true;
+
+      storage.impermanence.persist.directories = [
+        { directory = "/var/lib/private/fava"; mode = "0700"; defaultPerms.mode = "0700"; } # Set defaultperms.mode because systemd also needs /var/lib/private 0700
+      ];
+    };
+
+    systemd.services.fava = let
+      getFilePath = name: "/var/lib/private/fava/${name}.beancount";
+        # Cannot use $STATE_DIRECTORY here as:
+        # (1) it's not supported via fava environment variables
+        # (2) it is a symlink and fava does not like that
+      attrNamesToPaths = accounts: lib.attrsets.mapAttrs' (name: value: {
+        name = getFilePath name;
+        inherit value;
+      }) accounts;
+
+      sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result;
+
+      fileNames = lib.trivial.pipe sortedAccounts [
+        (map (account: account.name))
+        (map getFilePath)
+        (lib.strings.concatStringsSep " ")
+      ];
+
+      accountToCreationScript = { name, data }: let
+        path = getFilePath name;
+      in ''
+        ${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${path}")"
+        if [ ! -f "${path}" ]; then
+          ${pkgs.coreutils}/bin/echo 'option "title" "${data}"' >> ${path}
+        fi
+      '';
+
+      fileCreateCommands = lib.trivial.pipe sortedAccounts [
+        (map accountToCreationScript)
+        (lib.strings.concatStringsSep "\n")
+      ];
+    in {
+      requires = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []);
+      after = (if config.clicks.services.nginx.enable then [ "nginx.service" ] else []);
+
+      environment = {
+        FAVA_HOST = cfg.addr;
+        FAVA_PORT = builtins.toString cfg.port;
+      };
+
+      serviceConfig = {
+        StateDirectory = "fava";
+        DynamicUser = true;
+      };
+
+      script = "${pkgs.fava}/bin/fava ${if cfg.readOnly then "--read-only" else ""} ${fileNames}";
+      preStart = fileCreateCommands;
+
+      wantedBy = ["multi-user.target"];
+    };
+  };
+}
diff --git a/modules/nixos/clicks/storage/impermanence/default.nix b/modules/nixos/clicks/storage/impermanence/default.nix
index 5dd5e43..646e7b5 100644
--- a/modules/nixos/clicks/storage/impermanence/default.nix
+++ b/modules/nixos/clicks/storage/impermanence/default.nix
@@ -45,7 +45,14 @@
     };
     persist = {
       directories = lib.mkOption {
-        type = lib.types.listOf lib.types.str;
+        type = lib.types.listOf
+          (lib.types.oneOf [
+            lib.types.str
+            (lib.types.attrsOf (lib.types.oneOf [
+              lib.types.str
+              (lib.types.attrsOf lib.types.str)
+            ]))
+          ]);
         description = "List of directories to store between boots";
         default = [ ];
       };
diff --git a/systems/x86_64-linux/teal/default.nix b/systems/x86_64-linux/teal/default.nix
index ee2de50..2a9b99b 100644
--- a/systems/x86_64-linux/teal/default.nix
+++ b/systems/x86_64-linux/teal/default.nix
@@ -160,6 +160,17 @@
             ];
           };
       };
+      fava = {
+        enable = true;
+        tailscaleAuth = true;
+        accounts = {
+          "clicks" = lib.home-manager.hm.dag.entryAnywhere "Clicks Codes";
+          "coded" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] "Samuel Shuert";
+          "minion" = lib.home-manager.hm.dag.entryBetween [ "testing" ] [ "clicks" ] "Skyler Grey";
+          "testing" = lib.home-manager.hm.dag.entryAfter [ "clicks" ] "Test Data - May Be Wiped At Any Time";
+        };
+        domain = "fava.clicks.codes";
+      };
     };
 
     networking.tailscale = {