Add cert-resolver
This commit is contained in:
parent
0842bd53a2
commit
071889c976
343
modules/cloud/mail/default.nix
Normal file
343
modules/cloud/mail/default.nix
Normal file
|
@ -0,0 +1,343 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.cloud.mail;
|
||||
|
||||
name = "maddy";
|
||||
|
||||
stateDir = "/var/lib/${name}";
|
||||
runtimeDir = "/run/${name}";
|
||||
|
||||
smtpPort = 58787;
|
||||
smtpsPort = 46565;
|
||||
imapPort = 9333;
|
||||
mtaStsPort = 8003;
|
||||
in
|
||||
{
|
||||
options.cloud.mail = {
|
||||
enable = mkEnableOption "Enable the email server";
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.maddy;
|
||||
};
|
||||
|
||||
hostname = mkOption {
|
||||
type = types.str;
|
||||
default = "mx1.nkagami.me";
|
||||
description = "The hostname where the server is run on";
|
||||
};
|
||||
|
||||
primaryDomain = mkOption {
|
||||
type = types.str;
|
||||
default = "nkagami.me";
|
||||
description = "The primary email domain";
|
||||
};
|
||||
|
||||
additionalDomains = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Additional domain names to be used";
|
||||
};
|
||||
|
||||
tls = {
|
||||
certFile = mkOption {
|
||||
type = types.str;
|
||||
description = "Path to the certificate file.";
|
||||
};
|
||||
keyFile = mkOption {
|
||||
type = types.str;
|
||||
description = "Path to the key file.";
|
||||
};
|
||||
};
|
||||
|
||||
usersFile = mkOption {
|
||||
type = types.path;
|
||||
description = ''
|
||||
The file containing user:hashed-password pairs to be used in the mail server.
|
||||
Check `maddyctl hash` to generate hashed passwords.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config =
|
||||
let
|
||||
configFile = pkgs.writeText "maddy.conf" ''
|
||||
# Globals
|
||||
state_dir ${stateDir}
|
||||
runtime_dir ${runtimeDir}
|
||||
|
||||
# Base variables
|
||||
|
||||
$(hostname) = ${cfg.hostname}
|
||||
$(primary_domain) = ${cfg.primaryDomain}
|
||||
$(local_domains) = ${cfg.primaryDomain} ${lib.strings.concatStringsSep " " cfg.additionalDomains}
|
||||
|
||||
tls file "${cfg.tls.certFile}" "${cfg.tls.keyFile}"
|
||||
|
||||
# Authentication
|
||||
auth.pass_table local_authdb {
|
||||
table file ${cfg.usersFile}
|
||||
}
|
||||
|
||||
# Local storage
|
||||
storage.imapsql local_mailboxes {
|
||||
driver "postgres"
|
||||
dsn "dbname='${name}' user='${name}' host='/run/postgresql' sslmode=disable"
|
||||
appendlimit 256M
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# SMTP endpoints + message routing
|
||||
|
||||
hostname $(hostname)
|
||||
|
||||
msgpipeline local_routing {
|
||||
# Insert handling for special-purpose local domains here.
|
||||
# e.g.
|
||||
# destination lists.example.org {
|
||||
# deliver_to lmtp tcp://127.0.0.1:8024
|
||||
# }
|
||||
|
||||
destination postmaster $(local_domains) {
|
||||
modify {
|
||||
replace_rcpt regexp "(.+)\+(.+)@(.+)" "$1@$3"
|
||||
replace_rcpt file /etc/maddy/aliases
|
||||
}
|
||||
|
||||
deliver_to &local_mailboxes
|
||||
}
|
||||
|
||||
default_destination {
|
||||
reject 550 5.1.1 "User doesn't exist"
|
||||
}
|
||||
}
|
||||
|
||||
smtp tcp://0.0.0.0:25 {
|
||||
limits {
|
||||
# Up to 20 msgs/sec across max. 10 SMTP connections.
|
||||
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 tcp://0.0.0.0:${toString smtpPort} tls://0.0.0.0:${toString smtpsPort} {
|
||||
limits {
|
||||
# Up to 50 msgs/sec across any amount of SMTP connections.
|
||||
all rate 50 1s
|
||||
}
|
||||
|
||||
auth &local_authdb
|
||||
|
||||
source $(local_domains) {
|
||||
destination postmaster $(local_domains) {
|
||||
deliver_to &local_routing
|
||||
}
|
||||
default_destination {
|
||||
modify {
|
||||
dkim $(primary_domain) $(local_domains) default
|
||||
}
|
||||
deliver_to &remote_queue
|
||||
}
|
||||
}
|
||||
default_source {
|
||||
reject 501 5.1.8 "Non-local sender domain"
|
||||
}
|
||||
}
|
||||
|
||||
target.remote outbound_delivery {
|
||||
limits {
|
||||
# Up to 20 msgs/sec across max. 10 SMTP connections
|
||||
# for each recipient domain.
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# IMAP endpoints
|
||||
|
||||
imap tls://0.0.0.0:${toString imapPort} {
|
||||
auth &local_authdb
|
||||
storage &local_mailboxes
|
||||
}
|
||||
'';
|
||||
|
||||
mtaStsDir = pkgs.writeTextDir ".well-known/mta-sts.txt" ''
|
||||
version: STSv1
|
||||
mode: enforce
|
||||
max_age: 604800
|
||||
mx: ${cfg.hostname}
|
||||
'';
|
||||
in
|
||||
mkIf cfg.enable {
|
||||
# users
|
||||
users.users."${name}" = {
|
||||
group = "${name}";
|
||||
isSystemUser = true;
|
||||
};
|
||||
users.groups."${name}" = { };
|
||||
|
||||
# database
|
||||
cloud.postgresql.databases = [ name ];
|
||||
|
||||
# MTA-STS server
|
||||
services.nginx.enable = true;
|
||||
services.nginx.virtualHosts.maddy-mta-sts = {
|
||||
listen = [{ addr = "127.0.0.1"; port = mtaStsPort; }];
|
||||
root = mtaStsDir;
|
||||
};
|
||||
|
||||
# Firewall
|
||||
networking.firewall.allowedTCPPorts = [ 25 ];
|
||||
|
||||
# traefik
|
||||
cloud.traefik.hosts.maddy-smtp = {
|
||||
protocol = "tcp";
|
||||
port = smtpPort;
|
||||
host = "mx1.nkagami.me";
|
||||
tlsPassthrough = false;
|
||||
entrypoints = [ "smtp-submission" ];
|
||||
};
|
||||
cloud.traefik.hosts.maddy-smtps = {
|
||||
protocol = "tcp";
|
||||
port = smtpsPort;
|
||||
host = "mx1.nkagami.me";
|
||||
entrypoints = [ "smtp-submission-ssl" ];
|
||||
};
|
||||
cloud.traefik.hosts.maddy-imap = {
|
||||
protocol = "tcp";
|
||||
port = imapPort;
|
||||
host = "mx1.nkagami.me";
|
||||
entrypoints = [ "imap" ];
|
||||
};
|
||||
cloud.traefik.hosts.maddy-mta-sts = {
|
||||
port = mtaStsPort;
|
||||
host = "mta-sts.nkagami.me";
|
||||
};
|
||||
|
||||
# maddy itself
|
||||
systemd.services."${name}" = {
|
||||
after = [ "network.target" "traefik-certs-dumper.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
description = "maddy mail server";
|
||||
documentation = [
|
||||
"man:maddy(1)"
|
||||
"man:maddy.conf(5)"
|
||||
"https://maddy.email"
|
||||
];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "notify";
|
||||
NotifyAccess = "main";
|
||||
|
||||
User = name;
|
||||
Group = name;
|
||||
|
||||
WorkingDirectory = "/var/lib/${name}";
|
||||
|
||||
ConfigurationDirectory = name;
|
||||
RuntimeDirectory = name;
|
||||
StateDirectory = name;
|
||||
LogsDirectory = name;
|
||||
ReadOnlyPaths = "/usr/lib/${name} ${cfg.tls.keyFile} ${cfg.tls.certFile}";
|
||||
ReadWritePaths = "/var/lib/${name}";
|
||||
|
||||
# Strict sandboxing. You have no reason to trust code written by strangers from GitHub.
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectKernelTunables = true;
|
||||
ProtectHostname = true;
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
||||
|
||||
# Additional sandboxing. You need to disable all of these options
|
||||
# for privileged helper binaries (for system auth) to work correctly.
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
DeviceAllow = "/dev/syslog";
|
||||
RestrictSUIDSGID = true;
|
||||
ProtectKernelModules = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
LockPersonality = true;
|
||||
|
||||
# Graceful shutdown with a reasonable timeout.
|
||||
TimeoutStopSec = "7s";
|
||||
KillMode = "mixed";
|
||||
KillSignal = "SIGTERM";
|
||||
|
||||
|
||||
# Required to bind on ports lower than 1024.
|
||||
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
|
||||
CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
|
||||
|
||||
# Bump FD limitations. Even idle mail server can have a lot of FDs open (think
|
||||
# of idle IMAP connections, especially ones abandoned on the other end and
|
||||
# slowly timing out).
|
||||
LimitNOFILE = 131072;
|
||||
|
||||
# Limit processes count to something reasonable to
|
||||
# prevent resources exhausting due to big amounts of helper
|
||||
# processes launched.
|
||||
LimitNPROC = 512;
|
||||
|
||||
# Restart server on any problem.
|
||||
Restart = "on-failure";
|
||||
# ... Unless it is a configuration problem.
|
||||
RestartPreventExitStatus = 2;
|
||||
};
|
||||
script = "${cfg.package}/bin/maddy -config ${configFile}";
|
||||
reload = "/bin/kill -USR1 $MAINPID";
|
||||
};
|
||||
};
|
||||
}
|
46
modules/cloud/traefik/certs-dumper.nix
Normal file
46
modules/cloud/traefik/certs-dumper.nix
Normal file
|
@ -0,0 +1,46 @@
|
|||
{ pkgs, config, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.cloud.traefik.certsDumper;
|
||||
in
|
||||
{
|
||||
options.cloud.traefik.certsDumper = {
|
||||
enable = mkEnableOption "Dump certs onto a given directory ";
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.unstable.traefik-certs-dumper;
|
||||
description = "The certs dumper package to use";
|
||||
};
|
||||
destination = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/traefik-certs";
|
||||
description = "The destination folder to dump certs onto";
|
||||
};
|
||||
};
|
||||
|
||||
config.systemd.services.traefik-certs-dumper = mkIf cfg.enable {
|
||||
after = [ "traefik.service" ];
|
||||
path = with pkgs; [ openssl ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
description = "Dump certificates generated by traefik to a destination folder";
|
||||
serviceConfig =
|
||||
let
|
||||
user = config.systemd.services.traefik.serviceConfig.User;
|
||||
group = config.systemd.services.traefik.serviceConfig.Group;
|
||||
certsPath = config.cloud.traefik.certsPath;
|
||||
in
|
||||
{
|
||||
User = user;
|
||||
Group = group;
|
||||
ExecStart = "${cfg.package}/bin/traefik-certs-dumper file --watch --domain-subdir=true --version v2 --source ${certsPath} --dest ${cfg.destination} --post-hook 'chmod -R +r ${cfg.destination}'";
|
||||
LimitNOFILE = "1048576";
|
||||
PrivateTmp = "true";
|
||||
PrivateDevices = "true";
|
||||
ProtectHome = "true";
|
||||
ProtectSystem = "strict";
|
||||
StateDirectory = "traefik-certs";
|
||||
};
|
||||
};
|
||||
}
|
|
@ -7,18 +7,20 @@ let
|
|||
# Copied from traefik.nix
|
||||
jsonValue = with types;
|
||||
let
|
||||
valueType = nullOr (oneOf [
|
||||
bool
|
||||
int
|
||||
float
|
||||
str
|
||||
(lazyAttrsOf valueType)
|
||||
(listOf valueType)
|
||||
]) // {
|
||||
valueType = nullOr
|
||||
(oneOf [
|
||||
bool
|
||||
int
|
||||
float
|
||||
str
|
||||
(lazyAttrsOf valueType)
|
||||
(listOf valueType)
|
||||
]) // {
|
||||
description = "JSON value";
|
||||
emptyValue.value = { };
|
||||
};
|
||||
in valueType;
|
||||
in
|
||||
valueType;
|
||||
|
||||
hostType = with types; submodule {
|
||||
options = {
|
||||
|
@ -41,49 +43,76 @@ let
|
|||
description = "The port that the service is listening on";
|
||||
};
|
||||
entrypoints = mkOption {
|
||||
type = listOf (enum ["http" "https" "smtp-submission" "imap"]);
|
||||
type = listOf (enum [ "http" "https" "smtp-submission" "smtp-submission-ssl" "imap" ]);
|
||||
default = [ "https" ];
|
||||
description = "The entrypoints that will serve the host";
|
||||
};
|
||||
middlewares = mkOption {
|
||||
type = listOf jsonValue;
|
||||
default = [];
|
||||
default = [ ];
|
||||
description = "The middlewares to be used with the host.";
|
||||
};
|
||||
protocol = mkOption {
|
||||
type = enum [ "http" "tcp" ];
|
||||
default = "http";
|
||||
description = "The protocol of the router and service";
|
||||
};
|
||||
tlsPassthrough = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Sets the TCP passthrough value. Defaults to `true` if the connection is tcp";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Returns the filter given a host configuration
|
||||
filterOfHost = host :
|
||||
filterOfHost = host:
|
||||
let
|
||||
hostFilter = if host.protocol == "http" then "Host" else "HostSNI";
|
||||
in
|
||||
if host.filter != null then host.filter
|
||||
else if host.path == null then "Host(`${host.host}`)"
|
||||
else "Host(`${host.host}`) && Path(`${host.path}`)";
|
||||
else if host.path == null then "${hostFilter}(`${host.host}`)"
|
||||
else "${hostFilter}(`${host.host}`) && Path(`${host.path}`)";
|
||||
|
||||
# Turns a host configuration into dynamic traefik configuration
|
||||
hostToConfig = name : host : {
|
||||
http.routers."${name}-router" = {
|
||||
rule = filterOfHost host;
|
||||
entryPoints = host.entrypoints;
|
||||
tls.certResolver = "le";
|
||||
service = "${name}-service";
|
||||
middlewares = lists.imap0 (id: m: "${name}-middleware-${toString id}") host.middlewares;
|
||||
};
|
||||
http.services."${name}-service".loadBalancer.servers = [
|
||||
{ url = "http://localhost:${toString host.port}"; }
|
||||
];
|
||||
http.middlewares = builtins.listToAttrs (lists.imap0 (id: v: {
|
||||
name = "${name}-middleware-${toString id}";
|
||||
value = v;
|
||||
}) host.middlewares);
|
||||
hostToConfig = name: host: {
|
||||
"${host.protocol}" = {
|
||||
routers."${name}-router" = {
|
||||
rule = filterOfHost host;
|
||||
entryPoints = host.entrypoints;
|
||||
tls = { certResolver = "le"; } // (if host.protocol == "tcp" then { passthrough = if (host ? tlsPassthrough) then host.tlsPassthrough else true; } else { });
|
||||
service = "${name}-service";
|
||||
|
||||
} // (
|
||||
if host.protocol == "http" then
|
||||
{ middlewares = lists.imap0 (id: m: "${name}-middleware-${toString id}") host.middlewares; }
|
||||
else if host.middlewares == [ ] then
|
||||
{ }
|
||||
else abort "Cannot have middlewares on tcp routers"
|
||||
);
|
||||
services."${name}-service".loadBalancer.servers = [
|
||||
(if host.protocol == "http" then
|
||||
{ url = "http://localhost:${toString host.port}"; }
|
||||
else { address = "127.0.0.1:${toString host.port}"; }
|
||||
)
|
||||
];
|
||||
} // (if (host.middlewares != [ ]) then {
|
||||
middlewares = builtins.listToAttrs (lists.imap0
|
||||
(id: v: {
|
||||
name = "${name}-middleware-${toString id}";
|
||||
value = v;
|
||||
})
|
||||
host.middlewares);
|
||||
} else { });
|
||||
};
|
||||
in
|
||||
{
|
||||
|
||||
options.cloud.traefik.hosts = mkOption {
|
||||
type = types.attrsOf hostType;
|
||||
default = {};
|
||||
default = { };
|
||||
description = "The HTTP hosts to run on the server";
|
||||
};
|
||||
|
||||
config.cloud.traefik.config = builtins.foldl' attrsets.recursiveUpdate {} (attrsets.mapAttrsToList hostToConfig cfg.hosts);
|
||||
config.cloud.traefik.config = builtins.foldl' attrsets.recursiveUpdate { } (attrsets.mapAttrsToList hostToConfig cfg.hosts);
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ in
|
|||
entryPoints = [ "https" ];
|
||||
middlewares = [ "dashboard-auth" ];
|
||||
service = "api@internal";
|
||||
tls.certResolver = "le";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ let
|
|||
cfg = config.cloud.traefik;
|
||||
in
|
||||
{
|
||||
imports = [ ./config.nix ./dashboard.nix ];
|
||||
imports = [ ./config.nix ./dashboard.nix ./certs-dumper.nix ];
|
||||
options.cloud.traefik = {
|
||||
cloudflareKeyFile = mkOption {
|
||||
type = types.path;
|
||||
|
@ -33,6 +33,12 @@ in
|
|||
default = {};
|
||||
description = "The dynamic configuration to be passed to traefik";
|
||||
};
|
||||
|
||||
certsPath = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/traefik/acme.json";
|
||||
description = "The location to read and write the certificates file onto";
|
||||
};
|
||||
};
|
||||
|
||||
config.services.traefik = {
|
||||
|
@ -52,6 +58,7 @@ in
|
|||
## IMAP and SMTP
|
||||
entrypoints.imap.address = ":993";
|
||||
entrypoints.smtp-submission.address = ":587";
|
||||
entrypoints.smtp-submission-ssl.address = ":465";
|
||||
|
||||
# Logging
|
||||
# -------
|
||||
|
@ -62,7 +69,7 @@ in
|
|||
# ------------------
|
||||
certificatesResolvers.le.acme = {
|
||||
email = "natsukagami@gmail.com";
|
||||
storage = "/var/lib/traefik/acme.json";
|
||||
storage = cfg.certsPath;
|
||||
dnsChallenge.provider = "cloudflare";
|
||||
dnsChallenge.delayBeforeCheck = 10;
|
||||
};
|
||||
|
@ -74,6 +81,6 @@ in
|
|||
config.systemd.services.traefik.environment.CF_DNS_API_TOKEN_FILE = cfg.cloudflareKeyFile;
|
||||
|
||||
# Set up firewall to allow traefik traffic.
|
||||
config.networking.firewall.allowedTCPPorts = [ 80 443 993 587 ];
|
||||
config.networking.firewall.allowedTCPPorts = [ 80 443 993 587 465 ];
|
||||
config.networking.firewall.allowedUDPPorts = [ 443 ]; # QUIC
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
../modules/cloud/postgresql
|
||||
../modules/cloud/traefik
|
||||
../modules/cloud/bitwarden
|
||||
../modules/cloud/mail
|
||||
];
|
||||
|
||||
boot.cleanTmpDir = true;
|
||||
|
@ -54,4 +55,14 @@
|
|||
enable = true;
|
||||
usersFile = config.sops.secrets.traefik-dashboard-users.path;
|
||||
};
|
||||
cloud.traefik.certsDumper.enable = true;
|
||||
|
||||
# Mail
|
||||
sops.secrets.mail-users = { owner = "maddy"; };
|
||||
cloud.mail = {
|
||||
enable = true;
|
||||
tls.certFile = "${config.cloud.traefik.certsDumper.destination}/${config.cloud.mail.hostname}/certificate.crt";
|
||||
tls.keyFile = "${config.cloud.traefik.certsDumper.destination}/${config.cloud.mail.hostname}/privatekey.key";
|
||||
usersFile = config.sops.secrets.mail-users.path;
|
||||
};
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue