diff --git a/mapping.nix b/mapping.nix index 33f02aa..fc641cc 100644 --- a/mapping.nix +++ b/mapping.nix @@ -26,4 +26,14 @@ ./shared/dns.nix ]; }; + + mail = { + hosts = [ + "mail-mtz-lasuite-federez" + "mail-ren-lasuite-federez" + ]; + _inherit = [ + ./shared/mail.nix + ]; + }; } \ No newline at end of file diff --git a/secrets/dns/challenge.age b/secrets/dns/challenge.age new file mode 100644 index 0000000..3f50ce7 --- /dev/null +++ b/secrets/dns/challenge.age @@ -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 +tzOb3쁠'W7b,Uo*Z%;ʖ?F {MNg:nEXᢗz \r- + 5CN 7r^Mg>}m֐` \ No newline at end of file diff --git a/secrets/mail/mbox/test.age b/secrets/mail/mbox/test.age new file mode 100644 index 0000000..8905b4b --- /dev/null +++ b/secrets/mail/mbox/test.age @@ -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\܏ +YyӐ֯M?#>gfMxۓF7L?_KĈKn`/P9GbxAN \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 0d1d7b7..f8face1 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -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; } \ No newline at end of file diff --git a/shared/commons/nftables.nix b/shared/commons/nftables.nix index 111cfb5..52a0aa5 100644 --- a/shared/commons/nftables.nix +++ b/shared/commons/nftables.nix @@ -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: " diff --git a/shared/dns/knot.nix b/shared/dns/knot.nix index d42ea66..156c41a 100644 --- a/shared/dns/knot.nix +++ b/shared/dns/knot.nix @@ -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 diff --git a/shared/mail.nix b/shared/mail.nix new file mode 100644 index 0000000..39aa6d8 --- /dev/null +++ b/shared/mail.nix @@ -0,0 +1,14 @@ +{ ... }: +let + ensureAccountsWithoutIMAP = [ + "test@lasuite.federez.net" + ]; +in +{ + # Import dependencies + imports = [ + ./mail/maddy.nix + ]; + + _module.args.ensureAccountsWithoutIMAP = ensureAccountsWithoutIMAP; +} \ No newline at end of file diff --git a/shared/mail/maddy.nix b/shared/mail/maddy.nix new file mode 100644 index 0000000..a7664cf --- /dev/null +++ b/shared/mail/maddy.nix @@ -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}" + ''}"; + }; + }; + }; +} \ No newline at end of file diff --git a/shell.nix b/shell.nix index de2cada..c67ffd2 100644 --- a/shell.nix +++ b/shell.nix @@ -1,8 +1,9 @@ { pkgs ? import {} }: 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