| |
| # 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"]; |
| }; |
| }; |
| } |