| 1 | locals { |
| 2 | channels_domain = "channels.nixos.org" |
| 3 | |
| 4 | channels_index = templatefile("${path.module}/s3_listing.html.tpl", { |
| 5 | bucket_name = aws_s3_bucket.channels.bucket |
| 6 | bucket_url = "https://${aws_s3_bucket.channels.bucket_domain_name}" |
| 7 | bucket_website = "https://${local.channels_domain}" |
| 8 | }) |
| 9 | |
| 10 | # Use the website endpoint because the bucket is configured with website |
| 11 | # enabled. This also means we can't use TLS between Fastly and AWS because |
| 12 | # the website endpoint only has port 80 open. |
| 13 | channels_backend = "nix-channels.s3-website-us-east-1.amazonaws.com" |
| 14 | # TODO: Uncomment this once has been applied once. This is to work around fastly bug https://github.com/fastly/terraform-provider-fastly/issues/884 |
| 15 | # channels_backend = aws_s3_bucket_website_configuration.channels.website_endpoint |
| 16 | } |
| 17 | |
| 18 | resource "aws_s3_bucket" "channels" { |
| 19 | provider = aws.us |
| 20 | bucket = "nix-channels" |
| 21 | } |
| 22 | |
| 23 | resource "aws_s3_bucket_website_configuration" "channels" { |
| 24 | provider = aws.us |
| 25 | bucket = aws_s3_bucket.channels.id |
| 26 | |
| 27 | index_document { |
| 28 | suffix = "index.html" |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | import { |
| 33 | to = aws_s3_bucket_website_configuration.channels |
| 34 | id = aws_s3_bucket.channels.id |
| 35 | } |
| 36 | |
| 37 | resource "aws_s3_bucket_cors_configuration" "channels" { |
| 38 | provider = aws.us |
| 39 | bucket = aws_s3_bucket.channels.id |
| 40 | |
| 41 | cors_rule { |
| 42 | allowed_headers = ["*"] |
| 43 | allowed_methods = ["HEAD", "GET"] |
| 44 | allowed_origins = ["*"] |
| 45 | expose_headers = ["ETag"] |
| 46 | max_age_seconds = 3600 |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | import { |
| 51 | to = aws_s3_bucket_cors_configuration.channels |
| 52 | id = aws_s3_bucket.channels.id |
| 53 | |
| 54 | } |
| 55 | |
| 56 | resource "aws_s3_bucket_object" "channels-index-html" { |
| 57 | provider = aws.us |
| 58 | |
| 59 | acl = "public-read" |
| 60 | bucket = aws_s3_bucket.channels.bucket |
| 61 | content_type = "text/html" |
| 62 | etag = md5(local.channels_index) |
| 63 | key = "index.html" |
| 64 | content = local.channels_index |
| 65 | } |
| 66 | |
| 67 | resource "aws_s3_bucket_policy" "channels" { |
| 68 | provider = aws.us |
| 69 | bucket = aws_s3_bucket.channels.id |
| 70 | policy = <<EOF |
| 71 | { |
| 72 | "Version": "2008-10-17", |
| 73 | "Statement": [ |
| 74 | { |
| 75 | "Sid": "AllowPublicRead", |
| 76 | "Effect": "Allow", |
| 77 | "Principal": { |
| 78 | "AWS": "*" |
| 79 | }, |
| 80 | "Action": "s3:GetObject", |
| 81 | "Resource": "arn:aws:s3:::nix-channels/*" |
| 82 | }, |
| 83 | { |
| 84 | "Sid": "AllowPublicList", |
| 85 | "Effect": "Allow", |
| 86 | "Principal": { |
| 87 | "AWS": "*" |
| 88 | }, |
| 89 | "Action": [ |
| 90 | "s3:ListBucket", |
| 91 | "s3:GetBucketLocation" |
| 92 | ], |
| 93 | "Resource": "arn:aws:s3:::nix-channels" |
| 94 | }, |
| 95 | { |
| 96 | "Sid": "AllowUpload", |
| 97 | "Effect": "Allow", |
| 98 | "Principal": { |
| 99 | "AWS": [ |
| 100 | "arn:aws:iam::080433136561:user/s3-upload-releases", |
| 101 | "arn:aws:iam::065343343465:user/nixos-s3-upload-releases" |
| 102 | ] |
| 103 | }, |
| 104 | "Action": [ |
| 105 | "s3:PutObject", |
| 106 | "s3:PutObjectAcl" |
| 107 | ], |
| 108 | "Resource": "arn:aws:s3:::nix-channels/*" |
| 109 | } |
| 110 | ] |
| 111 | } |
| 112 | EOF |
| 113 | } |
| 114 | |
| 115 | resource "fastly_service_vcl" "channels" { |
| 116 | name = local.channels_domain |
| 117 | default_ttl = 3600 |
| 118 | |
| 119 | backend { |
| 120 | address = local.channels_backend |
| 121 | auto_loadbalance = false |
| 122 | connect_timeout = 5000 |
| 123 | name = local.channels_backend |
| 124 | override_host = local.channels_backend |
| 125 | request_condition = "not-flake-registry" |
| 126 | shield = "iad-va-us" |
| 127 | } |
| 128 | |
| 129 | backend { |
| 130 | # https://github.com/NixOS/flake-registry/raw/master/flake-registry.json |
| 131 | name = "flake-registry" |
| 132 | address = "raw.githubusercontent.com" |
| 133 | auto_loadbalance = false |
| 134 | override_host = "raw.githubusercontent.com" |
| 135 | port = 443 |
| 136 | use_ssl = true |
| 137 | ssl_check_cert = false |
| 138 | request_condition = "flake-registry" |
| 139 | } |
| 140 | |
| 141 | request_setting { |
| 142 | name = "Redirect HTTP to HTTPS" |
| 143 | force_ssl = true |
| 144 | } |
| 145 | |
| 146 | condition { |
| 147 | name = "Match /" |
| 148 | priority = 10 |
| 149 | statement = "req.url ~ \"^/$\"" |
| 150 | type = "REQUEST" |
| 151 | } |
| 152 | |
| 153 | condition { |
| 154 | name = "not-flake-registry" |
| 155 | statement = "req.url != \"/NixOS/flake-registry/master/flake-registry.json\"" |
| 156 | type = "REQUEST" |
| 157 | } |
| 158 | |
| 159 | condition { |
| 160 | name = "flake-registry" |
| 161 | statement = "req.url == \"/NixOS/flake-registry/master/flake-registry.json\"" |
| 162 | type = "REQUEST" |
| 163 | } |
| 164 | |
| 165 | domain { |
| 166 | name = local.channels_domain |
| 167 | } |
| 168 | |
| 169 | header { |
| 170 | action = "set" |
| 171 | destination = "url" |
| 172 | ignore_if_set = false |
| 173 | name = "Landing page" |
| 174 | priority = 10 |
| 175 | request_condition = "Match /" |
| 176 | source = "\"/index.html\"" |
| 177 | type = "request" |
| 178 | } |
| 179 | |
| 180 | # Clean headers for caching |
| 181 | header { |
| 182 | destination = "http.x-amz-request-id" |
| 183 | type = "cache" |
| 184 | action = "delete" |
| 185 | name = "remove x-amz-request-id" |
| 186 | } |
| 187 | header { |
| 188 | destination = "http.x-amz-version-id" |
| 189 | type = "cache" |
| 190 | action = "delete" |
| 191 | name = "remove x-amz-version-id" |
| 192 | } |
| 193 | header { |
| 194 | destination = "http.x-amz-id-2" |
| 195 | type = "cache" |
| 196 | action = "delete" |
| 197 | name = "remove x-amz-id-2" |
| 198 | } |
| 199 | |
| 200 | # Allow CORS GET requests. |
| 201 | header { |
| 202 | destination = "http.access-control-allow-origin" |
| 203 | type = "cache" |
| 204 | action = "set" |
| 205 | name = "CORS Allow" |
| 206 | source = "\"*\"" |
| 207 | } |
| 208 | |
| 209 | snippet { |
| 210 | content = "set req.url = querystring.remove(req.url);" |
| 211 | name = "Remove all query strings" |
| 212 | priority = 50 |
| 213 | type = "recv" |
| 214 | } |
| 215 | |
| 216 | snippet { |
| 217 | content = <<-EOT |
| 218 | if (beresp.status == 403) { |
| 219 | set beresp.status = 404; |
| 220 | set beresp.ttl = 86400s; |
| 221 | set beresp.grace = 0s; |
| 222 | set beresp.cacheable = true; |
| 223 | } |
| 224 | if (req.url ~ "/flake-registry.json") { |
| 225 | set beresp.stale_if_error = 1000000s; |
| 226 | } |
| 227 | EOT |
| 228 | name = "Change 403 from S3 to 404" |
| 229 | priority = 100 |
| 230 | type = "fetch" |
| 231 | } |
| 232 | |
| 233 | snippet { |
| 234 | name = "flake-registry" |
| 235 | content = <<-EOT |
| 236 | if (req.url == "/flake-registry.json") { |
| 237 | set req.url = "/NixOS/flake-registry/master/flake-registry.json"; |
| 238 | } |
| 239 | EOT |
| 240 | type = "recv" |
| 241 | } |
| 242 | |
| 243 | snippet { |
| 244 | content = <<-EOT |
| 245 | # S3 object-level redirects can only be 301s. We use them to point |
| 246 | # "latest" versions of various channel/release artifacts to the correct |
| 247 | # location. First, mark these redirects as temporary. Second, disable |
| 248 | # caching, since some of the artifacts need to have matching versions |
| 249 | # (e.g. a .iso and its checksum), which is near-impossible to guarantee |
| 250 | # with caching unless we explicitly perform invalidations. |
| 251 | # |
| 252 | # Note: we need to match on 301s and 302s here, since Fastly has multiple |
| 253 | # layers, and otherwise a redirect might still get cached at the second |
| 254 | # layer after the first layer turned a 301 into a 302. |
| 255 | # |
| 256 | # Additionally, this also implements the "Lockable HTTP Tarball Protocol" |
| 257 | # to use nixexprs.tar.zst (or any other nixexprs.tar.*) with Flakes and |
| 258 | # have it locked properly. |
| 259 | if (beresp.status == 301 || beresp.status == 302) { |
| 260 | set beresp.status = 302; |
| 261 | set beresp.ttl = 0s; |
| 262 | set beresp.grace = 0s; |
| 263 | set beresp.cacheable = false; |
| 264 | if (req.backend.is_origin && std.prefixof(bereq.url.basename, "nixexprs.tar.")) { |
| 265 | # pass redirect location into special flake "immutable tarball" header |
| 266 | set beresp.http.link = "<" + beresp.http.location + {">; rel="immutable""}; |
| 267 | # clear query string from redirect destination as precaution in case |
| 268 | # legacy consumers can't handle flake attributes like "?rev=" in it |
| 269 | set beresp.http.location = querystring.remove(beresp.http.location); |
| 270 | } |
| 271 | return (pass); |
| 272 | } |
| 273 | EOT |
| 274 | name = "Change 301 from S3 to 302" |
| 275 | # Keep close to last, since it conditionally returns. |
| 276 | priority = 999 |
| 277 | type = "fetch" |
| 278 | } |
| 279 | |
| 280 | logging_s3 { |
| 281 | name = "${local.channels_domain}-to-s3" |
| 282 | bucket_name = local.fastlylogs["bucket_name"] |
| 283 | compression_codec = "zstd" |
| 284 | domain = local.fastlylogs["s3_domain"] |
| 285 | format = local.fastlylogs["format"] |
| 286 | format_version = 2 |
| 287 | path = "${local.channels_domain}/" |
| 288 | period = local.fastlylogs["period"] |
| 289 | message_type = "blank" |
| 290 | s3_iam_role = local.fastlylogs["iam_role_arn"] |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | resource "fastly_tls_subscription" "channels-2025-11" { |
| 295 | domains = [for domain in fastly_service_vcl.channels.domain : domain.name] |
| 296 | configuration_id = local.fastly_tls13_quic_configuration_id |
| 297 | certificate_authority = "lets-encrypt" |
| 298 | } |
| 299 | |
| 300 | output "channels-managed_dns_challenge" { |
| 301 | value = fastly_tls_subscription.channels-2025-11.managed_dns_challenges |
| 302 | } |