commit edbd57893b6909fb991d0a1526e652be29ef046f Author: Jim Myhrberg Date: Wed Apr 26 01:02:08 2023 +0100 feat(module): initial implementation diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..366404a --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 Jim Myhrberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..32170e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: docs +.SILENT: docs +docs: + terraform-docs markdown . diff --git a/README.md b/README.md new file mode 100644 index 0000000..02665bb --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +

+ terraform-cloudflare-email +

+ +

+ + Terraform module to configure various email related DNS records on + Cloudflare. + +

+ +

+ + GitHub tag (latest SemVer) + + + GitHub issues + + + GitHub pull requests + + + License Status + +

+ +Module that configures various email related DNS records on Cloudflare, +including serving a MTA-STS policy text file via Cloudflare Workers. + +## Features + +- Configure MX records. +- Configure SPF record. +- Configure DMARC record. +- Configure SMTP TLS reporting record. +- Configure MTA-STS record, generate `mta-sts.txt` policy file and serve it with + a Cloudflare Worker on + `https://mta-sts./.well-known/mta-sts.txt`. +- Configure domain key records (`._domainkey.`). + +## Requirements + +| Name | Version | +| --------------------------------------------------------------------------- | ------------- | +| [cloudflare](#requirement_cloudflare) | >= 3.0, < 5.0 | + +## Providers + +| Name | Version | +| --------------------------------------------------------------------- | ------------- | +| [cloudflare](#provider_cloudflare) | >= 3.0, < 5.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| [cloudflare_record.dmarc](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.domainkeys](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.mta-sts-a](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.mta-sts-aaaa](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.mta_sts](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.mx](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.smtp_tls](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_record.spf](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/record) | resource | +| [cloudflare_worker_route.mta_sts_route](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/worker_route) | resource | +| [cloudflare_worker_script.mta_sts](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/worker_script) | resource | +| [cloudflare_workers_kv.mta_sts](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/workers_kv) | resource | +| [cloudflare_workers_kv_namespace.mta_sts](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/workers_kv_namespace) | resource | +| [cloudflare_zone.zone](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/data-sources/zone) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | :------: | +| [account_id](#input_account_id) | Cloudflare Account ID | `string` | n/a | yes | +| [dmarc_dkim_mode](#input_dmarc_dkim_mode) | The DMARC DKIM mode for alignment (options: `relaxed`, `strict`). | `string` | `"relaxed"` | no | +| [dmarc_fo](#input_dmarc_fo) | Failure reporting options for DMARC (characters: `0`, `1`, `d`, `s`, separated by `:`). | `string` | `"1:d:s"` | no | +| [dmarc_percent](#input_dmarc_percent) | Percentage of messages to apply the DMARC policy to (0-100). | `number` | `100` | no | +| [dmarc_policy](#input_dmarc_policy) | The DMARC policy to apply (options: `none`, `quarantine`, `reject`). | `string` | `"none"` | no | +| [dmarc_rua](#input_dmarc_rua) | Where aggregate DMARC reports about policy violations should be sent. | `list(string)` | n/a | yes | +| [dmarc_ruf](#input_dmarc_ruf) | Where failure/forensic DMARC reports about policy violations should be sent. | `list(string)` | `[]` | no | +| [dmarc_spf_mode](#input_dmarc_spf_mode) | The DMARC SPF mode for alignment (options: `relaxed`, `strict`). | `string` | `"relaxed"` | no | +| [dmarc_ttl](#input_dmarc_ttl) | TTL for `_dmarc` DNS record. `1` is auto. Default is `1`. | `number` | `1` | no | +| [domainkeys](#input_domainkeys) | Map of domain keys with name, record type (`TXT` or `CNAME`), and value. |
map(object({
type = string
value = string
}))
| `{}` | no | +| [mta_sts_max_age](#input_mta_sts_max_age) | Maximum lifetime of the policy in seconds, up to 31557600, defaults to 604800 (1 week) | `number` | `604800` | no | +| [mta_sts_mode](#input_mta_sts_mode) | MTA policy mode, https://tools.ietf.org/html/rfc8461#section-5 | `string` | `"testing"` | no | +| [mta_sts_mx](#input_mta_sts_mx) | Additional permitted MX hosts for the MTA STS policy. | `list(string)` | `[]` | no | +| [mx](#input_mx) | A map representing the MX records. Key is the priority and value is the mail server hostname. | `map(number)` | n/a | yes | +| [mx_subdomains](#input_mx_subdomains) | List of sub-domains to also apply MX records to. | `list(string)` | `[]` | no | +| [record_ttl](#input_record_ttl) | TTL for DNS records. `1` is auto. Default is `1`. | `number` | `1` | no | +| [spf_terms](#input_spf_terms) | List of SPF terms that should be included in the SPF TXT record. | `list(string)` |
[
"mx",
"a",
"~all"
]
| no | +| [tlsrpt_rua](#input_tlsrpt_rua) | Locations to which aggregate TLS SMTP reports about policy violations should be sent, either `mailto:` or `https:` schema. | `list(string)` | n/a | yes | +| [zone_id](#input_zone_id) | Cloudflare Zone ID | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ----------------------------------------------------------------------------------------- | ------------------------------- | +| [mta_sts_policy_url](#output_mta_sts_policy_url) | URL to the MTA-STS policy file. | diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..5287c1a --- /dev/null +++ b/main.tf @@ -0,0 +1,189 @@ +# +# General +# + +data "cloudflare_zone" "zone" { + account_id = var.account_id + zone_id = var.zone_id +} + +locals { + zone_name = data.cloudflare_zone.zone.name +} + +# +# MX +# + +locals { + mx_sets = flatten([ + for name in concat([local.zone_name], var.mx_subdomains) : [ + for mx, priority in var.mx : { + name = name + mx = mx + priority = priority + } if name != "" + ] + ]) + mx_records = { + for v in local.mx_sets : + "${v.name == local.zone_name ? "" : "${v.name}:"}${v.mx}" => v + } +} + +resource "cloudflare_record" "mx" { + for_each = local.mx_records + + name = each.value.name + priority = each.value.priority + proxied = false + ttl = var.record_ttl + type = "MX" + value = each.value.mx + zone_id = var.zone_id +} + +# +# SPF +# + +resource "cloudflare_record" "spf" { + name = local.zone_name + proxied = false + ttl = var.record_ttl + type = "TXT" + value = join(" ", concat(["v=spf1"], var.spf_terms)) + zone_id = var.zone_id +} + +# +# TLS SMTP +# + +resource "cloudflare_record" "smtp_tls" { + name = "_smtp._tls" + type = "TXT" + value = "v=TLSRPTv1; rua=${join(",", var.tlsrpt_rua)}" + zone_id = var.zone_id +} + +# +# MTA-STS +# + +locals { + policy = templatefile("${path.module}/mta-sts.txt.tpl", { + mode = var.mta_sts_mode + max_age = var.mta_sts_max_age + mx = sort(distinct(concat(keys(var.mx), var.mta_sts_mx))) + }) + policy_sha = sha1(local.policy) +} + +resource "cloudflare_record" "mta-sts-a" { + name = "mta-sts" + proxied = true + ttl = var.record_ttl + type = "A" + value = "192.0.2.1" + zone_id = var.zone_id +} + +resource "cloudflare_record" "mta-sts-aaaa" { + name = "mta-sts" + proxied = true + ttl = var.record_ttl + type = "AAAA" + value = "100::" + zone_id = var.zone_id +} + +resource "cloudflare_record" "mta_sts" { + name = "_mta-sts" + ttl = var.record_ttl + type = "TXT" + value = "v=STSv1; id=${local.policy_sha}" + zone_id = var.zone_id +} + +resource "cloudflare_workers_kv_namespace" "mta_sts" { + title = "mta-sts.${local.zone_name}" + account_id = var.account_id +} + +resource "cloudflare_workers_kv" "mta_sts" { + namespace_id = cloudflare_workers_kv_namespace.mta_sts.id + key = "mta-sts.txt" + value = local.policy + account_id = var.account_id +} + +resource "cloudflare_worker_script" "mta_sts" { + name = "mta-sts-${replace(local.zone_name, "/[^A-Za-z0-9-]/", "-")}" + content = file("${path.module}/mta-sts.js") + account_id = var.account_id + + kv_namespace_binding { + name = "FILES" + namespace_id = cloudflare_workers_kv_namespace.mta_sts.id + } +} + +resource "cloudflare_worker_route" "mta_sts_route" { + pattern = "mta-sts.${local.zone_name}/*" + script_name = cloudflare_worker_script.mta_sts.name + zone_id = var.zone_id +} + +# +# DMARC +# + +locals { + dmarc_modes = { + "relaxed" = "r" + "strict" = "s" + } + dmarc_values = { + "rua" = join(",", compact(var.dmarc_rua)) + "ruf" = join(",", compact(var.dmarc_ruf)) + } +} + +resource "cloudflare_record" "dmarc" { + name = "_dmarc" + proxied = false + ttl = floor(var.dmarc_ttl) + type = "TXT" + value = join(" ", flatten([ + "v=DMARC1;", + "p=${var.dmarc_policy};", + "pct=${floor(var.dmarc_percent)};", + "aspf=${local.dmarc_modes[var.dmarc_spf_mode]};", + "adkim=${local.dmarc_modes[var.dmarc_dkim_mode]};", + [ + for k, v in local.dmarc_values : + "${k}=${v};" if trimspace(v) != "" + ], + [ + for v in [var.dmarc_fo] : + "fo=${v};" if trimspace(local.dmarc_values["ruf"]) != "" + ], + ])) + zone_id = var.zone_id +} + +# +# Domain Keys (DKIM) +# + +resource "cloudflare_record" "domainkeys" { + for_each = var.domainkeys + + name = "${each.key}._domainkey" + proxied = false + ttl = var.record_ttl + type = upper(each.value.type) + value = each.value.value + zone_id = var.zone_id +} diff --git a/mta-sts.js b/mta-sts.js new file mode 100644 index 0000000..551c793 --- /dev/null +++ b/mta-sts.js @@ -0,0 +1,17 @@ +addEventListener('fetch', (event) => { + event.respondWith(handleRequest(event.request)); +}); + +async function handleRequest(request) { + const url = new URL(request.url); + + if (url.pathname === '/.well-known/mta-sts.txt') { + const response = await FILES.get('mta-sts.txt'); + + if (response) { + return new Response(response, { status: 200 }); + } + } + + return new Response('Not found', { status: 404 }); +} diff --git a/mta-sts.txt.tpl b/mta-sts.txt.tpl new file mode 100644 index 0000000..f02ba0e --- /dev/null +++ b/mta-sts.txt.tpl @@ -0,0 +1,6 @@ +version: STSv1 +mode: ${mode} +max_age: ${max_age} +%{for hostname in mx ~} +mx: ${hostname} +%{endfor ~} diff --git a/output.tf b/output.tf new file mode 100644 index 0000000..974e2f4 --- /dev/null +++ b/output.tf @@ -0,0 +1,4 @@ +output "mta_sts_policy_url" { + value = "https://mta-sts.${local.zone_name}/.well-known/mta-sts.txt" + description = "URL to the MTA-STS policy file." +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..285a419 --- /dev/null +++ b/variables.tf @@ -0,0 +1,245 @@ +# +# General +# + +variable "account_id" { + type = string + description = "Cloudflare Account ID" +} + +variable "zone_id" { + type = string + description = "Cloudflare Zone ID" +} + +variable "record_ttl" { + type = number + default = 1 + nullable = false + description = "TTL for DNS records. `1` is auto. Default is `1`." +} + +# +# MX +# + +variable "mx" { + type = map(number) + description = "A map representing the MX records. Key is the priority and value is the mail server hostname." + + validation { + condition = length(var.mx) > 0 + error_message = "At least one MX record is required." + } +} + +variable "mx_subdomains" { + type = list(string) + description = "List of sub-domains to also apply MX records to." + default = [] +} + +# +# SPF +# + +variable "spf_terms" { + type = list(string) + default = ["mx", "a", "~all"] + description = "List of SPF terms that should be included in the SPF TXT record." +} + +# +# TLS SMTP +# + +variable "tlsrpt_rua" { + type = list(string) + description = "Locations to which aggregate TLS SMTP reports about policy violations should be sent, either `mailto:` or `https:` schema." + + validation { + condition = length(var.tlsrpt_rua) != 0 + error_message = "At least one `mailto:` or `https:` endpoint provided." + } + + validation { + condition = can([ + for loc in var.tlsrpt_rua : regex("^(mailto|https):", loc) + ]) + error_message = "Locations must start with either the `mailto:` or `https` schema." + } +} + +# +# MTA-STS +# + +variable "mta_sts_mode" { + type = string + default = "testing" + description = "MTA policy mode, https://tools.ietf.org/html/rfc8461#section-5" + + validation { + condition = contains(["enforce", "testing", "none"], var.mta_sts_mode) + error_message = "Must be `enforce`, `testing`, or `none`." + } +} + +variable "mta_sts_max_age" { + type = number + default = 604800 # 1 week + description = "Maximum lifetime of the policy in seconds, up to 31557600, defaults to 604800 (1 week)" + + validation { + condition = var.mta_sts_max_age >= 0 + error_message = "Policy validity time must be positive." + } + + validation { + condition = var.mta_sts_max_age <= 31557600 + error_message = "Policy validity time must be less than 1 year (31557600 seconds)." + } +} + +variable "mta_sts_mx" { + type = list(string) + default = [] + description = "Additional permitted MX hosts for the MTA STS policy." +} + +# +# DMARC +# + +variable "dmarc_policy" { + type = string + default = "none" + description = "The DMARC policy to apply (options: `none`, `quarantine`, `reject`)." + + validation { + condition = contains(["none", "quarantine", "reject"], var.dmarc_policy) + error_message = "Must be `none`, `quarantine`, or `reject`." + } +} + +variable "dmarc_spf_mode" { + type = string + default = "relaxed" + description = "The DMARC SPF mode for alignment (options: `relaxed`, `strict`)." + + validation { + condition = contains(["relaxed", "strict"], var.dmarc_spf_mode) + error_message = "Must be `relaxed` or `strict`." + } +} + +variable "dmarc_dkim_mode" { + type = string + default = "relaxed" + description = "The DMARC DKIM mode for alignment (options: `relaxed`, `strict`)." + + validation { + condition = contains(["relaxed", "strict"], var.dmarc_dkim_mode) + error_message = "Must be `relaxed` or `strict`." + } +} + +variable "dmarc_percent" { + type = number + default = 100 + description = "Percentage of messages to apply the DMARC policy to (0-100)." + + validation { + condition = var.dmarc_percent > 0 && var.dmarc_percent <= 100 + error_message = "Must be between 0 and 100." + } +} + +variable "dmarc_ttl" { + type = number + default = 1 + description = "TTL for `_dmarc` DNS record. `1` is auto. Default is `1`." + + validation { + condition = var.dmarc_ttl > 0 && var.dmarc_ttl <= 604800 + error_message = "Must be between 1 and 604800." + } +} + +variable "dmarc_rua" { + type = list(string) + description = "Where aggregate DMARC reports about policy violations should be sent." + + validation { + condition = length(var.dmarc_rua) != 0 + error_message = "At least one `mailto:` endpoint must be provided." + } + + validation { + condition = can([ + for loc in var.dmarc_rua : regex("^mailto:.+", loc) + ]) + error_message = "All must start with `mailto:`." + } +} + +variable "dmarc_ruf" { + type = list(string) + default = [] + description = "Where failure/forensic DMARC reports about policy violations should be sent." + + validation { + condition = can([ + for loc in var.dmarc_ruf : regex("^mailto:.+", loc) + ]) + error_message = "All must start with `mailto:`." + } +} + +variable "dmarc_fo" { + type = string + default = "1:d:s" + description = "Failure reporting options for DMARC (characters: `0`, `1`, `d`, `s`, separated by `:`)." + + validation { + condition = alltrue([ + for v in split(":", var.dmarc_fo) : contains(["0", "1", "d", "s"], v) + ]) + error_message = "Only `0`, `1`, `d`, and `s` are supported, separated by `:`." + } +} + +# +# Domain Keys (DKIM) +# + +variable "domainkeys" { + type = map(object({ + type = string + value = string + })) + default = {} + description = "Map of domain keys with name, record type (`TXT` or `CNAME`), and value." + + validation { + condition = alltrue([ + for name, dk in var.domainkeys : trimspace(name) != "" + ]) + error_message = "Domain key name cannot be empty." + } + + validation { + condition = alltrue([ + for name, dk in var.domainkeys : + contains(["TXT", "CNAME"], upper(dk.type)) + ]) + error_message = "Domain key type must be `TXT` or `CNAME`." + } + + validation { + condition = alltrue([ + for name, dk in var.domainkeys : trimspace(dk.value) != "" + ]) + error_message = "Domain key value cannot be empty." + } +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..fd4fd50 --- /dev/null +++ b/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = ">= 3.0, < 5.0" + } + } +}