From fcd6401d2d1773f2c6193af5e901fefaa09317f0 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 4 Aug 2026 15:04:23 -0700 Subject: [PATCH 1/4] Add pfp red support --- main.tf | 37 +++++ modules/redirect-domain/main.tf | 231 ++++++++++++++++++++++++++++ modules/redirect-domain/provider.tf | 12 ++ 3 files changed, 280 insertions(+) create mode 100644 modules/redirect-domain/main.tf create mode 100644 modules/redirect-domain/provider.tf diff --git a/main.tf b/main.tf index c72e388..e787e11 100644 --- a/main.tf +++ b/main.tf @@ -31,3 +31,40 @@ module "playful-web" { } } } + +module "pfp-red" { + count = var.env == "prod" ? 1 : 0 + source = "./modules/redirect-domain" + + domain = "pfp.red" + backend_host = var.playful_web_host + clean_urls = true + + redirects = { + "/" = { + location = "https://playfulprogramming.com/" + preserve_query = true + status = 307 + } + "/bootcamp" = { + location = "https://playfulprogramming.com/events/sacramento-bootcamp" + preserve_query = true + status = 307 + } + "/bootcamp/" = { + location = "https://playfulprogramming.com/events/sacramento-bootcamp" + preserve_query = true + status = 307 + } + "/book-club" = { + location = "https://playfulprogramming.com/events/book-club" + preserve_query = true + status = 307 + } + "/book-club/" = { + location = "https://playfulprogramming.com/events/book-club" + preserve_query = true + status = 307 + } + } +} diff --git a/modules/redirect-domain/main.tf b/modules/redirect-domain/main.tf new file mode 100644 index 0000000..8673462 --- /dev/null +++ b/modules/redirect-domain/main.tf @@ -0,0 +1,231 @@ +variable "domain" { + type = string + description = "The apex domain that serves the redirects" + + validation { + condition = var.domain == lower(var.domain) && can(regex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", var.domain)) + error_message = "Domain must be a lowercase DNS name." + } +} + +variable "backend_host" { + type = string + description = "A safety backend required by Fastly; redirect-domain requests never reach it" +} + +variable "clean_urls" { + type = bool + description = "Whether .html request paths receive a query-preserving 308 to their extensionless path" + default = false +} + +variable "redirects" { + type = map(object({ + location = string + preserve_query = optional(bool, true) + status = optional(number, 307) + })) + description = "Map of exact request paths to redirect responses" + + validation { + condition = length(var.redirects) > 0 + error_message = "At least one redirect must be configured." + } + + validation { + condition = alltrue([ + for path in keys(var.redirects) : + startswith(path, "/") && length(regexall("[?#]", path)) == 0 + ]) + error_message = "Redirect paths must start with / and cannot contain a query string or fragment." + } + + validation { + condition = alltrue([ + for redirect in values(var.redirects) : + contains([301, 302, 303, 307, 308], redirect.status) + ]) + error_message = "Redirect status must be one of 301, 302, 303, 307, or 308." + } + + validation { + condition = alltrue([ + for redirect in values(var.redirects) : + startswith(redirect.location, "https://") && ( + !redirect.preserve_query || length(regexall("[?#]", redirect.location)) == 0 + ) + ]) + error_message = "Redirect locations must use HTTPS and cannot contain a query or fragment when preserve_query is enabled." + } +} + +locals { + managed_dns_challenge = one([ + for challenge in fastly_tls_subscription.main.managed_dns_challenges : + challenge if challenge.record_name == "_acme-challenge.${var.domain}" + ]) + fastly_apex_addresses = sort([ + for record in data.fastly_tls_configuration.default_tls.dns_records : + record.record_value if record.record_type == "A" + ]) +} + +resource "fastly_service_vcl" "redirects" { + activate = true + comment = "Managed by Tofu" + http3 = true + name = "Redirects for ${var.domain}" + stage = false + + domain { + name = var.domain + } + + # Fastly VCL services require a backend even though every request for the + # configured domain terminates in vcl_recv without reaching an origin. + backend { + address = var.backend_host + name = "Unused safety backend" + port = 443 + ssl_cert_hostname = var.backend_host + ssl_sni_hostname = var.backend_host + use_ssl = true + } + + dynamic "snippet" { + for_each = var.clean_urls ? [1] : [] + content { + name = "Clean URLs (recv)" + type = "recv" + priority = 90 + content = <<-VCL + if (std.tolower(req.http.host) == ${jsonencode(var.domain)} && req.url.path ~ "[.]html$") { + error 617 "clean-url"; + } + VCL + } + } + + snippet { + name = "Path redirects (tables)" + type = "init" + priority = 100 + content = join("\n", concat( + ["table redirect_locations STRING {"], + [for path, redirect in var.redirects : " ${jsonencode(path)}: ${jsonencode(redirect.location)},"], + ["}", "", "table redirect_statuses INTEGER {"], + [for path, redirect in var.redirects : " ${jsonencode(path)}: ${redirect.status},"], + ["}", "", "table redirect_preserve_queries BOOL {"], + [for path, redirect in var.redirects : " ${jsonencode(path)}: ${redirect.preserve_query},"], + ["}"], + )) + } + + snippet { + name = "Path redirects (recv)" + type = "recv" + priority = 100 + content = <<-VCL + if (std.tolower(req.http.host) == ${jsonencode(var.domain)}) { + if (table.contains(redirect_locations, req.url.path)) { + error 618 "path-redirect"; + } + error 619 "redirect-not-found"; + } + VCL + } + + snippet { + name = "Path redirects (error)" + type = "error" + priority = 100 + content = <<-VCL + if (obj.status == 617 && obj.response == "clean-url") { + set obj.status = 308; + set obj.response = "Permanent Redirect"; + set obj.http.Location = regsub(req.url.path, "[.]html$", ""); + if (std.strlen(req.url.qs) > 0) { + set obj.http.Location = obj.http.Location + "?" + req.url.qs; + } + synthetic ""; + return (deliver); + } + + if (obj.status == 618 && obj.response == "path-redirect") { + set obj.status = table.lookup_integer(redirect_statuses, req.url.path, 307); + if (obj.status == 301) { + set obj.response = "Moved Permanently"; + } else if (obj.status == 302) { + set obj.response = "Found"; + } else if (obj.status == 303) { + set obj.response = "See Other"; + } else if (obj.status == 307) { + set obj.response = "Temporary Redirect"; + } else { + set obj.response = "Permanent Redirect"; + } + set obj.http.Location = table.lookup(redirect_locations, req.url.path, ""); + if (table.lookup_bool(redirect_preserve_queries, req.url.path, false) && std.strlen(req.url.qs) > 0) { + set obj.http.Location = obj.http.Location + "?" + req.url.qs; + } + synthetic ""; + return (deliver); + } + + if (obj.status == 619 && obj.response == "redirect-not-found") { + set obj.status = 404; + set obj.response = "Not Found"; + set obj.http.Content-Type = "text/plain; charset=utf-8"; + synthetic "Not Found"; + return (deliver); + } + VCL + } +} + +resource "fastly_tls_subscription" "main" { + depends_on = [fastly_service_vcl.redirects] + domains = [var.domain] + certificate_authority = "certainly" +} + +resource "porkbun_dns_record" "domain_validation" { + depends_on = [fastly_tls_subscription.main] + + domain = var.domain + subdomain = "_acme-challenge" + type = local.managed_dns_challenge.record_type + content = local.managed_dns_challenge.record_value + ttl = 600 + prio = 10 +} + +resource "fastly_tls_subscription_validation" "main" { + subscription_id = fastly_tls_subscription.main.id + depends_on = [porkbun_dns_record.domain_validation] +} + +data "fastly_tls_configuration" "default_tls" { + default = true + depends_on = [fastly_tls_subscription_validation.main] +} + +resource "porkbun_dns_record" "apex" { + # Fastly publishes exactly four global A records for apex domains. Keeping + # these instance keys static allows the first plan to succeed even though + # their values are unavailable until TLS validation completes. + for_each = { for index in range(4) : tostring(index) => index } + + domain = var.domain + subdomain = "" + type = "A" + content = local.fastly_apex_addresses[each.value] + ttl = 600 + + lifecycle { + precondition { + condition = length(local.fastly_apex_addresses) == 4 + error_message = "Fastly's default TLS configuration must provide exactly four apex A records." + } + } +} diff --git a/modules/redirect-domain/provider.tf b/modules/redirect-domain/provider.tf new file mode 100644 index 0000000..730348f --- /dev/null +++ b/modules/redirect-domain/provider.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + fastly = { + source = "fastly/fastly" + version = ">=1.0.0" + } + porkbun = { + source = "marcfrederick/porkbun" + version = ">=1.3.1" + } + } +} From 7bedaf96c917206b430269923931cb8af3088393 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 4 Aug 2026 15:05:58 -0700 Subject: [PATCH 2/4] Remove clean URLs logic --- main.tf | 1 - modules/redirect-domain/main.tf | 31 ------------------------------- 2 files changed, 32 deletions(-) diff --git a/main.tf b/main.tf index e787e11..e9342ee 100644 --- a/main.tf +++ b/main.tf @@ -38,7 +38,6 @@ module "pfp-red" { domain = "pfp.red" backend_host = var.playful_web_host - clean_urls = true redirects = { "/" = { diff --git a/modules/redirect-domain/main.tf b/modules/redirect-domain/main.tf index 8673462..c08018a 100644 --- a/modules/redirect-domain/main.tf +++ b/modules/redirect-domain/main.tf @@ -13,12 +13,6 @@ variable "backend_host" { description = "A safety backend required by Fastly; redirect-domain requests never reach it" } -variable "clean_urls" { - type = bool - description = "Whether .html request paths receive a query-preserving 308 to their extensionless path" - default = false -} - variable "redirects" { type = map(object({ location = string @@ -92,20 +86,6 @@ resource "fastly_service_vcl" "redirects" { use_ssl = true } - dynamic "snippet" { - for_each = var.clean_urls ? [1] : [] - content { - name = "Clean URLs (recv)" - type = "recv" - priority = 90 - content = <<-VCL - if (std.tolower(req.http.host) == ${jsonencode(var.domain)} && req.url.path ~ "[.]html$") { - error 617 "clean-url"; - } - VCL - } - } - snippet { name = "Path redirects (tables)" type = "init" @@ -140,17 +120,6 @@ resource "fastly_service_vcl" "redirects" { type = "error" priority = 100 content = <<-VCL - if (obj.status == 617 && obj.response == "clean-url") { - set obj.status = 308; - set obj.response = "Permanent Redirect"; - set obj.http.Location = regsub(req.url.path, "[.]html$", ""); - if (std.strlen(req.url.qs) > 0) { - set obj.http.Location = obj.http.Location + "?" + req.url.qs; - } - synthetic ""; - return (deliver); - } - if (obj.status == 618 && obj.response == "path-redirect") { set obj.status = table.lookup_integer(redirect_statuses, req.url.path, 307); if (obj.status == 301) { From f523aff959885b1c8b6a35aeb6ce8e521af802ca Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 4 Aug 2026 15:11:09 -0700 Subject: [PATCH 3/4] Add support for PFP red subdomain --- main.tf | 4 +-- modules/redirect-domain/main.tf | 43 +++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/main.tf b/main.tf index e9342ee..f7d3d7e 100644 --- a/main.tf +++ b/main.tf @@ -33,10 +33,10 @@ module "playful-web" { } module "pfp-red" { - count = var.env == "prod" ? 1 : 0 source = "./modules/redirect-domain" - domain = "pfp.red" + domain = var.env == "prod" ? "pfp.red" : "staging.pfp.red" + dns_zone = "pfp.red" backend_host = var.playful_web_host redirects = { diff --git a/modules/redirect-domain/main.tf b/modules/redirect-domain/main.tf index c08018a..242354d 100644 --- a/modules/redirect-domain/main.tf +++ b/modules/redirect-domain/main.tf @@ -1,6 +1,6 @@ variable "domain" { type = string - description = "The apex domain that serves the redirects" + description = "The domain that serves the redirects" validation { condition = var.domain == lower(var.domain) && can(regex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", var.domain)) @@ -8,6 +8,16 @@ variable "domain" { } } +variable "dns_zone" { + type = string + description = "The Porkbun DNS zone containing the redirect domain" + + validation { + condition = var.dns_zone == lower(var.dns_zone) && can(regex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", var.dns_zone)) + error_message = "DNS zone must be a lowercase DNS name." + } +} + variable "backend_host" { type = string description = "A safety backend required by Fastly; redirect-domain requests never reach it" @@ -54,14 +64,20 @@ variable "redirects" { } locals { + dns_subdomain = var.domain == var.dns_zone ? "" : trimsuffix(var.domain, ".${var.dns_zone}") managed_dns_challenge = one([ for challenge in fastly_tls_subscription.main.managed_dns_challenges : challenge if challenge.record_name == "_acme-challenge.${var.domain}" ]) + dns_challenge_subdomain = trimsuffix(local.managed_dns_challenge.record_name, ".${var.dns_zone}") fastly_apex_addresses = sort([ for record in data.fastly_tls_configuration.default_tls.dns_records : record.record_value if record.record_type == "A" ]) + fastly_cname = one([ + for record in data.fastly_tls_configuration.default_tls.dns_records : + record.record_value if record.record_type == "CNAME" + ]) } resource "fastly_service_vcl" "redirects" { @@ -71,6 +87,13 @@ resource "fastly_service_vcl" "redirects" { name = "Redirects for ${var.domain}" stage = false + lifecycle { + precondition { + condition = var.domain == var.dns_zone || endswith(var.domain, ".${var.dns_zone}") + error_message = "Redirect domain must be the DNS zone or one of its subdomains." + } + } + domain { name = var.domain } @@ -161,8 +184,8 @@ resource "fastly_tls_subscription" "main" { resource "porkbun_dns_record" "domain_validation" { depends_on = [fastly_tls_subscription.main] - domain = var.domain - subdomain = "_acme-challenge" + domain = var.dns_zone + subdomain = local.dns_challenge_subdomain type = local.managed_dns_challenge.record_type content = local.managed_dns_challenge.record_value ttl = 600 @@ -183,9 +206,9 @@ resource "porkbun_dns_record" "apex" { # Fastly publishes exactly four global A records for apex domains. Keeping # these instance keys static allows the first plan to succeed even though # their values are unavailable until TLS validation completes. - for_each = { for index in range(4) : tostring(index) => index } + for_each = local.dns_subdomain == "" ? { for index in range(4) : tostring(index) => index } : {} - domain = var.domain + domain = var.dns_zone subdomain = "" type = "A" content = local.fastly_apex_addresses[each.value] @@ -198,3 +221,13 @@ resource "porkbun_dns_record" "apex" { } } } + +resource "porkbun_dns_record" "subdomain" { + for_each = local.dns_subdomain == "" ? {} : { redirect = true } + + domain = var.dns_zone + subdomain = local.dns_subdomain + type = "CNAME" + content = local.fastly_cname + ttl = 600 +} From 3a2a6b6f873d0a3bad844c85484783398cc59c6a Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 4 Aug 2026 15:31:25 -0700 Subject: [PATCH 4/4] Fix naming --- modules/redirect-domain/main.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/redirect-domain/main.tf b/modules/redirect-domain/main.tf index 242354d..d773ff6 100644 --- a/modules/redirect-domain/main.tf +++ b/modules/redirect-domain/main.tf @@ -110,7 +110,7 @@ resource "fastly_service_vcl" "redirects" { } snippet { - name = "Path redirects (tables)" + name = "redirects_init" type = "init" priority = 100 content = join("\n", concat( @@ -125,7 +125,7 @@ resource "fastly_service_vcl" "redirects" { } snippet { - name = "Path redirects (recv)" + name = "redirects_recv" type = "recv" priority = 100 content = <<-VCL @@ -139,7 +139,7 @@ resource "fastly_service_vcl" "redirects" { } snippet { - name = "Path redirects (error)" + name = "redirects_error" type = "error" priority = 100 content = <<-VCL