machines/nix/systems/aarch64-linux/mx.kaareskovgaard.net/mailserver/accounts.nix
Kaare Hoff Skovgaard 1f88fa3c49
Some checks failed
/ dev-shell (push) Successful in 43s
/ terraform-providers (push) Successful in 47s
/ check (push) Failing after 1m55s
/ rust-packages (push) Successful in 48s
/ systems (push) Successful in 4m9s
Move kas.codes over to using mx.kaareskovgaard.net
2025-08-01 02:04:06 +02:00

266 lines
8.5 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfg = config.khscodes."mx.kaareskovgaard.net";
passDbFile = "/run/secret/dovecot/passwd";
isCatchAllAddress = addr: lib.strings.hasPrefix "@" addr;
bogusPasswdFile = pkgs.writeTextFile {
name = "bogus-passwd";
text = "$6$1234";
};
userDbAccountOpts =
name: account:
lib.strings.concatStringsSep " " (
(lib.lists.optional (account.quota != null) "userdb_quota_rule=*:storage=${account.quota}")
++ [ "userdb_user=${name}" ]
);
userDbLine =
addr: name: account:
"${addr}:::::::${userDbAccountOpts name account}";
userDbFile = pkgs.writeTextFile {
name = "userdb";
text = lib.strings.concatStringsSep "\n" (
lib.lists.flatten (
lib.mapAttrsToList (
name: account:
[
(userDbLine name name account)
]
++ (lib.lists.map (addr: userDbLine addr name account) (
# Don't include catch all addresses, they become aliases in postfix
lib.lists.filter (addr: !(isCatchAllAddress addr)) account.addresses
))
) cfg.accounts
)
);
};
firstNonCatchAllAddress =
account: lib.lists.head (lib.lists.filter (addr: !(isCatchAllAddress addr)) account.addresses);
accountOption = lib.khscodes.mkSubmodule {
description = "mail account";
options = {
name = lib.mkOption {
type = lib.types.str;
example = "user1@example.com";
description = "Username";
};
addresses = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [
"abuse@example.com"
"postmaster@example.com"
];
default = [ ];
description = ''
A list of addresses for the account.
Note: Use list entires like "@example.com" to create a catchAll
that allows sending from an email address in these domains.
'';
};
quota = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "2G";
description = ''
Per user quota rules. Accepted sizes are `xx k/M/G/T` with the
obvious meaning. Leave blank for the standard quota `100G`.
'';
};
sieveScript = lib.mkOption {
type = lib.types.nullOr lib.types.lines;
default = null;
example = ''
require ["fileinto", "mailbox"];
if address :is "from" "gitlab@mg.gitlab.com" {
fileinto :create "GitLab";
stop;
}
# This must be the last rule, it will check if list-id is set, and
# file the message into the Lists folder for further investigation
elsif header :matches "list-id" "<?*>" {
fileinto :create "Lists";
stop;
}
'';
description = ''
Per-user sieve script.
'';
};
sendOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Specifies if the account should be a send-only account.
Emails sent to send-only accounts will be rejected from
unauthorized senders with the `sendOnlyRejectMessage`
stating the reason.
'';
};
sendOnlyRejectMessage = lib.mkOption {
type = lib.types.str;
default = "This account cannot receive emails.";
description = ''
The message that will be returned to the sender when an email is
sent to a send-only account. Only used if the account is marked
as send-only.
'';
};
isLdapAccount = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Marks the account as an LDAP account. Non LDAP accounts gets passwords provisioned, such that other services can use them";
};
};
};
systemAccounts = lib.attrsets.foldlAttrs (
acc: name: value:
if value.isLdapAccount then acc else acc ++ [ name ]
) [ ] cfg.accounts;
systemAccountsPassDbTemplateContents =
lib.concatStringsSep "\n" (
lib.lists.map (account: ''
{{- with secret "mx.kaareskovgaard.net/data/users/${account}" -}}
${account}:{{ .Data.data.hashed_password }}::::::
{{- end -}}
'') systemAccounts
)
# Just make sure the file is not empty
+ "\n";
catchAllAliases = lib.strings.concatStringsSep "\n" (
lib.lists.flatten (
lib.attrsets.mapAttrsToList (
name: account:
let
regularEmailAddr = firstNonCatchAllAddress account;
catchAlls = lib.lists.filter isCatchAllAddress account.addresses;
in
lib.lists.map (addr: "${addr} ${regularEmailAddr}") catchAlls
) cfg.accounts
)
);
postmasterAliases = lib.strings.concatStringsSep "\n" (
lib.lists.map (domain: "postmaster@${domain} ${cfg.postmaster}") cfg.domains
);
abuseAliases = lib.strings.concatStringsSep "\n" (
lib.lists.map (domain: "abuse@${domain} ${cfg.abuse}") cfg.domains
);
extraVirtualAliases = lib.strings.concatStringsSep "\n" [
catchAllAliases
postmasterAliases
abuseAliases
];
in
{
options.khscodes."mx.kaareskovgaard.net".accounts = lib.mkOption {
type = lib.types.attrsOf accountOption;
default = { };
};
config = {
mailserver = {
loginAccounts = lib.attrsets.mapAttrs (name: value: {
inherit (value)
name
quota
sieveScript
sendOnly
sendOnlyRejectMessage
;
aliases = value.addresses;
hashedPasswordFile = bogusPasswdFile;
}) cfg.accounts;
extraVirtualAliases = { };
};
khscodes.infrastructure.vault-server-approle.policy = {
"mx.kaareskovgaard.net/data/users/*" = {
capabilities = [ "read" ];
};
};
khscodes.infrastructure.provisioning.pre.modules = [
{
terraform.required_providers.random = {
source = "hashicorp/random";
version = "3.7.2";
};
provider.random = { };
}
]
++ (lib.lists.map (
account:
let
tfName = lib.khscodes.sanitize-terraform-name account;
in
{ config, ... }:
{
resource.random_password."${tfName}" = {
length = 48;
numeric = true;
lower = true;
upper = true;
special = false;
};
resource.vault_kv_secret_v2."${tfName}" = {
mount = config.khscodes.vault.output.mount."mx.kaareskovgaard.net".path;
name = "users/${account}";
data_json = ''
{
"hashed_password": ''${ jsonencode(resource.random_password.${tfName}.bcrypt_hash) },
"password": ''${ jsonencode(resource.random_password.${tfName}.result) }
}
'';
};
}
) systemAccounts);
khscodes.services.vault-agent.templates = [
{
contents = systemAccountsPassDbTemplateContents;
destination = passDbFile;
perms = "0600";
owner = "dovecot2";
group = "dovecot2";
restartUnits = [
"dovecot2.service"
];
}
];
systemd.services.dovecot2 = {
unitConfig.ConditionPathExists = [ passDbFile ];
serviceConfig.ReadOnlyPaths = [ passDbFile ];
# simple-nixos-mailserver creates its own passwd file,
# but the passwords in that file are all bogus, so replace them
# with our own.
preStart = lib.mkAfter ''
cp ${passDbFile} /run/dovecot2/passwd
cp ${userDbFile} /run/dovecot2/userdb
'';
};
# The stuff simple nixos-mailserver sets up here creates aliases to the accounts listed,
# but I use usernames for the accounts, which ends up creating forwards to stuff like khs@mx.kaareskovgaard.net, which causes issues.
# Instead I just want the entries in virtual_mailbox_maps for all regular addresses, which will leave the addresses alone (ie. not rewrite them)
# and send them off via lmtp to dovecot2. Dovecot2 has a fixed userdb which accepts all regular email addresses for every login and maps it to that
# users mailbox. The only thing not handled this way is catchAll addresses (and regexes, which I do not support at the moment, and hopefully won't need).
services.postfix = {
virtual = lib.mkForce extraVirtualAliases;
config.virtual_alias_maps = lib.mkForce [ "hash:/etc/postfix/virtual" ];
};
# This prevents local usernames without domain names to get rewritten.
# services.postfix.submissionOptions = submissionOptions;
# services.postfix.submissionsOptions = submissionOptions;
};
}