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 = [ ];
};