blob: 73cab367a41d5acb78fa0050229a43c49b671aaa [file] [log] [blame]
# 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.submodule (submodule: let
filename = let
optionPath = submodule.options._module.args.loc;
len = lib.lists.length optionPath;
index = len - 4; # Ends ["data" "_module" "args"] in a DAG
in lib.lists.elemAt optionPath index;
in {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "The friendly name of the ledger";
example = "Clicks Codes";
};
favaExtraOptions = lib.mkOption {
type = lib.types.attrsOf (lib.types.nullOr lib.types.str);
description = "Extra options to set on this ledger for fava. See https://fava.pythonanywhere.com/example-beancount-file/help/options/ for a list of valid options. These mostly control fava UI, etc. Setting an option to null means that it will be given without a value";
example = {
invert-income-liabilities-equity = true;
};
};
beancountExtraOptions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "Extra options to set on this ledger for beancount. See https://beancount.github.io/docs/beancount_options_reference.html for a list of valid options. These mostly control the way beancount interprets numbers, account names, etc.";
example = {
long_string_maxlines = "128";
};
};
extraConfig = lib.mkOption {
type = lib.types.str;
description = "Extra lines to be added to the end of the beancount config file verbatim. Probably you want these to contain configuration, but it's not required - for example it would be valid to open accounts here to force those accounts to be opened";
example = ''
; lines to be added to the beancount config file directly...
'';
default = "";
};
mainFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
internal = true;
description = "The file that you will write your transactions, etc. in";
default = "/var/lib/private/fava/${filename}.beancount";
};
configFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
internal = true;
description = "The file with all of the beancount/fava/etc. options compiled";
};
};
config = {
beancountExtraOptions.title = lib.mkDefault submodule.config.name;
favaExtraOptions.default-file = lib.mkDefault submodule.config.mainFile;
configFile = builtins.toString (let
generateFavaOption = name: value:
if value == null
then ''1970-01-01 custom "fava-option" "${name}"''
else ''1970-01-01 custom "fava-option" "${name}" "${value}"'';
favaOptions = lib.pipe submodule.config.favaExtraOptions [
(lib.attrsets.mapAttrsToList generateFavaOption)
(lib.strings.concatStringsSep "\n")
];
generateBeancountOption = name: value: ''option "${name}" "${value}"'';
beancountOptions = lib.pipe submodule.config.beancountExtraOptions [
(lib.attrsets.mapAttrsToList generateBeancountOption)
(lib.strings.concatStringsSep "\n")
];
in pkgs.writeText "${filename}-config" ''
${favaOptions}
${beancountOptions}
${submodule.config.extraConfig}
include "${submodule.config.mainFile}"
'');
};
}));
example = {
"clicks" = lib.home-manager.hm.dag.entryAnywhere {
name = "Clicks Codes";
favaExtraOptions = {
invert-income-liabilities-equity = true;
};
beancountExtraOptions = {
long_string_maxlines = "128";
};
extraConfig = ''
; lines to be added to the beancount config file directly...
'';
};
};
description = "A mapping of file names to ledger settings. Ledger settings will not be written to ledgers that are already created, although accompanying files will still be written";
};
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
sortedAccounts = (lib.home-manager.hm.dag.topoSort cfg.accounts).result;
fileNames = lib.trivial.pipe sortedAccounts [
(map (account: account.data.configFile))
(lib.strings.concatStringsSep " ")
];
accountToCreationScript = { data, ... }: let
in ''
${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "${data.mainFile}")"
if [ ! -f "${data.mainFile}" ]; then
touch ${data.mainFile}
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"];
};
};
}