Merge pull request 'mail' (#2) from mail into main

Reviewed-on: #2
This commit is contained in:
asyncnomi 2025-08-01 21:50:30 +02:00
commit 18351a41f3
9 changed files with 337 additions and 6 deletions

View file

@ -26,4 +26,14 @@
./shared/dns.nix
];
};
mail = {
hosts = [
"mail-mtz-lasuite-federez"
"mail-ren-lasuite-federez"
];
_inherit = [
./shared/mail.nix
];
};
}

16
secrets/dns/challenge.age Normal file
View file

@ -0,0 +1,16 @@
age-encryption.org/v1
-> ssh-ed25519 VQDASQ mK2dVgCVFtLPW3eaRemGvmV8XRCXjvlWgh3CyEP/HRs
Iv49IVQqzO14ggd3yqTbjEzEhWxLlxdeXEKATO6i3sE
-> ssh-ed25519 PV+Tsg vuk/IMPh7O6erZ4/lWAAeVQODnMFFIOrfY0fLOXT6Sk
B13BqD51YsPppMEJRnApEQ4N20YWSzHLv3YOGvXpERE
-> ssh-ed25519 Ecp4NA medZ8WVt4uwDLfFtxnpGbbBwP/JZQlWoHYfbuDtb0Us
JjKUAZUMYK4vXaS6QFVZVKoQO+zO7+kUyJGXC050yvE
-> ssh-ed25519 vHebMw FkWAklfnd60izhTwSPkmBZqMawBgcmG3p4xWhcUFElc
1TBv5EfHjaJFY/7kpDt7MBBB/svS2zqlNw3/4HkDxfk
-> ssh-ed25519 um7xWA lcSH23MCgWy3YevbzzZeOI9S7U1uzOwJvSagskSyl2k
7BoMoGFVx/I6P6kQE0Ku4wXMASCIdioX19n9BOlEoNM
-> ssh-ed25519 oRtTqQ QRA+pNeSutaGDIE1Ov2e6z/6/zwWnhhN+iTQoABvWgc
gTxHrYwKwr6sQXA5ySKWav8+IYxw+NN+54q+3fc6C4I
--- qrDkUs3UczTG7EDiOxbULZQ8+O4aAP6J6a6ys2tuyjI
t<02>zOêb3ãÿì<C3BF> ¸¾Ò'ÍW7bôš,Uo*<2A>ßZ%;ÆÊ–Ý?F· © {ùÂMôNg:šênEXᢗz•— óÜÆ\Îr¹-
ð¹Ð <05>5«ðýáìC²N¡˜ Ð7ræ^ÜòMgÀ>}£ËmÞÖ<C39E>Ã`±°‘

View file

@ -0,0 +1,16 @@
age-encryption.org/v1
-> ssh-ed25519 VQDASQ iTY4Dv+Hhxdd7XBliAhAKvPvm96cZcKOqkbC1/PPZEA
buKTlrIQwx2O6dw0PO2U20dYwHfyjvb99IVp9xAKmCc
-> ssh-ed25519 PV+Tsg fEV9Wshu13hi5+GNCgQM8p9JSFm3N7vhZENxEXBdqWM
uXyz+OfdsLV4PEwjErBIR46cEzp4wYyB/q2XtIgh8ic
-> ssh-ed25519 Ecp4NA oaBz9Yd6fgz3wqf85RR1+m45Rj61t1OOiJ3J92WCsx8
4XSBmWE7VlkcvmProSMN1YJ67I2c2VDDZz46QYEiUVI
-> ssh-ed25519 vHebMw ngxp3WhtYOOCUdmqK3PUqVv94iSzQkcXwTkugQ6mzBI
PNZZbo9nXJmpe2ajwn8KQ101zP71bfcYNd3FdCS/+Fs
-> ssh-ed25519 um7xWA ukb8EQy2ksckyJI6XAPviKdiFj+KdiW2uyKo8W+ZV1k
62WU+yOy322tDkuodVdq7405lt+PlUMCOEom3/0JbR0
-> ssh-ed25519 oRtTqQ liDAk9KgFQLzcNDa+EKeLMoXHJoc1UnPrhSd7iPWDgo
0x/LIAuRb4f5ebWREJc5XwP2UmObNlaQAR1s0vW1WH4
--- +Mt/wbUkihs5CGurWGR2IWjolm2APznuFHs7tUR+teY
á(/wr\Ü<>à
<EFBFBD>YyÔÓ<EFBFBD>¥Ö¯Mðª?<3F>Í#>Îg€fMÇxÛ“F7¹õ«™L?Â_KĈŠâKn`‚œÿ/Pþ¼ì9üGbxAN

View file

@ -97,8 +97,11 @@ in
# DNS secrets
"dns/tsig.age".publicKeys = system-dns ++ users;
# This is the secret of the tsig key isolated, if the tsig.age is modified, this one should be updated accordingly
"dns/challenge.age".publicKeys = system-mail ++ users;
# Mail secrets
"mail/dkim.age".publicKeys = system-mail ++ users;
"mail/mbox/test.age".publicKeys = system-mail ++ users;
}

View file

@ -65,6 +65,14 @@ in
udp dport 53 accept
'' else ""}
${if lib.elem myName mapping.mail.hosts then ''
# Mail server (without IMAP)
# With support for both SSL & STARTTLS
tcp dport 25 accept
tcp dport 465 accept
tcp dport 587 accept
'' else ""}
# Log anything else
ip protocol tcp counter log prefix "tcp.in.dropped: "
ip protocol udp counter log prefix "udp.in.dropped: "

View file

@ -94,7 +94,20 @@ let
toNSRecord = host: "\tIN NS ${hostToDomain host}.net.";
nsRecords = map toNSRecord mapping.dns.secondary;
# Gen MX
toMXRecord = host: "\tIN MX 10 ${hostToDomain host}.net.";
mxRecords = map toMXRecord mapping.mail.hosts;
dnsSecondaryConfigs = lib.filterAttrs (peerName: _peerConfig: lib.elem peerName mapping.dns.secondary) nodes;
mailConfigs = lib.filterAttrs (peerName: _peerConfig: lib.elem peerName mapping.mail.hosts) nodes;
# For now lasuite.federez.net will redirect to mail server
soaARecords = lib.flatten (lib.mapAttrsToList (hostname: node:
lib.optional (supportsIPv4 node) "\tIN A ${rmCidr node.ip4}"
) mailConfigs);
soaAAAARecords = lib.flatten (lib.mapAttrsToList (hostname: node:
lib.optional (supportsIPv6 node) "\tIN AAAA ${rmCidr node.ip6}"
) mailConfigs);
# Gen A NS
nsARecords = lib.flatten (lib.mapAttrsToList (hostname: node:
@ -106,6 +119,16 @@ let
lib.optional (supportsIPv6 node) "${hostToDomain hostname}.net. IN AAAA ${rmCidr node.ip6}"
) dnsSecondaryConfigs);
# Gen A MX
mxARecords = lib.flatten (lib.mapAttrsToList (hostname: node:
lib.optional (supportsIPv4 node) "${hostToDomain hostname}.net. IN A ${rmCidr node.ip4}"
) mailConfigs);
# Gen AAAA MX
mxAAAARecords = lib.flatten (lib.mapAttrsToList (hostname: node:
lib.optional (supportsIPv6 node) "${hostToDomain hostname}.net. IN AAAA ${rmCidr node.ip6}"
) mailConfigs);
# Gen A records for lf zone
lfARecords = lib.flatten (lib.mapAttrsToList (hostname: node:
"${hostToLfDomain hostname} IN A 172.19.${toString node.zone}.${toString node.id}"
@ -120,34 +143,48 @@ let
firstNS = builtins.head mapping.dns.secondary;
firstNSDn = "${hostToDomain firstNS}.net.";
# Generate SPF record with all mail server IPs
spfIpv4Records = lib.flatten (lib.mapAttrsToList (hostname: node:
lib.optional (supportsIPv4 node) "ip4:${rmCidr node.ip4}"
) mailConfigs);
spfIpv6Records = lib.flatten (lib.mapAttrsToList (hostname: node:
lib.optional (supportsIPv6 node) "ip6:${rmCidr node.ip6}"
) mailConfigs);
spfRecord = "v=spf1 a:lasuite.federez.net ${builtins.concatStringsSep " " (spfIpv4Records ++ spfIpv6Records)} ~all";
# Zone conf
zone-lasuite-federez-net = pkgs.writeText "zone-lasuite-federez-net" ''
$ORIGIN lasuite.federez.net.
$TTL 60
@ IN SOA ${firstNSDn} monitoring.lasuite.federez.net. (
@ IN SOA ${firstNSDn} monitoring.federez.net. (
2025072701 ; serial
60 ; refresh
60 ; retry
60 ; expire
60 ) ; minimum TTL
IN TXT "v=spf1 a:lasuite.federez.net ~all"
IN TXT "${spfRecord}"
${builtins.concatStringsSep "\n" nsRecords}
${builtins.concatStringsSep "\n" mxRecords}
${builtins.concatStringsSep "\n" soaARecords}
${builtins.concatStringsSep "\n" soaAAAARecords}
${builtins.concatStringsSep "\n" nsARecords}
${builtins.concatStringsSep "\n" nsAAAARecords}
${builtins.concatStringsSep "\n" mxARecords}
${builtins.concatStringsSep "\n" mxAAAARecords}
_dmarc IN TXT "v=DMARC1; p=quarantine; ruf=mailto:postmaster@lasuite.federez.net"
_dmarc IN TXT "v=DMARC1; p=quarantine; ruf=mailto:monitoring@federez.net"
_mta-sts IN TXT "v=STSv1; id=1"
_smtp._tls IN TXT "v=TLSRPTv1;rua=mailto:postmaster@lasuite.federez.net"
_smtp._tls IN TXT "v=TLSRPTv1;rua=mailto:monitoring@federez.net"
default._domainkey IN TXT "${lib.concatStringsSep "\" \"" domainkeySplitted}"
'';
zone-lf = pkgs.writeText "zone-lf" ''
$ORIGIN lf.
$TTL 60
@ IN SOA dns.lf. monitoring.lasuite.federez.net. (
@ IN SOA dns.lf. monitoring.federez.net. (
2025072701 ; serial
60 ; refresh
60 ; retry

14
shared/mail.nix Normal file
View file

@ -0,0 +1,14 @@
{ ... }:
let
ensureAccountsWithoutIMAP = [
"test@lasuite.federez.net"
];
in
{
# Import dependencies
imports = [
./mail/maddy.nix
];
_module.args.ensureAccountsWithoutIMAP = ensureAccountsWithoutIMAP;
}

226
shared/mail/maddy.nix Normal file
View file

@ -0,0 +1,226 @@
{ config, lib, pkgs, ensureAccountsWithoutIMAP, ... }:
let
# Import nodes
nodes = import ./../../nodes.nix;
myName = config.hostName;
myNode = nodes."${myName}";
# And mapping
mapping = import ./../../mapping.nix;
acct = ensureAccountsWithoutIMAP;
generatedCredsCommand = mbox: ''
${pkgs.maddy}/bin/maddyctl creds remove --yes ${mbox}
${pkgs.maddy}/bin/maddyctl creds create --password $(cat ${config.age.secrets."mbox-${builtins.head (builtins.split "@" mbox)}".path}) ${mbox}
'';
maddyDeployCreds = pkgs.writeShellScriptBin "maddyDeployCreds" ''
${builtins.concatStringsSep "\n" (map generatedCredsCommand acct)}
'';
hostToDomain = hostname: "${builtins.replaceStrings ["-"] ["."] hostname}.net";
buildSecret = mbox: {
"mbox-${builtins.head (builtins.split "@" mbox)}" = {
file = ./../../secrets/mail/mbox + ( "/" + builtins.head (builtins.split "@" mbox) + ".age" );
owner = "maddy";
group = "maddy";
};
};
generatedSecrets = lib.foldl' (acc: mbox: acc // buildSecret mbox) {} acct;
in
{
age.secrets = generatedSecrets // {
"challenge" = {
file = ./../../secrets/dns/challenge.age;
owner = "acme";
group = "acme";
};
"dkim-keys" = {
file = ./../../secrets/mail/dkim.age;
mode = "440";
owner = "maddy";
group = "maddy";
};
};
services.maddy = {
enable = true;
hostname = "lasuite.federez.net";
user = "maddy";
group = "maddy";
primaryDomain = "lasuite.federez.net";
tls = {
loader = "file";
certificates = [{
keyPath = "/var/lib/acme/lasuite.federez.net/key.pem";
certPath = "/var/lib/acme/lasuite.federez.net/fullchain.pem";
}];
extraConfig = ''
protocols tls1.0 tls1.3
'';
};
config = ''
auth.pass_table local_authdb {
table sql_table {
driver sqlite3
dsn credentials.db
table_name passwords
}
}
table.chain local_rewrites {
optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
optional_step static {
entry postmaster postmaster@$(primary_domain)
}
optional_step file /etc/maddy/aliases
}
msgpipeline local_routing {
default_destination {
reject 550 5.1.1 "We don't accept incoming email on lasuite.federez.net, please use federez.net ml"
}
}
smtp tcp://0.0.0.0:25 {
limits {
all rate 20 1s
all concurrency 10
}
dmarc yes
check {
require_mx_record
dkim
spf
}
source $(local_domains) {
reject 501 5.1.8 "Use Submission for outgoing SMTP"
}
default_source {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
}
submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
limits {
all rate 50 1s
}
auth &local_authdb
source $(local_domains) {
check {
authorize_sender {
prepare_email &local_rewrites
user_to_email identity
}
}
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
modify {
dkim {
debug yes
domains $(primary_domain) $(local_domains)
selector default
key_path ${config.age.secrets.dkim-keys.path}
}
}
deliver_to &remote_queue
}
}
default_source {
reject 501 5.1.8 "Non-local sender domain"
}
}
target.remote outbound_delivery {
limits {
destination rate 20 1s
destination concurrency 10
}
mx_auth {
dane
mtasts {
cache fs
fs_dir mtasts_cache/
}
local_policy {
min_tls_level encrypted
min_mx_level none
}
}
}
target.queue remote_queue {
target &outbound_delivery
autogenerated_msg_domain $(primary_domain)
bounce {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
}
}
}
'';
# ensureAccounts automatically create an IMAP account which we do not wish
ensureAccounts = [];
ensureCredentials = {
# Be careful there: those are deployed at runtime only if they do not already exists
# As a result using agenix to deploy them will silently failed
# See impl: https://github.com/NixOS/nixpkgs/blob/nixos-25.05/nixos/modules/services/mail/maddy.nix
# We will write our own script.
# But avoid doing this:
# "test@lasuite.federez.net".passwordFile = config.age.secrets.monitoring.path;
# "test2@lasuite.federez.net".passwordFile = config.age.secrets.postmaster.path;
# ...
};
};
# Execute custom password setup script
systemd.services.maddyDeployCredsSrv = {
wantedBy = [ "multi-user.target" ];
after = [ "maddy.service" ];
description = "Add password to maddy's static account";
serviceConfig = {
Type = "oneshot";
User = "maddy";
ExecStart = "${maddyDeployCreds}/bin/maddyDeployCreds";
};
};
security.acme = {
acceptTerms = true;
defaults.email = "monitoring@federez.net";
certs = {
"lasuite.federez.net" = {
domain = "lasuite.federez.net";
# Needed for mta-sts compliance
extraDomainNames = map hostToDomain mapping.mail.hosts;
group = "maddy";
dnsProvider = "rfc2136";
reloadServices = [
"maddy"
];
dnsPropagationCheck = true;
enableDebugLogs = true;
environmentFile = "${pkgs.writeText "dns-creds" ''
RFC2136_NAMESERVER=172.19.${toString nodes.${mapping.dns.master}.zone}.${toString nodes.${mapping.dns.master}.id}
RFC2136_TSIG_KEY=letsencrypt
RFC2136_TSIG_ALGORITHM=hmac-sha512.
RFC2136_TSIG_SECRET_FILE="${config.age.secrets.challenge.path}"
''}";
};
};
};
}

View file

@ -1,8 +1,9 @@
{ pkgs ? import <nixpkgs> {} }:
let
agenixCommit = "531beac616433bac6f9e2a19feb8e99a22a66baf";
agenixSrc = fetchTarball {
url = "https://github.com/ryantm/agenix/archive/main.tar.gz";
url = "https://github.com/ryantm/agenix/archive/${agenixCommit}.tar.gz";
sha256 = "103slb8xy5sb68zxjjbb9d0svq8xz751a7yrg6vrz5rh4374bzgl";
};
in