| 1 | import * as cloudflare from "@pulumi/cloudflare"; |
| 2 | import * as pulumi from "@pulumi/pulumi"; |
| 3 | |
| 4 | const config = new pulumi.Config(); |
| 5 | const accountId = config.require("cloudflareAccountId"); |
| 6 | const targetUrl = normalizeTarget( |
| 7 | config.get("targetUrl") ?? "https://flagon.io", |
| 8 | ); |
| 9 | const domains = normalizeDomains(config.requireObject<string[]>("domains")); |
| 10 | |
| 11 | const redirectListName = "flagon_alternate_domains"; |
| 12 | const redirectList = new cloudflare.List("alternate-domains", { |
| 13 | accountId, |
| 14 | name: redirectListName, |
| 15 | description: "Alternate and commonly misspelled Flagon domains", |
| 16 | kind: "redirect", |
| 17 | items: domains.map((domain) => ({ |
| 18 | comment: `Redirect ${domain} and its subdomains to ${targetUrl}`, |
| 19 | redirect: { |
| 20 | sourceUrl: `${domain}/`, |
| 21 | targetUrl, |
| 22 | statusCode: 301, |
| 23 | includeSubdomains: true, |
| 24 | subpathMatching: true, |
| 25 | preservePathSuffix: true, |
| 26 | preserveQueryString: true, |
| 27 | }, |
| 28 | })), |
| 29 | }); |
| 30 | |
| 31 | const zones = domains.map((domain) => { |
| 32 | const resourceName = toResourceName(domain); |
| 33 | const zone = new cloudflare.Zone(resourceName, { |
| 34 | account: { id: accountId }, |
| 35 | name: domain, |
| 36 | type: "full", |
| 37 | }); |
| 38 | |
| 39 | // Bulk Redirects only see traffic proxied by Cloudflare. These documentation |
| 40 | // addresses must never be used as origins; the redirect executes first. |
| 41 | for (const [label, name] of [ |
| 42 | ["apex", domain], |
| 43 | ["wildcard", `*.${domain}`], |
| 44 | ] as const) { |
| 45 | new cloudflare.DnsRecord(`${resourceName}-${label}`, { |
| 46 | zoneId: zone.id, |
| 47 | name, |
| 48 | type: "A", |
| 49 | content: "192.0.2.1", |
| 50 | ttl: 1, |
| 51 | proxied: true, |
| 52 | comment: "Proxied placeholder for the account-level Bulk Redirect", |
| 53 | }); |
| 54 | } |
| 55 | |
| 56 | return zone; |
| 57 | }); |
| 58 | |
| 59 | new cloudflare.Ruleset( |
| 60 | "alternate-domain-redirects", |
| 61 | { |
| 62 | accountId, |
| 63 | name: "Flagon alternate domain redirects", |
| 64 | description: "Enables the managed list of alternate Flagon domains", |
| 65 | kind: "root", |
| 66 | phase: "http_request_redirect", |
| 67 | rules: [ |
| 68 | { |
| 69 | ref: "redirect_flagon_alternate_domains", |
| 70 | action: "redirect", |
| 71 | actionParameters: { |
| 72 | fromList: { |
| 73 | key: "http.request.full_uri", |
| 74 | name: redirectListName, |
| 75 | }, |
| 76 | }, |
| 77 | description: "Redirect alternate Flagon domains", |
| 78 | expression: `http.request.full_uri in $${redirectListName}`, |
| 79 | enabled: true, |
| 80 | }, |
| 81 | ], |
| 82 | }, |
| 83 | { dependsOn: [redirectList] }, |
| 84 | ); |
| 85 | |
| 86 | export const configuredDomains = domains; |
| 87 | export const target = targetUrl; |
| 88 | export const zoneNameServers = Object.fromEntries( |
| 89 | domains.map((domain, index) => [domain, zones[index]!.nameServers]), |
| 90 | ); |
| 91 | |
| 92 | function normalizeDomains(values: string[]): string[] { |
| 93 | if (values.length === 0) { |
| 94 | throw new pulumi.RunError("The 'domains' configuration must not be empty."); |
| 95 | } |
| 96 | |
| 97 | const domains = values.map((value) => |
| 98 | value.trim().toLowerCase().replace(/\.$/, ""), |
| 99 | ); |
| 100 | const invalid = domains.find( |
| 101 | (domain) => |
| 102 | !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test( |
| 103 | domain, |
| 104 | ), |
| 105 | ); |
| 106 | if (invalid) { |
| 107 | throw new pulumi.RunError( |
| 108 | `Invalid domain in 'domains' configuration: ${invalid}`, |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | const unique = [...new Set(domains)].sort(); |
| 113 | if (unique.includes(new URL(targetUrl).hostname)) { |
| 114 | throw new pulumi.RunError( |
| 115 | "The redirect target must not also appear in 'domains'.", |
| 116 | ); |
| 117 | } |
| 118 | return unique; |
| 119 | } |
| 120 | |
| 121 | function normalizeTarget(value: string): string { |
| 122 | let url: URL; |
| 123 | try { |
| 124 | url = new URL(value); |
| 125 | } catch { |
| 126 | throw new pulumi.RunError(`Invalid 'targetUrl' configuration: ${value}`); |
| 127 | } |
| 128 | if ( |
| 129 | url.protocol !== "https:" || |
| 130 | url.username || |
| 131 | url.password || |
| 132 | url.search || |
| 133 | url.hash |
| 134 | ) { |
| 135 | throw new pulumi.RunError( |
| 136 | "The 'targetUrl' configuration must be an HTTPS URL without credentials, a query, or a fragment.", |
| 137 | ); |
| 138 | } |
| 139 | return url.toString().replace(/\/$/, ""); |
| 140 | } |
| 141 | |
| 142 | function toResourceName(domain: string): string { |
| 143 | return domain.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); |
| 144 | } |