diff --git a/nix/homes/aarch64-linux/khs@mx.kaareskovgaard.net/default.nix b/nix/homes/aarch64-linux/khs@mx.kaareskovgaard.net/default.nix new file mode 100644 index 0000000..d214165 --- /dev/null +++ b/nix/homes/aarch64-linux/khs@mx.kaareskovgaard.net/default.nix @@ -0,0 +1,7 @@ +{ + ... +}: +{ + khscodes.khs.enable = true; + khscodes.khs.shell.oh-my-posh.enable = true; +} diff --git a/nix/modules/nixos/infrastructure/hetzner-instance/default.nix b/nix/modules/nixos/infrastructure/hetzner-instance/default.nix index a6c9ec8..ba6f223 100644 --- a/nix/modules/nixos/infrastructure/hetzner-instance/default.nix +++ b/nix/modules/nixos/infrastructure/hetzner-instance/default.nix @@ -97,10 +97,15 @@ in { options.khscodes.infrastructure.hetzner-instance = { enable = lib.mkEnableOption "enables generating a opentofu config"; - dnsNames = lib.mkOption { + dnsName = lib.mkOption { + type = lib.types.str; + default = fqdn; + }; + dnsAliases = lib.mkOption { type = lib.types.listOf lib.types.str; - description = "DNS names for the server"; - default = lib.lists.unique ([ fqdn ] ++ config.khscodes.networking.aliases); + default = lib.lists.unique ( + lib.lists.filter (alias: alias != cfg.dnsName) config.khscodes.networking.aliases + ); }; bucket = { key = lib.mkOption { @@ -206,14 +211,22 @@ in enable = true; dns = { enable = true; - aRecords = lib.lists.map (d: { - fqdn = d; - content = config.khscodes.hcloud.output.server.compute.ipv4_address; - }) cfg.dnsNames; - aaaaRecords = lib.lists.map (d: { - fqdn = d; - content = config.khscodes.hcloud.output.server.compute.ipv6_address; - }) cfg.dnsNames; + aRecords = [ + { + fqdn = cfg.dnsName; + content = config.khscodes.hcloud.output.server.compute.ipv4_address; + } + ]; + aaaaRecords = [ + { + fqdn = cfg.dnsName; + content = config.khscodes.hcloud.output.server.compute.ipv6_address; + } + ]; + cnameRecords = lib.lists.map (domain: { + fqdn = domain; + content = cfg.dnsName; + }) cfg.dnsAliases; }; }; resource.hcloud_firewall.fw = lib.mkIf firewallEnable { diff --git a/nix/modules/nixos/infrastructure/kanidm-client-application/default.nix b/nix/modules/nixos/infrastructure/kanidm-client-application/default.nix index a1c167b..09447b4 100644 --- a/nix/modules/nixos/infrastructure/kanidm-client-application/default.nix +++ b/nix/modules/nixos/infrastructure/kanidm-client-application/default.nix @@ -19,6 +19,10 @@ in type = lib.types.str; default = cfg.secretOwner; }; + perms = lib.mkOption { + type = lib.types.str; + default = "0600"; + }; reloadOrRestartUnits = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; @@ -41,7 +45,7 @@ in destination = cfg.secretFile; owner = cfg.secretOwner; group = cfg.secretGroup; - perms = "0600"; + perms = cfg.perms; } ]; khscodes.infrastructure.vault-server-approle.policy = { diff --git a/nix/modules/nixos/infrastructure/khs-openstack-instance/default.nix b/nix/modules/nixos/infrastructure/khs-openstack-instance/default.nix index b641ed5..e1b5ab7 100644 --- a/nix/modules/nixos/infrastructure/khs-openstack-instance/default.nix +++ b/nix/modules/nixos/infrastructure/khs-openstack-instance/default.nix @@ -63,11 +63,14 @@ in { options.khscodes.infrastructure.khs-openstack-instance = { enable = lib.mkEnableOption "enables generating a opentofu config for khs openstack instance"; - dnsNames = lib.mkOption { + dnsName = lib.mkOption { + type = lib.types.str; + default = config.khscodes.networking.fqdn; + }; + dnsAliases = lib.mkOption { type = lib.types.listOf lib.types.str; - description = "DNS names for the instance"; default = lib.lists.unique ( - [ config.khscodes.networking.fqdn ] ++ config.khscodes.networking.aliases + lib.lists.filter (alias: alias != cfg.dnsName) config.khscodes.networking.aliases ); }; bucket = { @@ -153,16 +156,20 @@ in enable = true; dns = { enable = true; - aRecords = lib.mkIf cfg.dns.mapIpv4Address ( - lib.lists.map (d: { - fqdn = d; + aRecords = lib.mkIf cfg.dns.mapIpv4Address [ + { + fqdn = cfg.dnsName; content = config.khscodes.openstack.output.compute_instance.compute.ipv4_address; - }) cfg.dnsNames - ); + } + ]; aaaaRecords = lib.lists.map (d: { fqdn = d; content = config.khscodes.openstack.output.compute_instance.compute.ipv6_address; - }) cfg.dnsNames; + }) cfg.dnsAliases; + cnameRecords = lib.lists.map (domain: { + fqdn = domain; + content = cfg.dnsName; + }) cfg.dnsAliases; }; }; output.ipv4_address = { diff --git a/nix/modules/nixos/infrastructure/mailserver/dane.nix b/nix/modules/nixos/infrastructure/mailserver/dane.nix new file mode 100644 index 0000000..e064f2e --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/dane.nix @@ -0,0 +1,6 @@ +# I cannot right now figure out the best way forward with implementing DANE. +# It seems to me that the server itself needs access to cloudflare to update its +# DNS records, then I need to coordinate with the ACME setup to not rotate the key (reuse_key) +# before the DNS records are updated. +# This all seems like a lot of hassle, and for now, I am foregoing this. +{ } diff --git a/nix/modules/nixos/infrastructure/mailserver/default.nix b/nix/modules/nixos/infrastructure/mailserver/default.nix index 41afd19..bc257a4 100644 --- a/nix/modules/nixos/infrastructure/mailserver/default.nix +++ b/nix/modules/nixos/infrastructure/mailserver/default.nix @@ -1,84 +1,12 @@ { config, lib, - pkgs, + inputs, ... }: let cfg = config.khscodes.infrastructure.mailserver; fqdn = config.khscodes.networking.fqdn; - adminCredentialsFile = "/etc/stalwart/admin-pw"; - stalwart-spam-filter = pkgs.callPackage ./stalwart-spam-filter.nix { }; - ifthen = condition: expr: { - "if" = condition; - "then" = expr; - }; - otherwise = expr: { "else" = expr; }; - authDkimForDomain = domain: [ - (ifthen "sender_domain = '${domain}'" "['${domain}_rsa', '${domain}_ed25519']") - ]; - authDkim = lib.lists.flatten (lib.lists.map authDkimForDomain cfg.domains); - signatureForDomain = domain: [ - { - name = "${domain}_rsa"; - value = { - inherit domain; - private-key = "%{file:/run/secret/dkim/${domain}.snm_rsa.key}"; - selector = "snm_rsa"; - headers = [ - "From" - "To" - "Cc" - "Date" - "Subject" - "Message-ID" - "Organization" - "MIME-Version" - "Content-Type" - "In-Reply-To" - "References" - "List-Id" - "User-Agent" - "Thread-Topic" - "Thread-Index" - ]; - algorithm = "rsa-sha256"; - canonicalization = "relaxed/relaxed"; - report = true; - }; - } - { - name = "${domain}_ed25519"; - value = { - inherit domain; - private-key = "%{file:/run/secret/dkim/${domain}.snm_ed25519.key}"; - selector = "snm_ed25519"; - headers = [ - "From" - "To" - "Cc" - "Date" - "Subject" - "Message-ID" - "Organization" - "MIME-Version" - "Content-Type" - "In-Reply-To" - "References" - "List-Id" - "User-Agent" - "Thread-Topic" - "Thread-Index" - ]; - algorithm = "ed25519-sha256"; - canonicalization = "relaxed/relaxed"; - report = true; - }; - } - ]; - dkimSignatures = { - signature = lib.listToAttrs (lib.lists.flatten (lib.lists.map signatureForDomain cfg.domains)); - }; in { options.khscodes.infrastructure.mailserver = { @@ -89,7 +17,16 @@ in }; }; imports = [ + inputs.simple-nixos-mailserver.nixosModules.mailserver + ./dmarc.nix + ./dane.nix ./dkim.nix + ./mta-sts.nix + ./spf.nix + ./tls-rpt.nix + ./prometheus.nix + ./openid-connect.nix + ./package/nixos-module.nix ]; config = lib.mkIf cfg.enable { # TODO: Include a similiar rule for openstack @@ -117,90 +54,83 @@ in ttl = 600; }) cfg.domains ); + khscodes.cloudflare.dns.srvRecords = lib.lists.flatten ( + lib.lists.map (domain: [ + { + fqdn = "_imaps._tcp.${domain}"; + content = fqdn; + priority = 0; + weight = 1; + port = 993; + ttl = 600; + } + { + fqdn = "_submissions._tcp.${domain}"; + content = fqdn; + priority = 0; + weight = 1; + port = 465; + ttl = 600; + } + ]) cfg.domains + ); } ) ]; - environment.etc."stalwart/admin-pw".text = "test"; + mailserver = { + enable = true; + enableImap = false; + enableImapSsl = true; + enableSubmission = false; + enableSubmissionSsl = true; + stateVersion = 3; + fqdn = config.khscodes.networking.fqdn; + systemDomain = config.khscodes.networking.fqdn; + useUTF8FolderNames = true; + domains = cfg.domains; + certificateScheme = "acme"; + }; + services.dovecot2.extraConfig = '' + oauth2 { + openid_configuration_url = https://login.kaareskovgaard.net/oauth2/openid/stalwart/.well-known/openid-configuration + scope = email openid profile + username_attribute = preferred_username + client_id = stalwart + client_secret = <${config.khscodes.infrastructure.kanidm-client-application.secretFile} + tokeninfo_url = https://login.kaareskovgaard.net/oauth2/token + introspection_url = https://login.kaareskovgaard.net/oauth2/token/introspect + introspection_mode = post + } + ''; + services.prometheus.exporters.postfix = { + enable = true; + }; + khscodes.infrastructure.vault-prometheus-sender.exporters.enabled = [ "postfix" ]; + services.fail2ban.jails = { + postfix = { + settings = { + enabled = true; + mode = "aggressive"; + findtime = 600; + bantime = "1d"; + maxretry = 3; + }; + }; + dovecot = { + settings = { + enabled = true; + mode = "aggressive"; + findtime = 600; + bantime = "1d"; + maxretry = 3; + }; + }; + }; + networking.firewall.allowedTCPPorts = [ 25 465 993 ]; - security.acme.certs."${fqdn}" = { - # Not sure if this does an actual reload (which is not supported apparently), - # or if it does a full restart (which would be needed). - # If it doesn't work, then I should look into using post run to query the API. - reloadServices = [ "stalwart-mail.service" ]; - }; - users.users.stalwart-mail.extraGroups = [ config.security.acme.certs.${fqdn}.group ]; - systemd.services.stalwart-mail = { - serviceConfig = { - ReadOnlyPaths = [ config.security.acme.certs."${fqdn}".directory ]; - }; - wants = [ "acme-${fqdn}.service" ]; - after = [ "acme-${fqdn}.service" ]; - }; - services.stalwart-mail = { - enable = true; - package = pkgs.stalwart-mail; - openFirewall = false; - settings = { - http = { - url = "https://${fqdn}"; - use-x-forwarded = true; - }; - server = { - hostname = fqdn; - tls = { - enable = true; - certificate = "default"; - implicit = true; - ignore-client-order = true; - }; - listener = { - smtp = { - protocol = "smtp"; - bind = "[::]:25"; - }; - submissions = { - bind = "[::]:465"; - protocol = "smtp"; - }; - imaps = { - bind = "[::]:993"; - protocol = "imap"; - }; - management = { - bind = [ "127.0.0.1:8080" ]; - protocol = "http"; - }; - }; - }; - # There's a bug in 25.05 that references the wrong file. Fixed in master/unstable. - spam-filter.resource = "file://${stalwart-spam-filter}/spam-filter.toml"; - authentication.fallback-admin = { - user = "admin"; - secret = "%{file:/etc/stalwart/admin-pw}%"; - }; - certificate.default = { - cert = "%{file://${config.security.acme.certs."${fqdn}".directory}/fullchain.pem}"; - private-key = "%{file://${config.security.acme.certs."${fqdn}".directory}/key.pem}"; - default = true; - }; - auth.dkim = { - sign = authDkim ++ [ - (otherwise false) - ]; - }; - directory.memory = { - type = "memory"; - domains = [ - "agerlinskovgaard.dk" - "agerlin-skovgaard.dk" - ]; - }; - } - // dkimSignatures; - }; }; } diff --git a/nix/modules/nixos/infrastructure/mailserver/dkim.nix b/nix/modules/nixos/infrastructure/mailserver/dkim.nix index d32fd0f..30c0c35 100644 --- a/nix/modules/nixos/infrastructure/mailserver/dkim.nix +++ b/nix/modules/nixos/infrastructure/mailserver/dkim.nix @@ -1,5 +1,4 @@ { - pkgs, lib, config, ... @@ -52,37 +51,6 @@ in }; }; config = lib.mkIf (cfg.enable) { - # mailserver = { - # dkimSigning = false; - # }; - # services.rspamd.locals."dkim_signing.conf" = lib.mkForce { - # text = '' - # enabled = true; - # domain { - # ${lib.strings.concatStringsSep "\n " (lib.lists.map dkimSigningForDomain cfg.domains)} - # } - # ''; - # }; - # systemd.services.rspamd = { - # unitConfig = { - # ConditionPathExists = domainKeyPaths; - # }; - # }; - # systemd.services.postfix = { - # unitConfig = { - # ConditionPathExists = domainKeyPaths; - # }; - # }; - systemd.services.stalwart-mail = { - unitConfig = { - ConditionPathExists = domainKeyPaths; - }; - serviceConfig = { - ReadOnlyPaths = [ - "/run/secret/dkim" - ]; - }; - }; khscodes.infrastructure.vault-server-approle.policy = { "${cfg.dkim.vault.mount}/data/${cfg.dkim.vault.prefixPath}/*" = { capabilities = [ "read" ]; @@ -90,7 +58,7 @@ in }; khscodes.infrastructure.provisioning.pre.modules = [ ( - { config, ... }: + { ... }: { terraform.required_providers.tls = { source = "hashicorp/tls"; @@ -128,16 +96,6 @@ in fqdn = "snm_ed25519._domainkey.${domain}"; content = ''"''${ join("\" \"", regexall(".{1,255}", "v=DKIM1;k=ed25519;p=${dkimPublicKey "tls_private_key.${lib.khscodes.sanitize-terraform-name domain}_dkim_ed25519"}" )) }"''; ttl = 600; - }) cfg.domains) - ++ (lib.lists.map (domain: { - fqdn = domain; - content = ''"v=spf1 mx -all"''; - ttl = 600; - }) cfg.domains) - ++ (lib.lists.map (domain: { - fqdn = "_dmarc.${domain}"; - content = ''"v=DMARC1; p=reject; adkim=s; aspf=s;"''; - ttl = 600; }) cfg.domains); resource.vault_kv_secret_v2 = @@ -168,11 +126,6 @@ in } ) ]; - systemd.services.stalwart-mail.serviceConfig = { - ReadWritePaths = [ - "/run/secret/dkim" - ]; - }; khscodes.services.vault-agent.templates = lib.lists.flatten ( lib.lists.map (domain: [ { @@ -183,10 +136,10 @@ in ''; destination = rsaKeyPath domain; perms = "0600"; - owner = "stalwart-mail"; - group = "stalwart-mail"; + owner = "rspamd"; + group = "rspamd"; restartUnits = [ - "stalwart-mail.service" + "rspamd.service" ]; } { @@ -197,13 +150,34 @@ in ''; destination = ed25519KeyPath domain; perms = "0600"; - owner = "stalwart-mail"; - group = "stalwart-mail"; + owner = "rspamd"; + group = "rspamd"; restartUnits = [ - "stalwart-mail.service" + "rspamd.service" ]; } ]) cfg.domains ); + mailserver = { + dkimSigning = false; + }; + services.rspamd.locals."dkim_signing.conf" = lib.mkForce { + text = '' + enabled = true; + domain { + ${lib.strings.concatStringsSep "\n " (lib.lists.map dkimSigningForDomain cfg.domains)} + } + ''; + }; + systemd.services.rspamd = { + unitConfig = { + ConditionPathExists = domainKeyPaths; + }; + }; + systemd.services.postfix = { + unitConfig = { + ConditionPathExists = domainKeyPaths; + }; + }; }; } diff --git a/nix/modules/nixos/infrastructure/mailserver/dmarc.nix b/nix/modules/nixos/infrastructure/mailserver/dmarc.nix new file mode 100644 index 0000000..bd5a147 --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/dmarc.nix @@ -0,0 +1,17 @@ +{ config, lib, ... }: +let + cfg = config.khscodes.infrastructure.mailserver; +in +{ + config = lib.mkIf cfg.enable { + khscodes.infrastructure.provisioning.pre.modules = [ + { + khscodes.cloudflare.dns.txtRecords = lib.lists.map (domain: { + fqdn = "_dmarc.${domain}"; + content = ''"v=DMARC1; p=reject; rua=mailto:postmaster@${domain}; ruf=mailto:postmaster@${domain}; adkim=s; aspf=s;"''; + ttl = 600; + }) cfg.domains; + } + ]; + }; +} diff --git a/nix/modules/nixos/infrastructure/mailserver/mta-sts.nix b/nix/modules/nixos/infrastructure/mailserver/mta-sts.nix new file mode 100644 index 0000000..ebb9536 --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/mta-sts.nix @@ -0,0 +1,54 @@ +{ + config, + lib, + pkgs, + ... +}: +let + fqdn = config.khscodes.networking.fqdn; + cfg = config.khscodes.infrastructure.mailserver; + # Increment this if ever changing mta-sts settings. + policyVersion = 2; + mtaStsWellKnown = pkgs.writeTextFile "mta-sts.txt" '' + version: STSv1 + mode: enforce + max_age: 600 + mx: ${fqdn} + ''; +in +{ + config = lib.mkIf cfg.enable { + khscodes.services.nginx.virtualHosts = ( + lib.listToAttrs ( + lib.lists.map (domain: { + name = "mta-sts.${domain}"; + value = { + locations."=/.well-known/mta-sts.txt" = { + tryFiles = "${mtaStsWellKnown} =404"; + }; + locations."/" = { + return = 404; + }; + }; + }) cfg.domains + ) + ); + khscodes.infrastructure.provisioning.pre.modules = [ + { + khscodes.cloudflare.dns.txtRecords = ( + lib.lists.map (domain: { + fqdn = "_mta-sts.${domain}"; + content = ''"v=STSv1; id=${builtins.toString policyVersion}"''; + }) cfg.domains + ); + khscodes.cloudflare.dns.cnameRecords = ( + lib.lists.map (domain: { + fqdn = "mta-sts.${domain}"; + content = fqdn; + ttl = 600; + }) cfg.domains + ); + } + ]; + }; +} diff --git a/nix/modules/nixos/infrastructure/mailserver/openid-connect.nix b/nix/modules/nixos/infrastructure/mailserver/openid-connect.nix new file mode 100644 index 0000000..fdb959d --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/openid-connect.nix @@ -0,0 +1,7 @@ +{ config, lib, ... }: +let + cfg = config.khscodes.infrastructure.mailserver; +in +{ + config = lib.mkIf cfg.enable { }; +} diff --git a/nix/modules/nixos/infrastructure/mailserver/package/nixos-module.nix b/nix/modules/nixos/infrastructure/mailserver/package/nixos-module.nix new file mode 100644 index 0000000..5d4160c --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/package/nixos-module.nix @@ -0,0 +1,23 @@ +# This file contains patches for Nixos 25.05 to be compatible with new stalwart mail +{ + lib, + config, + pkgs, + ... +}: +let + configFormat = pkgs.formats.toml { }; + configFile = configFormat.generate "stalwart-mail.toml" config.services.stalwart-mail.settings; +in +{ + systemd.services.stalwart-mail = lib.mkIf config.services.stalwart-mail.enable { + serviceConfig = { + User = "stalwart-mail"; + Group = "stalwart-mail"; + ExecStart = lib.mkForce [ + "" + "${lib.getExe config.services.stalwart-mail.package} --config=${configFile}" + ]; + }; + }; +} diff --git a/nix/modules/nixos/infrastructure/mailserver/package/package.nix b/nix/modules/nixos/infrastructure/mailserver/package/package.nix new file mode 100644 index 0000000..b1c1256 --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/package/package.nix @@ -0,0 +1,194 @@ +{ + lib, + rustPlatform, + fetchFromGitHub, + pkg-config, + protobuf, + bzip2, + openssl, + sqlite, + foundationdb, + zstd, + stdenv, + nix-update-script, + nixosTests, + rocksdb, + callPackage, + withFoundationdb ? false, + stalwartEnterprise ? false, +}: + +rustPlatform.buildRustPackage (finalAttrs: { + pname = "stalwart-mail" + (lib.optionalString stalwartEnterprise "-enterprise"); + version = "0.13.2"; + + src = fetchFromGitHub { + owner = "stalwartlabs"; + repo = "stalwart"; + rev = "51a0a1445d74a8cfb880e9d88f5be390fa0e9365"; + hash = "sha256-VdeHb1HVGXA5RPenhhK4r/kkQiLG8/4qhdxoJ3xIqR4="; + }; + + cargoHash = "sha256-Wu6skjs3Stux5nCX++yoQPeA33Qln67GoKcob++Ldng="; + + nativeBuildInputs = [ + pkg-config + protobuf + rustPlatform.bindgenHook + ]; + + buildInputs = [ + bzip2 + openssl + sqlite + zstd + ] + ++ lib.optionals (stdenv.hostPlatform.isLinux && withFoundationdb) [ foundationdb ]; + + # Issue: https://github.com/stalwartlabs/stalwart/issues/1104 + buildNoDefaultFeatures = true; + buildFeatures = [ + "postgres" + "rocks" + "elastic" + "redis" + ] + ++ lib.optionals withFoundationdb [ "foundationdb" ] + ++ lib.optionals stalwartEnterprise [ "enterprise" ]; + + env = { + OPENSSL_NO_VENDOR = true; + ZSTD_SYS_USE_PKG_CONFIG = true; + ROCKSDB_INCLUDE_DIR = "${rocksdb}/include"; + ROCKSDB_LIB_DIR = "${rocksdb}/lib"; + }; + + postInstall = '' + mkdir -p $out/etc/stalwart + + mkdir -p $out/lib/systemd/system + + substitute resources/systemd/stalwart-mail.service $out/lib/systemd/system/stalwart-mail.service \ + --replace "__PATH__" "$out" + ''; + + checkFlags = lib.forEach [ + # Require running mysql, postgresql daemon + "directory::imap::imap_directory" + "directory::internal::internal_directory" + "directory::ldap::ldap_directory" + "directory::sql::sql_directory" + "directory::oidc::oidc_directory" + "store::blob::blob_tests" + "store::lookup::lookup_tests" + "smtp::lookup::sql::lookup_sql" + # thread 'directory::smtp::lmtp_directory' panicked at tests/src/store/mod.rs:122:44: + # called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" } + "directory::smtp::lmtp_directory" + # thread 'imap::imap_tests' panicked at tests/src/imap/mod.rs:436:14: + # Missing store type. Try running `STORE= cargo test`: NotPresent + "imap::imap_tests" + # thread 'jmap::jmap_tests' panicked at tests/src/jmap/mod.rs:303:14: + # Missing store type. Try running `STORE= cargo test`: NotPresent + "jmap::jmap_tests" + # Failed to read system DNS config: io error: No such file or directory (os error 2) + "smtp::inbound::data::data" + # Expected "X-My-Header: true" but got Received: from foobar.net (unknown [10.0.0.123]) + "smtp::inbound::scripts::sieve_scripts" + # thread 'smtp::outbound::lmtp::lmtp_delivery' panicked at tests/src/smtp/session.rs:313:13: + # Expected " (failed to lookup" but got From: "Mail Delivery Subsystem" + "smtp::outbound::lmtp::lmtp_delivery" + # thread 'smtp::outbound::extensions::extensions' panicked at tests/src/smtp/inbound/mod.rs:45:23: + # No queue event received. + "smtp::outbound::extensions::extensions" + # panicked at tests/src/smtp/outbound/smtp.rs:173:5: + "smtp::outbound::smtp::smtp_delivery" + # panicked at tests/src/smtp/outbound/lmtp.rs + "smtp::outbound::lmtp::lmtp_delivery" + # thread 'smtp::queue::retry::queue_retry' panicked at tests/src/smtp/queue/retry.rs:119:5: + # assertion `left == right` failed + # left: [1, 2, 2] + # right: [1, 2, 3] + "smtp::queue::retry::queue_retry" + # thread 'smtp::queue::virtualq::virtual_queue' panicked at /build/source/crates/store/src/dispatch/store.rs:548:14: + # called `Result::unwrap()` on an `Err` value: Error(Event { inner: Store(MysqlError), keys: [(Reason, String("Input/output error: Input/output error: Connection refused (os error 111)")), (CausedBy, String("crates/store/src/dispatch/store.rs:301"))] }) + "smtp::queue::virtualq::virtual_queue" + # Missing store type. Try running `STORE= cargo test`: NotPresent + "store::store_tests" + # Missing store type. Try running `STORE= cargo test`: NotPresent + "cluster::cluster_tests" + # Missing store type. Try running `STORE= cargo test`: NotPresent + "webdav::webdav_tests" + # thread 'config::parser::tests::toml_parse' panicked at crates/utils/src/config/parser.rs:463:58: + # called `Result::unwrap()` on an `Err` value: "Expected ['\\n'] but found '!' in value at line 70." + "config::parser::tests::toml_parse" + # error[E0432]: unresolved import `r2d2_sqlite` + # use of undeclared crate or module `r2d2_sqlite` + "backend::sqlite::pool::SqliteConnectionManager::with_init" + # thread 'smtp::reporting::analyze::report_analyze' panicked at tests/src/smtp/reporting/analyze.rs:88:5: + # assertion `left == right` failed + # left: 0 + # right: 12 + "smtp::reporting::analyze::report_analyze" + # thread 'smtp::inbound::dmarc::dmarc' panicked at tests/src/smtp/inbound/mod.rs:59:26: + # Expected empty queue but got Reload + "smtp::inbound::dmarc::dmarc" + # thread 'smtp::queue::concurrent::concurrent_queue' panicked at tests/src/smtp/inbound/mod.rs:65:9: + # assertion `left == right` failed + "smtp::queue::concurrent::concurrent_queue" + # Failed to read system DNS config: io error: No such file or directory (os error 2) + "smtp::inbound::auth::auth" + # Failed to read system DNS config: io error: No such file or directory (os error 2) + "smtp::inbound::antispam::antispam" + # Failed to read system DNS config: io error: No such file or directory (os error 2) + "smtp::inbound::vrfy::vrfy_expn" + # thread 'smtp::management::queue::manage_queue' panicked at tests/src/smtp/inbound/mod.rs:45:23: + # No queue event received. + # NOTE: Test unreliable on high load systems + "smtp::management::queue::manage_queue" + # thread 'responses::tests::parse_responses' panicked at crates/dav-proto/src/responses/mod.rs:671:17: + # assertion `left == right` failed: failed for 008.xml + # left: ElementEnd + # right: Bytes([...]) + "responses::tests::parse_responses" + ] (test: "--skip=${test}"); + + doCheck = !(stdenv.hostPlatform.isLinux && stdenv.hostPlatform.isAarch64); + + # Allow network access during tests on Darwin/macOS + __darwinAllowLocalNetworking = true; + + passthru = { + inherit rocksdb; # make used rocksdb version available (e.g., for backup scripts) + webadmin = callPackage ./webadmin.nix { }; + spam-filter = callPackage ./spam-filter.nix { }; + updateScript = nix-update-script { }; + tests.stalwart-mail = nixosTests.stalwart-mail; + }; + + meta = { + description = "Secure & Modern All-in-One Mail Server (IMAP, JMAP, SMTP)"; + homepage = "https://github.com/stalwartlabs/mail-server"; + changelog = "https://github.com/stalwartlabs/mail-server/blob/main/CHANGELOG.md"; + license = [ + lib.licenses.agpl3Only + ] + ++ lib.optionals stalwartEnterprise [ + { + fullName = "Stalwart Enterprise License 1.0 (SELv1) Agreement"; + url = "https://github.com/stalwartlabs/mail-server/blob/main/LICENSES/LicenseRef-SEL.txt"; + free = false; + redistributable = false; + } + ]; + + mainProgram = "stalwart"; + maintainers = with lib.maintainers; [ + happysalada + onny + oddlama + pandapip1 + norpol + ]; + }; +}) diff --git a/nix/modules/nixos/infrastructure/mailserver/stalwart-spam-filter.nix b/nix/modules/nixos/infrastructure/mailserver/package/spam-filter.nix similarity index 100% rename from nix/modules/nixos/infrastructure/mailserver/stalwart-spam-filter.nix rename to nix/modules/nixos/infrastructure/mailserver/package/spam-filter.nix diff --git a/nix/modules/nixos/infrastructure/mailserver/package/webadmin.nix b/nix/modules/nixos/infrastructure/mailserver/package/webadmin.nix new file mode 100644 index 0000000..18785be --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/package/webadmin.nix @@ -0,0 +1,77 @@ +{ + lib, + rustPlatform, + stalwart-mail, + fetchFromGitHub, + trunk, + tailwindcss_3, + fetchNpmDeps, + nix-update-script, + nodejs, + npmHooks, + llvmPackages, + wasm-bindgen-cli_0_2_93, + binaryen, + zip, +}: + +rustPlatform.buildRustPackage (finalAttrs: { + pname = "webadmin"; + version = "0.1.31"; + + src = fetchFromGitHub { + owner = "stalwartlabs"; + repo = "webadmin"; + rev = "6f1368b8a1160341b385980accea489ee0e45440"; + hash = "sha256-/EWn/wiY6zFNhObfo11OkoGufcUODMYs18P3vTBbB8s="; + }; + + npmDeps = fetchNpmDeps { + name = "${finalAttrs.pname}-npm-deps"; + hash = "sha256-na1HEueX8w7kuDp8LEtJ0nD1Yv39cyk6sEMpS1zix2s="; + }; + + cargoHash = "sha256-Q05+wH9+NfkfmEDJFLuWVQ7wuDeEu9h1XmOMN6SYdyU="; + + postPatch = '' + # Using local tailwindcss for compilation + substituteInPlace Trunk.toml --replace-fail "npx tailwindcss" "tailwindcss" + ''; + + nativeBuildInputs = [ + binaryen + llvmPackages.bintools-unwrapped + nodejs + npmHooks.npmConfigHook + tailwindcss_3 + trunk + # needs to match with wasm-bindgen version in upstreams Cargo.lock + wasm-bindgen-cli_0_2_93 + + zip + ]; + + NODE_PATH = "$npmDeps"; + + buildPhase = '' + trunk build --offline --frozen --release + ''; + + installPhase = '' + cd dist + mkdir -p $out + zip -r $out/webadmin.zip * + ''; + + passthru = { + updateScript = nix-update-script { }; + }; + + meta = { + description = "Secure & modern all-in-one mail server Stalwart (webadmin module)"; + homepage = "https://github.com/stalwartlabs/webadmin"; + changelog = "https://github.com/stalwartlabs/webadmin/blob/${finalAttrs.src.tag}/CHANGELOG.md"; + license = lib.licenses.agpl3Only; + inherit (stalwart-mail.meta) maintainers; + }; +}) diff --git a/nix/modules/nixos/infrastructure/mailserver/prometheus.nix b/nix/modules/nixos/infrastructure/mailserver/prometheus.nix new file mode 100644 index 0000000..682c33a --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/prometheus.nix @@ -0,0 +1,15 @@ +{ config, lib, ... }: +let + fqdn = config.khscodes.networking.fqdn; + cfg = config.khscodes.infrastructure.mailserver; +in +{ + config = lib.mkIf cfg.enable { + services.stalwart-mail.settings.metrics.prometheus = { + enable = true; + }; + khscodes.services.nginx.virtualHosts."${fqdn}".locations."=/metrics/prometheus" = { + return = 404; + }; + }; +} diff --git a/nix/modules/nixos/infrastructure/mailserver/spf.nix b/nix/modules/nixos/infrastructure/mailserver/spf.nix new file mode 100644 index 0000000..97b437f --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/spf.nix @@ -0,0 +1,17 @@ +{ config, lib, ... }: +let + cfg = config.khscodes.infrastructure.mailserver; +in +{ + config = lib.mkIf cfg.enable { + khscodes.infrastructure.provisioning.pre.modules = [ + { + khscodes.cloudflare.dns.txtRecords = lib.lists.map (domain: { + fqdn = domain; + content = ''"v=spf1 mx ra=postmaster -all"''; + ttl = 600; + }) cfg.domains; + } + ]; + }; +} diff --git a/nix/modules/nixos/infrastructure/mailserver/tls-rpt.nix b/nix/modules/nixos/infrastructure/mailserver/tls-rpt.nix new file mode 100644 index 0000000..d4418a3 --- /dev/null +++ b/nix/modules/nixos/infrastructure/mailserver/tls-rpt.nix @@ -0,0 +1,17 @@ +{ config, lib, ... }: +let + cfg = config.khscodes.infrastructure.mailserver; +in +{ + config = lib.mkIf cfg.enable { + khscodes.infrastructure.provisioning.pre.modules = [ + { + khscodes.cloudflare.dns.txtRecords = lib.lists.map (domain: { + fqdn = "_smtp._tls.${domain}"; + content = ''"v=TLSRPTv1; rua=mailto:postmaster@${domain}"''; + ttl = 600; + }) cfg.domains; + } + ]; + }; +} diff --git a/nix/modules/terranix/cloudflare/default.nix b/nix/modules/terranix/cloudflare/default.nix index ff44ef7..fcf774a 100644 --- a/nix/modules/terranix/cloudflare/default.nix +++ b/nix/modules/terranix/cloudflare/default.nix @@ -19,6 +19,35 @@ let top = lib.lists.takeEnd 2 split; in lib.strings.concatStringsSep "." top; + serviceFromFqdn = + fqdn: + let + split = lib.strings.splitString "." fqdn; + in + assert + lib.strings.hasPrefix "_" (builtins.elemAt split 0) + && lib.strings.hasPrefix "_" (builtins.elemAt split 1); + builtins.elemAt split 0; + protocolFromFqdn = + fqdn: + let + split = lib.strings.splitString "." fqdn; + in + assert + lib.strings.hasPrefix "_" (builtins.elemAt split 0) + && lib.strings.hasPrefix "_" (builtins.elemAt split 1); + builtins.elemAt split 1; + nameFromFqdn = + fqdn: + let + split = lib.strings.splitString "." fqdn; + in + assert + lib.strings.hasPrefix "_" (builtins.elemAt split 0) + && lib.strings.hasPrefix "_" (builtins.elemAt split 1); + lib.strings.concatStringsSep "." ( + lib.lists.removePrefix [ (builtins.elemAt split 0) (builtins.elemAt split 1) ] split + ); dnsARecordModule = lib.khscodes.mkSubmodule { description = "Module for defining dns A/AAAA record"; options = { @@ -42,6 +71,29 @@ let }; }; }; + dnsCnameRecordModule = lib.khscodes.mkSubmodule { + description = "Module for defining dns CNAME record"; + options = { + fqdn = lib.mkOption { + type = lib.types.str; + description = "The FQDN of the CNAME record to create"; + }; + content = lib.mkOption { + type = lib.types.str; + description = "The content of the CNAME record (canonical name)"; + }; + proxied = lib.mkOption { + type = lib.types.bool; + description = "Creates a proxied record in cloudflare"; + default = false; + }; + ttl = lib.mkOption { + type = lib.types.int; + description = "Time to Live for the CNAME record"; + default = 600; + }; + }; + }; dnsTxtRecordModule = lib.khscodes.mkSubmodule { description = "Module for defining dns TXT record"; options = { @@ -60,6 +112,54 @@ let }; }; }; + dnsSrvRecordModule = lib.khscodes.mkSubmodule { + description = "Module for defining dns SRV record"; + options = { + fqdn = lib.mkOption { + type = lib.types.str; + description = "The FQDN of the SRV record to create"; + }; + content = lib.mkOption { + type = lib.types.str; + description = "The content of the SRV record"; + }; + priority = lib.mkOption { + type = lib.types.int; + description = "Priority for the SRV record"; + }; + weight = lib.mkOption { + type = lib.types.int; + description = "Weight for the SRV record"; + }; + port = lib.mkOption { + type = lib.types.int; + description = "Port for the SRV record"; + }; + ttl = lib.mkOption { + type = lib.types.int; + description = "Time to Live for the SRV record"; + default = 600; + }; + }; + }; + dnsTlsaRecordModule = lib.khscodes.mkSubmodule { + description = "Module for defining dns TLSA record"; + options = { + fqdn = lib.mkOption { + type = lib.types.str; + description = "The FQDN of the TLSA record to create"; + }; + content = lib.mkOption { + type = lib.types.str; + description = "The content of the TLSA record"; + }; + ttl = lib.mkOption { + type = lib.types.int; + description = "Time to Live for the TLSA record"; + default = 600; + }; + }; + }; dnsMxRecordModule = lib.khscodes.mkSubmodule { description = "Module for defining dns MX record"; options = { @@ -99,11 +199,26 @@ in default = [ ]; description = "AAAA records to create in the zone"; }; + cnameRecords = lib.mkOption { + type = lib.types.listOf dnsCnameRecordModule; + default = [ ]; + description = "CNAME records to create in the zone"; + }; txtRecords = lib.mkOption { type = lib.types.listOf dnsTxtRecordModule; default = [ ]; description = "TXT Records to create"; }; + srvRecords = lib.mkOption { + type = lib.types.listOf dnsSrvRecordModule; + default = [ ]; + description = "SRV Records to create"; + }; + tlsaRecords = lib.mkOption { + type = lib.types.listOf dnsTlsaRecordModule; + default = [ ]; + description = "TLSA Records to create"; + }; mxRecords = lib.mkOption { type = lib.types.listOf dnsMxRecordModule; default = [ ]; @@ -125,7 +240,14 @@ in khscodes.cloudflare.data.dns_zones = lib.lists.unique ( lib.lists.map (a: tldFromFqdn (a.fqdn)) ( - cfg.dns.aRecords ++ cfg.dns.aaaaRecords ++ cfg.dns.txtRecords ++ cfg.dns.mxRecords + cfg.dns.aRecords + ++ cfg.dns.aaaaRecords + ++ cfg.dns.txtRecords + ++ cfg.dns.mxRecords + ++ cfg.dns.cnameRecords + ++ cfg.dns.srvRecords + ++ cfg.dns.tlsaRecords + ++ cfg.dns.cnameRecords ) ); resource.cloudflare_record = lib.attrsets.optionalAttrs cfg.dns.enable ( @@ -162,6 +284,22 @@ in }; } ) cfg.dns.aaaaRecords) + ++ (lib.lists.map ( + record: + let + zoneName = tldFromFqdn record.fqdn; + in + { + name = "${lib.khscodes.sanitize-terraform-name record.fqdn}_cname"; + value = { + inherit (record) content ttl proxied; + name = nameFromFQDNAndZone record.fqdn zoneName; + type = "CNAME"; + zone_id = "\${ data.cloudflare_zone.${lib.khscodes.sanitize-terraform-name zoneName}.id }"; + comment = "app=${zoneName}"; + }; + } + ) cfg.dns.cnameRecords) ++ (lib.lists.map ( record: let @@ -194,6 +332,41 @@ in }; } ) cfg.dns.mxRecords) + ++ (lib.lists.map ( + record: + let + zoneName = tldFromFqdn record.fqdn; + in + { + name = "${lib.khscodes.sanitize-terraform-name record.fqdn}_srv"; + value = { + name = nameFromFQDNAndZone record.fqdn zoneName; + type = "SRV"; + zone_id = "\${ data.cloudflare_zone.${lib.khscodes.sanitize-terraform-name zoneName}.id }"; + comment = "app=${zoneName}"; + data = { + inherit (record) priority weight port; + target = record.content; + }; + }; + } + ) cfg.dns.srvRecords) + ++ (lib.lists.map ( + record: + let + zoneName = tldFromFqdn record.fqdn; + in + { + name = "${lib.khscodes.sanitize-terraform-name record.fqdn}_tlsa"; + value = { + inherit (record) content; + name = nameFromFQDNAndZone record.fqdn zoneName; + type = "TLSA"; + zone_id = "\${ data.cloudflare_zone.${lib.khscodes.sanitize-terraform-name zoneName}.id }"; + comment = "app=${zoneName}"; + }; + } + ) cfg.dns.tlsaRecords) ) ); }; diff --git a/nix/systems/aarch64-linux/mx.kaareskovgaard.net/default.nix b/nix/systems/aarch64-linux/mx.kaareskovgaard.net/default.nix index 42ad3a2..b5e223b 100644 --- a/nix/systems/aarch64-linux/mx.kaareskovgaard.net/default.nix +++ b/nix/systems/aarch64-linux/mx.kaareskovgaard.net/default.nix @@ -27,6 +27,12 @@ ) ]; khscodes.infrastructure = { + kanidm-client-application = { + enable = true; + appName = "stalwart"; + secretOwner = "dovecot2"; + perms = "0600"; + }; hetzner-instance = { enable = true; mapRdns = true; @@ -46,6 +52,20 @@ }; }; }; + services.stalwart-mail.settings.directory.memory = { + type = "memory"; + principals = [ + { + class = "individual"; + name = "khs"; + fullName = "Kaare Agerlin Skovgaard"; + email = [ + "kaare@agerlinskovgaard.dk" + "kaare@agerlin-skovgaard.dk" + ]; + } + ]; + }; services.roundcube = { enable = true; hostName = "mail.kaareskovgaard.net"; @@ -53,34 +73,22 @@ extraConfig = '' # starttls needed for authentication, so the fqdn required to match # the certificate - $config['smtp_host'] = "ssl://${config.khscodes.networking.fqdn}:465"; - $config['imap_host'] = "ssl://${config.khscodes.networking.fqdn}:993"; + $config['smtp_host'] = "ssl://${config.khscodes.networking.fqdn}"; + $config['imap_host'] = "ssl://${config.khscodes.networking.fqdn}"; + $config['oauth_provider'] = 'generic'; + $config['oauth_provider_name'] = 'Kanidm'; + $config['oauth_client_id'] = 'stalwart'; + $config['oauth_client_secret'] = file_get_contents("${config.khscodes.infrastructure.kanidm-client-application.secretFile}"); + $config['oauth_auth_uri'] = 'https://login.kaareskovgaard.net/ui/oauth2'; + $config['oauth_token_uri'] = 'https://login.kaareskovgaard.net/oauth2/token'; + $config['oauth_identity_uri'] = 'https://login.kaareskovgaard.net/oauth2/openid/stalwart/userinfo'; + $config['oauth_identity_fields'] = ['preferred_username']; + $config['oauth_scope'] = 'email openid profile'; ''; }; khscodes.services.nginx = { enable = true; - virtualHosts."mx.kaareskovgaard.net" = { - locations."/" = { - proxyPass = "http://127.0.0.1:8080"; - proxyWebsockets = true; - recommendedProxySettings = true; - }; - }; virtualHosts."mail.kaareskovgaard.net" = { }; - virtualHosts."autoconfig.kaareskovgaard.net" = { - locations."/" = { - proxyPass = "http://127.0.0.1:8080"; - proxyWebsockets = true; - recommendedProxySettings = true; - }; - }; - virtualHosts."autodiscover.kaareskovgaard.net" = { - locations."/" = { - proxyPass = "http://127.0.0.1:8080"; - proxyWebsockets = true; - recommendedProxySettings = true; - }; - }; }; khscodes.networking.fqdn = "mx.kaareskovgaard.net"; system.stateVersion = "25.05"; diff --git a/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm.nix b/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm.nix index 8e45c37..7d3c376 100644 --- a/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm.nix +++ b/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm.nix @@ -120,6 +120,19 @@ in }; }; }; + stalwart = { + allowedRedirectUris = [ "https://mail.kaareskovgaard.net/index.php/login/oauth" ]; + landingUri = "https://mail.kaareskovgaard.net"; + displayName = "Mail"; + allowInsecureClientDisablePkce = true; + scopeMaps = { + "mail_user" = [ + "profile" + "email" + "openid" + ]; + }; + }; }; services.kanidm = { enableServer = true; @@ -163,6 +176,10 @@ in present = true; members = [ "khs" ]; }; + groups.mail_user = { + present = true; + members = [ "khs" ]; + }; }; }; diff --git a/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm_application.nix b/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm_application.nix index e6817e7..208e1f9 100644 --- a/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm_application.nix +++ b/nix/systems/aarch64-linux/security.kaareskovgaard.net/kanidm_application.nix @@ -47,7 +47,7 @@ let } ) cfg.applications; systemsOauth2 = lib.attrsets.mapAttrs (key: value: { - inherit (value) scopeMaps claimMaps; + inherit (value) scopeMaps claimMaps allowInsecureClientDisablePkce; present = true; public = false; preferShortUsername = true; @@ -72,6 +72,10 @@ let allowedRedirectUris = lib.mkOption { type = lib.types.listOf lib.types.str; }; + allowInsecureClientDisablePkce = lib.mkOption { + type = lib.types.bool; + default = false; + }; landingUri = lib.mkOption { type = lib.types.str; };