From 98f789d2c85da4dfc556b518e940ded68dc59985 Mon Sep 17 00:00:00 2001 From: Alexandre Pires Date: Sat, 15 Mar 2025 23:01:46 +0100 Subject: [PATCH] Implement mail server --- terraform/k8sapps/buckets.tf | 42 +++ terraform/k8sapps/config.tf | 50 ++- .../k8sapps/{ => disabled}/chronograf.tf.bak | 0 .../{ => disabled}/collabora.tf.disabled | 0 .../k8sapps/{ => disabled}/fluent_bit.tf.bak | 0 .../k8sapps/{ => disabled}/influxdb.tf.bak | 0 .../k8sapps/{ => disabled}/kapacitor.tf.bak | 0 .../k8sapps/{ => disabled}/monitoring.tf.bak | 0 .../k8sapps/{ => disabled}/telegraf.tf.bak | 0 terraform/k8sapps/iam.tf | 26 ++ terraform/k8sapps/m3uproxy.tf | 4 - terraform/k8sapps/mail.tf | 320 ++++++++++++++++++ terraform/k8sapps/resources/mail/dovecot.conf | 239 +++++++++++++ .../k8sapps/resources/mail/getmailrc.tpl | 19 ++ terraform/k8sapps/sectool.env | 2 + terraform/k8sapps/variables.tf | 10 + 16 files changed, 703 insertions(+), 9 deletions(-) rename terraform/k8sapps/{ => disabled}/chronograf.tf.bak (100%) rename terraform/k8sapps/{ => disabled}/collabora.tf.disabled (100%) rename terraform/k8sapps/{ => disabled}/fluent_bit.tf.bak (100%) rename terraform/k8sapps/{ => disabled}/influxdb.tf.bak (100%) rename terraform/k8sapps/{ => disabled}/kapacitor.tf.bak (100%) rename terraform/k8sapps/{ => disabled}/monitoring.tf.bak (100%) rename terraform/k8sapps/{ => disabled}/telegraf.tf.bak (100%) create mode 100644 terraform/k8sapps/mail.tf create mode 100644 terraform/k8sapps/resources/mail/dovecot.conf create mode 100644 terraform/k8sapps/resources/mail/getmailrc.tpl diff --git a/terraform/k8sapps/buckets.tf b/terraform/k8sapps/buckets.tf index bcb2cbe..5ec812e 100644 --- a/terraform/k8sapps/buckets.tf +++ b/terraform/k8sapps/buckets.tf @@ -118,3 +118,45 @@ resource "scaleway_object_bucket_policy" "nextcloud" { ] }) } + +resource "scaleway_object_bucket" "dovecot" { + name = "a13labs-dovecot-${random_string.bucket_suffix.result}" + project_id = var.scaleway_project_id + region = "fr-par" +} + +resource "scaleway_object_bucket_policy" "dovecot" { + bucket = scaleway_object_bucket.dovecot.name + policy = jsonencode({ + Version = "2023-04-17", + Id = "AllowAccess", + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:*", + ] + Principal = { + SCW = "user_id:${var.scaleway_user_id}" + } + Resource = [ + scaleway_object_bucket.dovecot.name, + "${scaleway_object_bucket.dovecot.name}/*", + ] + }, + { + Effect = "Allow" + Action = [ + "s3:*", + ] + Principal = { + SCW = "application_id:${scaleway_iam_application.dovecot_app.id}" + } + Resource = [ + scaleway_object_bucket.dovecot.name, + "${scaleway_object_bucket.dovecot.name}/*", + ] + }, + ] + }) +} diff --git a/terraform/k8sapps/config.tf b/terraform/k8sapps/config.tf index b4c68ee..bfe25c0 100644 --- a/terraform/k8sapps/config.tf +++ b/terraform/k8sapps/config.tf @@ -1,18 +1,20 @@ locals { + domain_name = "alexpires.me" + # apps wordpress_version = "6.7.2" - wordpress_fqdns = ["blog.alexpires.me", "www.alexpires.me", "alexpires.me"] + wordpress_fqdns = ["blog.${local.domain_name}", "www.${local.domain_name}", "${local.domain_name}"] wordpress_sql_backup_cron_schedule = "0 2 * * *" wordpress_files_backup_cron_schedule = "10 2 * * *" gitea_version = "1.23.4" - gitea_fqdn = "code.alexpires.me" + gitea_fqdn = "code.${local.domain_name}" gitea_sql_backup_cron_schedule = "20 2 * * *" gitea_files_backup_cron_schedule = "30 2 * * *" nextcloud_version = "30.0.0-apache" - nextcloud_fqdn = "cloud.alexpires.me" + nextcloud_fqdn = "cloud.${local.domain_name}" nextcloud_sql_backup_cron_schedule = "40 2 * * *" nextcloud_files_backup_cron_schedule = "50 2 * * *" @@ -27,7 +29,7 @@ locals { m3uproxy_version = "latest" wireguard_version = "1.0.20210914-r4-ls54" squid_version = "6.1-23.10_edge" - m3uproxy_fqdn = "tv.alexpires.me" + m3uproxy_fqdn = "tv.${local.domain_name}" geoip_image = "maxmindinc/geoipupdate" geoip_version = "latest" @@ -40,7 +42,7 @@ locals { # rustdesk rustdesk_version = "latest" - rustdesk_fqdn = "rustdesk.alexpires.me" + rustdesk_fqdn = "rustdesk.${local.domain_name}" # backup local_backup_retention_days = 2 @@ -50,6 +52,44 @@ locals { scaleway_smtp_host = "smtp.tem.scw.cloud" scaleway_smtp_port = 587 + # mail settings + getmail_version = "v0.0.1-6.19.07" + fetch_emails_schedule = "*/2 * * * *" + email_accounts = [ + { + domain = "alexpires.me" + recipient = "c.alexandre.pires" + receive = { + type = "imap" + server = "mail-pt.securemail.pro" + port = 993 + } + send = { + server = "smtp-pt.securemail.pro" + port = 465 + } + username = var.alexpires_me_email_username + password = var.alexpires_me_email_password + mailboxes = ["INBOX", "Sent", "Drafts", "Trash", "Spam"] + } + ] + + getmail_jobs = flatten([ + for account in local.email_accounts : [ + for mailbox in account.mailboxes : { + name = "${account.recipient}-${mailbox}" + recipient = "${account.recipient}+${mailbox}" + domain = account.domain + server = account.receive.server + port = account.receive.port + username = account.username + password = account.password + mailbox = mailbox + delete_after = try(account.delete_after, 30) + } + ] + ]) + # storage persistent_folder = "/mnt/microk8s_persistent" database_folder = "/var/lib" diff --git a/terraform/k8sapps/chronograf.tf.bak b/terraform/k8sapps/disabled/chronograf.tf.bak similarity index 100% rename from terraform/k8sapps/chronograf.tf.bak rename to terraform/k8sapps/disabled/chronograf.tf.bak diff --git a/terraform/k8sapps/collabora.tf.disabled b/terraform/k8sapps/disabled/collabora.tf.disabled similarity index 100% rename from terraform/k8sapps/collabora.tf.disabled rename to terraform/k8sapps/disabled/collabora.tf.disabled diff --git a/terraform/k8sapps/fluent_bit.tf.bak b/terraform/k8sapps/disabled/fluent_bit.tf.bak similarity index 100% rename from terraform/k8sapps/fluent_bit.tf.bak rename to terraform/k8sapps/disabled/fluent_bit.tf.bak diff --git a/terraform/k8sapps/influxdb.tf.bak b/terraform/k8sapps/disabled/influxdb.tf.bak similarity index 100% rename from terraform/k8sapps/influxdb.tf.bak rename to terraform/k8sapps/disabled/influxdb.tf.bak diff --git a/terraform/k8sapps/kapacitor.tf.bak b/terraform/k8sapps/disabled/kapacitor.tf.bak similarity index 100% rename from terraform/k8sapps/kapacitor.tf.bak rename to terraform/k8sapps/disabled/kapacitor.tf.bak diff --git a/terraform/k8sapps/monitoring.tf.bak b/terraform/k8sapps/disabled/monitoring.tf.bak similarity index 100% rename from terraform/k8sapps/monitoring.tf.bak rename to terraform/k8sapps/disabled/monitoring.tf.bak diff --git a/terraform/k8sapps/telegraf.tf.bak b/terraform/k8sapps/disabled/telegraf.tf.bak similarity index 100% rename from terraform/k8sapps/telegraf.tf.bak rename to terraform/k8sapps/disabled/telegraf.tf.bak diff --git a/terraform/k8sapps/iam.tf b/terraform/k8sapps/iam.tf index 681eead..3b57a50 100644 --- a/terraform/k8sapps/iam.tf +++ b/terraform/k8sapps/iam.tf @@ -10,6 +10,10 @@ resource "scaleway_iam_application" "nextcloud_app" { name = "nextcloud_app" } +resource "scaleway_iam_application" "dovecot_app" { + name = "dovecot_app" +} + resource "scaleway_iam_policy" "object_read_write_backup" { name = "K8s backup app policy" description = "Gives k8s backup job access to object storage in project" @@ -56,6 +60,23 @@ resource "scaleway_iam_policy" "object_read_write_nextcloud" { } } +resource "scaleway_iam_policy" "object_read_write_dovecot" { + name = "Dovecot app policy" + description = "Gives dovecot job access to object storage in project" + application_id = scaleway_iam_application.dovecot_app.id + + rule { + project_ids = [data.scaleway_account_project.default.id] + permission_set_names = [ + "ObjectStorageObjectsWrite", + "ObjectStorageObjectsRead", + "ObjectStorageBucketsRead", + "ObjectStorageBucketsWrite", + "ObjectStorageObjectsDelete" + ] + } +} + resource "scaleway_iam_api_key" "backup_app_api" { application_id = scaleway_iam_application.backup_app.id description = "k8s backup app API key" @@ -70,3 +91,8 @@ resource "scaleway_iam_api_key" "nextcloud_app_api" { application_id = scaleway_iam_application.nextcloud_app.id description = "nextcloud access API key" } + +resource "scaleway_iam_api_key" "dovecot_app_api" { + application_id = scaleway_iam_application.dovecot_app.id + description = "dovecot access API key" +} diff --git a/terraform/k8sapps/m3uproxy.tf b/terraform/k8sapps/m3uproxy.tf index 165e9c0..28f1a93 100644 --- a/terraform/k8sapps/m3uproxy.tf +++ b/terraform/k8sapps/m3uproxy.tf @@ -16,7 +16,6 @@ locals { m3uproxy_config = templatefile("${path.module}/resources/m3uproxy/m3uproxy.json", { m3uproxy_secret = var.m3uproxy_secret }) - } resource "kubernetes_namespace" "m3uproxy" { @@ -24,14 +23,11 @@ resource "kubernetes_namespace" "m3uproxy" { annotations = { name = "m3uproxy" } - labels = { name = "m3uproxy" } - name = "m3uproxy" } - } resource "kubernetes_secret" "m3uproxy_admin_credentials" { diff --git a/terraform/k8sapps/mail.tf b/terraform/k8sapps/mail.tf new file mode 100644 index 0000000..d53b801 --- /dev/null +++ b/terraform/k8sapps/mail.tf @@ -0,0 +1,320 @@ +locals { + mail_home = "${local.persistent_folder}/mail" + mailboxes_path = "${local.mail_home}/mailboxes" + getmail_path = "${local.mail_home}/getmail" +} + +resource "kubernetes_namespace" "mail" { + metadata { + annotations = { + name = "mail" + } + labels = { + name = "mail" + } + name = "mail" + } +} + +resource "kubernetes_secret" "dovecot_users" { + metadata { + name = "dovecot-users" + namespace = kubernetes_namespace.mail.metadata[0].name + } + data = { + "users" = join("\n", [for account in local.email_accounts : "${account.username}:{plain}${account.password}:1000:1000::/var/mail/${account.username}::"]) + } +} + +resource "kubernetes_service_account" "mail_service_account" { + + metadata { + name = "mail-app-sa" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + automount_service_account_token = false +} + +resource "kubernetes_config_map" "getmail_config" { + metadata { + name = "getmail-config" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + data = { for job in local.getmail_jobs : "getmailrc_${job.name}" => + + templatefile("${path.module}/resources/mail/getmailrc.tpl", { + recipient = job.recipient + domain = job.domain + server = job.server + port = job.port + username = job.username + password = job.password + mailbox = job.mailbox + delete_after = job.delete_after + dovecot_lmtp_address = "dovecot-lmtp.mail.svc.cluster.local" + dovecot_lmtp_port = 31024 + }) + } +} + +resource "kubernetes_config_map" "dovecot_config" { + metadata { + name = "dovecot-config" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + data = { + "dovecot.conf" = file("${path.module}/resources/mail/dovecot.conf") + } +} + +resource "kubernetes_config_map" "dovecot_sieve" { + metadata { + name = "dovecot-sieve" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + data = { + "sieve" = file("${path.module}/resources/mail/sieve") + } +} + +resource "kubernetes_manifest" "mail_certificate" { + manifest = { + apiVersion = "cert-manager.io/v1" + kind = "Certificate" + metadata = { + name = "mail-cert" + namespace = kubernetes_namespace.mail.metadata[0].name + } + spec = { + secretName = "mail-tls" + issuerRef = { + name = "letsencrypt-prod" + kind = "ClusterIssuer" + } + commonName = "imap.${local.domain_name}" + dnsNames = ["imap.${local.domain_name}", "smtp.${local.domain_name}"] + } + } +} + +resource "kubernetes_cron_job_v1" "fetch_emails" { + metadata { + namespace = kubernetes_namespace.mail.metadata[0].name + name = "fetch-emails" + } + + spec { + schedule = local.fetch_emails_schedule + + job_template { + metadata { + name = "fetch-emails" + } + spec { + backoff_limit = 1 + parallelism = 1 + completions = 1 + active_deadline_seconds = 300 + + template { + metadata { + labels = { + app = "fetch-emails" + } + } + + spec { + container { + name = "getmail" + image = "a13labs/getmail6:${local.getmail_version}" + args = concat(["--getmaildir", "/var/getmail"], flatten([for job in local.getmail_jobs : ["-r", "/getmail/getmailrc_${job.name}"]])) + + volume_mount { + name = "getmail-config" + mount_path = "/getmail" + read_only = true + } + + security_context { + run_as_user = 1000 + run_as_group = 1000 + } + + volume_mount { + name = "getmail-cache" + mount_path = "/var/getmail" + } + } + + + restart_policy = "Never" + + volume { + name = "getmail-config" + config_map { + name = "getmail-config" + } + } + + volume { + name = "getmail-cache" + host_path { + path = local.getmail_path + } + } + } + } + } + } + } +} + +resource "kubernetes_deployment" "mail" { + metadata { + namespace = kubernetes_namespace.mail.metadata[0].name + name = "mail" + labels = { + app = "mail" + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = "mail" + } + } + template { + metadata { + labels = { + app = "mail" + } + } + spec { + service_account_name = kubernetes_service_account.mail_service_account.metadata[0].name + container { + name = "dovecot" + image = "dovecot/dovecot:latest" + port { + container_port = 31993 + host_port = 993 + } + port { + container_port = 31024 + } + volume_mount { + name = "email-storage" + mount_path = "/var/mail" + } + volume_mount { + name = "users" + mount_path = "/etc/dovecot/users" + sub_path = "users" + read_only = true + } + volume_mount { + name = "dovecot-config" + mount_path = "/etc/dovecot/dovecot.conf" + sub_path = "dovecot.conf" + read_only = true + } + volume_mount { + name = "dovecot-sieve" + mount_path = "/etc/dovecot/sieve/default.sieve" + sub_path = "sieve" + read_only = true + } + volume_mount { + name = "certs" + mount_path = "/etc/letsencrypt" + read_only = true + } + } + volume { + name = "email-storage" + host_path { + path = local.mailboxes_path + } + } + volume { + name = "users" + secret { + secret_name = kubernetes_secret.dovecot_users.metadata[0].name + } + } + volume { + name = "dovecot-config" + config_map { + name = kubernetes_config_map.dovecot_config.metadata[0].name + } + } + volume { + name = "certs" + secret { + secret_name = "mail-tls" + } + } + volume { + name = "dovecot-sieve" + config_map { + name = kubernetes_config_map.dovecot_sieve.metadata[0].name + } + } + } + } + } + depends_on = [kubernetes_manifest.mail_certificate, kubernetes_service_account.mail_service_account] + lifecycle { + replace_triggered_by = [ + kubernetes_config_map.dovecot_config, + kubernetes_secret.dovecot_users, + kubernetes_config_map.dovecot_sieve, + ] + } +} + +resource "kubernetes_service" "dovecot" { + metadata { + namespace = kubernetes_namespace.mail.metadata[0].name + name = "mail" + } + spec { + selector = { + app = "mail" + } + port { + name = "imap" + port = 993 + target_port = 31993 + } + + type = "LoadBalancer" + } + + lifecycle { + ignore_changes = [ + metadata[0].annotations + ] + } +} + +resource "kubernetes_service" "dovecot_lmtp" { + metadata { + name = "dovecot-lmtp" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + spec { + selector = { + app = "mail" + } + port { + port = 31024 + } + cluster_ip = "None" + } +} diff --git a/terraform/k8sapps/resources/mail/dovecot.conf b/terraform/k8sapps/resources/mail/dovecot.conf new file mode 100644 index 0000000..493b3d3 --- /dev/null +++ b/terraform/k8sapps/resources/mail/dovecot.conf @@ -0,0 +1,239 @@ +dovecot_config_version = "2.4.0" +dovecot_storage_version = "2.4.0" + +base_dir = /run/dovecot +state_dir = /run/dovecot + +protocols = imap lmtp +listen = * + +mail_driver=maildir +mailbox_list_layout=index +mailbox_list_utf8=yes +mail_path=~/mail +mail_home=/var/mail/%{user|lower} +mail_utf8_extensions = yes + +default_internal_user = vmail +default_login_user = vmail +default_internal_group = vmail + +mail_uid = vmail +mail_gid = vmail + +namespace inbox { + inbox = yes + + mailbox Drafts { + special_use = \Drafts + auto = create + auto = subscribe + } + + mailbox Spam { + special_use = \Spam + auto = create + auto = subscribe + } + + mailbox Trash { + special_use = \Trash + auto = create + auto = subscribe + } + + mailbox Sent { + special_use = \Sent + auto = create + auto = subscribe + } + + mailbox Archive { + special_use = \Archive + auto = create + auto = subscribe + } + + mailbox Important { + special_use = \Important + comment = All my important messages + auto = create + auto = subscribe + } +} + +passdb passwd-file { + driver = passwd-file + passwd_file_path = /etc/dovecot/users +} + +ssl_server { + cert_file = /etc/letsencrypt/tls.crt + key_file = /etc/letsencrypt/tls.key +} + +mail_attribute { + dict file { + path = %{home}/dovecot-attributes + } +} + +log_path = /dev/stdout + +imap_hibernate_timeout = 5s + +mail_plugins { + fts = yes + fts_flatcurve = yes + mail_log = yes + notify = yes +} + +mail_log_events = delete undelete expunge save copy mailbox_create mailbox_delete mailbox_rename flag_change + +fts_autoindex = yes +fts_autoindex_max_recent_msgs = 999 +fts_search_add_missing = yes +language_filters = normalizer-icu snowball stopwords + +language_tokenizers = generic email-address +language_tokenizer_generic_algorithm = simple + +language en { + default = yes + filters = lowercase snowball english-possessive stopwords +} + +fts flatcurve { + substring_search = yes +} + +protocol imap { + mail_plugins { + imap_sieve = yes + imap_filter_sieve = yes + } +} + +lmtp_save_to_detail_mailbox = yes +recipient_delimiter = + + +protocol lmtp { + mail_plugins { + sieve = yes + } +} + +service imap-login { + process_min_avail = 1 + client_limit = 100 + inet_listener imap { + port = 31143 + } + inet_listener imaps { + port = 31993 + ssl = yes + } +} + +service stats { + process_min_avail = 1 + inet_listener http { + port = 9090 + } +} + +service auth { + inet_listener auth { + port = 31000 + } +} + +# LMTP service to store sent emails +service lmtp { + inet_listener auth { + port = 31024 + } + mail_plugins { + sieve = yes + } +} + +event_exporter log { + format = json + time_format = rfc3339 +} + +metric auth_success { + filter = (event=auth_request_finished AND success=yes) +} + +metric auth_failure { + filter = (event=auth_request_finished AND NOT success=yes) + exporter = log +} + +metric imap_command { + filter = event=imap_command_finished + group_by cmd_name { + method discrete { + } + } + group_by tagged_reply_state { + method discrete { + } + } +} + +metric smtp_command { + filter = event=smtp_server_command_finished and protocol=submission + group_by cmd_name { + method discrete { + } + } + group_by status_code { + method discrete { + } + } + group_by duration { + method exponential { + base = 10 + min_magnitude = 1 + max_magnitude = 5 + } + } +} + +metric lmtp_command { + filter = event=smtp_server_command_finished and protocol=lmtp + group_by cmd_name { + method discrete { + } + } + group_by status_code { + method discrete { + } + } + group_by duration { + method exponential { + base = 10 + min_magnitude = 1 + max_magnitude = 5 + } + } +} + +metric mail_delivery { + filter = event=mail_delivery_finished + group_by duration { + method exponential { + base = 10 + min_magnitude = 1 + max_magnitude = 5 + } + } +} + +sieve_script default { + path = /etc/dovecot/sieve/default.sieve +} diff --git a/terraform/k8sapps/resources/mail/getmailrc.tpl b/terraform/k8sapps/resources/mail/getmailrc.tpl new file mode 100644 index 0000000..7974a6f --- /dev/null +++ b/terraform/k8sapps/resources/mail/getmailrc.tpl @@ -0,0 +1,19 @@ +[retriever] +type = SimpleIMAPSSLRetriever +server = ${server} +port = ${port} +username = ${username} +password = ${password} +mailboxes = ('${mailbox}',) + +[destination] +type = MDA_lmtp +host = ${dovecot_lmtp_address} +port = ${dovecot_lmtp_port} +override = ${recipient}@${domain} + +[options] +read_all = true +delete_after = ${delete_after} +verbose = 1 + diff --git a/terraform/k8sapps/sectool.env b/terraform/k8sapps/sectool.env index 0687b9f..c7dab3b 100644 --- a/terraform/k8sapps/sectool.env +++ b/terraform/k8sapps/sectool.env @@ -19,3 +19,5 @@ TF_VAR_maxmind_account_id=$MAXMIND_ACCOUNT_ID TF_VAR_maxmind_license_key=$MAXMIND_LICENSE_KEY TF_VAR_rustdesk_private_key=$RUSTDESK_PRIVATE_KEY TF_VAR_rustdesk_public_key=$RUSTDESK_PUBLIC_KEY +TF_VAR_alexpires_me_email_username=$ALEXPIRES_ME_EMAIL_USERNAME +TF_VAR_alexpires_me_email_password=$ALEXPIRES_ME_EMAIL_PASSWORD diff --git a/terraform/k8sapps/variables.tf b/terraform/k8sapps/variables.tf index 85190d8..eb4a8c9 100644 --- a/terraform/k8sapps/variables.tf +++ b/terraform/k8sapps/variables.tf @@ -89,3 +89,13 @@ variable "rustdesk_public_key" { description = "Rustdesk public key" type = string } + +variable "alexpires_me_email_username" { + description = "POP3 username" + type = string +} + +variable "alexpires_me_email_password" { + description = "POP3 password" + type = string +}