From 3b1b427e67d22a7d251a5b2a07fdfceb2b4bb98b Mon Sep 17 00:00:00 2001 From: roch Date: Tue, 13 Aug 2024 19:44:40 +0100 Subject: [PATCH] Add new edge configuration for Chat. In normal operation Fastly will send traffic directly to the origin unless we set the disable_service variable to true, then it will return a 503 error. --- chat.tf | 44 ++++++++ modules/chat/chat.vcl.tftpl | 206 ++++++++++++++++++++++++++++++++++++ modules/chat/main.tf | 184 ++++++++++++++++++++++++++++++++ modules/chat/outputs.tf | 0 modules/chat/provider.tf | 9 ++ modules/chat/variables.tf | 16 +++ variables.tf | 12 +++ 7 files changed, 471 insertions(+) create mode 100644 chat.tf create mode 100644 modules/chat/chat.vcl.tftpl create mode 100644 modules/chat/main.tf create mode 100644 modules/chat/outputs.tf create mode 100644 modules/chat/provider.tf create mode 100644 modules/chat/variables.tf diff --git a/chat.tf b/chat.tf new file mode 100644 index 0000000..2890fc0 --- /dev/null +++ b/chat.tf @@ -0,0 +1,44 @@ +module "chat-integration" { + source = "./modules/chat" + + configuration = { + environment = "integration" + git_hash = var.TFC_CONFIGURATION_VERSION_GIT_COMMIT_SHA + probe = "/" + disable_service = false + } + + secrets = yamldecode(var.chat_integration) + + dictionaries = local.dictionaries +} + +module "chat-staging" { + source = "./modules/chat" + + configuration = { + environment = "staging" + git_hash = var.TFC_CONFIGURATION_VERSION_GIT_COMMIT_SHA + probe = "/" + disable_service = false + } + + secrets = yamldecode(var.chat_staging) + + dictionaries = local.dictionaries +} + +module "chat-production" { + source = "./modules/chat" + + configuration = { + environment = "production" + git_hash = var.TFC_CONFIGURATION_VERSION_GIT_COMMIT_SHA + probe = "/" + disable_service = false + } + + secrets = yamldecode(var.chat_production) + + dictionaries = local.dictionaries +} diff --git a/modules/chat/chat.vcl.tftpl b/modules/chat/chat.vcl.tftpl new file mode 100644 index 0000000..1edb604 --- /dev/null +++ b/modules/chat/chat.vcl.tftpl @@ -0,0 +1,206 @@ +backend F_awsorigin { + .connect_timeout = 5s; + .dynamic = true; + .port = "${aws_origin_port}"; + .host = "${aws_origin_hostname}"; + .first_byte_timeout = 15s; + .max_connections = 200; + .between_bytes_timeout = 10s; + + .ssl = true; + .ssl_check_cert = always; + .min_tls_version = "${minimum_tls_version}"; + .ssl_ciphers = "${ssl_ciphers}"; + .ssl_cert_hostname = "${aws_origin_hostname}"; + .ssl_sni_hostname = "${aws_origin_hostname}"; + + .probe = { + .dummy = ${probe_dns_only}; + .request = + "HEAD /__canary__ HTTP/1.1" + "Host: ${aws_origin_hostname}" + "User-Agent: Fastly healthcheck (Git commit: ${git_hash})" + "Connection: close"; + .threshold = 1; + .window = 2; + .timeout = 5s; + .initial = 1; + .expected_response = 200; + .interval = ${probe_interval}; + } +} + +acl purge_ip_allowlist { +%{ if environment == "integration" ~} + "34.248.229.46"; # AWS Integration NAT gateways + "34.248.44.175"; + "52.51.97.232"; + "18.203.77.149"; # EKS Integration NAT gateways + "52.212.155.150"; + "18.202.190.16"; +%{ endif ~} +%{ if environment == "staging" ~} + "18.203.108.248"; # AWS Staging NAT gateways + "18.202.183.143"; + "18.203.90.80"; + "108.128.15.82"; # EKS Staging NAT gateways + "46.137.141.50"; + "18.200.65.72"; +%{ endif ~} +%{ if environment == "production" ~} + "18.202.136.43"; # AWS Production NAT gateways + "34.246.209.74"; + "34.253.57.8"; + "63.33.241.191"; # EKS Production NAT gateways + "52.208.193.230"; + "54.220.6.200"; + "52.51.83.47"; # EKS Production licensify NAT gateways + "46.137.63.103"; + "34.249.23.204"; +%{ endif ~} +} + +sub vcl_recv { + ${indent(2, file("${module_path}/../shared/_boundary_headers.vcl.tftpl"))} + + # Require authentication for FASTLYPURGE requests unless from IP in ACL + if (req.request == "FASTLYPURGE" && client.ip !~ purge_ip_allowlist) { + set req.http.Fastly-Purge-Requires-Auth = "1"; + } + + # Check whether the remote IP address is in the list of blocked IPs + if (table.lookup(ip_address_denylist, client.ip)) { + error 403 "Forbidden"; + } + + # Force SSL. + if (!req.http.Fastly-SSL) { + error 801 "Force SSL"; + } + + ${indent(2, file("${module_path}/../shared/_security_txt_request.vcl"))} + + # Default backend. + set req.backend = F_awsorigin; + set req.http.Fastly-Backend-Name = "awsorigin"; + +#FASTLY recv + +%{ if disable_service == true } + error 503 "Service unavailable"; +%{ endif } + + return(pass); +} + +sub vcl_fetch { +#FASTLY fetch + + set beresp.http.Fastly-Backend-Name = req.http.Fastly-Backend-Name; + + if ((beresp.status >= 500 && beresp.status <= 599) && req.restarts < 3 && (req.request == "GET" || req.request == "HEAD") && !beresp.http.No-Fallback) { + set beresp.saintmode = 5s; + return (restart); + } + + if (req.restarts == 0) { + # Keep stale for origin + set beresp.stale_if_error = 24h; + } + + if(req.restarts > 0 ) { + set beresp.http.Fastly-Restarts = req.restarts; + } + + if (beresp.http.Cache-Control ~ "private") { + return (pass); + } + + if (beresp.http.Cache-Control ~ "max-age=0") { + return (pass); + } + + if (beresp.http.Cache-Control ~ "no-(store|cache)") { + return (pass); + } + + if (beresp.status >= 500 && beresp.status <= 599) { + set beresp.ttl = 1s; + set beresp.stale_if_error = 5s; + return (deliver); + } + + if (beresp.http.Expires || beresp.http.Surrogate-Control ~ "max-age" || beresp.http.Cache-Control ~"(s-maxage|max-age)") { + # keep the ttl here + } else { + # apply the default ttl + set beresp.ttl = ${default_ttl}s; + # S3 does not set cache headers by default. Override TTL and add cache-control with 15 minutes + if (beresp.http.Fastly-Backend-Name ~ "mirrorS3") { + set beresp.ttl = 900s; + set beresp.http.Cache-Control = "max-age=900"; + } + } + + # Override default.vcl behaviour of return(pass). + if (beresp.http.Set-Cookie) { + return (deliver); + } +} + +sub vcl_hit { +#FASTLY hit +} + +sub vcl_miss { +#FASTLY miss +} + +sub vcl_deliver { +#FASTLY deliver +} + +sub vcl_error { + if (obj.status == 801) { + set obj.status = 301; + set obj.response = "Moved Permanently"; + set obj.http.Location = "https://" req.http.host req.url; + synthetic {""}; + return (deliver); + } + + ${indent(2, file("${module_path}/../shared/_security_txt_response.vcl"))} + + # Serve stale from error subroutine as recommended in: + # https://docs.fastly.com/guides/performance-tuning/serving-stale-content + # The use of `req.restarts == 0` condition is to enforce the restriction + # of serving stale only when the backend is the origin. + if ((req.restarts == 0) && (obj.status >= 500 && obj.status < 600)) { + /* deliver stale object if it is available */ + if (stale.exists) { + return(deliver_stale); + } + } + + # Assume we've hit vcl_error() because the backend is unavailable + # for the first two retries. By restarting, vcl_recv() will try + # serving from stale before failing over to the mirrors. + if (req.restarts < 3) { + return (restart); + } + + synthetic {" + Sorry, this service is unavailable at the moment."}; + + return (deliver); + +#FASTLY error +} + +sub vcl_pass { +#FASTLY pass +} + +sub vcl_hash { +#FASTLY hash +} diff --git a/modules/chat/main.tf b/modules/chat/main.tf new file mode 100644 index 0000000..e21f6a3 --- /dev/null +++ b/modules/chat/main.tf @@ -0,0 +1,184 @@ +locals { + template_values = merge( + { # some defaults + aws_origin_port = 443 + minimum_tls_version = "1.2" + ssl_ciphers = "ECDHE-RSA-AES256-GCM-SHA384" + basic_authentication = null + probe_dns_only = false + }, + { # computed values + module_path = path.module + }, + var.configuration, + var.secrets + ) +} + +resource "fastly_service_vcl" "service" { + name = "${title(local.template_values["environment"])} Chat" + comment = "" + + http3 = true + + domain { + name = local.template_values["hostname"] + } + + vcl { + main = true + name = "main" + content = templatefile("${path.module}/${var.vcl_template_file}", local.template_values) + } + + dynamic "condition" { + for_each = { + for c in lookup(local.template_values, "conditions", []) : c.name => c + } + iterator = each + content { + name = each.key + priority = each.value.priority + statement = each.value.statement + type = each.value.type + } + } + + dynamic "backend" { + for_each = { + for b in lookup(local.template_values, "backends", []) : b.name => b + } + iterator = each + content { + name = each.key + address = each.value.address + use_ssl = true + request_condition = lookup(each.value, "request_condition", "") + port = lookup(each.value, "port", 443) + ssl_cert_hostname = each.value.address + ssl_sni_hostname = each.value.address + shield = lookup(each.value, "shield", "london-uk") + keepalive_time = 0 + healthcheck = "" + max_tls_version = "" + min_tls_version = "" + ssl_ca_cert = "" + ssl_ciphers = "" + ssl_client_cert = "" + ssl_client_key = "" + override_host = each.value.address + } + } + + dynamic "dictionary" { + for_each = var.dictionaries + content { + name = dictionary.key + } + } + + rate_limiter { + name = "rate_limiter_chat_${local.template_values["environment"]}" + + rps_limit = 100 + window_size = 10 + penalty_box_duration = 5 + + client_key = "req.http.Fastly-Client-IP" + http_methods = "GET,PUT,TRACE,POST,HEAD,DELETE,PATCH,OPTIONS" + + action = "response" + response { + content = "Too many requests" + content_type = "plain/text" + status = 429 + } + } + + dynamic "logging_splunk" { + for_each = { + for splunk in lookup(var.secrets, "splunk", []) : splunk.name => splunk + } + iterator = each + content { + name = "Splunk" + format_version = 2 + format = lookup(each.value, "format", chomp( + <<-EOT + { + "time": %%{time.start.sec}V, + "host": "Fastly", + "index": "${each.value.index}", + "source": "%%{server.region}V:%%{server.datacenter}V:%%{server.hostname}V", + "sourcetype": "csv:govukcdn_extended", + "event": "%h %t \\"%r\\" %>s %b \\"%%{Content-Type}o\\" \\"%%{User-Agent}i\\" \\"%%{Referer}i\\" \\"%%{X-Forwarded-For}i\\" \\"%%{Accept}i\\" %%{fastly_info.state}V" + } + EOT + )) + tls_hostname = each.value.hostname + token = each.value.token + url = each.value.url + use_tls = true + response_condition = lookup(each.value, "response_condition", null) + } + } + + dynamic "logging_s3" { + for_each = { + for s3 in lookup(var.secrets, "s3", []) : s3.name => s3 + } + iterator = each + content { + name = each.key + bucket_name = each.value.bucket_name + domain = each.value.domain + path = each.value.path + period = each.value.period + redundancy = each.value.redundancy + s3_access_key = each.value.access_key_id + s3_secret_key = each.value.secret_access_key + response_condition = lookup(each.value, "response_condition", null) + + format_version = 2 + message_type = "blank" + gzip_level = 9 + timestamp_format = "%Y-%m-%dT%H:%M:%S.000" + + format = lookup(each.value, "format", chomp( + <<-EOT + { + "client_ip":"%%{json.escape(client.ip)}V", + "request_received":"%%{begin:%Y-%m-%d %H:%M:%S.}t%%{time.start.msec_frac}V", + "request_received_offset":"%%{begin:%z}t", + "method":"%%{json.escape(req.method)}V", + "url":"%%{json.escape(req.url)}V", + "status":%>s, + "protocol":"%%{json.escape(req.proto)}V", + "request_time":%%{time.elapsed.sec}V.%%{time.elapsed.msec_frac}V, + "time_to_generate_response":%%{time.to_first_byte}V, + "bytes":%B, + "content_type":"%%{json.escape(resp.http.Content-Type)}V", + "user_agent":"%%{json.escape(req.http.User-Agent)}V", + "fastly_backend":"%%{json.escape(resp.http.Fastly-Backend-Name)}V", + "data_centre":"%%{json.escape(server.datacenter)}V", + "cache_hit":%%{if(fastly_info.state ~"^(HIT|MISS)(?:-|$)", "true", "false")}V, + "cache_response":"%%{regsub(fastly_info.state, "^(HIT-(SYNTH)|(HITPASS|HIT|MISS|PASS|ERROR|PIPE)).*", "\\2\\3") }V", + "tls_client_protocol":"%%{json.escape(tls.client.protocol)}V", + "tls_client_cipher":"%%{json.escape(tls.client.cipher)}V", + "client_ja3":"%%{json.escape(req.http.Client-JA3)}V" + } + EOT + )) + } + } +} + +resource "fastly_service_dictionary_items" "items" { + for_each = { + for d in fastly_service_vcl.service.dictionary : d.name => d + } + service_id = fastly_service_vcl.service.id + dictionary_id = each.value.dictionary_id + items = var.dictionaries[each.key] + manage_items = true +} diff --git a/modules/chat/outputs.tf b/modules/chat/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/modules/chat/provider.tf b/modules/chat/provider.tf new file mode 100644 index 0000000..47c6ee4 --- /dev/null +++ b/modules/chat/provider.tf @@ -0,0 +1,9 @@ +terraform { + required_version = "~> 1.7" + required_providers { + fastly = { + source = "fastly/fastly" + version = "5.11.0" + } + } +} diff --git a/modules/chat/variables.tf b/modules/chat/variables.tf new file mode 100644 index 0000000..facdd88 --- /dev/null +++ b/modules/chat/variables.tf @@ -0,0 +1,16 @@ +variable "secrets" { + default = {} +} + +variable "configuration" { + default = {} +} + +variable "dictionaries" { + default = {} +} + +variable "vcl_template_file" { + type = string + default = "chat.vcl.tftpl" +} diff --git a/variables.tf b/variables.tf index 2f93af6..1bb4593 100644 --- a/variables.tf +++ b/variables.tf @@ -38,6 +38,18 @@ variable "www_production" { type = string } +variable "chat_integration" { + type = string +} + +variable "chat_staging" { + type = string +} + +variable "chat_production" { + type = string +} + variable "TFC_CONFIGURATION_VERSION_GIT_COMMIT_SHA" { type = string default = "unknown"