feat(module): initial implementation

This commit is contained in:
2023-04-26 01:02:08 +01:00
commit edbd57893b
9 changed files with 596 additions and 0 deletions

20
LICENSE Normal file
View File

@@ -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.

4
Makefile Normal file
View File

@@ -0,0 +1,4 @@
.PHONY: docs
.SILENT: docs
docs:
terraform-docs markdown .

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
<h1 align="center">
terraform-cloudflare-email
</h1>
<p align="center">
<strong>
Terraform module to configure various email related DNS records on
Cloudflare.
</strong>
</p>
<p align="center">
<a href="https://github.com/jimeh/terraform-cloudflare-email/releases">
<img src="https://img.shields.io/github/v/tag/jimeh/terraform-cloudflare-email?label=release" alt="GitHub tag (latest SemVer)">
</a>
<a href="https://github.com/jimeh/terraform-cloudflare-email/issues">
<img src="https://img.shields.io/github/issues-raw/jimeh/terraform-cloudflare-email.svg?style=flat&logo=github&logoColor=white" alt="GitHub issues">
</a>
<a href="https://github.com/jimeh/terraform-cloudflare-email/pulls">
<img src="https://img.shields.io/github/issues-pr-raw/jimeh/terraform-cloudflare-email.svg?style=flat&logo=github&logoColor=white" alt="GitHub pull requests">
</a>
<a href="https://github.com/jimeh/terraform-cloudflare-email/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/jimeh/terraform-cloudflare-email.svg?style=flat" alt="License Status">
</a>
</p>
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.<your-domain>/.well-known/mta-sts.txt`.
- Configure domain key records (`<selector>._domainkey.<your-domain>`).
## Requirements
| Name | Version |
| --------------------------------------------------------------------------- | ------------- |
| <a name="requirement_cloudflare"></a> [cloudflare](#requirement_cloudflare) | >= 3.0, < 5.0 |
## Providers
| Name | Version |
| --------------------------------------------------------------------- | ------------- |
| <a name="provider_cloudflare"></a> [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 |
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | :------: |
| <a name="input_account_id"></a> [account_id](#input_account_id) | Cloudflare Account ID | `string` | n/a | yes |
| <a name="input_dmarc_dkim_mode"></a> [dmarc_dkim_mode](#input_dmarc_dkim_mode) | The DMARC DKIM mode for alignment (options: `relaxed`, `strict`). | `string` | `"relaxed"` | no |
| <a name="input_dmarc_fo"></a> [dmarc_fo](#input_dmarc_fo) | Failure reporting options for DMARC (characters: `0`, `1`, `d`, `s`, separated by `:`). | `string` | `"1:d:s"` | no |
| <a name="input_dmarc_percent"></a> [dmarc_percent](#input_dmarc_percent) | Percentage of messages to apply the DMARC policy to (0-100). | `number` | `100` | no |
| <a name="input_dmarc_policy"></a> [dmarc_policy](#input_dmarc_policy) | The DMARC policy to apply (options: `none`, `quarantine`, `reject`). | `string` | `"none"` | no |
| <a name="input_dmarc_rua"></a> [dmarc_rua](#input_dmarc_rua) | Where aggregate DMARC reports about policy violations should be sent. | `list(string)` | n/a | yes |
| <a name="input_dmarc_ruf"></a> [dmarc_ruf](#input_dmarc_ruf) | Where failure/forensic DMARC reports about policy violations should be sent. | `list(string)` | `[]` | no |
| <a name="input_dmarc_spf_mode"></a> [dmarc_spf_mode](#input_dmarc_spf_mode) | The DMARC SPF mode for alignment (options: `relaxed`, `strict`). | `string` | `"relaxed"` | no |
| <a name="input_dmarc_ttl"></a> [dmarc_ttl](#input_dmarc_ttl) | TTL for `_dmarc` DNS record. `1` is auto. Default is `1`. | `number` | `1` | no |
| <a name="input_domainkeys"></a> [domainkeys](#input_domainkeys) | Map of domain keys with name, record type (`TXT` or `CNAME`), and value. | <pre>map(object({<br> type = string<br> value = string<br> }))</pre> | `{}` | no |
| <a name="input_mta_sts_max_age"></a> [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 |
| <a name="input_mta_sts_mode"></a> [mta_sts_mode](#input_mta_sts_mode) | MTA policy mode, https://tools.ietf.org/html/rfc8461#section-5 | `string` | `"testing"` | no |
| <a name="input_mta_sts_mx"></a> [mta_sts_mx](#input_mta_sts_mx) | Additional permitted MX hosts for the MTA STS policy. | `list(string)` | `[]` | no |
| <a name="input_mx"></a> [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 |
| <a name="input_mx_subdomains"></a> [mx_subdomains](#input_mx_subdomains) | List of sub-domains to also apply MX records to. | `list(string)` | `[]` | no |
| <a name="input_record_ttl"></a> [record_ttl](#input_record_ttl) | TTL for DNS records. `1` is auto. Default is `1`. | `number` | `1` | no |
| <a name="input_spf_terms"></a> [spf_terms](#input_spf_terms) | List of SPF terms that should be included in the SPF TXT record. | `list(string)` | <pre>[<br> "mx",<br> "a",<br> "~all"<br>]</pre> | no |
| <a name="input_tlsrpt_rua"></a> [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 |
| <a name="input_zone_id"></a> [zone_id](#input_zone_id) | Cloudflare Zone ID | `string` | n/a | yes |
## Outputs
| Name | Description |
| ----------------------------------------------------------------------------------------- | ------------------------------- |
| <a name="output_mta_sts_policy_url"></a> [mta_sts_policy_url](#output_mta_sts_policy_url) | URL to the MTA-STS policy file. |

189
main.tf Normal file
View File

@@ -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
}

17
mta-sts.js Normal file
View File

@@ -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 });
}

6
mta-sts.txt.tpl Normal file
View File

@@ -0,0 +1,6 @@
version: STSv1
mode: ${mode}
max_age: ${max_age}
%{for hostname in mx ~}
mx: ${hostname}
%{endfor ~}

4
output.tf Normal file
View File

@@ -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."
}

245
variables.tf Normal file
View File

@@ -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."
}
}

8
versions.tf Normal file
View File

@@ -0,0 +1,8 @@
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = ">= 3.0, < 5.0"
}
}
}