granny-devops 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- granny/__init__.py +19 -0
- granny/analyze/__init__.py +6 -0
- granny/analyze/lambdas.py +59 -0
- granny/analyze/vpcs.py +57 -0
- granny/cdn/__init__.py +9 -0
- granny/cdn/bunny.py +231 -0
- granny/cli/__init__.py +0 -0
- granny/cli/analyze.py +66 -0
- granny/cli/cdn.py +210 -0
- granny/cli/create.py +94 -0
- granny/cli/credentials.py +99 -0
- granny/cli/dns.py +290 -0
- granny/cli/docker.py +165 -0
- granny/cli/edge.py +106 -0
- granny/cli/email.py +224 -0
- granny/cli/main.py +98 -0
- granny/cli/serverless.py +278 -0
- granny/cli/storage.py +249 -0
- granny/create/__init__.py +4 -0
- granny/create/auto_certificate.py +1899 -0
- granny/create/cloudfront-security-headers.js +53 -0
- granny/create/manage-dns.sh +321 -0
- granny/create/manage_mailjet_contacts.py +619 -0
- granny/create/registrars.py +363 -0
- granny/create/setup_aws_cloudfront.py +2808 -0
- granny/create/setup_bunny_edge_script.py +923 -0
- granny/create/setup_bunny_storage.py +1719 -0
- granny/create/setup_cognito_identity_pool.py +740 -0
- granny/create/setup_hetzner_bunny.py +1482 -0
- granny/create/setup_mailjet_dns.py +1103 -0
- granny/create/setup_private_cdn.py +547 -0
- granny/create/setup_s3_website.py +1512 -0
- granny/create/setup_scaleway_faas.py +1165 -0
- granny/create/setup_workmail.py +1217 -0
- granny/create/www-redirect-function.js +17 -0
- granny/credentials/__init__.py +15 -0
- granny/credentials/secrets.py +403 -0
- granny/dns/__init__.py +22 -0
- granny/dns/base.py +113 -0
- granny/dns/bunny.py +150 -0
- granny/dns/cloudflare.py +192 -0
- granny/dns/cloudns.py +162 -0
- granny/dns/desec.py +152 -0
- granny/dns/factory.py +72 -0
- granny/dns/hetzner.py +165 -0
- granny/dns/manual.py +64 -0
- granny/dns/records.py +29 -0
- granny/docker/__init__.py +5 -0
- granny/docker/build_base.py +204 -0
- granny/edge/__init__.py +5 -0
- granny/edge/bunny.py +147 -0
- granny/email/__init__.py +7 -0
- granny/email/mailjet.py +119 -0
- granny/email/mailjet_contacts.py +115 -0
- granny/email/ses_forwarding.py +281 -0
- granny/email/workmail.py +145 -0
- granny/report.py +128 -0
- granny/serverless/__init__.py +5 -0
- granny/serverless/scaleway.py +264 -0
- granny/storage/__init__.py +7 -0
- granny/storage/aws.py +113 -0
- granny/storage/bunny.py +98 -0
- granny/storage/hetzner.py +118 -0
- granny_devops-0.4.0.dist-info/METADATA +445 -0
- granny_devops-0.4.0.dist-info/RECORD +68 -0
- granny_devops-0.4.0.dist-info/WHEEL +4 -0
- granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
- granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,2808 @@
|
|
|
1
|
+
import boto3
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
import argparse
|
|
7
|
+
import requests
|
|
8
|
+
import socket
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
load_dotenv()
|
|
14
|
+
try:
|
|
15
|
+
from granny.credentials import load_secrets_into_env
|
|
16
|
+
load_secrets_into_env()
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
# Optional imports for DNS providers
|
|
21
|
+
Cloudflare = None
|
|
22
|
+
try:
|
|
23
|
+
from cloudflare import Cloudflare
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass # Will be checked when cloudflare provider is selected
|
|
26
|
+
|
|
27
|
+
HetznerDnsZone = None
|
|
28
|
+
HetznerDnsRecord = None
|
|
29
|
+
try:
|
|
30
|
+
from hetzner_dns_api import DnsZone as HetznerDnsZone, DnsRecord as HetznerDnsRecord
|
|
31
|
+
except ImportError:
|
|
32
|
+
pass # Will be checked when hetzner provider is selected
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
Unified S3 Website CDN Setup Script
|
|
36
|
+
|
|
37
|
+
This script sets up a CDN for static content using AWS S3, CloudFront, and Cloudflare.
|
|
38
|
+
Supports both subdomain deployments (cdn.example.com) and production apex deployments (example.com).
|
|
39
|
+
|
|
40
|
+
MODES:
|
|
41
|
+
------
|
|
42
|
+
1. Subdomain Mode (default):
|
|
43
|
+
- Setup CDN for subdomain.example.com
|
|
44
|
+
- Use: python setup_s3_website.py --domain example.com --subdomain cdn-staging
|
|
45
|
+
|
|
46
|
+
2. Apex Mode (production):
|
|
47
|
+
- Setup CDN for example.com with optional www ā apex redirect
|
|
48
|
+
- Uses CloudFront Function for reliable www redirect (not Cloudflare rules)
|
|
49
|
+
- Use: python setup_s3_website.py --domain example.com --apex --www-redirect --spa-mode
|
|
50
|
+
|
|
51
|
+
SSL Configuration:
|
|
52
|
+
- When using --cloudflare-ssl (default): Cloudflare handles SSL termination
|
|
53
|
+
- When using --acm-ssl: AWS ACM certificate is created
|
|
54
|
+
- For apex mode: Certificate covers both apex (example.com) AND wildcard (*.example.com)
|
|
55
|
+
|
|
56
|
+
SPA (Single Page Application) Support:
|
|
57
|
+
- Use --spa-mode to configure CloudFront for client-side routing (React, Vue, Angular, etc.)
|
|
58
|
+
- 404 errors will be redirected to /index.html (or custom path) with HTTP 200 status
|
|
59
|
+
|
|
60
|
+
WWW Redirect (apex mode only):
|
|
61
|
+
- Use --www-redirect with --apex to redirect www.example.com ā example.com
|
|
62
|
+
- Implemented via CloudFront Function (reliable, works regardless of Cloudflare plan)
|
|
63
|
+
- 301 permanent redirect preserving path and query string
|
|
64
|
+
|
|
65
|
+
Usage Examples:
|
|
66
|
+
# Subdomain: Basic static site
|
|
67
|
+
python setup_s3_website.py --domain example.com --subdomain cdn-staging
|
|
68
|
+
|
|
69
|
+
# Subdomain: SPA application
|
|
70
|
+
python setup_s3_website.py --domain example.com --subdomain app-dev --spa-mode
|
|
71
|
+
|
|
72
|
+
# Apex: Production website with www redirect
|
|
73
|
+
python setup_s3_website.py --domain example.com --apex --www-redirect --spa-mode
|
|
74
|
+
|
|
75
|
+
# Apex: Production without www redirect (both domains serve content)
|
|
76
|
+
python setup_s3_website.py --domain example.com --apex
|
|
77
|
+
|
|
78
|
+
Environment Variables:
|
|
79
|
+
CLOUDFLARE_API_TOKEN: Required for Cloudflare DNS provider
|
|
80
|
+
HETZNER_DNS_API_TOKEN: Required for Hetzner DNS provider
|
|
81
|
+
DESEC_API_TOKEN: Required for deSEC DNS provider
|
|
82
|
+
AWS_PROFILE: Optional - AWS profile to use
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# =============================================================================
|
|
87
|
+
# DNS Provider Abstraction Layer
|
|
88
|
+
# =============================================================================
|
|
89
|
+
|
|
90
|
+
class DNSProvider(ABC):
|
|
91
|
+
"""Abstract base class for DNS providers."""
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
95
|
+
"""Get zone ID by domain name."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
100
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
101
|
+
"""Create or update a CNAME record."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def create_validation_records(self, zone_id: str, zone_name: str,
|
|
106
|
+
validation_input) -> list[dict]:
|
|
107
|
+
"""Create DNS records for ACM certificate validation.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
zone_id: The zone ID
|
|
111
|
+
zone_name: The zone/domain name
|
|
112
|
+
validation_input: Either a single validation CNAME dict or list of validation options
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of created DNS records for cleanup
|
|
116
|
+
"""
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def cleanup_validation_records(self, zone_id: str,
|
|
121
|
+
records: list[dict]) -> None:
|
|
122
|
+
"""Remove DNS validation records after certificate issuance."""
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
@abstractmethod
|
|
126
|
+
def supports_apex_cname(self) -> bool:
|
|
127
|
+
"""Returns True if provider supports CNAME-like records at apex."""
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
132
|
+
cloudfront_domain: str) -> None:
|
|
133
|
+
"""Create DNS records for apex domain setup."""
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def supports_zone_creation(self) -> bool:
|
|
137
|
+
"""Returns True if provider supports creating new zones via API.
|
|
138
|
+
|
|
139
|
+
Override in subclasses that support zone creation.
|
|
140
|
+
"""
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def create_zone(self, zone_name: str) -> str:
|
|
144
|
+
"""Create a new DNS zone.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
zone_name: The domain name for the zone (e.g., 'example.com')
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The zone ID for the newly created zone
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
NotImplementedError: If provider doesn't support zone creation
|
|
154
|
+
"""
|
|
155
|
+
raise NotImplementedError(
|
|
156
|
+
f"{self.__class__.__name__} does not support zone creation via API. "
|
|
157
|
+
f"Please create the zone manually in the provider's dashboard."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def get_or_create_zone(self, zone_name: str, auto_create: bool = False) -> str:
|
|
161
|
+
"""Get zone ID, optionally creating the zone if it doesn't exist.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
zone_name: The domain name for the zone
|
|
165
|
+
auto_create: If True and zone doesn't exist, attempt to create it
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The zone ID
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
Exception: If zone doesn't exist and auto_create is False or unsupported
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
return self.get_zone_id(zone_name)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
if not auto_create:
|
|
177
|
+
raise
|
|
178
|
+
if not self.supports_zone_creation():
|
|
179
|
+
raise Exception(
|
|
180
|
+
f"Zone '{zone_name}' not found and {self.__class__.__name__} "
|
|
181
|
+
f"does not support automatic zone creation. "
|
|
182
|
+
f"Please create the zone manually first."
|
|
183
|
+
) from e
|
|
184
|
+
logging.info(f"Zone '{zone_name}' not found. Creating new zone...")
|
|
185
|
+
return self.create_zone(zone_name)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class CloudflareDNSProvider(DNSProvider):
|
|
189
|
+
"""Cloudflare DNS API adapter."""
|
|
190
|
+
|
|
191
|
+
def __init__(self):
|
|
192
|
+
if Cloudflare is None:
|
|
193
|
+
raise ImportError(
|
|
194
|
+
"Cloudflare library not found. Install it: pip install cloudflare"
|
|
195
|
+
)
|
|
196
|
+
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
|
197
|
+
if not token:
|
|
198
|
+
raise ValueError("CLOUDFLARE_API_TOKEN environment variable not set.")
|
|
199
|
+
self.client = Cloudflare(api_token=token)
|
|
200
|
+
|
|
201
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
202
|
+
"""Get the Cloudflare zone ID for the domain."""
|
|
203
|
+
zones = self.client.zones.list(name=zone_name)
|
|
204
|
+
if not zones.result:
|
|
205
|
+
raise Exception(f"Zone not found in Cloudflare: {zone_name}")
|
|
206
|
+
return zones.result[0].id
|
|
207
|
+
|
|
208
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
209
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
210
|
+
"""Creates or updates a CNAME record in Cloudflare pointing to CloudFront."""
|
|
211
|
+
full_domain_name = f"{name}.{zone_name}"
|
|
212
|
+
logging.info(f"Creating/updating CNAME record for '{full_domain_name}' -> '{target}'...")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
# Search for existing records
|
|
216
|
+
existing_records = self.client.dns.records.list(zone_id=zone_id, name=full_domain_name)
|
|
217
|
+
|
|
218
|
+
if not existing_records.result:
|
|
219
|
+
existing_records = self.client.dns.records.list(zone_id=zone_id, name=name)
|
|
220
|
+
|
|
221
|
+
dns_record = {
|
|
222
|
+
'name': name,
|
|
223
|
+
'type': 'CNAME',
|
|
224
|
+
'content': target,
|
|
225
|
+
'proxied': False # Don't proxy - CloudFront handles SSL
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
cname_record_exists = False
|
|
229
|
+
records_to_delete = []
|
|
230
|
+
|
|
231
|
+
if existing_records.result:
|
|
232
|
+
for record in existing_records.result:
|
|
233
|
+
if record.type == 'CNAME':
|
|
234
|
+
cname_record_exists = True
|
|
235
|
+
if record.content != target or record.proxied:
|
|
236
|
+
logging.info("Updating existing CNAME record")
|
|
237
|
+
self.client.dns.records.update(
|
|
238
|
+
zone_id=zone_id, dns_record_id=record.id, **dns_record
|
|
239
|
+
)
|
|
240
|
+
logging.info("CNAME record updated successfully.")
|
|
241
|
+
else:
|
|
242
|
+
logging.info("CNAME record already correct.")
|
|
243
|
+
elif record.type in ['A', 'AAAA']:
|
|
244
|
+
records_to_delete.append((record.id, record.type))
|
|
245
|
+
|
|
246
|
+
# Delete conflicting A/AAAA records
|
|
247
|
+
for record_id, record_type in records_to_delete:
|
|
248
|
+
logging.info(f"Deleting conflicting {record_type} record")
|
|
249
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
|
250
|
+
|
|
251
|
+
# Create new CNAME if needed
|
|
252
|
+
if not cname_record_exists:
|
|
253
|
+
logging.info(f"Creating new CNAME record for '{name}'")
|
|
254
|
+
self.client.dns.records.create(zone_id=zone_id, **dns_record)
|
|
255
|
+
logging.info("CNAME record created successfully.")
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logging.error(f"Error creating/updating Cloudflare CNAME record: {e}")
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
def create_validation_records(self, zone_id: str, zone_name: str,
|
|
262
|
+
validation_input) -> list[dict]:
|
|
263
|
+
"""Creates CNAME record(s) in Cloudflare for ACM validation."""
|
|
264
|
+
logging.info("Setting up DNS validation record(s) in Cloudflare...")
|
|
265
|
+
|
|
266
|
+
# Normalize input to list
|
|
267
|
+
if isinstance(validation_input, list):
|
|
268
|
+
validation_records = [opt['ResourceRecord'] for opt in validation_input if 'ResourceRecord' in opt]
|
|
269
|
+
else:
|
|
270
|
+
validation_records = [validation_input]
|
|
271
|
+
|
|
272
|
+
created_records = []
|
|
273
|
+
for validation_cname in validation_records:
|
|
274
|
+
dns_record = {
|
|
275
|
+
'name': validation_cname['Name'],
|
|
276
|
+
'type': validation_cname['Type'],
|
|
277
|
+
'content': validation_cname['Value'].rstrip('.'),
|
|
278
|
+
'proxied': False,
|
|
279
|
+
'ttl': 60
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
self.client.dns.records.create(zone_id=zone_id, **dns_record)
|
|
284
|
+
logging.info(f"DNS validation record created for {validation_cname['Name']}")
|
|
285
|
+
created_records.append(dns_record)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
if "already exists" in str(e):
|
|
288
|
+
logging.warning(f"DNS record already exists for {validation_cname['Name']}")
|
|
289
|
+
else:
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
return created_records
|
|
293
|
+
|
|
294
|
+
def cleanup_validation_records(self, zone_id: str, records: list[dict]) -> None:
|
|
295
|
+
"""Removes the DNS validation records from Cloudflare."""
|
|
296
|
+
if not records:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
for dns_record in records:
|
|
300
|
+
logging.info(f"Cleaning up DNS validation record: {dns_record['name']}")
|
|
301
|
+
try:
|
|
302
|
+
found = self.client.dns.records.list(
|
|
303
|
+
zone_id=zone_id, name=dns_record['name'], type=dns_record['type']
|
|
304
|
+
)
|
|
305
|
+
if found.result:
|
|
306
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=found.result[0].id)
|
|
307
|
+
logging.info("DNS validation record cleaned up.")
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logging.error(f"Error cleaning up DNS record: {e}")
|
|
310
|
+
|
|
311
|
+
def supports_apex_cname(self) -> bool:
|
|
312
|
+
return True # Cloudflare supports CNAME flattening
|
|
313
|
+
|
|
314
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
315
|
+
cloudfront_domain: str) -> None:
|
|
316
|
+
"""Create Cloudflare DNS records for apex domain with CNAME flattening."""
|
|
317
|
+
logging.info("Configuring Cloudflare DNS for apex domain...")
|
|
318
|
+
|
|
319
|
+
def delete_conflicting_records(name: str):
|
|
320
|
+
full_name = name if '.' in name and name != apex_domain else f"{name}.{apex_domain}" if name != apex_domain else name
|
|
321
|
+
try:
|
|
322
|
+
existing = self.client.dns.records.list(zone_id=zone_id, name=full_name)
|
|
323
|
+
if existing.result:
|
|
324
|
+
for rec in existing.result:
|
|
325
|
+
if rec.type in ['A', 'AAAA']:
|
|
326
|
+
logging.info(f"Deleting conflicting {rec.type} record: {full_name}")
|
|
327
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=rec.id)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logging.warning(f"Error deleting records for {full_name}: {e}")
|
|
330
|
+
|
|
331
|
+
def create_or_update_record(name: str, content: str, proxied: bool = False):
|
|
332
|
+
full_name = name if name == apex_domain else f"{name}.{apex_domain}" if '.' not in name else name
|
|
333
|
+
|
|
334
|
+
delete_conflicting_records(name)
|
|
335
|
+
|
|
336
|
+
existing = self.client.dns.records.list(zone_id=zone_id, name=full_name)
|
|
337
|
+
record_data = {'name': name, 'type': 'CNAME', 'content': content, 'proxied': proxied}
|
|
338
|
+
|
|
339
|
+
if existing.result:
|
|
340
|
+
for rec in existing.result:
|
|
341
|
+
if rec.type == 'CNAME':
|
|
342
|
+
if rec.content != content or rec.proxied != proxied:
|
|
343
|
+
logging.info(f"Updating CNAME: {full_name} ā {content}")
|
|
344
|
+
self.client.dns.records.update(zone_id=zone_id, dns_record_id=rec.id, **record_data)
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
logging.info(f"Creating CNAME: {full_name} ā {content}")
|
|
348
|
+
self.client.dns.records.create(zone_id=zone_id, **record_data)
|
|
349
|
+
|
|
350
|
+
# Apex domain (CNAME flattening)
|
|
351
|
+
create_or_update_record(apex_domain, cloudfront_domain, proxied=False)
|
|
352
|
+
logging.info(f"Apex configured: {apex_domain} ā {cloudfront_domain}")
|
|
353
|
+
|
|
354
|
+
# WWW domain
|
|
355
|
+
create_or_update_record('www', cloudfront_domain, proxied=False)
|
|
356
|
+
logging.info(f"WWW configured: {www_domain} ā {cloudfront_domain}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class HetznerDNSProvider(DNSProvider):
|
|
360
|
+
"""Hetzner DNS API adapter."""
|
|
361
|
+
|
|
362
|
+
def __init__(self):
|
|
363
|
+
if HetznerDnsZone is None or HetznerDnsRecord is None:
|
|
364
|
+
raise ImportError(
|
|
365
|
+
"hetzner-dns-api library not found. Install it: pip install hetzner-dns-api"
|
|
366
|
+
)
|
|
367
|
+
token = os.environ.get("HETZNER_DNS_API_TOKEN")
|
|
368
|
+
if not token:
|
|
369
|
+
raise ValueError("HETZNER_DNS_API_TOKEN environment variable not set.")
|
|
370
|
+
self.zone_api = HetznerDnsZone(token)
|
|
371
|
+
self.record_api = HetznerDnsRecord(token)
|
|
372
|
+
|
|
373
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
374
|
+
"""Get the Hetzner zone ID for the domain."""
|
|
375
|
+
logging.info(f"Looking up Hetzner zone for '{zone_name}'...")
|
|
376
|
+
for zone in self.zone_api.all(name=zone_name):
|
|
377
|
+
if zone.name == zone_name:
|
|
378
|
+
logging.info(f"Found zone ID: {zone.id}")
|
|
379
|
+
return zone.id
|
|
380
|
+
raise Exception(f"Zone not found in Hetzner DNS: {zone_name}")
|
|
381
|
+
|
|
382
|
+
def _get_records_by_name(self, zone_id: str, name: str, record_type: Optional[str] = None) -> list:
|
|
383
|
+
"""Get all records matching name and optionally type."""
|
|
384
|
+
matching = []
|
|
385
|
+
for record in self.record_api.all(zone_id=zone_id):
|
|
386
|
+
if record.name == name:
|
|
387
|
+
if record_type is None or record.type == record_type:
|
|
388
|
+
matching.append(record)
|
|
389
|
+
return matching
|
|
390
|
+
|
|
391
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
392
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
393
|
+
"""Creates or updates a CNAME record in Hetzner DNS."""
|
|
394
|
+
logging.info(f"Creating/updating CNAME record for '{name}.{zone_name}' -> '{target}'...")
|
|
395
|
+
|
|
396
|
+
# Remove trailing dot from target if present
|
|
397
|
+
target = target.rstrip('.')
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
# Find existing records
|
|
401
|
+
existing_cname = self._get_records_by_name(zone_id, name, 'CNAME')
|
|
402
|
+
existing_a = self._get_records_by_name(zone_id, name, 'A')
|
|
403
|
+
existing_aaaa = self._get_records_by_name(zone_id, name, 'AAAA')
|
|
404
|
+
|
|
405
|
+
# Delete conflicting A/AAAA records
|
|
406
|
+
for record in existing_a + existing_aaaa:
|
|
407
|
+
logging.info(f"Deleting conflicting {record.type} record for '{name}'")
|
|
408
|
+
self.record_api.delete(record.id)
|
|
409
|
+
|
|
410
|
+
if existing_cname:
|
|
411
|
+
# Update existing CNAME
|
|
412
|
+
record = existing_cname[0]
|
|
413
|
+
if record.value != target:
|
|
414
|
+
logging.info(f"Updating existing CNAME record: {record.value} ā {target}")
|
|
415
|
+
self.record_api.update(
|
|
416
|
+
record_id=record.id,
|
|
417
|
+
zone_id=zone_id,
|
|
418
|
+
name=name,
|
|
419
|
+
record_type='CNAME',
|
|
420
|
+
value=target,
|
|
421
|
+
ttl=ttl
|
|
422
|
+
)
|
|
423
|
+
logging.info("CNAME record updated successfully.")
|
|
424
|
+
else:
|
|
425
|
+
logging.info("CNAME record already points to correct target.")
|
|
426
|
+
else:
|
|
427
|
+
# Create new CNAME
|
|
428
|
+
logging.info(f"Creating new CNAME record: {name} ā {target}")
|
|
429
|
+
self.record_api.create(
|
|
430
|
+
zone_id=zone_id,
|
|
431
|
+
name=name,
|
|
432
|
+
record_type='CNAME',
|
|
433
|
+
value=target,
|
|
434
|
+
ttl=ttl
|
|
435
|
+
)
|
|
436
|
+
logging.info("CNAME record created successfully.")
|
|
437
|
+
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logging.error(f"Error creating/updating Hetzner CNAME record: {e}")
|
|
440
|
+
raise
|
|
441
|
+
|
|
442
|
+
def create_validation_records(self, zone_id: str, zone_name: str,
|
|
443
|
+
validation_input) -> list[dict]:
|
|
444
|
+
"""Creates CNAME records in Hetzner for ACM validation."""
|
|
445
|
+
logging.info("Setting up DNS validation record(s) in Hetzner DNS...")
|
|
446
|
+
|
|
447
|
+
# Normalize input
|
|
448
|
+
if isinstance(validation_input, list):
|
|
449
|
+
validation_records = [opt['ResourceRecord'] for opt in validation_input if 'ResourceRecord' in opt]
|
|
450
|
+
else:
|
|
451
|
+
validation_records = [validation_input]
|
|
452
|
+
|
|
453
|
+
created_records = []
|
|
454
|
+
for validation_cname in validation_records:
|
|
455
|
+
# Extract the subdomain part from the full validation name
|
|
456
|
+
# e.g., _abc123.example.com -> _abc123
|
|
457
|
+
full_name = validation_cname['Name'].rstrip('.')
|
|
458
|
+
if full_name.endswith(f".{zone_name}"):
|
|
459
|
+
name = full_name[:-len(f".{zone_name}")-1]
|
|
460
|
+
else:
|
|
461
|
+
name = full_name
|
|
462
|
+
|
|
463
|
+
value = validation_cname['Value'].rstrip('.')
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
# Check if already exists
|
|
467
|
+
existing = self._get_records_by_name(zone_id, name, 'CNAME')
|
|
468
|
+
if existing:
|
|
469
|
+
logging.info(f"Validation record already exists for {name}")
|
|
470
|
+
else:
|
|
471
|
+
self.record_api.create(
|
|
472
|
+
zone_id=zone_id,
|
|
473
|
+
name=name,
|
|
474
|
+
record_type='CNAME',
|
|
475
|
+
value=value,
|
|
476
|
+
ttl=60
|
|
477
|
+
)
|
|
478
|
+
logging.info(f"DNS validation record created for {name}")
|
|
479
|
+
|
|
480
|
+
created_records.append({
|
|
481
|
+
'name': name,
|
|
482
|
+
'type': 'CNAME',
|
|
483
|
+
'value': value
|
|
484
|
+
})
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logging.error(f"Error creating validation record: {e}")
|
|
487
|
+
raise
|
|
488
|
+
|
|
489
|
+
return created_records
|
|
490
|
+
|
|
491
|
+
def cleanup_validation_records(self, zone_id: str, records: list[dict]) -> None:
|
|
492
|
+
"""Removes DNS validation records from Hetzner."""
|
|
493
|
+
if not records:
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
for dns_record in records:
|
|
497
|
+
logging.info(f"Cleaning up DNS validation record: {dns_record['name']}")
|
|
498
|
+
try:
|
|
499
|
+
existing = self._get_records_by_name(zone_id, dns_record['name'], 'CNAME')
|
|
500
|
+
for record in existing:
|
|
501
|
+
self.record_api.delete(record.id)
|
|
502
|
+
logging.info("DNS validation record cleaned up.")
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logging.error(f"Error cleaning up DNS record: {e}")
|
|
505
|
+
|
|
506
|
+
def supports_apex_cname(self) -> bool:
|
|
507
|
+
return False # Hetzner doesn't support CNAME at apex
|
|
508
|
+
|
|
509
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
510
|
+
cloudfront_domain: str) -> None:
|
|
511
|
+
"""Hetzner doesn't support CNAME at apex - create A records with CloudFront IPs."""
|
|
512
|
+
logging.warning("=" * 60)
|
|
513
|
+
logging.warning(" HETZNER DNS APEX DOMAIN LIMITATION")
|
|
514
|
+
logging.warning("=" * 60)
|
|
515
|
+
logging.warning("Hetzner DNS does not support CNAME records at the apex domain.")
|
|
516
|
+
logging.warning("Creating A records pointing to CloudFront IP addresses.")
|
|
517
|
+
logging.warning("")
|
|
518
|
+
logging.warning(" WARNING: CloudFront IPs may change without notice!")
|
|
519
|
+
logging.warning(" Monitor your site and update A records if CDN stops working.")
|
|
520
|
+
logging.warning(" For production apex domains, consider using:")
|
|
521
|
+
logging.warning(" --dns-provider desec (EU + DNSSEC + HTTPS records)")
|
|
522
|
+
logging.warning(" --dns-provider cloudflare (CNAME flattening)")
|
|
523
|
+
logging.warning("=" * 60)
|
|
524
|
+
|
|
525
|
+
# Resolve CloudFront domain to IP addresses
|
|
526
|
+
try:
|
|
527
|
+
logging.info(f"Resolving CloudFront domain: {cloudfront_domain}")
|
|
528
|
+
ips = socket.getaddrinfo(cloudfront_domain, 443, socket.AF_INET)
|
|
529
|
+
unique_ips = list(set(ip[4][0] for ip in ips))
|
|
530
|
+
logging.info(f"Found CloudFront IPs: {unique_ips}")
|
|
531
|
+
except socket.gaierror as e:
|
|
532
|
+
raise Exception(f"Could not resolve CloudFront domain {cloudfront_domain}: {e}")
|
|
533
|
+
|
|
534
|
+
# Delete existing A/AAAA/CNAME records at apex
|
|
535
|
+
existing_a = self._get_records_by_name(zone_id, '@', 'A')
|
|
536
|
+
existing_aaaa = self._get_records_by_name(zone_id, '@', 'AAAA')
|
|
537
|
+
for record in existing_a + existing_aaaa:
|
|
538
|
+
logging.info(f"Deleting existing {record.type} record at apex")
|
|
539
|
+
self.record_api.delete(record.id)
|
|
540
|
+
|
|
541
|
+
# Create A records for each CloudFront IP
|
|
542
|
+
for ip in unique_ips:
|
|
543
|
+
logging.info(f"Creating A record: @ ā {ip}")
|
|
544
|
+
self.record_api.create(
|
|
545
|
+
zone_id=zone_id,
|
|
546
|
+
name='@',
|
|
547
|
+
record_type='A',
|
|
548
|
+
value=ip,
|
|
549
|
+
ttl=300 # Short TTL for faster updates if needed
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
logging.info(f"Apex A records created: {apex_domain} ā {unique_ips}")
|
|
553
|
+
|
|
554
|
+
# Create www CNAME
|
|
555
|
+
self.create_cname_record(zone_id, 'www', cloudfront_domain, apex_domain)
|
|
556
|
+
logging.info(f"WWW configured: {www_domain} ā {cloudfront_domain}")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class DeSECDNSProvider(DNSProvider):
|
|
561
|
+
"""deSEC DNS API adapter with HTTPS record support for apex domains.
|
|
562
|
+
|
|
563
|
+
deSEC is a German non-profit providing free DNS with always-on DNSSEC.
|
|
564
|
+
For apex domains, it uses HTTPS records (RFC 9460) instead of CNAME flattening.
|
|
565
|
+
|
|
566
|
+
API Documentation: https://desec.readthedocs.io/
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
API_BASE = "https://desec.io/api/v1"
|
|
570
|
+
# deSEC requires minimum TTL of 3600 seconds (1 hour)
|
|
571
|
+
MIN_TTL = 3600
|
|
572
|
+
|
|
573
|
+
def __init__(self):
|
|
574
|
+
token = os.environ.get("DESEC_API_TOKEN")
|
|
575
|
+
if not token:
|
|
576
|
+
raise ValueError("DESEC_API_TOKEN environment variable not set.")
|
|
577
|
+
self.token = token
|
|
578
|
+
self.session = requests.Session()
|
|
579
|
+
self.session.headers.update({
|
|
580
|
+
"Authorization": f"Token {token}",
|
|
581
|
+
"Content-Type": "application/json"
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
585
|
+
"""Make an API request to deSEC."""
|
|
586
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
587
|
+
try:
|
|
588
|
+
if method == "GET":
|
|
589
|
+
response = self.session.get(url)
|
|
590
|
+
elif method == "POST":
|
|
591
|
+
response = self.session.post(url, json=data)
|
|
592
|
+
elif method == "PUT":
|
|
593
|
+
response = self.session.put(url, json=data)
|
|
594
|
+
elif method == "PATCH":
|
|
595
|
+
response = self.session.patch(url, json=data)
|
|
596
|
+
elif method == "DELETE":
|
|
597
|
+
response = self.session.delete(url)
|
|
598
|
+
else:
|
|
599
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
600
|
+
|
|
601
|
+
# Handle rate limiting
|
|
602
|
+
if response.status_code == 429:
|
|
603
|
+
retry_after = int(response.headers.get("Retry-After", 5))
|
|
604
|
+
logging.warning(f"Rate limited by deSEC API. Waiting {retry_after}s...")
|
|
605
|
+
time.sleep(retry_after)
|
|
606
|
+
return self._api_request(method, endpoint, data)
|
|
607
|
+
|
|
608
|
+
if response.status_code >= 400:
|
|
609
|
+
logging.error(f"deSEC API error: {response.status_code} - {response.text}")
|
|
610
|
+
response.raise_for_status()
|
|
611
|
+
|
|
612
|
+
if response.status_code == 204: # No content
|
|
613
|
+
return {}
|
|
614
|
+
|
|
615
|
+
return response.json() if response.text else {}
|
|
616
|
+
|
|
617
|
+
except requests.RequestException as e:
|
|
618
|
+
logging.error(f"deSEC API request failed: {e}")
|
|
619
|
+
raise
|
|
620
|
+
|
|
621
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
622
|
+
"""Get zone info - deSEC uses domain name as identifier."""
|
|
623
|
+
logging.info(f"Looking up deSEC zone for '{zone_name}'...")
|
|
624
|
+
try:
|
|
625
|
+
result = self._api_request("GET", f"/domains/{zone_name}/")
|
|
626
|
+
if result and result.get("name") == zone_name:
|
|
627
|
+
logging.info(f"Found deSEC zone: {zone_name}")
|
|
628
|
+
return zone_name # deSEC uses domain name as ID
|
|
629
|
+
raise Exception(f"Zone not found: {zone_name}")
|
|
630
|
+
except requests.HTTPError as e:
|
|
631
|
+
if e.response.status_code == 404:
|
|
632
|
+
raise Exception(f"Zone not found in deSEC: {zone_name}")
|
|
633
|
+
raise
|
|
634
|
+
|
|
635
|
+
def supports_zone_creation(self) -> bool:
|
|
636
|
+
"""deSEC supports creating zones via API."""
|
|
637
|
+
return True
|
|
638
|
+
|
|
639
|
+
def create_zone(self, zone_name: str) -> str:
|
|
640
|
+
"""Create a new DNS zone in deSEC.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
zone_name: The domain name for the zone (e.g., 'example.com')
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
The zone name (deSEC uses domain name as ID)
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
Exception: If zone creation fails
|
|
650
|
+
"""
|
|
651
|
+
logging.info(f"Creating new deSEC zone for '{zone_name}'...")
|
|
652
|
+
try:
|
|
653
|
+
result = self._api_request("POST", "/domains/", {"name": zone_name})
|
|
654
|
+
if result and result.get("name") == zone_name:
|
|
655
|
+
logging.info(f"Successfully created deSEC zone: {zone_name}")
|
|
656
|
+
# Log nameservers for user to configure at registrar
|
|
657
|
+
if "keys" in result:
|
|
658
|
+
logging.info("Zone created with DNSSEC enabled (deSEC default)")
|
|
659
|
+
# Get and display the nameservers
|
|
660
|
+
self._display_nameservers(zone_name)
|
|
661
|
+
return zone_name
|
|
662
|
+
raise Exception(f"Unexpected response when creating zone: {result}")
|
|
663
|
+
except requests.HTTPError as e:
|
|
664
|
+
if e.response.status_code == 400:
|
|
665
|
+
error_detail = e.response.json() if e.response.text else {}
|
|
666
|
+
if "name" in error_detail:
|
|
667
|
+
# Check for common errors
|
|
668
|
+
name_errors = error_detail["name"]
|
|
669
|
+
if any("already exists" in str(err).lower() for err in name_errors):
|
|
670
|
+
logging.info(f"Zone '{zone_name}' already exists in deSEC")
|
|
671
|
+
return zone_name
|
|
672
|
+
raise Exception(f"Failed to create zone: {name_errors}")
|
|
673
|
+
if e.response.status_code == 403:
|
|
674
|
+
raise Exception(
|
|
675
|
+
f"Permission denied creating zone '{zone_name}'. "
|
|
676
|
+
f"Check your deSEC API token permissions."
|
|
677
|
+
)
|
|
678
|
+
raise Exception(f"Failed to create deSEC zone: {e}")
|
|
679
|
+
|
|
680
|
+
def _display_nameservers(self, zone_name: str) -> None:
|
|
681
|
+
"""Display nameservers for a zone."""
|
|
682
|
+
try:
|
|
683
|
+
result = self._api_request("GET", f"/domains/{zone_name}/")
|
|
684
|
+
if result:
|
|
685
|
+
# deSEC nameservers are typically ns1.desec.io and ns2.desec.org
|
|
686
|
+
logging.info("=" * 60)
|
|
687
|
+
logging.info("IMPORTANT: Configure these nameservers at your domain registrar:")
|
|
688
|
+
logging.info(" ns1.desec.io")
|
|
689
|
+
logging.info(" ns2.desec.org")
|
|
690
|
+
logging.info("=" * 60)
|
|
691
|
+
except Exception as e:
|
|
692
|
+
logging.warning(f"Could not retrieve nameserver info: {e}")
|
|
693
|
+
|
|
694
|
+
def _get_rrset(self, domain: str, subname: str, record_type: str) -> Optional[dict]:
|
|
695
|
+
"""Get a specific RRset."""
|
|
696
|
+
try:
|
|
697
|
+
endpoint = f"/domains/{domain}/rrsets/{subname}/{record_type}/"
|
|
698
|
+
return self._api_request("GET", endpoint)
|
|
699
|
+
except requests.HTTPError as e:
|
|
700
|
+
if e.response.status_code == 404:
|
|
701
|
+
return None
|
|
702
|
+
raise
|
|
703
|
+
|
|
704
|
+
def _create_or_update_rrset(self, domain: str, subname: str, record_type: str,
|
|
705
|
+
records: list, ttl: int = 3600) -> None:
|
|
706
|
+
"""Create or update an RRset.
|
|
707
|
+
|
|
708
|
+
Note: deSEC requires minimum TTL of 3600 seconds. Any lower value will be
|
|
709
|
+
automatically adjusted to MIN_TTL.
|
|
710
|
+
"""
|
|
711
|
+
# Enforce minimum TTL
|
|
712
|
+
if ttl < self.MIN_TTL:
|
|
713
|
+
logging.debug(f"TTL {ttl} below deSEC minimum, using {self.MIN_TTL}")
|
|
714
|
+
ttl = self.MIN_TTL
|
|
715
|
+
|
|
716
|
+
existing = self._get_rrset(domain, subname, record_type)
|
|
717
|
+
|
|
718
|
+
data = {
|
|
719
|
+
"subname": subname,
|
|
720
|
+
"type": record_type,
|
|
721
|
+
"records": records,
|
|
722
|
+
"ttl": ttl
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if existing:
|
|
726
|
+
# Update existing
|
|
727
|
+
endpoint = f"/domains/{domain}/rrsets/{subname}/{record_type}/"
|
|
728
|
+
self._api_request("PUT", endpoint, data)
|
|
729
|
+
logging.info(f"Updated {record_type} record for {subname or '@'}.{domain}")
|
|
730
|
+
else:
|
|
731
|
+
# Create new
|
|
732
|
+
endpoint = f"/domains/{domain}/rrsets/"
|
|
733
|
+
self._api_request("POST", endpoint, data)
|
|
734
|
+
logging.info(f"Created {record_type} record for {subname or '@'}.{domain}")
|
|
735
|
+
|
|
736
|
+
def _delete_rrset(self, domain: str, subname: str, record_type: str) -> None:
|
|
737
|
+
"""Delete an RRset."""
|
|
738
|
+
try:
|
|
739
|
+
endpoint = f"/domains/{domain}/rrsets/{subname}/{record_type}/"
|
|
740
|
+
self._api_request("DELETE", endpoint)
|
|
741
|
+
logging.info(f"Deleted {record_type} record for {subname or '@'}.{domain}")
|
|
742
|
+
except requests.HTTPError as e:
|
|
743
|
+
if e.response.status_code != 404:
|
|
744
|
+
raise
|
|
745
|
+
|
|
746
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
747
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
748
|
+
"""Creates or updates a CNAME record in deSEC."""
|
|
749
|
+
logging.info(f"Creating/updating CNAME record for '{name}.{zone_name}' -> '{target}'...")
|
|
750
|
+
|
|
751
|
+
# Ensure target ends with dot for deSEC
|
|
752
|
+
if not target.endswith('.'):
|
|
753
|
+
target = f"{target}."
|
|
754
|
+
|
|
755
|
+
# Delete conflicting A/AAAA records
|
|
756
|
+
self._delete_rrset(zone_name, name, 'A')
|
|
757
|
+
self._delete_rrset(zone_name, name, 'AAAA')
|
|
758
|
+
|
|
759
|
+
# Create/update CNAME
|
|
760
|
+
self._create_or_update_rrset(zone_name, name, 'CNAME', [target], ttl)
|
|
761
|
+
|
|
762
|
+
def create_validation_records(self, zone_id: str, zone_name: str,
|
|
763
|
+
validation_input) -> list[dict]:
|
|
764
|
+
"""Creates CNAME records in deSEC for ACM validation."""
|
|
765
|
+
logging.info("Setting up DNS validation record(s) in deSEC...")
|
|
766
|
+
|
|
767
|
+
# Normalize input
|
|
768
|
+
if isinstance(validation_input, list):
|
|
769
|
+
validation_records = [opt['ResourceRecord'] for opt in validation_input if 'ResourceRecord' in opt]
|
|
770
|
+
else:
|
|
771
|
+
validation_records = [validation_input]
|
|
772
|
+
|
|
773
|
+
created_records = []
|
|
774
|
+
for validation_cname in validation_records:
|
|
775
|
+
# Extract subdomain from full name
|
|
776
|
+
full_name = validation_cname['Name'].rstrip('.')
|
|
777
|
+
if full_name.endswith(f".{zone_name}"):
|
|
778
|
+
subname = full_name[:-len(f".{zone_name}")-1]
|
|
779
|
+
else:
|
|
780
|
+
subname = full_name
|
|
781
|
+
|
|
782
|
+
value = validation_cname['Value']
|
|
783
|
+
if not value.endswith('.'):
|
|
784
|
+
value = f"{value}."
|
|
785
|
+
|
|
786
|
+
try:
|
|
787
|
+
self._create_or_update_rrset(zone_name, subname, 'CNAME', [value], 60)
|
|
788
|
+
created_records.append({
|
|
789
|
+
'name': subname,
|
|
790
|
+
'type': 'CNAME',
|
|
791
|
+
'value': value,
|
|
792
|
+
'domain': zone_name
|
|
793
|
+
})
|
|
794
|
+
except Exception as e:
|
|
795
|
+
logging.error(f"Error creating validation record: {e}")
|
|
796
|
+
raise
|
|
797
|
+
|
|
798
|
+
return created_records
|
|
799
|
+
|
|
800
|
+
def cleanup_validation_records(self, zone_id: str, records: list[dict]) -> None:
|
|
801
|
+
"""Removes DNS validation records from deSEC."""
|
|
802
|
+
if not records:
|
|
803
|
+
return
|
|
804
|
+
|
|
805
|
+
for dns_record in records:
|
|
806
|
+
logging.info(f"Cleaning up DNS validation record: {dns_record['name']}")
|
|
807
|
+
try:
|
|
808
|
+
domain = dns_record.get('domain', zone_id)
|
|
809
|
+
self._delete_rrset(domain, dns_record['name'], 'CNAME')
|
|
810
|
+
except Exception as e:
|
|
811
|
+
logging.error(f"Error cleaning up DNS record: {e}")
|
|
812
|
+
|
|
813
|
+
def supports_apex_cname(self) -> bool:
|
|
814
|
+
return True # deSEC supports apex via HTTPS records (RFC 9460)
|
|
815
|
+
|
|
816
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
817
|
+
cloudfront_domain: str) -> None:
|
|
818
|
+
"""Create deSEC DNS records for apex domain using HTTPS records."""
|
|
819
|
+
logging.info("Configuring deSEC DNS for apex domain with HTTPS records...")
|
|
820
|
+
logging.info("Using RFC 9460 HTTPS records for apex domain support.")
|
|
821
|
+
|
|
822
|
+
# Ensure CloudFront domain ends with dot
|
|
823
|
+
cf_target = cloudfront_domain if cloudfront_domain.endswith('.') else f"{cloudfront_domain}."
|
|
824
|
+
|
|
825
|
+
# Delete any conflicting A/AAAA records at apex
|
|
826
|
+
self._delete_rrset(apex_domain, '', 'A')
|
|
827
|
+
self._delete_rrset(apex_domain, '', 'AAAA')
|
|
828
|
+
|
|
829
|
+
# Create HTTPS record at apex (RFC 9460)
|
|
830
|
+
# Format: priority target [params]
|
|
831
|
+
# priority=1, target=cloudfront domain, alpn for HTTP/2 support
|
|
832
|
+
https_value = f'1 {cf_target} alpn="h2,http/1.1"'
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
self._create_or_update_rrset(apex_domain, '', 'HTTPS', [https_value], 300)
|
|
836
|
+
logging.info(f"Apex HTTPS record created: {apex_domain} -> {cloudfront_domain}")
|
|
837
|
+
except Exception as e:
|
|
838
|
+
logging.error(f"Failed to create HTTPS record: {e}")
|
|
839
|
+
logging.warning("Falling back to A records (less reliable)...")
|
|
840
|
+
# Fallback to A records like Hetzner
|
|
841
|
+
self._create_apex_a_records(apex_domain, cloudfront_domain)
|
|
842
|
+
|
|
843
|
+
# Create www CNAME
|
|
844
|
+
self.create_cname_record(zone_id, 'www', cloudfront_domain, apex_domain)
|
|
845
|
+
logging.info(f"WWW configured: {www_domain} -> {cloudfront_domain}")
|
|
846
|
+
|
|
847
|
+
def _create_apex_a_records(self, apex_domain: str, cloudfront_domain: str) -> None:
|
|
848
|
+
"""Fallback: Create A records at apex (if HTTPS record fails)."""
|
|
849
|
+
logging.warning("Creating A records as fallback - CloudFront IPs may change!")
|
|
850
|
+
|
|
851
|
+
try:
|
|
852
|
+
ips = socket.getaddrinfo(cloudfront_domain, 443, socket.AF_INET)
|
|
853
|
+
unique_ips = list(set(ip[4][0] for ip in ips))
|
|
854
|
+
logging.info(f"Resolved CloudFront IPs: {unique_ips}")
|
|
855
|
+
|
|
856
|
+
self._create_or_update_rrset(apex_domain, '', 'A', unique_ips, 300)
|
|
857
|
+
logging.info(f"Apex A records created: {apex_domain} -> {unique_ips}")
|
|
858
|
+
except Exception as e:
|
|
859
|
+
raise Exception(f"Failed to create apex A records: {e}")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def get_dns_provider(provider_name: str) -> DNSProvider:
|
|
863
|
+
"""Factory function to get the appropriate DNS provider."""
|
|
864
|
+
providers = {
|
|
865
|
+
'cloudflare': CloudflareDNSProvider,
|
|
866
|
+
'hetzner': HetznerDNSProvider,
|
|
867
|
+
'desec': DeSECDNSProvider,
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if provider_name not in providers:
|
|
871
|
+
raise ValueError(f"Unknown DNS provider: {provider_name}. Available: {list(providers.keys())}")
|
|
872
|
+
|
|
873
|
+
return providers[provider_name]()
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def parse_arguments():
|
|
877
|
+
"""Parse command line arguments."""
|
|
878
|
+
parser = argparse.ArgumentParser(
|
|
879
|
+
description='Setup CDN with S3, CloudFront, and Cloudflare',
|
|
880
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
881
|
+
epilog="""
|
|
882
|
+
Examples:
|
|
883
|
+
# Basic create with Cloudflare SSL (default)
|
|
884
|
+
python setup_s3_website.py --domain lularge.com --subdomain cdn-staging
|
|
885
|
+
|
|
886
|
+
# Apex domain setup (no subdomain)
|
|
887
|
+
python setup_s3_website.py --domain example.com --apex
|
|
888
|
+
|
|
889
|
+
# Apex with www redirect
|
|
890
|
+
python setup_s3_website.py --domain example.com --apex --www-redirect
|
|
891
|
+
|
|
892
|
+
# Setup with ACM SSL instead of Cloudflare
|
|
893
|
+
python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --acm-ssl
|
|
894
|
+
|
|
895
|
+
# Use specific AWS profile
|
|
896
|
+
python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --aws-profile production
|
|
897
|
+
|
|
898
|
+
# Force recreate CloudFront distribution
|
|
899
|
+
python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --force
|
|
900
|
+
|
|
901
|
+
# Specify different region
|
|
902
|
+
python setup_s3_website.py --domain lularge.com --subdomain cdn-staging --region eu-west-1
|
|
903
|
+
|
|
904
|
+
# Setup for Single Page Application (React, Vue, Angular, etc.)
|
|
905
|
+
python setup_s3_website.py --domain lularge.com --subdomain app-dev --spa-mode
|
|
906
|
+
|
|
907
|
+
# Custom SPA error handling
|
|
908
|
+
python setup_s3_website.py --domain lularge.com --subdomain app --spa-mode --spa-error-path /app.html --spa-error-code 200
|
|
909
|
+
|
|
910
|
+
Environment Variables:
|
|
911
|
+
CLOUDFLARE_API_TOKEN: Required - Your Cloudflare API token
|
|
912
|
+
AWS_PROFILE: Optional - AWS profile to use (can be overridden by --aws-profile)
|
|
913
|
+
"""
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
parser.add_argument('--domain', required=True,
|
|
917
|
+
help='The root domain (e.g., lularge.com)')
|
|
918
|
+
parser.add_argument('--subdomain', default=None,
|
|
919
|
+
help='The subdomain for the CDN (e.g., cdn-staging). Omit for apex domain.')
|
|
920
|
+
parser.add_argument('--apex', action='store_true',
|
|
921
|
+
help='Setup for apex domain (no subdomain, e.g., example.com)')
|
|
922
|
+
parser.add_argument('--www-redirect', action='store_true',
|
|
923
|
+
help='Create www redirect to apex domain (only valid with --apex)')
|
|
924
|
+
parser.add_argument('--region', default='us-east-1',
|
|
925
|
+
help='AWS region for S3 bucket (default: us-east-1)')
|
|
926
|
+
parser.add_argument('--aws-profile', default=None,
|
|
927
|
+
help='AWS profile to use for credentials (default: uses default profile or AWS_PROFILE env var)')
|
|
928
|
+
|
|
929
|
+
ssl_group = parser.add_mutually_exclusive_group()
|
|
930
|
+
ssl_group.add_argument('--cloudflare-ssl', action='store_true', default=True,
|
|
931
|
+
help='Use Cloudflare for SSL (default)')
|
|
932
|
+
ssl_group.add_argument('--acm-ssl', action='store_true',
|
|
933
|
+
help='Use AWS ACM for SSL instead of Cloudflare')
|
|
934
|
+
|
|
935
|
+
parser.add_argument('--test-timeout', type=int, default=60,
|
|
936
|
+
help='Timeout for CDN tests in seconds (default: 60)')
|
|
937
|
+
parser.add_argument('--skip-tests', action='store_true',
|
|
938
|
+
help='Skip the CDN functionality tests')
|
|
939
|
+
parser.add_argument('--force', action='store_true',
|
|
940
|
+
help='Force recreate CloudFront distribution even if it exists')
|
|
941
|
+
|
|
942
|
+
# SPA (Single Page Application) support
|
|
943
|
+
parser.add_argument('--spa-mode', action='store_true',
|
|
944
|
+
help='Configure CloudFront for Single Page Application routing (redirect 404s to index.html)')
|
|
945
|
+
parser.add_argument('--spa-error-path', default='/index.html',
|
|
946
|
+
help='Path to serve for 404 errors in SPA mode (default: /index.html)')
|
|
947
|
+
parser.add_argument('--spa-error-code', type=int, default=200,
|
|
948
|
+
help='HTTP response code for SPA 404 redirects (default: 200)')
|
|
949
|
+
|
|
950
|
+
# DNS Provider selection
|
|
951
|
+
parser.add_argument('--dns-provider', choices=['cloudflare', 'hetzner', 'desec'],
|
|
952
|
+
default='cloudflare',
|
|
953
|
+
help='DNS provider to use (default: cloudflare). '
|
|
954
|
+
'Requires corresponding API token env var.')
|
|
955
|
+
parser.add_argument('--create-zone', action='store_true',
|
|
956
|
+
help='Create DNS zone if it does not exist (only supported by deSEC). '
|
|
957
|
+
'Note: You must configure nameservers at your registrar after creation.')
|
|
958
|
+
|
|
959
|
+
return parser.parse_args()
|
|
960
|
+
|
|
961
|
+
# Parse command line arguments
|
|
962
|
+
args = parse_arguments()
|
|
963
|
+
|
|
964
|
+
# --- Configuration from command line ---
|
|
965
|
+
DOMAIN_NAME = args.domain
|
|
966
|
+
SUBDOMAIN = args.subdomain
|
|
967
|
+
APEX_MODE = args.apex
|
|
968
|
+
WWW_REDIRECT = args.www_redirect
|
|
969
|
+
DNS_PROVIDER_NAME = args.dns_provider
|
|
970
|
+
|
|
971
|
+
# Determine bucket name based on mode
|
|
972
|
+
if APEX_MODE:
|
|
973
|
+
BUCKET_NAME = DOMAIN_NAME # Apex domain as bucket name (e.g., example.com)
|
|
974
|
+
WWW_DOMAIN = f"www.{DOMAIN_NAME}"
|
|
975
|
+
elif SUBDOMAIN:
|
|
976
|
+
BUCKET_NAME = f"{SUBDOMAIN}.{DOMAIN_NAME}"
|
|
977
|
+
WWW_DOMAIN = None
|
|
978
|
+
else:
|
|
979
|
+
print("Error: Either --subdomain or --apex must be specified")
|
|
980
|
+
exit(1)
|
|
981
|
+
|
|
982
|
+
CLOUDFLARE_ZONE_NAME = DOMAIN_NAME
|
|
983
|
+
AWS_REGION = args.region
|
|
984
|
+
AWS_PROFILE = args.aws_profile
|
|
985
|
+
USE_CLOUDFLARE_SSL = not args.acm_ssl # If --acm-ssl is specified, don't use Cloudflare SSL
|
|
986
|
+
TEST_TIMEOUT = args.test_timeout
|
|
987
|
+
SKIP_TESTS = args.skip_tests
|
|
988
|
+
FORCE_RECREATE = args.force
|
|
989
|
+
SPA_MODE = args.spa_mode
|
|
990
|
+
SPA_ERROR_PATH = args.spa_error_path
|
|
991
|
+
SPA_ERROR_CODE = args.spa_error_code
|
|
992
|
+
CREATE_ZONE = args.create_zone
|
|
993
|
+
|
|
994
|
+
# --- Setup logging ---
|
|
995
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
996
|
+
|
|
997
|
+
# Log the configuration
|
|
998
|
+
logging.info("=== CDN Setup Configuration ===")
|
|
999
|
+
logging.info(f"Domain: {DOMAIN_NAME}")
|
|
1000
|
+
if APEX_MODE:
|
|
1001
|
+
logging.info("Mode: Apex domain (production)")
|
|
1002
|
+
logging.info(f"Apex Domain: {BUCKET_NAME}")
|
|
1003
|
+
logging.info(f"WWW Domain: {WWW_DOMAIN}")
|
|
1004
|
+
logging.info(f"WWW Redirect: {WWW_REDIRECT}")
|
|
1005
|
+
else:
|
|
1006
|
+
logging.info("Mode: Subdomain")
|
|
1007
|
+
logging.info(f"Subdomain: {SUBDOMAIN}")
|
|
1008
|
+
logging.info(f"Full domain: {BUCKET_NAME}")
|
|
1009
|
+
logging.info(f"AWS Region: {AWS_REGION}")
|
|
1010
|
+
logging.info(f"AWS Profile: {AWS_PROFILE if AWS_PROFILE else 'default'}")
|
|
1011
|
+
logging.info(f"SSL Method: {'Cloudflare' if USE_CLOUDFLARE_SSL else 'AWS ACM'}")
|
|
1012
|
+
logging.info(f"Skip Tests: {SKIP_TESTS}")
|
|
1013
|
+
logging.info(f"Force Recreate: {FORCE_RECREATE}")
|
|
1014
|
+
logging.info(f"SPA Mode: {SPA_MODE}")
|
|
1015
|
+
if SPA_MODE:
|
|
1016
|
+
logging.info(f" SPA Error Path: {SPA_ERROR_PATH}")
|
|
1017
|
+
logging.info(f" SPA Response Code: {SPA_ERROR_CODE}")
|
|
1018
|
+
logging.info(f"DNS Provider: {DNS_PROVIDER_NAME}")
|
|
1019
|
+
logging.info(f"Create Zone: {CREATE_ZONE}")
|
|
1020
|
+
logging.info("===============================")
|
|
1021
|
+
|
|
1022
|
+
# --- Initialize AWS clients ---
|
|
1023
|
+
def create_aws_clients(aws_profile=None, region=None):
|
|
1024
|
+
"""Create AWS clients with optional profile support.
|
|
1025
|
+
|
|
1026
|
+
Note: ACM client is ALWAYS created in us-east-1 because CloudFront
|
|
1027
|
+
requires certificates to be in us-east-1 regardless of the S3 bucket region.
|
|
1028
|
+
"""
|
|
1029
|
+
if aws_profile:
|
|
1030
|
+
logging.info(f"Creating AWS clients using profile: {aws_profile}")
|
|
1031
|
+
session = boto3.Session(profile_name=aws_profile)
|
|
1032
|
+
else:
|
|
1033
|
+
logging.info("Creating AWS clients using default credentials")
|
|
1034
|
+
session = boto3.Session()
|
|
1035
|
+
|
|
1036
|
+
# S3 client uses the specified region for bucket operations
|
|
1037
|
+
s3_region = region or 'us-east-1'
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
's3': session.client('s3', region_name=s3_region),
|
|
1041
|
+
# ACM MUST be in us-east-1 for CloudFront - this is an AWS requirement
|
|
1042
|
+
'acm': session.client('acm', region_name='us-east-1'),
|
|
1043
|
+
'cloudfront': session.client('cloudfront'),
|
|
1044
|
+
'sts': session.client('sts')
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
# Create AWS clients with the specified profile
|
|
1048
|
+
aws_clients = create_aws_clients(AWS_PROFILE, AWS_REGION)
|
|
1049
|
+
s3_client = aws_clients['s3']
|
|
1050
|
+
acm_client = aws_clients['acm']
|
|
1051
|
+
cloudfront_client = aws_clients['cloudfront']
|
|
1052
|
+
sts_client = aws_clients['sts']
|
|
1053
|
+
|
|
1054
|
+
def get_cloudflare_client():
|
|
1055
|
+
"""Gets Cloudflare API token from environment variable and returns a client."""
|
|
1056
|
+
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
|
1057
|
+
if not token:
|
|
1058
|
+
logging.error("CLOUDFLARE_API_TOKEN environment variable not set.")
|
|
1059
|
+
raise ValueError("CLOUDFLARE_API_TOKEN not found in environment variables.")
|
|
1060
|
+
return Cloudflare(api_token=token)
|
|
1061
|
+
|
|
1062
|
+
def create_s3_bucket(bucket_name):
|
|
1063
|
+
"""Creates an S3 bucket if it doesn't exist."""
|
|
1064
|
+
try:
|
|
1065
|
+
logging.info(f"Checking if bucket '{bucket_name}' exists...")
|
|
1066
|
+
s3_client.head_bucket(Bucket=bucket_name)
|
|
1067
|
+
logging.info(f"Bucket '{bucket_name}' already exists.")
|
|
1068
|
+
except s3_client.exceptions.ClientError as e:
|
|
1069
|
+
if e.response['Error']['Code'] == '404':
|
|
1070
|
+
logging.info(f"Bucket '{bucket_name}' does not exist. Creating...")
|
|
1071
|
+
if AWS_REGION == 'us-east-1':
|
|
1072
|
+
# For us-east-1, don't specify LocationConstraint
|
|
1073
|
+
s3_client.create_bucket(Bucket=bucket_name)
|
|
1074
|
+
else:
|
|
1075
|
+
# For other regions, specify the LocationConstraint
|
|
1076
|
+
s3_client.create_bucket(
|
|
1077
|
+
Bucket=bucket_name,
|
|
1078
|
+
CreateBucketConfiguration={'LocationConstraint': AWS_REGION}
|
|
1079
|
+
)
|
|
1080
|
+
logging.info(f"Bucket '{bucket_name}' created successfully.")
|
|
1081
|
+
else:
|
|
1082
|
+
logging.error(f"Error checking bucket: {e}")
|
|
1083
|
+
raise
|
|
1084
|
+
|
|
1085
|
+
def create_test_content(bucket_name):
|
|
1086
|
+
"""Creates a simple test HTML file in the S3 bucket."""
|
|
1087
|
+
logging.info(f"Creating test content in bucket '{bucket_name}'...")
|
|
1088
|
+
|
|
1089
|
+
test_html = f"""<!DOCTYPE html>
|
|
1090
|
+
<html>
|
|
1091
|
+
<head>
|
|
1092
|
+
<meta charset="UTF-8">
|
|
1093
|
+
<title>CDN Test Page</title>
|
|
1094
|
+
<style>
|
|
1095
|
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
1096
|
+
.container {{ max-width: 600px; margin: 0 auto; text-align: center; }}
|
|
1097
|
+
.success {{ color: #28a745; }}
|
|
1098
|
+
.info {{ color: #17a2b8; background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
|
|
1099
|
+
</style>
|
|
1100
|
+
</head>
|
|
1101
|
+
<body>
|
|
1102
|
+
<div class="container">
|
|
1103
|
+
<h1 class="success">🎉 CDN Setup Successful!</h1>
|
|
1104
|
+
<p>Your CDN is working correctly.</p>
|
|
1105
|
+
<div class="info">
|
|
1106
|
+
<h3>Bucket Information</h3>
|
|
1107
|
+
<p><strong>Bucket:</strong> {bucket_name}</p>
|
|
1108
|
+
<p><strong>Region:</strong> {AWS_REGION}</p>
|
|
1109
|
+
<p><strong>Served via:</strong> CloudFront + Cloudflare</p>
|
|
1110
|
+
<p><strong>SSL:</strong> Cloudflare</p>
|
|
1111
|
+
<p><strong>S3 Website Endpoint:</strong> {get_s3_website_endpoint(bucket_name, AWS_REGION)}</p>
|
|
1112
|
+
</div>
|
|
1113
|
+
<p><small>Generated by setup_s3_website.py</small></p>
|
|
1114
|
+
</div>
|
|
1115
|
+
</body>
|
|
1116
|
+
</html>"""
|
|
1117
|
+
|
|
1118
|
+
error_html = f"""<!DOCTYPE html>
|
|
1119
|
+
<html>
|
|
1120
|
+
<head>
|
|
1121
|
+
<meta charset="UTF-8">
|
|
1122
|
+
<title>Page Not Found - CDN</title>
|
|
1123
|
+
<style>
|
|
1124
|
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
1125
|
+
.container {{ max-width: 600px; margin: 0 auto; text-align: center; }}
|
|
1126
|
+
.error {{ color: #dc3545; }}
|
|
1127
|
+
.info {{ color: #6c757d; background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
|
|
1128
|
+
</style>
|
|
1129
|
+
</head>
|
|
1130
|
+
<body>
|
|
1131
|
+
<div class="container">
|
|
1132
|
+
<h1 class="error">404 - Page Not Found</h1>
|
|
1133
|
+
<p>The page you're looking for doesn't exist.</p>
|
|
1134
|
+
<div class="info">
|
|
1135
|
+
<p>This is a custom error page served from S3 static website hosting.</p>
|
|
1136
|
+
<p><a href="/">ā Back to Home</a></p>
|
|
1137
|
+
</div>
|
|
1138
|
+
<p><small>CDN: {bucket_name}</small></p>
|
|
1139
|
+
</div>
|
|
1140
|
+
</body>
|
|
1141
|
+
</html>"""
|
|
1142
|
+
|
|
1143
|
+
try:
|
|
1144
|
+
# Upload the test HTML file
|
|
1145
|
+
s3_client.put_object(
|
|
1146
|
+
Bucket=bucket_name,
|
|
1147
|
+
Key='index-test.html',
|
|
1148
|
+
Body=test_html.encode('utf-8'),
|
|
1149
|
+
ContentType='text/html',
|
|
1150
|
+
CacheControl='max-age=300'
|
|
1151
|
+
)
|
|
1152
|
+
logging.info("Test content (index-test.html) uploaded successfully.")
|
|
1153
|
+
|
|
1154
|
+
# Upload the error HTML file
|
|
1155
|
+
s3_client.put_object(
|
|
1156
|
+
Bucket=bucket_name,
|
|
1157
|
+
Key='error.html',
|
|
1158
|
+
Body=error_html.encode('utf-8'),
|
|
1159
|
+
ContentType='text/html',
|
|
1160
|
+
CacheControl='max-age=300'
|
|
1161
|
+
)
|
|
1162
|
+
logging.info("Error page (error.html) uploaded successfully.")
|
|
1163
|
+
|
|
1164
|
+
# Also create a simple test file for verification
|
|
1165
|
+
test_text = f"CDN test file - {bucket_name} - Generated at {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}"
|
|
1166
|
+
s3_client.put_object(
|
|
1167
|
+
Bucket=bucket_name,
|
|
1168
|
+
Key='test.txt',
|
|
1169
|
+
Body=test_text.encode('utf-8'),
|
|
1170
|
+
ContentType='text/plain',
|
|
1171
|
+
CacheControl='max-age=300'
|
|
1172
|
+
)
|
|
1173
|
+
logging.info("Test file (test.txt) uploaded successfully.")
|
|
1174
|
+
|
|
1175
|
+
# If SPA mode is enabled, create a proper index.html file for SPA testing
|
|
1176
|
+
if SPA_MODE:
|
|
1177
|
+
spa_index_html = f"""<!DOCTYPE html>
|
|
1178
|
+
<html>
|
|
1179
|
+
<head>
|
|
1180
|
+
<meta charset="UTF-8">
|
|
1181
|
+
<title>SPA Test - {bucket_name}</title>
|
|
1182
|
+
<style>
|
|
1183
|
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
1184
|
+
.container {{ max-width: 800px; margin: 0 auto; }}
|
|
1185
|
+
.success {{ color: #28a745; }}
|
|
1186
|
+
.info {{ color: #17a2b8; background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
|
|
1187
|
+
.route {{ padding: 10px; margin: 10px 0; background: #e9ecef; border-radius: 3px; }}
|
|
1188
|
+
a {{ color: #007bff; text-decoration: none; }}
|
|
1189
|
+
a:hover {{ text-decoration: underline; }}
|
|
1190
|
+
</style>
|
|
1191
|
+
</head>
|
|
1192
|
+
<body>
|
|
1193
|
+
<div class="container">
|
|
1194
|
+
<h1 class="success">🎉 SPA-Enabled CDN Setup Successful!</h1>
|
|
1195
|
+
<p>Your CDN is configured for Single Page Application routing.</p>
|
|
1196
|
+
<div class="info">
|
|
1197
|
+
<h3>Configuration</h3>
|
|
1198
|
+
<p><strong>Bucket:</strong> {bucket_name}</p>
|
|
1199
|
+
<p><strong>Region:</strong> {AWS_REGION}</p>
|
|
1200
|
+
<p><strong>SPA Mode:</strong> Enabled</p>
|
|
1201
|
+
<p><strong>Error Path:</strong> {SPA_ERROR_PATH}</p>
|
|
1202
|
+
<p><strong>Error Code:</strong> {SPA_ERROR_CODE}</p>
|
|
1203
|
+
</div>
|
|
1204
|
+
<div class="info">
|
|
1205
|
+
<h3>Test SPA Routing</h3>
|
|
1206
|
+
<p>The following URLs should all serve this page (thanks to CloudFront custom error responses):</p>
|
|
1207
|
+
<div class="route">
|
|
1208
|
+
<a href="/">https://{bucket_name}/</a> - Root page
|
|
1209
|
+
</div>
|
|
1210
|
+
<div class="route">
|
|
1211
|
+
<a href="/about">https://{bucket_name}/about</a> - Should serve this page
|
|
1212
|
+
</div>
|
|
1213
|
+
<div class="route">
|
|
1214
|
+
<a href="/products/123">https://{bucket_name}/products/123</a> - Should serve this page
|
|
1215
|
+
</div>
|
|
1216
|
+
<div class="route">
|
|
1217
|
+
<a href="/any/nested/route">https://{bucket_name}/any/nested/route</a> - Should serve this page
|
|
1218
|
+
</div>
|
|
1219
|
+
</div>
|
|
1220
|
+
<p><small>Generated by setup_s3_website.py with --spa-mode</small></p>
|
|
1221
|
+
</div>
|
|
1222
|
+
</body>
|
|
1223
|
+
</html>"""
|
|
1224
|
+
|
|
1225
|
+
s3_client.put_object(
|
|
1226
|
+
Bucket=bucket_name,
|
|
1227
|
+
Key='index.html',
|
|
1228
|
+
Body=spa_index_html.encode('utf-8'),
|
|
1229
|
+
ContentType='text/html',
|
|
1230
|
+
CacheControl='max-age=300'
|
|
1231
|
+
)
|
|
1232
|
+
logging.info("SPA index.html uploaded successfully.")
|
|
1233
|
+
|
|
1234
|
+
except Exception as e:
|
|
1235
|
+
logging.error(f"Error uploading test content: {e}")
|
|
1236
|
+
raise
|
|
1237
|
+
|
|
1238
|
+
def find_existing_acm_certificate(domain_name):
|
|
1239
|
+
"""Find an existing ACM certificate that covers the domain."""
|
|
1240
|
+
logging.info(f"Searching for existing ACM certificates for '{domain_name}'...")
|
|
1241
|
+
|
|
1242
|
+
try:
|
|
1243
|
+
# List all certificates in the region
|
|
1244
|
+
response = acm_client.list_certificates(
|
|
1245
|
+
CertificateStatuses=['ISSUED'] # Only look for valid certificates
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
for cert_summary in response['CertificateSummaryList']:
|
|
1249
|
+
cert_arn = cert_summary['CertificateArn']
|
|
1250
|
+
cert_domain = cert_summary['DomainName']
|
|
1251
|
+
|
|
1252
|
+
# Get detailed certificate info to check Subject Alternative Names
|
|
1253
|
+
cert_details = acm_client.describe_certificate(CertificateArn=cert_arn)
|
|
1254
|
+
cert_info = cert_details['Certificate']
|
|
1255
|
+
|
|
1256
|
+
# Check if this certificate covers our domain
|
|
1257
|
+
covered_domains = [cert_info['DomainName']]
|
|
1258
|
+
if 'SubjectAlternativeNames' in cert_info:
|
|
1259
|
+
covered_domains.extend(cert_info['SubjectAlternativeNames'])
|
|
1260
|
+
|
|
1261
|
+
for covered_domain in covered_domains:
|
|
1262
|
+
# Check for exact match or wildcard match
|
|
1263
|
+
if (covered_domain == domain_name or
|
|
1264
|
+
(covered_domain.startswith('*.') and
|
|
1265
|
+
domain_name.endswith(covered_domain[1:]))):
|
|
1266
|
+
|
|
1267
|
+
logging.info(f"Found existing certificate: {cert_arn}")
|
|
1268
|
+
logging.info(f" Certificate domain: {cert_domain}")
|
|
1269
|
+
logging.info(f" Covers domains: {covered_domains}")
|
|
1270
|
+
logging.info(f" Status: {cert_info['Status']}")
|
|
1271
|
+
return cert_arn
|
|
1272
|
+
|
|
1273
|
+
logging.info(f"No existing ACM certificate found for '{domain_name}'")
|
|
1274
|
+
return None
|
|
1275
|
+
|
|
1276
|
+
except Exception as e:
|
|
1277
|
+
logging.error(f"Error searching for existing certificates: {e}")
|
|
1278
|
+
return None
|
|
1279
|
+
|
|
1280
|
+
def find_certificate_covering_domains(domains):
|
|
1281
|
+
"""Find an existing ACM certificate that covers ALL specified domains.
|
|
1282
|
+
|
|
1283
|
+
This is used for apex domain setups where we need a certificate that covers
|
|
1284
|
+
both the apex domain (example.com) and subdomains (*.example.com).
|
|
1285
|
+
A wildcard cert alone does NOT cover the apex domain.
|
|
1286
|
+
"""
|
|
1287
|
+
logging.info(f"Searching for certificate covering all domains: {domains}")
|
|
1288
|
+
|
|
1289
|
+
try:
|
|
1290
|
+
response = acm_client.list_certificates(CertificateStatuses=['ISSUED'])
|
|
1291
|
+
|
|
1292
|
+
for cert_summary in response['CertificateSummaryList']:
|
|
1293
|
+
cert_arn = cert_summary['CertificateArn']
|
|
1294
|
+
cert_details = acm_client.describe_certificate(CertificateArn=cert_arn)
|
|
1295
|
+
cert_info = cert_details['Certificate']
|
|
1296
|
+
|
|
1297
|
+
covered_domains = set([cert_info['DomainName']])
|
|
1298
|
+
if 'SubjectAlternativeNames' in cert_info:
|
|
1299
|
+
covered_domains.update(cert_info['SubjectAlternativeNames'])
|
|
1300
|
+
|
|
1301
|
+
# Check if ALL required domains are covered
|
|
1302
|
+
all_covered = True
|
|
1303
|
+
for domain in domains:
|
|
1304
|
+
domain_covered = False
|
|
1305
|
+
for covered in covered_domains:
|
|
1306
|
+
if covered == domain:
|
|
1307
|
+
domain_covered = True
|
|
1308
|
+
break
|
|
1309
|
+
# Wildcard match (*.example.com covers www.example.com but NOT example.com)
|
|
1310
|
+
if covered.startswith('*.') and domain.endswith(covered[1:]) and domain != covered[2:]:
|
|
1311
|
+
domain_covered = True
|
|
1312
|
+
break
|
|
1313
|
+
if not domain_covered:
|
|
1314
|
+
all_covered = False
|
|
1315
|
+
break
|
|
1316
|
+
|
|
1317
|
+
if all_covered:
|
|
1318
|
+
logging.info(f"Found certificate covering all domains: {cert_arn}")
|
|
1319
|
+
logging.info(f" Covered domains: {covered_domains}")
|
|
1320
|
+
return cert_arn
|
|
1321
|
+
|
|
1322
|
+
logging.info("No existing certificate covers all required domains")
|
|
1323
|
+
return None
|
|
1324
|
+
|
|
1325
|
+
except Exception as e:
|
|
1326
|
+
logging.error(f"Error searching certificates: {e}")
|
|
1327
|
+
return None
|
|
1328
|
+
|
|
1329
|
+
def request_acm_certificate(domain_name, san_domains=None):
|
|
1330
|
+
"""Requests an ACM certificate for the domain and returns its ARN and validation info.
|
|
1331
|
+
|
|
1332
|
+
Args:
|
|
1333
|
+
domain_name: Primary domain for the certificate
|
|
1334
|
+
san_domains: Optional list of Subject Alternative Names (e.g., for apex + wildcard)
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
Tuple of (certificate_arn, validation_options, waiter)
|
|
1338
|
+
- validation_options is a list when san_domains is provided, single dict otherwise
|
|
1339
|
+
"""
|
|
1340
|
+
logging.info(f"Requesting ACM certificate for '{domain_name}'...")
|
|
1341
|
+
|
|
1342
|
+
cert_params = {
|
|
1343
|
+
'DomainName': domain_name,
|
|
1344
|
+
'ValidationMethod': 'DNS'
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if san_domains:
|
|
1348
|
+
cert_params['SubjectAlternativeNames'] = san_domains
|
|
1349
|
+
logging.info(f"Including SANs: {san_domains}")
|
|
1350
|
+
|
|
1351
|
+
response = acm_client.request_certificate(**cert_params)
|
|
1352
|
+
certificate_arn = response['CertificateArn']
|
|
1353
|
+
|
|
1354
|
+
logging.info(f"Waiting for certificate details to become available for {certificate_arn}...")
|
|
1355
|
+
time.sleep(10) # Give some time for the validation options to be available
|
|
1356
|
+
|
|
1357
|
+
cert_description = acm_client.describe_certificate(CertificateArn=certificate_arn)
|
|
1358
|
+
validation_options = cert_description['Certificate']['DomainValidationOptions']
|
|
1359
|
+
|
|
1360
|
+
# Wait for all validation records to be available
|
|
1361
|
+
while not all('ResourceRecord' in opt for opt in validation_options):
|
|
1362
|
+
logging.info("Waiting for DNS validation records to be created by AWS...")
|
|
1363
|
+
time.sleep(10)
|
|
1364
|
+
cert_description = acm_client.describe_certificate(CertificateArn=certificate_arn)
|
|
1365
|
+
validation_options = cert_description['Certificate']['DomainValidationOptions']
|
|
1366
|
+
|
|
1367
|
+
waiter = acm_client.get_waiter('certificate_validated')
|
|
1368
|
+
|
|
1369
|
+
logging.info(f"Certificate requested. ARN: {certificate_arn}")
|
|
1370
|
+
for opt in validation_options:
|
|
1371
|
+
if 'ResourceRecord' in opt:
|
|
1372
|
+
logging.info(f"DNS validation record: {opt['ResourceRecord']['Name']} -> {opt['ResourceRecord']['Value']}")
|
|
1373
|
+
|
|
1374
|
+
# For backward compatibility, return single validation_cname if no SANs
|
|
1375
|
+
if not san_domains:
|
|
1376
|
+
return certificate_arn, validation_options[0]['ResourceRecord'], waiter
|
|
1377
|
+
else:
|
|
1378
|
+
return certificate_arn, validation_options, waiter
|
|
1379
|
+
|
|
1380
|
+
def setup_dns_validation(cf_client, zone_name, validation_input):
|
|
1381
|
+
"""Creates CNAME record(s) in Cloudflare for ACM validation.
|
|
1382
|
+
|
|
1383
|
+
Args:
|
|
1384
|
+
cf_client: Cloudflare client
|
|
1385
|
+
zone_name: Cloudflare zone name
|
|
1386
|
+
validation_input: Either a single validation CNAME dict or a list of validation options
|
|
1387
|
+
|
|
1388
|
+
Returns:
|
|
1389
|
+
Tuple of (zone_id, created_records) where created_records is a list of dicts
|
|
1390
|
+
"""
|
|
1391
|
+
logging.info("Setting up DNS validation record(s) in Cloudflare...")
|
|
1392
|
+
try:
|
|
1393
|
+
zones = cf_client.zones.list(name=zone_name)
|
|
1394
|
+
if not zones.result:
|
|
1395
|
+
logging.error(f"Zone '{zone_name}' not found in Cloudflare.")
|
|
1396
|
+
raise Exception(f"Zone not found: {zone_name}")
|
|
1397
|
+
zone_id = zones.result[0].id
|
|
1398
|
+
|
|
1399
|
+
# Normalize input to list of validation records
|
|
1400
|
+
if isinstance(validation_input, list):
|
|
1401
|
+
# List of validation options from SAN certificate
|
|
1402
|
+
validation_records = []
|
|
1403
|
+
for opt in validation_input:
|
|
1404
|
+
if 'ResourceRecord' in opt:
|
|
1405
|
+
validation_records.append(opt['ResourceRecord'])
|
|
1406
|
+
else:
|
|
1407
|
+
# Single validation CNAME dict
|
|
1408
|
+
validation_records = [validation_input]
|
|
1409
|
+
|
|
1410
|
+
created_records = []
|
|
1411
|
+
for validation_cname in validation_records:
|
|
1412
|
+
dns_record = {
|
|
1413
|
+
'name': validation_cname['Name'],
|
|
1414
|
+
'type': validation_cname['Type'],
|
|
1415
|
+
'content': validation_cname['Value'].rstrip('.'),
|
|
1416
|
+
'proxied': False,
|
|
1417
|
+
'ttl': 60
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
try:
|
|
1421
|
+
cf_client.dns.records.create(zone_id=zone_id, **dns_record)
|
|
1422
|
+
logging.info(f"DNS validation record created for {validation_cname['Name']}.")
|
|
1423
|
+
created_records.append(dns_record)
|
|
1424
|
+
except Exception as e:
|
|
1425
|
+
if "already exists" in str(e):
|
|
1426
|
+
logging.warning(f"DNS record for {validation_cname['Name']} already exists.")
|
|
1427
|
+
else:
|
|
1428
|
+
raise
|
|
1429
|
+
|
|
1430
|
+
return zone_id, created_records
|
|
1431
|
+
except Exception as e:
|
|
1432
|
+
if "already exists" in str(e):
|
|
1433
|
+
logging.warning("DNS record already exists.")
|
|
1434
|
+
return None, None
|
|
1435
|
+
logging.error(f"Cloudflare API error: {e}")
|
|
1436
|
+
raise
|
|
1437
|
+
|
|
1438
|
+
def wait_for_cert_validation(waiter, certificate_arn):
|
|
1439
|
+
"""Waits for the ACM certificate to be validated."""
|
|
1440
|
+
logging.info("Waiting for ACM certificate validation... This may take a few minutes.")
|
|
1441
|
+
try:
|
|
1442
|
+
waiter.wait(
|
|
1443
|
+
CertificateArn=certificate_arn,
|
|
1444
|
+
WaiterConfig={'Delay': 30, 'MaxAttempts': 20}
|
|
1445
|
+
)
|
|
1446
|
+
logging.info("Certificate has been validated successfully.")
|
|
1447
|
+
except Exception as e:
|
|
1448
|
+
logging.error(f"Certificate validation failed: {e}")
|
|
1449
|
+
raise
|
|
1450
|
+
|
|
1451
|
+
def get_existing_oac(oac_name):
|
|
1452
|
+
"""Check if an OAC with the given name already exists."""
|
|
1453
|
+
try:
|
|
1454
|
+
response = cloudfront_client.list_origin_access_controls()
|
|
1455
|
+
for oac in response['OriginAccessControlList']['Items']:
|
|
1456
|
+
if oac['Name'] == oac_name:
|
|
1457
|
+
logging.info(f"OAC '{oac_name}' already exists with ID: {oac['Id']}")
|
|
1458
|
+
return oac['Id']
|
|
1459
|
+
return None
|
|
1460
|
+
except Exception as e:
|
|
1461
|
+
logging.error(f"Error checking existing OACs: {e}")
|
|
1462
|
+
return None
|
|
1463
|
+
|
|
1464
|
+
def create_cloudfront_oac():
|
|
1465
|
+
"""Creates a CloudFront Origin Access Control."""
|
|
1466
|
+
oac_name = f"oac-{BUCKET_NAME}"
|
|
1467
|
+
logging.info(f"Checking if OAC '{oac_name}' already exists...")
|
|
1468
|
+
|
|
1469
|
+
existing_oac_id = get_existing_oac(oac_name)
|
|
1470
|
+
if existing_oac_id:
|
|
1471
|
+
return existing_oac_id
|
|
1472
|
+
|
|
1473
|
+
logging.info("Creating CloudFront Origin Access Control (OAC)...")
|
|
1474
|
+
response = cloudfront_client.create_origin_access_control(
|
|
1475
|
+
OriginAccessControlConfig={
|
|
1476
|
+
'Name': oac_name,
|
|
1477
|
+
'Description': f"OAC for {BUCKET_NAME}",
|
|
1478
|
+
'SigningProtocol': 'sigv4',
|
|
1479
|
+
'SigningBehavior': 'always',
|
|
1480
|
+
'OriginAccessControlOriginType': 's3'
|
|
1481
|
+
}
|
|
1482
|
+
)
|
|
1483
|
+
oac_id = response['OriginAccessControl']['Id']
|
|
1484
|
+
logging.info(f"OAC created with ID: {oac_id}")
|
|
1485
|
+
return oac_id
|
|
1486
|
+
|
|
1487
|
+
def get_existing_distribution(bucket_name):
|
|
1488
|
+
"""Check if a CloudFront distribution for the bucket already exists."""
|
|
1489
|
+
try:
|
|
1490
|
+
response = cloudfront_client.list_distributions()
|
|
1491
|
+
if 'DistributionList' in response and 'Items' in response['DistributionList']:
|
|
1492
|
+
for dist in response['DistributionList']['Items']:
|
|
1493
|
+
# Check origins to see if any match our bucket
|
|
1494
|
+
for origin in dist.get('Origins', {}).get('Items', []):
|
|
1495
|
+
if bucket_name in origin.get('DomainName', ''):
|
|
1496
|
+
logging.info(f"Found existing distribution for '{bucket_name}': {dist['Id']}")
|
|
1497
|
+
return dist
|
|
1498
|
+
return None
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
logging.error(f"Error checking existing distributions: {e}")
|
|
1501
|
+
return None
|
|
1502
|
+
|
|
1503
|
+
def find_distribution_using_cname(domain_name):
|
|
1504
|
+
"""Find any CloudFront distribution that uses the specified domain as an alias."""
|
|
1505
|
+
try:
|
|
1506
|
+
logging.info(f"Searching for distributions using CNAME '{domain_name}'...")
|
|
1507
|
+
response = cloudfront_client.list_distributions()
|
|
1508
|
+
if 'DistributionList' in response and 'Items' in response['DistributionList']:
|
|
1509
|
+
for dist in response['DistributionList']['Items']:
|
|
1510
|
+
# Check aliases
|
|
1511
|
+
aliases = dist.get('Aliases', {}).get('Items', [])
|
|
1512
|
+
if domain_name in aliases:
|
|
1513
|
+
logging.info(f"Found distribution using CNAME '{domain_name}': {dist['Id']}")
|
|
1514
|
+
logging.info(f" Distribution status: {dist['Status']}")
|
|
1515
|
+
logging.info(f" Distribution enabled: {dist['Enabled']}")
|
|
1516
|
+
logging.info(f" All aliases: {aliases}")
|
|
1517
|
+
return dist
|
|
1518
|
+
return None
|
|
1519
|
+
except Exception as e:
|
|
1520
|
+
logging.error(f"Error searching for distributions using CNAME: {e}")
|
|
1521
|
+
return None
|
|
1522
|
+
|
|
1523
|
+
def enable_s3_website_hosting(bucket_name):
|
|
1524
|
+
"""Enable static website hosting on the S3 bucket."""
|
|
1525
|
+
logging.info(f"Enabling static website hosting for bucket '{bucket_name}'...")
|
|
1526
|
+
|
|
1527
|
+
try:
|
|
1528
|
+
# First, disable block public access settings to allow public bucket policy
|
|
1529
|
+
logging.info("Disabling block public access settings...")
|
|
1530
|
+
s3_client.put_public_access_block(
|
|
1531
|
+
Bucket=bucket_name,
|
|
1532
|
+
PublicAccessBlockConfiguration={
|
|
1533
|
+
'BlockPublicAcls': False,
|
|
1534
|
+
'IgnorePublicAcls': False,
|
|
1535
|
+
'BlockPublicPolicy': False,
|
|
1536
|
+
'RestrictPublicBuckets': False
|
|
1537
|
+
}
|
|
1538
|
+
)
|
|
1539
|
+
logging.info("Block public access settings disabled successfully.")
|
|
1540
|
+
|
|
1541
|
+
# Wait a moment for the settings to propagate
|
|
1542
|
+
time.sleep(5)
|
|
1543
|
+
|
|
1544
|
+
# Configure the bucket for static website hosting
|
|
1545
|
+
website_configuration = {
|
|
1546
|
+
'IndexDocument': {
|
|
1547
|
+
'Suffix': 'index.html'
|
|
1548
|
+
},
|
|
1549
|
+
'ErrorDocument': {
|
|
1550
|
+
'Key': 'error.html'
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
s3_client.put_bucket_website(
|
|
1555
|
+
Bucket=bucket_name,
|
|
1556
|
+
WebsiteConfiguration=website_configuration
|
|
1557
|
+
)
|
|
1558
|
+
logging.info("S3 static website hosting enabled successfully.")
|
|
1559
|
+
|
|
1560
|
+
# Set bucket policy to allow public read access for website hosting
|
|
1561
|
+
public_read_policy = {
|
|
1562
|
+
"Version": "2012-10-17",
|
|
1563
|
+
"Statement": [
|
|
1564
|
+
{
|
|
1565
|
+
"Sid": "PublicReadGetObject",
|
|
1566
|
+
"Effect": "Allow",
|
|
1567
|
+
"Principal": "*",
|
|
1568
|
+
"Action": "s3:GetObject",
|
|
1569
|
+
"Resource": f"arn:aws:s3:::{bucket_name}/*"
|
|
1570
|
+
}
|
|
1571
|
+
]
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
s3_client.put_bucket_policy(
|
|
1575
|
+
Bucket=bucket_name,
|
|
1576
|
+
Policy=json.dumps(public_read_policy)
|
|
1577
|
+
)
|
|
1578
|
+
logging.info("S3 bucket policy updated for public read access.")
|
|
1579
|
+
|
|
1580
|
+
except Exception as e:
|
|
1581
|
+
logging.error(f"Error enabling S3 website hosting: {e}")
|
|
1582
|
+
raise
|
|
1583
|
+
|
|
1584
|
+
def get_s3_website_endpoint(bucket_name, region):
|
|
1585
|
+
"""Get the S3 static website endpoint for the bucket."""
|
|
1586
|
+
if region == 'us-east-1':
|
|
1587
|
+
return f"{bucket_name}.s3-website-us-east-1.amazonaws.com"
|
|
1588
|
+
else:
|
|
1589
|
+
return f"{bucket_name}.s3-website.{region}.amazonaws.com"
|
|
1590
|
+
|
|
1591
|
+
def update_cloudfront_distribution_origin(distribution_id, bucket_name, oac_id):
|
|
1592
|
+
"""Update an existing CloudFront distribution with the correct S3 website origin."""
|
|
1593
|
+
logging.info(f"Updating CloudFront distribution {distribution_id} with S3 website origin...")
|
|
1594
|
+
|
|
1595
|
+
try:
|
|
1596
|
+
# Get the current distribution configuration
|
|
1597
|
+
response = cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
1598
|
+
distribution_config = response['DistributionConfig']
|
|
1599
|
+
etag = response['ETag']
|
|
1600
|
+
|
|
1601
|
+
# Use the S3 website endpoint
|
|
1602
|
+
s3_website_domain = get_s3_website_endpoint(bucket_name, AWS_REGION)
|
|
1603
|
+
|
|
1604
|
+
logging.info(f"Updating origin to use S3 website endpoint: {s3_website_domain}")
|
|
1605
|
+
|
|
1606
|
+
# Use the domain name (bucket_name) as the origin ID
|
|
1607
|
+
origin_id = bucket_name
|
|
1608
|
+
|
|
1609
|
+
# Update the origin configuration
|
|
1610
|
+
for origin in distribution_config.get('Origins', {}).get('Items', []):
|
|
1611
|
+
if bucket_name in origin.get('DomainName', ''):
|
|
1612
|
+
logging.info(f"Updating origin: {origin.get('DomainName')} -> {s3_website_domain}")
|
|
1613
|
+
origin['Id'] = origin_id # Update the origin ID to use domain name
|
|
1614
|
+
origin['DomainName'] = s3_website_domain
|
|
1615
|
+
|
|
1616
|
+
# Remove S3OriginConfig and OAC since we're using website endpoint
|
|
1617
|
+
if 'S3OriginConfig' in origin:
|
|
1618
|
+
del origin['S3OriginConfig']
|
|
1619
|
+
if 'OriginAccessControlId' in origin:
|
|
1620
|
+
del origin['OriginAccessControlId']
|
|
1621
|
+
|
|
1622
|
+
# Use CustomOriginConfig for S3 website endpoint
|
|
1623
|
+
# OriginSslProtocols is required even for http-only
|
|
1624
|
+
origin['CustomOriginConfig'] = {
|
|
1625
|
+
'HTTPPort': 80,
|
|
1626
|
+
'HTTPSPort': 443,
|
|
1627
|
+
'OriginProtocolPolicy': 'http-only',
|
|
1628
|
+
'OriginSslProtocols': {
|
|
1629
|
+
'Quantity': 1,
|
|
1630
|
+
'Items': ['TLSv1.2']
|
|
1631
|
+
},
|
|
1632
|
+
'OriginReadTimeout': 30,
|
|
1633
|
+
'OriginKeepaliveTimeout': 5
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
logging.info(f"Origin configuration updated: {origin}")
|
|
1637
|
+
break
|
|
1638
|
+
|
|
1639
|
+
# Update the default cache behavior to use the new origin ID
|
|
1640
|
+
if 'DefaultCacheBehavior' in distribution_config:
|
|
1641
|
+
distribution_config['DefaultCacheBehavior']['TargetOriginId'] = origin_id
|
|
1642
|
+
logging.info(f"Updated default cache behavior to target origin ID: {origin_id}")
|
|
1643
|
+
else:
|
|
1644
|
+
logging.warning("DefaultCacheBehavior not found in distribution config, skipping target origin update")
|
|
1645
|
+
|
|
1646
|
+
# Ensure aliases are properly configured (always ensure aliases are configured)
|
|
1647
|
+
aliases_items = distribution_config.get('Aliases', {}).get('Items', [])
|
|
1648
|
+
if 'Aliases' not in distribution_config or not aliases_items:
|
|
1649
|
+
logging.info(f"Adding missing alias: {bucket_name}")
|
|
1650
|
+
distribution_config['Aliases'] = {
|
|
1651
|
+
'Quantity': 1,
|
|
1652
|
+
'Items': [bucket_name]
|
|
1653
|
+
}
|
|
1654
|
+
elif bucket_name not in aliases_items:
|
|
1655
|
+
logging.info(f"Adding alias to existing list: {bucket_name}")
|
|
1656
|
+
distribution_config['Aliases']['Items'].append(bucket_name)
|
|
1657
|
+
distribution_config['Aliases']['Quantity'] = len(distribution_config['Aliases']['Items'])
|
|
1658
|
+
else:
|
|
1659
|
+
logging.info(f"Alias {bucket_name} already configured")
|
|
1660
|
+
|
|
1661
|
+
if USE_CLOUDFLARE_SSL:
|
|
1662
|
+
logging.info("Using Cloudflare SSL - but aliases are still required for proper routing")
|
|
1663
|
+
|
|
1664
|
+
# Add custom error responses for SPA mode if updating existing distribution
|
|
1665
|
+
if SPA_MODE:
|
|
1666
|
+
existing_error_responses = distribution_config.get('CustomErrorResponses', {}).get('Items', [])
|
|
1667
|
+
spa_error_exists = any(
|
|
1668
|
+
err.get('ErrorCode') == 404 and err.get('ResponsePagePath') == SPA_ERROR_PATH
|
|
1669
|
+
for err in existing_error_responses
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
if not spa_error_exists:
|
|
1673
|
+
logging.info("Adding SPA mode error handling to existing distribution")
|
|
1674
|
+
distribution_config['CustomErrorResponses'] = {
|
|
1675
|
+
'Quantity': 1,
|
|
1676
|
+
'Items': [{
|
|
1677
|
+
'ErrorCode': 404,
|
|
1678
|
+
'ResponsePagePath': SPA_ERROR_PATH,
|
|
1679
|
+
'ResponseCode': str(SPA_ERROR_CODE),
|
|
1680
|
+
'ErrorCachingMinTTL': 0
|
|
1681
|
+
}]
|
|
1682
|
+
}
|
|
1683
|
+
else:
|
|
1684
|
+
logging.info("SPA error handling already configured for distribution")
|
|
1685
|
+
|
|
1686
|
+
# Update the distribution
|
|
1687
|
+
update_response = cloudfront_client.update_distribution(
|
|
1688
|
+
Id=distribution_id,
|
|
1689
|
+
DistributionConfig=distribution_config,
|
|
1690
|
+
IfMatch=etag
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
logging.info("CloudFront distribution updated successfully.")
|
|
1694
|
+
logging.info(f"Origin ID: {origin_id}")
|
|
1695
|
+
logging.info("Note: It may take 5-15 minutes for changes to propagate globally.")
|
|
1696
|
+
return update_response['Distribution']
|
|
1697
|
+
|
|
1698
|
+
except Exception as e:
|
|
1699
|
+
logging.error(f"Error updating CloudFront distribution: {e}")
|
|
1700
|
+
raise
|
|
1701
|
+
|
|
1702
|
+
def delete_cloudfront_distribution(distribution_id):
|
|
1703
|
+
"""Delete an existing CloudFront distribution."""
|
|
1704
|
+
logging.info(f"Deleting CloudFront distribution {distribution_id}...")
|
|
1705
|
+
|
|
1706
|
+
try:
|
|
1707
|
+
# First, get the distribution configuration
|
|
1708
|
+
response = cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
1709
|
+
distribution_config = response['DistributionConfig']
|
|
1710
|
+
etag = response['ETag']
|
|
1711
|
+
|
|
1712
|
+
# Disable the distribution first
|
|
1713
|
+
if distribution_config['Enabled']:
|
|
1714
|
+
logging.info("Disabling CloudFront distribution...")
|
|
1715
|
+
distribution_config['Enabled'] = False
|
|
1716
|
+
|
|
1717
|
+
cloudfront_client.update_distribution(
|
|
1718
|
+
Id=distribution_id,
|
|
1719
|
+
DistributionConfig=distribution_config,
|
|
1720
|
+
IfMatch=etag
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
# Wait for the distribution to be disabled
|
|
1724
|
+
logging.info("Waiting for distribution to be disabled...")
|
|
1725
|
+
waiter = cloudfront_client.get_waiter('distribution_deployed')
|
|
1726
|
+
waiter.wait(Id=distribution_id, WaiterConfig={'Delay': 30, 'MaxAttempts': 20})
|
|
1727
|
+
|
|
1728
|
+
# Get the updated ETag after disabling
|
|
1729
|
+
response = cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
1730
|
+
etag = response['ETag']
|
|
1731
|
+
|
|
1732
|
+
# Now delete the distribution
|
|
1733
|
+
logging.info("Deleting CloudFront distribution...")
|
|
1734
|
+
cloudfront_client.delete_distribution(Id=distribution_id, IfMatch=etag)
|
|
1735
|
+
logging.info(f"CloudFront distribution {distribution_id} deleted successfully.")
|
|
1736
|
+
|
|
1737
|
+
# Wait a bit for the deletion to propagate and release CNAMEs
|
|
1738
|
+
logging.info("Waiting 30 seconds for distribution deletion to propagate...")
|
|
1739
|
+
time.sleep(30)
|
|
1740
|
+
|
|
1741
|
+
except Exception as e:
|
|
1742
|
+
logging.error(f"Error deleting CloudFront distribution: {e}")
|
|
1743
|
+
raise
|
|
1744
|
+
|
|
1745
|
+
def update_distribution_spa_mode(distribution_id, error_path='/index.html', response_code=200):
|
|
1746
|
+
"""Update an existing CloudFront distribution to handle SPA routing."""
|
|
1747
|
+
logging.info(f"Updating CloudFront distribution {distribution_id} for SPA mode...")
|
|
1748
|
+
|
|
1749
|
+
try:
|
|
1750
|
+
# Get the current distribution configuration
|
|
1751
|
+
response = cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
1752
|
+
distribution_config = response['DistributionConfig']
|
|
1753
|
+
etag = response['ETag']
|
|
1754
|
+
|
|
1755
|
+
# Check if SPA error handling already exists
|
|
1756
|
+
existing_error_responses = distribution_config.get('CustomErrorResponses', {}).get('Items', [])
|
|
1757
|
+
spa_error_exists = any(
|
|
1758
|
+
err.get('ErrorCode') == 404 and err.get('ResponsePagePath') == error_path
|
|
1759
|
+
for err in existing_error_responses
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
if spa_error_exists:
|
|
1763
|
+
logging.info(f"SPA error handling already configured for distribution {distribution_id}")
|
|
1764
|
+
return True
|
|
1765
|
+
|
|
1766
|
+
# Add or update custom error responses
|
|
1767
|
+
new_error_response = {
|
|
1768
|
+
'ErrorCode': 404,
|
|
1769
|
+
'ResponsePagePath': error_path,
|
|
1770
|
+
'ResponseCode': str(response_code),
|
|
1771
|
+
'ErrorCachingMinTTL': 0
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if existing_error_responses:
|
|
1775
|
+
# Update existing 404 handler or add new one
|
|
1776
|
+
found_404 = False
|
|
1777
|
+
for err in existing_error_responses:
|
|
1778
|
+
if err.get('ErrorCode') == 404:
|
|
1779
|
+
err.update(new_error_response)
|
|
1780
|
+
found_404 = True
|
|
1781
|
+
break
|
|
1782
|
+
|
|
1783
|
+
if not found_404:
|
|
1784
|
+
existing_error_responses.append(new_error_response)
|
|
1785
|
+
|
|
1786
|
+
distribution_config['CustomErrorResponses'] = {
|
|
1787
|
+
'Quantity': len(existing_error_responses),
|
|
1788
|
+
'Items': existing_error_responses
|
|
1789
|
+
}
|
|
1790
|
+
else:
|
|
1791
|
+
# Create new custom error responses
|
|
1792
|
+
distribution_config['CustomErrorResponses'] = {
|
|
1793
|
+
'Quantity': 1,
|
|
1794
|
+
'Items': [new_error_response]
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
# Update the distribution
|
|
1798
|
+
logging.info(f"Adding SPA mode: 404 errors will return '{error_path}' with status {response_code}")
|
|
1799
|
+
cloudfront_client.update_distribution(
|
|
1800
|
+
Id=distribution_id,
|
|
1801
|
+
DistributionConfig=distribution_config,
|
|
1802
|
+
IfMatch=etag
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1805
|
+
logging.info("Distribution updated successfully for SPA mode.")
|
|
1806
|
+
logging.info("Note: It may take 5-15 minutes for changes to propagate globally.")
|
|
1807
|
+
return True
|
|
1808
|
+
|
|
1809
|
+
except Exception as e:
|
|
1810
|
+
logging.error(f"Error updating distribution for SPA mode: {e}")
|
|
1811
|
+
raise
|
|
1812
|
+
|
|
1813
|
+
def ensure_distribution_aliases(distribution_id, bucket_name):
|
|
1814
|
+
"""Ensure the CloudFront distribution has the correct aliases configured."""
|
|
1815
|
+
logging.info(f"Checking aliases for CloudFront distribution {distribution_id}...")
|
|
1816
|
+
|
|
1817
|
+
try:
|
|
1818
|
+
# Get the current distribution configuration
|
|
1819
|
+
response = cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
1820
|
+
distribution_config = response['DistributionConfig']
|
|
1821
|
+
etag = response['ETag']
|
|
1822
|
+
|
|
1823
|
+
# Check if aliases are properly configured
|
|
1824
|
+
aliases_missing = False
|
|
1825
|
+
current_aliases = distribution_config.get('Aliases', {}).get('Items', [])
|
|
1826
|
+
|
|
1827
|
+
if not current_aliases:
|
|
1828
|
+
logging.info(f"No aliases found, adding: {bucket_name}")
|
|
1829
|
+
aliases_missing = True
|
|
1830
|
+
elif bucket_name not in current_aliases:
|
|
1831
|
+
logging.info(f"Alias {bucket_name} missing from current aliases: {current_aliases}")
|
|
1832
|
+
aliases_missing = True
|
|
1833
|
+
else:
|
|
1834
|
+
logging.info(f"Alias {bucket_name} already configured correctly")
|
|
1835
|
+
return True
|
|
1836
|
+
|
|
1837
|
+
if aliases_missing:
|
|
1838
|
+
# Add or update aliases
|
|
1839
|
+
if bucket_name not in current_aliases:
|
|
1840
|
+
current_aliases.append(bucket_name)
|
|
1841
|
+
|
|
1842
|
+
distribution_config['Aliases'] = {
|
|
1843
|
+
'Quantity': len(current_aliases),
|
|
1844
|
+
'Items': current_aliases
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
# Update the distribution
|
|
1848
|
+
logging.info(f"Updating distribution aliases to: {current_aliases}")
|
|
1849
|
+
cloudfront_client.update_distribution(
|
|
1850
|
+
Id=distribution_id,
|
|
1851
|
+
DistributionConfig=distribution_config,
|
|
1852
|
+
IfMatch=etag
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
logging.info("Distribution aliases updated successfully.")
|
|
1856
|
+
logging.info("Note: It may take 5-15 minutes for changes to propagate globally.")
|
|
1857
|
+
return True
|
|
1858
|
+
|
|
1859
|
+
except Exception as e:
|
|
1860
|
+
logging.error(f"Error updating distribution aliases: {e}")
|
|
1861
|
+
raise
|
|
1862
|
+
|
|
1863
|
+
def create_cloudfront_distribution(bucket_name, certificate_arn, oac_id):
|
|
1864
|
+
"""Creates or updates a CloudFront distribution."""
|
|
1865
|
+
logging.info(f"Checking if CloudFront distribution for '{bucket_name}' already exists...")
|
|
1866
|
+
|
|
1867
|
+
# First check for distributions using our bucket
|
|
1868
|
+
existing_dist = get_existing_distribution(bucket_name)
|
|
1869
|
+
|
|
1870
|
+
# Also check for any distribution using our domain as CNAME (to handle conflicts)
|
|
1871
|
+
cname_conflict_dist = find_distribution_using_cname(bucket_name)
|
|
1872
|
+
|
|
1873
|
+
# If there's a CNAME conflict with a different distribution, handle it
|
|
1874
|
+
if cname_conflict_dist and (not existing_dist or cname_conflict_dist['Id'] != existing_dist['Id']):
|
|
1875
|
+
logging.warning(f"CNAME conflict detected! Domain '{bucket_name}' is used by distribution {cname_conflict_dist['Id']}")
|
|
1876
|
+
if FORCE_RECREATE:
|
|
1877
|
+
logging.info("Force recreate enabled - deleting conflicting distribution...")
|
|
1878
|
+
delete_cloudfront_distribution(cname_conflict_dist['Id'])
|
|
1879
|
+
logging.info("Conflicting distribution deleted, proceeding with creation...")
|
|
1880
|
+
else:
|
|
1881
|
+
logging.error(f"Cannot proceed: Domain '{bucket_name}' is already used by distribution {cname_conflict_dist['Id']}")
|
|
1882
|
+
logging.error("Options:")
|
|
1883
|
+
logging.error(" 1. Use --force flag to delete the conflicting distribution")
|
|
1884
|
+
logging.error(" 2. Choose a different subdomain")
|
|
1885
|
+
logging.error(" 3. Manually delete the conflicting distribution first")
|
|
1886
|
+
raise Exception(f"CNAME conflict: {bucket_name} already in use by distribution {cname_conflict_dist['Id']}")
|
|
1887
|
+
|
|
1888
|
+
if existing_dist:
|
|
1889
|
+
if FORCE_RECREATE:
|
|
1890
|
+
logging.info(f"Force recreate enabled - deleting existing distribution: {existing_dist['Id']}")
|
|
1891
|
+
delete_cloudfront_distribution(existing_dist['Id'])
|
|
1892
|
+
logging.info("Existing distribution deleted, creating new one...")
|
|
1893
|
+
else:
|
|
1894
|
+
logging.info(f"Found existing CloudFront distribution: {existing_dist['Id']}")
|
|
1895
|
+
|
|
1896
|
+
# Check if the origin is using the correct endpoint
|
|
1897
|
+
current_origin = None
|
|
1898
|
+
for origin in existing_dist.get('Origins', {}).get('Items', []):
|
|
1899
|
+
if bucket_name in origin.get('DomainName', ''):
|
|
1900
|
+
current_origin = origin
|
|
1901
|
+
break
|
|
1902
|
+
|
|
1903
|
+
origin_needs_update = False
|
|
1904
|
+
if current_origin:
|
|
1905
|
+
current_domain = current_origin.get('DomainName', '')
|
|
1906
|
+
expected_domain = get_s3_website_endpoint(bucket_name, AWS_REGION)
|
|
1907
|
+
|
|
1908
|
+
logging.info(f"Current origin domain: {current_domain}")
|
|
1909
|
+
logging.info(f"Expected origin domain: {expected_domain}")
|
|
1910
|
+
|
|
1911
|
+
if current_domain != expected_domain or 'CustomOriginConfig' not in current_origin:
|
|
1912
|
+
origin_needs_update = True
|
|
1913
|
+
else:
|
|
1914
|
+
logging.warning("Could not find matching origin in existing distribution.")
|
|
1915
|
+
origin_needs_update = True
|
|
1916
|
+
|
|
1917
|
+
# Check if aliases are properly configured (always check now, regardless of SSL mode)
|
|
1918
|
+
aliases_need_update = False
|
|
1919
|
+
current_aliases = existing_dist.get('Aliases', {}).get('Items', [])
|
|
1920
|
+
aliases_need_update = bucket_name not in current_aliases
|
|
1921
|
+
logging.info(f"Current aliases: {current_aliases}")
|
|
1922
|
+
logging.info(f"Aliases need update: {aliases_need_update}")
|
|
1923
|
+
if USE_CLOUDFLARE_SSL:
|
|
1924
|
+
logging.info("Using Cloudflare SSL - but still ensuring aliases are configured")
|
|
1925
|
+
|
|
1926
|
+
# Check if SPA mode needs to be configured
|
|
1927
|
+
spa_needs_update = False
|
|
1928
|
+
if SPA_MODE:
|
|
1929
|
+
existing_error_responses = existing_dist.get('CustomErrorResponses', {}).get('Items', [])
|
|
1930
|
+
spa_error_exists = any(
|
|
1931
|
+
err.get('ErrorCode') == 404 and err.get('ResponsePagePath') == SPA_ERROR_PATH
|
|
1932
|
+
for err in existing_error_responses
|
|
1933
|
+
)
|
|
1934
|
+
spa_needs_update = not spa_error_exists
|
|
1935
|
+
logging.info(f"SPA mode configured: {not spa_needs_update}")
|
|
1936
|
+
|
|
1937
|
+
if origin_needs_update or spa_needs_update:
|
|
1938
|
+
logging.info("Origin domain or SPA mode needs updating...")
|
|
1939
|
+
return update_cloudfront_distribution_origin(existing_dist['Id'], bucket_name, oac_id)
|
|
1940
|
+
elif aliases_need_update:
|
|
1941
|
+
logging.info("Origin is correct but aliases need updating...")
|
|
1942
|
+
ensure_distribution_aliases(existing_dist['Id'], bucket_name)
|
|
1943
|
+
if spa_needs_update:
|
|
1944
|
+
update_distribution_spa_mode(existing_dist['Id'], SPA_ERROR_PATH, SPA_ERROR_CODE)
|
|
1945
|
+
return existing_dist
|
|
1946
|
+
else:
|
|
1947
|
+
logging.info("Origin domain and aliases are correct, using existing distribution.")
|
|
1948
|
+
return existing_dist
|
|
1949
|
+
|
|
1950
|
+
logging.info(f"Creating new CloudFront distribution for '{bucket_name}'...")
|
|
1951
|
+
caller_reference = f"dist-for-{bucket_name}-{int(time.time())}"
|
|
1952
|
+
|
|
1953
|
+
# Use the S3 website endpoint
|
|
1954
|
+
s3_website_domain = get_s3_website_endpoint(bucket_name, AWS_REGION)
|
|
1955
|
+
|
|
1956
|
+
logging.info(f"Using S3 website endpoint: {s3_website_domain}")
|
|
1957
|
+
|
|
1958
|
+
# Create the origin configuration for S3 website endpoint
|
|
1959
|
+
# Use the domain name (bucket_name) as the origin ID instead of generic naming
|
|
1960
|
+
origin_id = bucket_name
|
|
1961
|
+
|
|
1962
|
+
origin_config = {
|
|
1963
|
+
'Id': origin_id,
|
|
1964
|
+
'DomainName': s3_website_domain,
|
|
1965
|
+
'CustomOriginConfig': {
|
|
1966
|
+
'HTTPPort': 80,
|
|
1967
|
+
'HTTPSPort': 443,
|
|
1968
|
+
'OriginProtocolPolicy': 'http-only',
|
|
1969
|
+
'OriginSslProtocols': {
|
|
1970
|
+
'Quantity': 1,
|
|
1971
|
+
'Items': ['TLSv1.2']
|
|
1972
|
+
},
|
|
1973
|
+
'OriginReadTimeout': 30,
|
|
1974
|
+
'OriginKeepaliveTimeout': 5
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
distribution_config = {
|
|
1979
|
+
'CallerReference': caller_reference,
|
|
1980
|
+
'Comment': f"Distribution for {bucket_name}",
|
|
1981
|
+
'Enabled': True,
|
|
1982
|
+
'DefaultRootObject': 'index.html',
|
|
1983
|
+
'Origins': {
|
|
1984
|
+
'Quantity': 1,
|
|
1985
|
+
'Items': [origin_config]
|
|
1986
|
+
},
|
|
1987
|
+
'DefaultCacheBehavior': {
|
|
1988
|
+
'TargetOriginId': origin_id,
|
|
1989
|
+
'ViewerProtocolPolicy': 'redirect-to-https',
|
|
1990
|
+
'AllowedMethods': {
|
|
1991
|
+
'Quantity': 2,
|
|
1992
|
+
'Items': ['GET', 'HEAD'],
|
|
1993
|
+
'CachedMethods': { 'Quantity': 2, 'Items': ['GET', 'HEAD']}
|
|
1994
|
+
},
|
|
1995
|
+
'Compress': True,
|
|
1996
|
+
'ForwardedValues': {
|
|
1997
|
+
'QueryString': False,
|
|
1998
|
+
'Cookies': {'Forward': 'none'}
|
|
1999
|
+
},
|
|
2000
|
+
'TrustedSigners': {
|
|
2001
|
+
'Enabled': False,
|
|
2002
|
+
'Quantity': 0
|
|
2003
|
+
},
|
|
2004
|
+
'MinTTL': 0,
|
|
2005
|
+
'DefaultTTL': 86400,
|
|
2006
|
+
'MaxTTL': 31536000,
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
# Add custom error responses for SPA mode
|
|
2011
|
+
if SPA_MODE:
|
|
2012
|
+
distribution_config['CustomErrorResponses'] = {
|
|
2013
|
+
'Quantity': 1,
|
|
2014
|
+
'Items': [{
|
|
2015
|
+
'ErrorCode': 404,
|
|
2016
|
+
'ResponsePagePath': SPA_ERROR_PATH,
|
|
2017
|
+
'ResponseCode': str(SPA_ERROR_CODE),
|
|
2018
|
+
'ErrorCachingMinTTL': 0 # Don't cache error responses
|
|
2019
|
+
}]
|
|
2020
|
+
}
|
|
2021
|
+
logging.info(f"Configured SPA mode: 404 errors will return '{SPA_ERROR_PATH}' with status {SPA_ERROR_CODE}")
|
|
2022
|
+
|
|
2023
|
+
# Add SSL certificate configuration and aliases
|
|
2024
|
+
# Always add aliases since CloudFront requires them for custom domains
|
|
2025
|
+
# For apex mode with www redirect, include both apex and www domains
|
|
2026
|
+
if APEX_MODE and WWW_DOMAIN:
|
|
2027
|
+
aliases = [bucket_name, WWW_DOMAIN]
|
|
2028
|
+
logging.info(f"Apex mode: Adding aliases for both {bucket_name} and {WWW_DOMAIN}")
|
|
2029
|
+
else:
|
|
2030
|
+
aliases = [bucket_name]
|
|
2031
|
+
|
|
2032
|
+
distribution_config['Aliases'] = {
|
|
2033
|
+
'Quantity': len(aliases),
|
|
2034
|
+
'Items': aliases
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
if not USE_CLOUDFLARE_SSL and certificate_arn:
|
|
2038
|
+
# Use ACM certificate for SSL when explicitly requested
|
|
2039
|
+
distribution_config['ViewerCertificate'] = {
|
|
2040
|
+
'ACMCertificateArn': certificate_arn,
|
|
2041
|
+
'SSLSupportMethod': 'sni-only',
|
|
2042
|
+
'MinimumProtocolVersion': 'TLSv1.2_2021'
|
|
2043
|
+
}
|
|
2044
|
+
else:
|
|
2045
|
+
# For Cloudflare SSL mode, try to find existing certificate
|
|
2046
|
+
# If no certificate is provided, try to find an existing one
|
|
2047
|
+
if not certificate_arn:
|
|
2048
|
+
certificate_arn = find_existing_acm_certificate(bucket_name)
|
|
2049
|
+
|
|
2050
|
+
if certificate_arn:
|
|
2051
|
+
# Use ACM certificate (either existing or newly created)
|
|
2052
|
+
logging.info(f"Using ACM certificate for aliases: {certificate_arn}")
|
|
2053
|
+
distribution_config['ViewerCertificate'] = {
|
|
2054
|
+
'ACMCertificateArn': certificate_arn,
|
|
2055
|
+
'SSLSupportMethod': 'sni-only',
|
|
2056
|
+
'MinimumProtocolVersion': 'TLSv1.2_2021'
|
|
2057
|
+
}
|
|
2058
|
+
else:
|
|
2059
|
+
# This should not happen anymore since we create certificates automatically
|
|
2060
|
+
logging.error(f"No ACM certificate available for '{bucket_name}' - this should not happen!")
|
|
2061
|
+
raise Exception("Certificate creation failed - cannot proceed without valid SSL certificate for aliases")
|
|
2062
|
+
|
|
2063
|
+
try:
|
|
2064
|
+
response = cloudfront_client.create_distribution(DistributionConfig=distribution_config)
|
|
2065
|
+
distribution = response['Distribution']
|
|
2066
|
+
logging.info(f"CloudFront distribution created. ID: {distribution['Id']}")
|
|
2067
|
+
logging.info(f"Domain Name: {distribution['DomainName']}")
|
|
2068
|
+
logging.info(f"Origin ID: {origin_id}")
|
|
2069
|
+
return distribution
|
|
2070
|
+
except Exception as e:
|
|
2071
|
+
error_str = str(e)
|
|
2072
|
+
if "CNAMEAlreadyExists" in error_str:
|
|
2073
|
+
logging.error(f"CNAME conflict error: {e}")
|
|
2074
|
+
logging.error(f"The domain '{bucket_name}' is still associated with another CloudFront distribution.")
|
|
2075
|
+
logging.error("This can happen when:")
|
|
2076
|
+
logging.error(" 1. A distribution was recently deleted but not fully propagated")
|
|
2077
|
+
logging.error(" 2. Another distribution is using the same domain")
|
|
2078
|
+
logging.error(" 3. DNS propagation delays")
|
|
2079
|
+
logging.error("\nTroubleshooting steps:")
|
|
2080
|
+
logging.error(" 1. Wait 5-10 minutes and try again")
|
|
2081
|
+
logging.error(" 2. Check for other distributions using this domain:")
|
|
2082
|
+
logging.error(f" create cloudfront list-distributions --query 'DistributionList.Items[?Aliases.Items[?contains(@, `{bucket_name}`)]]'")
|
|
2083
|
+
logging.error(" 3. If found, delete the conflicting distribution manually")
|
|
2084
|
+
|
|
2085
|
+
# Try to find the conflicting distribution and update the CNAME
|
|
2086
|
+
logging.info("Attempting to resolve CNAME conflict by updating the CNAME record...")
|
|
2087
|
+
|
|
2088
|
+
# Create the distribution without the alias
|
|
2089
|
+
logging.info("Creating distribution without the conflicting alias...")
|
|
2090
|
+
distribution_config['Aliases'] = {'Quantity': 0, 'Items': []}
|
|
2091
|
+
|
|
2092
|
+
try:
|
|
2093
|
+
response = cloudfront_client.create_distribution(DistributionConfig=distribution_config)
|
|
2094
|
+
distribution = response['Distribution']
|
|
2095
|
+
distribution_domain = distribution['DomainName']
|
|
2096
|
+
logging.info(f"CloudFront distribution created without alias. ID: {distribution['Id']}")
|
|
2097
|
+
logging.info(f"Domain Name: {distribution_domain}")
|
|
2098
|
+
|
|
2099
|
+
# Update the Cloudflare CNAME record to point to the new distribution
|
|
2100
|
+
logging.info(f"Updating Cloudflare CNAME record to point to the new distribution: {distribution_domain}")
|
|
2101
|
+
cf_client = get_cloudflare_client()
|
|
2102
|
+
zone_id = get_cloudflare_zone_id(cf_client, CLOUDFLARE_ZONE_NAME)
|
|
2103
|
+
create_cloudflare_cname_record(cf_client, zone_id, SUBDOMAIN, distribution_domain)
|
|
2104
|
+
|
|
2105
|
+
logging.info("CNAME record updated successfully to point to the new distribution.")
|
|
2106
|
+
|
|
2107
|
+
# Now add the alias to the distribution since the CNAME conflict should be resolved
|
|
2108
|
+
logging.info("Adding alias to the distribution after CNAME update...")
|
|
2109
|
+
logging.info("Waiting 30 seconds for DNS changes to propagate before adding alias...")
|
|
2110
|
+
time.sleep(30)
|
|
2111
|
+
|
|
2112
|
+
# Add the alias to the distribution
|
|
2113
|
+
ensure_distribution_aliases(distribution['Id'], bucket_name)
|
|
2114
|
+
|
|
2115
|
+
# Add SPA mode if enabled
|
|
2116
|
+
if SPA_MODE:
|
|
2117
|
+
update_distribution_spa_mode(distribution['Id'], SPA_ERROR_PATH, SPA_ERROR_CODE)
|
|
2118
|
+
|
|
2119
|
+
return distribution
|
|
2120
|
+
except Exception as inner_e:
|
|
2121
|
+
logging.error(f"Error creating distribution without alias: {inner_e}")
|
|
2122
|
+
raise inner_e
|
|
2123
|
+
else:
|
|
2124
|
+
logging.error(f"Error creating CloudFront distribution: {e}")
|
|
2125
|
+
raise
|
|
2126
|
+
|
|
2127
|
+
def update_s3_bucket_policy(bucket_name, distribution_arn):
|
|
2128
|
+
"""Updates S3 bucket policy to grant access to CloudFront."""
|
|
2129
|
+
logging.info(f"Updating bucket policy for '{bucket_name}'...")
|
|
2130
|
+
policy = {
|
|
2131
|
+
"Version": "2012-10-17",
|
|
2132
|
+
"Statement": [
|
|
2133
|
+
{
|
|
2134
|
+
"Sid": "AllowCloudFrontServicePrincipal",
|
|
2135
|
+
"Effect": "Allow",
|
|
2136
|
+
"Principal": {"Service": "cloudfront.amazonaws.com"},
|
|
2137
|
+
"Action": "s3:GetObject",
|
|
2138
|
+
"Resource": f"arn:create:s3:::{bucket_name}/*",
|
|
2139
|
+
"Condition": {"StringEquals": {"AWS:SourceArn": distribution_arn}}
|
|
2140
|
+
}
|
|
2141
|
+
]
|
|
2142
|
+
}
|
|
2143
|
+
s3_client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(policy))
|
|
2144
|
+
logging.info("Bucket policy updated successfully.")
|
|
2145
|
+
|
|
2146
|
+
def get_cloudflare_zone_id(cf_client, zone_name):
|
|
2147
|
+
"""Get the Cloudflare zone ID for the domain."""
|
|
2148
|
+
zones = cf_client.zones.list(name=zone_name)
|
|
2149
|
+
if not zones.result:
|
|
2150
|
+
logging.error(f"Zone '{zone_name}' not found in Cloudflare.")
|
|
2151
|
+
raise Exception(f"Zone not found: {zone_name}")
|
|
2152
|
+
return zones.result[0].id
|
|
2153
|
+
|
|
2154
|
+
def create_cloudflare_cname_record(cf_client, zone_id, cname_name, cname_content):
|
|
2155
|
+
"""Creates or updates a CNAME record in Cloudflare pointing to CloudFront."""
|
|
2156
|
+
# Build the full domain name for the CNAME record
|
|
2157
|
+
full_domain_name = f"{cname_name}.{CLOUDFLARE_ZONE_NAME}"
|
|
2158
|
+
logging.info(f"Creating/updating CNAME record for '{full_domain_name}' -> '{cname_content}'...")
|
|
2159
|
+
|
|
2160
|
+
try:
|
|
2161
|
+
# Search for any existing records with the same name (A, AAAA, or CNAME)
|
|
2162
|
+
# Try both the subdomain and the full domain name
|
|
2163
|
+
existing_records = cf_client.dns.records.list(zone_id=zone_id, name=full_domain_name)
|
|
2164
|
+
|
|
2165
|
+
# If not found with FQDN, try with just the subdomain
|
|
2166
|
+
if not existing_records.result:
|
|
2167
|
+
logging.info(f"No records found for '{full_domain_name}', trying '{cname_name}'...")
|
|
2168
|
+
existing_records = cf_client.dns.records.list(zone_id=zone_id, name=cname_name)
|
|
2169
|
+
|
|
2170
|
+
dns_record = {
|
|
2171
|
+
'name': cname_name, # Cloudflare expects just the subdomain when creating/updating
|
|
2172
|
+
'type': 'CNAME',
|
|
2173
|
+
'content': cname_content,
|
|
2174
|
+
'proxied': False # Don't proxy - CloudFront handles SSL with ACM certificate
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
cname_record_exists = False
|
|
2178
|
+
records_to_delete = []
|
|
2179
|
+
|
|
2180
|
+
if existing_records.result:
|
|
2181
|
+
logging.info(f"Found {len(existing_records.result)} existing record(s) for the domain")
|
|
2182
|
+
for record in existing_records.result:
|
|
2183
|
+
record_type = record.type
|
|
2184
|
+
record_id = record.id
|
|
2185
|
+
record_name = record.name
|
|
2186
|
+
|
|
2187
|
+
logging.info(f" Record name: '{record_name}', type: {record_type}, ID: {record_id}")
|
|
2188
|
+
|
|
2189
|
+
if record_type == 'CNAME':
|
|
2190
|
+
# Found existing CNAME record
|
|
2191
|
+
cname_record_exists = True
|
|
2192
|
+
current_content = record.content
|
|
2193
|
+
current_proxied = record.proxied
|
|
2194
|
+
|
|
2195
|
+
logging.info(f" Current CNAME content: '{current_content}'")
|
|
2196
|
+
logging.info(f" Target CNAME content: '{cname_content}'")
|
|
2197
|
+
logging.info(f" Current proxied: {current_proxied}")
|
|
2198
|
+
logging.info(" Target proxied: False")
|
|
2199
|
+
|
|
2200
|
+
# Check if we need to update it
|
|
2201
|
+
if current_content != cname_content or current_proxied:
|
|
2202
|
+
logging.info(f"Updating existing CNAME record '{record_name}' (ID: {record_id})")
|
|
2203
|
+
logging.info(f" Old target: {current_content} (proxied: {current_proxied})")
|
|
2204
|
+
logging.info(f" New target: {cname_content} (proxied: False)")
|
|
2205
|
+
cf_client.dns.records.update(zone_id=zone_id, dns_record_id=record_id, **dns_record)
|
|
2206
|
+
logging.info("Cloudflare CNAME record updated successfully.")
|
|
2207
|
+
else:
|
|
2208
|
+
logging.info(f"CNAME record for '{record_name}' is already pointing to the correct target.")
|
|
2209
|
+
elif record_type in ['A', 'AAAA']:
|
|
2210
|
+
# Found conflicting A or AAAA record - need to delete it first
|
|
2211
|
+
logging.info(f"Found conflicting {record_type} record for '{record_name}' (ID: {record_id})")
|
|
2212
|
+
logging.info(f" {record_type} record content: {record.content}")
|
|
2213
|
+
records_to_delete.append((record_id, record_type))
|
|
2214
|
+
else:
|
|
2215
|
+
logging.info(f"No existing records found for '{full_domain_name}' or '{cname_name}'")
|
|
2216
|
+
|
|
2217
|
+
# Delete conflicting A/AAAA records
|
|
2218
|
+
for record_id, record_type in records_to_delete:
|
|
2219
|
+
logging.info(f"Deleting conflicting {record_type} record (ID: {record_id})")
|
|
2220
|
+
cf_client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
|
2221
|
+
logging.info(f"Conflicting {record_type} record deleted successfully.")
|
|
2222
|
+
|
|
2223
|
+
# Create new CNAME record if none existed or we deleted conflicting records
|
|
2224
|
+
if not cname_record_exists or records_to_delete:
|
|
2225
|
+
logging.info(f"Creating new CNAME record for '{cname_name}'")
|
|
2226
|
+
cf_client.dns.records.create(zone_id=zone_id, **dns_record)
|
|
2227
|
+
logging.info("Cloudflare CNAME record created successfully.")
|
|
2228
|
+
|
|
2229
|
+
except Exception as e:
|
|
2230
|
+
logging.error(f"Error creating/updating Cloudflare CNAME record: {e}")
|
|
2231
|
+
raise
|
|
2232
|
+
|
|
2233
|
+
def cleanup_dns_validation(cf_client, zone_id, dns_records_to_delete):
|
|
2234
|
+
"""Removes the DNS validation record(s) from Cloudflare.
|
|
2235
|
+
|
|
2236
|
+
Args:
|
|
2237
|
+
cf_client: Cloudflare client
|
|
2238
|
+
zone_id: Cloudflare zone ID
|
|
2239
|
+
dns_records_to_delete: Single dict or list of dicts with 'name' and 'type' keys
|
|
2240
|
+
"""
|
|
2241
|
+
if not dns_records_to_delete:
|
|
2242
|
+
return
|
|
2243
|
+
|
|
2244
|
+
# Normalize to list
|
|
2245
|
+
if isinstance(dns_records_to_delete, dict):
|
|
2246
|
+
dns_records_to_delete = [dns_records_to_delete]
|
|
2247
|
+
|
|
2248
|
+
for dns_record in dns_records_to_delete:
|
|
2249
|
+
logging.info(f"Cleaning up DNS validation record: {dns_record['name']}")
|
|
2250
|
+
try:
|
|
2251
|
+
records = cf_client.dns.records.list(zone_id=zone_id, name=dns_record['name'], type=dns_record['type'])
|
|
2252
|
+
if records.result:
|
|
2253
|
+
record_id = records.result[0].id
|
|
2254
|
+
cf_client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
|
2255
|
+
logging.info("DNS validation record cleaned up successfully.")
|
|
2256
|
+
else:
|
|
2257
|
+
logging.warning("Could not find DNS validation record to cleanup.")
|
|
2258
|
+
except Exception as e:
|
|
2259
|
+
logging.error(f"Error cleaning up DNS record: {e}")
|
|
2260
|
+
|
|
2261
|
+
def create_cloudfront_www_redirect_function(apex_domain, distribution_id):
|
|
2262
|
+
"""Create and attach a CloudFront Function for www ā apex redirect.
|
|
2263
|
+
|
|
2264
|
+
This is more reliable than Cloudflare redirect rules because:
|
|
2265
|
+
1. Works regardless of Cloudflare plan/permissions
|
|
2266
|
+
2. Executes at CloudFront edge (faster)
|
|
2267
|
+
3. No dependency on Cloudflare proxy mode
|
|
2268
|
+
|
|
2269
|
+
Args:
|
|
2270
|
+
apex_domain: The apex domain (e.g., example.com)
|
|
2271
|
+
distribution_id: CloudFront distribution ID to attach the function to
|
|
2272
|
+
|
|
2273
|
+
Returns:
|
|
2274
|
+
bool: True if function was created/attached successfully
|
|
2275
|
+
"""
|
|
2276
|
+
function_name = f"www-redirect-{apex_domain.replace('.', '-')}"
|
|
2277
|
+
logging.info(f"Setting up CloudFront Function for www redirect: {function_name}")
|
|
2278
|
+
|
|
2279
|
+
try:
|
|
2280
|
+
# CloudFront Function code for www ā apex redirect
|
|
2281
|
+
function_code = """function handler(event) {
|
|
2282
|
+
var request = event.request;
|
|
2283
|
+
var host = request.headers.host.value;
|
|
2284
|
+
|
|
2285
|
+
if (host.startsWith('www.')) {
|
|
2286
|
+
var newHost = host.substring(4);
|
|
2287
|
+
return {
|
|
2288
|
+
statusCode: 301,
|
|
2289
|
+
statusDescription: 'Moved Permanently',
|
|
2290
|
+
headers: {
|
|
2291
|
+
'location': { value: 'https://' + newHost + request.uri }
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
return request;
|
|
2297
|
+
}"""
|
|
2298
|
+
|
|
2299
|
+
function_arn = None
|
|
2300
|
+
|
|
2301
|
+
# Check if function already exists
|
|
2302
|
+
try:
|
|
2303
|
+
existing = cloudfront_client.describe_function(Name=function_name, Stage='LIVE')
|
|
2304
|
+
function_arn = existing['FunctionSummary']['FunctionMetadata']['FunctionARN']
|
|
2305
|
+
logging.info(f"CloudFront Function already exists: {function_name}")
|
|
2306
|
+
except cloudfront_client.exceptions.NoSuchFunctionExists:
|
|
2307
|
+
# Create new function
|
|
2308
|
+
logging.info(f"Creating CloudFront Function: {function_name}")
|
|
2309
|
+
create_response = cloudfront_client.create_function(
|
|
2310
|
+
Name=function_name,
|
|
2311
|
+
FunctionConfig={
|
|
2312
|
+
'Comment': f'Redirect www to apex for {apex_domain}',
|
|
2313
|
+
'Runtime': 'cloudfront-js-2.0'
|
|
2314
|
+
},
|
|
2315
|
+
FunctionCode=function_code.encode('utf-8')
|
|
2316
|
+
)
|
|
2317
|
+
etag = create_response['ETag']
|
|
2318
|
+
|
|
2319
|
+
# Publish the function
|
|
2320
|
+
logging.info(f"Publishing CloudFront Function: {function_name}")
|
|
2321
|
+
publish_response = cloudfront_client.publish_function(
|
|
2322
|
+
Name=function_name,
|
|
2323
|
+
IfMatch=etag
|
|
2324
|
+
)
|
|
2325
|
+
function_arn = publish_response['FunctionSummary']['FunctionMetadata']['FunctionARN']
|
|
2326
|
+
logging.info(f"CloudFront Function published: {function_arn}")
|
|
2327
|
+
|
|
2328
|
+
# Attach function to distribution
|
|
2329
|
+
if function_arn and distribution_id:
|
|
2330
|
+
logging.info(f"Attaching function to distribution {distribution_id}...")
|
|
2331
|
+
|
|
2332
|
+
# Get current distribution config
|
|
2333
|
+
dist_response = cloudfront_client.get_distribution_config(Id=distribution_id)
|
|
2334
|
+
config = dist_response['DistributionConfig']
|
|
2335
|
+
etag = dist_response['ETag']
|
|
2336
|
+
|
|
2337
|
+
# Check if function is already attached
|
|
2338
|
+
existing_associations = config['DefaultCacheBehavior'].get('FunctionAssociations', {})
|
|
2339
|
+
existing_items = existing_associations.get('Items', [])
|
|
2340
|
+
|
|
2341
|
+
already_attached = any(
|
|
2342
|
+
item.get('FunctionARN') == function_arn
|
|
2343
|
+
for item in existing_items
|
|
2344
|
+
)
|
|
2345
|
+
|
|
2346
|
+
if already_attached:
|
|
2347
|
+
logging.info("Function already attached to distribution")
|
|
2348
|
+
return True
|
|
2349
|
+
|
|
2350
|
+
# Add function association
|
|
2351
|
+
new_item = {
|
|
2352
|
+
'FunctionARN': function_arn,
|
|
2353
|
+
'EventType': 'viewer-request'
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if existing_items:
|
|
2357
|
+
existing_items.append(new_item)
|
|
2358
|
+
else:
|
|
2359
|
+
existing_items = [new_item]
|
|
2360
|
+
|
|
2361
|
+
config['DefaultCacheBehavior']['FunctionAssociations'] = {
|
|
2362
|
+
'Quantity': len(existing_items),
|
|
2363
|
+
'Items': existing_items
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
# Update distribution
|
|
2367
|
+
cloudfront_client.update_distribution(
|
|
2368
|
+
Id=distribution_id,
|
|
2369
|
+
IfMatch=etag,
|
|
2370
|
+
DistributionConfig=config
|
|
2371
|
+
)
|
|
2372
|
+
logging.info("CloudFront Function attached to distribution")
|
|
2373
|
+
|
|
2374
|
+
# Wait for distribution to deploy
|
|
2375
|
+
logging.info("Waiting for distribution to deploy (this may take 3-5 minutes)...")
|
|
2376
|
+
waiter = cloudfront_client.get_waiter('distribution_deployed')
|
|
2377
|
+
waiter.wait(
|
|
2378
|
+
Id=distribution_id,
|
|
2379
|
+
WaiterConfig={'Delay': 15, 'MaxAttempts': 40}
|
|
2380
|
+
)
|
|
2381
|
+
logging.info("Distribution deployed with www redirect function")
|
|
2382
|
+
|
|
2383
|
+
return True
|
|
2384
|
+
|
|
2385
|
+
except Exception as e:
|
|
2386
|
+
logging.error(f"Failed to create CloudFront Function: {e}")
|
|
2387
|
+
return False
|
|
2388
|
+
|
|
2389
|
+
def create_apex_cloudflare_cname_records(cf_client, zone_id, apex_domain, www_domain, cloudfront_domain):
|
|
2390
|
+
"""Create Cloudflare DNS records for apex domain setup with www redirect.
|
|
2391
|
+
|
|
2392
|
+
This uses CNAME flattening for the apex domain (Cloudflare automatically converts
|
|
2393
|
+
CNAME at apex to A records) and a standard CNAME for www.
|
|
2394
|
+
|
|
2395
|
+
Args:
|
|
2396
|
+
cf_client: Cloudflare client
|
|
2397
|
+
zone_id: Cloudflare zone ID
|
|
2398
|
+
apex_domain: The apex domain (e.g., example.com)
|
|
2399
|
+
www_domain: The www domain (e.g., www.example.com)
|
|
2400
|
+
cloudfront_domain: CloudFront distribution domain
|
|
2401
|
+
"""
|
|
2402
|
+
logging.info("Configuring Cloudflare DNS records for apex domain setup...")
|
|
2403
|
+
|
|
2404
|
+
def delete_conflicting_records(name):
|
|
2405
|
+
"""Delete ALL existing A, AAAA records for a domain (to allow CNAME)."""
|
|
2406
|
+
full_name = name if '.' in name and name != apex_domain else f"{name}.{apex_domain}" if name != apex_domain else name
|
|
2407
|
+
|
|
2408
|
+
try:
|
|
2409
|
+
existing = cf_client.dns.records.list(zone_id=zone_id, name=full_name)
|
|
2410
|
+
if existing.result:
|
|
2411
|
+
for rec in existing.result:
|
|
2412
|
+
if rec.type in ['A', 'AAAA']:
|
|
2413
|
+
logging.info(f"Deleting conflicting {rec.type} record: {full_name} ā {rec.content}")
|
|
2414
|
+
cf_client.dns.records.delete(zone_id=zone_id, dns_record_id=rec.id)
|
|
2415
|
+
except Exception as e:
|
|
2416
|
+
logging.warning(f"Error deleting records for {full_name}: {e}")
|
|
2417
|
+
|
|
2418
|
+
def create_or_update_record(name, content, record_type='CNAME', proxied=False):
|
|
2419
|
+
"""Create or update a DNS record."""
|
|
2420
|
+
full_name = name if name == apex_domain else f"{name}.{apex_domain}" if '.' not in name else name
|
|
2421
|
+
|
|
2422
|
+
try:
|
|
2423
|
+
# First, delete ALL conflicting A/AAAA records
|
|
2424
|
+
delete_conflicting_records(name)
|
|
2425
|
+
|
|
2426
|
+
# Check for existing CNAME records
|
|
2427
|
+
existing = cf_client.dns.records.list(zone_id=zone_id, name=full_name)
|
|
2428
|
+
|
|
2429
|
+
record_data = {
|
|
2430
|
+
'name': name,
|
|
2431
|
+
'type': record_type,
|
|
2432
|
+
'content': content,
|
|
2433
|
+
'proxied': proxied
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
if existing.result:
|
|
2437
|
+
for rec in existing.result:
|
|
2438
|
+
if rec.type == 'CNAME':
|
|
2439
|
+
if rec.content != content or rec.proxied != proxied:
|
|
2440
|
+
logging.info(f"Updating CNAME record: {full_name} ā {content} (proxied={proxied})")
|
|
2441
|
+
cf_client.dns.records.update(zone_id=zone_id, dns_record_id=rec.id, **record_data)
|
|
2442
|
+
else:
|
|
2443
|
+
logging.info(f"CNAME record already correct: {full_name} ā {content} (proxied={proxied})")
|
|
2444
|
+
return
|
|
2445
|
+
|
|
2446
|
+
logging.info(f"Creating {record_type} record: {full_name} ā {content}")
|
|
2447
|
+
cf_client.dns.records.create(zone_id=zone_id, **record_data)
|
|
2448
|
+
|
|
2449
|
+
except Exception as e:
|
|
2450
|
+
if "already exists" not in str(e):
|
|
2451
|
+
raise
|
|
2452
|
+
logging.warning(f"Record already exists: {full_name}")
|
|
2453
|
+
|
|
2454
|
+
# Apex domain (CNAME flattening in Cloudflare)
|
|
2455
|
+
# Cloudflare automatically flattens CNAME at apex to A records
|
|
2456
|
+
create_or_update_record(apex_domain, cloudfront_domain, proxied=False)
|
|
2457
|
+
logging.info(f"Apex domain configured: {apex_domain} ā {cloudfront_domain} (CNAME flattening)")
|
|
2458
|
+
|
|
2459
|
+
# WWW domain - use proxied=False (no Cloudflare proxy)
|
|
2460
|
+
# The redirect is handled by CloudFront Function, not Cloudflare
|
|
2461
|
+
create_or_update_record('www', cloudfront_domain, proxied=False)
|
|
2462
|
+
logging.info(f"WWW DNS configured: {www_domain} ā {cloudfront_domain} (redirect via CloudFront Function)")
|
|
2463
|
+
|
|
2464
|
+
def test_cdn_functionality(domain, timeout=60):
|
|
2465
|
+
"""Test the CDN functionality by fetching pages and validating responses."""
|
|
2466
|
+
logging.info("\n=== Testing CDN Functionality ===")
|
|
2467
|
+
|
|
2468
|
+
test_urls = [
|
|
2469
|
+
f"https://{domain}/index-test.html",
|
|
2470
|
+
f"https://{domain}/test.txt",
|
|
2471
|
+
f"https://{domain}/nonexistent-page.html" # Should return 404 with custom error page
|
|
2472
|
+
]
|
|
2473
|
+
|
|
2474
|
+
# Add SPA routing tests if SPA mode is enabled
|
|
2475
|
+
if SPA_MODE:
|
|
2476
|
+
test_urls.extend([
|
|
2477
|
+
f"https://{domain}/", # Root should work
|
|
2478
|
+
f"https://{domain}/about", # SPA route should serve index.html
|
|
2479
|
+
f"https://{domain}/products/123", # Nested SPA route should serve index.html
|
|
2480
|
+
f"https://{domain}/any/nested/route" # Deep nested route should serve index.html
|
|
2481
|
+
])
|
|
2482
|
+
|
|
2483
|
+
results = {}
|
|
2484
|
+
|
|
2485
|
+
for url in test_urls:
|
|
2486
|
+
logging.info(f"Testing URL: {url}")
|
|
2487
|
+
|
|
2488
|
+
try:
|
|
2489
|
+
# Make request with timeout
|
|
2490
|
+
response = requests.get(url, timeout=timeout, allow_redirects=True)
|
|
2491
|
+
|
|
2492
|
+
results[url] = {
|
|
2493
|
+
'status_code': response.status_code,
|
|
2494
|
+
'success': True,
|
|
2495
|
+
'response_time': response.elapsed.total_seconds(),
|
|
2496
|
+
'content_length': len(response.content),
|
|
2497
|
+
'headers': dict(response.headers)
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
# Validate specific responses
|
|
2501
|
+
if url.endswith('index-test.html'):
|
|
2502
|
+
# Main test page should return 200 and contain success message
|
|
2503
|
+
if response.status_code == 200:
|
|
2504
|
+
if 'CDN Setup Successful' in response.text:
|
|
2505
|
+
logging.info("ā
Test page test PASSED - Found success message")
|
|
2506
|
+
results[url]['validation'] = 'PASSED'
|
|
2507
|
+
else:
|
|
2508
|
+
logging.warning("ā ļø Test page test PARTIAL - Page loaded but missing expected content")
|
|
2509
|
+
results[url]['validation'] = 'PARTIAL'
|
|
2510
|
+
else:
|
|
2511
|
+
logging.error(f"ā Test page test FAILED - Status: {response.status_code}")
|
|
2512
|
+
results[url]['validation'] = 'FAILED'
|
|
2513
|
+
|
|
2514
|
+
elif url.endswith('.txt'):
|
|
2515
|
+
# Text file should return 200 and contain expected content
|
|
2516
|
+
if response.status_code == 200:
|
|
2517
|
+
if 'CDN test file' in response.text:
|
|
2518
|
+
logging.info("ā
Text file test PASSED - Found expected content")
|
|
2519
|
+
results[url]['validation'] = 'PASSED'
|
|
2520
|
+
else:
|
|
2521
|
+
logging.warning("ā ļø Text file test PARTIAL - File loaded but unexpected content")
|
|
2522
|
+
results[url]['validation'] = 'PARTIAL'
|
|
2523
|
+
else:
|
|
2524
|
+
logging.error(f"ā Text file test FAILED - Status: {response.status_code}")
|
|
2525
|
+
results[url]['validation'] = 'FAILED'
|
|
2526
|
+
|
|
2527
|
+
elif 'nonexistent' in url:
|
|
2528
|
+
# 404 page should return 404 and show custom error page (unless SPA mode)
|
|
2529
|
+
if SPA_MODE:
|
|
2530
|
+
# In SPA mode, this should return 200 with index.html content
|
|
2531
|
+
if response.status_code == 200:
|
|
2532
|
+
if 'SPA-Enabled CDN Setup Successful' in response.text:
|
|
2533
|
+
logging.info("ā
SPA 404 redirect test PASSED - Returned index.html with 200")
|
|
2534
|
+
results[url]['validation'] = 'PASSED'
|
|
2535
|
+
else:
|
|
2536
|
+
logging.warning("ā ļø SPA 404 redirect test PARTIAL - 200 returned but not index.html")
|
|
2537
|
+
results[url]['validation'] = 'PARTIAL'
|
|
2538
|
+
else:
|
|
2539
|
+
logging.error(f"ā SPA 404 redirect test FAILED - Status: {response.status_code} (expected 200)")
|
|
2540
|
+
results[url]['validation'] = 'FAILED'
|
|
2541
|
+
else:
|
|
2542
|
+
# Normal mode - expect 404 with custom error page
|
|
2543
|
+
if response.status_code == 404:
|
|
2544
|
+
if 'Page Not Found' in response.text:
|
|
2545
|
+
logging.info("ā
404 error page test PASSED - Custom error page displayed")
|
|
2546
|
+
results[url]['validation'] = 'PASSED'
|
|
2547
|
+
else:
|
|
2548
|
+
logging.warning("ā ļø 404 error page test PARTIAL - 404 returned but no custom error page")
|
|
2549
|
+
results[url]['validation'] = 'PARTIAL'
|
|
2550
|
+
else:
|
|
2551
|
+
logging.warning(f"ā ļø 404 error page test UNEXPECTED - Status: {response.status_code} (expected 404)")
|
|
2552
|
+
results[url]['validation'] = 'UNEXPECTED'
|
|
2553
|
+
|
|
2554
|
+
elif SPA_MODE and any(path in url for path in ['/about', '/products/', '/any/']):
|
|
2555
|
+
# SPA routing tests - these should all return 200 with index.html content
|
|
2556
|
+
if response.status_code == 200:
|
|
2557
|
+
if 'SPA-Enabled CDN Setup Successful' in response.text:
|
|
2558
|
+
logging.info(f"ā
SPA routing test PASSED - '{url}' served index.html")
|
|
2559
|
+
results[url]['validation'] = 'PASSED'
|
|
2560
|
+
else:
|
|
2561
|
+
logging.warning("ā ļø SPA routing test PARTIAL - 200 returned but not index.html")
|
|
2562
|
+
results[url]['validation'] = 'PARTIAL'
|
|
2563
|
+
else:
|
|
2564
|
+
logging.error(f"ā SPA routing test FAILED - Status: {response.status_code} (expected 200)")
|
|
2565
|
+
results[url]['validation'] = 'FAILED'
|
|
2566
|
+
|
|
2567
|
+
# Log response details
|
|
2568
|
+
logging.info(f" Status: {response.status_code}")
|
|
2569
|
+
logging.info(f" Response time: {response.elapsed.total_seconds():.2f}s")
|
|
2570
|
+
logging.info(f" Content length: {len(response.content)} bytes")
|
|
2571
|
+
|
|
2572
|
+
# Check for CloudFront headers
|
|
2573
|
+
if 'X-Amz-Cf-Id' in response.headers:
|
|
2574
|
+
logging.info(f" ā
CloudFront serving content (X-Amz-Cf-Id: {response.headers['X-Amz-Cf-Id'][:20]}...)")
|
|
2575
|
+
else:
|
|
2576
|
+
logging.warning(" ā ļø CloudFront headers not detected")
|
|
2577
|
+
|
|
2578
|
+
except requests.exceptions.Timeout:
|
|
2579
|
+
logging.error(f"ā TIMEOUT - Request to {url} timed out after {timeout}s")
|
|
2580
|
+
results[url] = {'success': False, 'error': 'timeout', 'validation': 'FAILED'}
|
|
2581
|
+
|
|
2582
|
+
except requests.exceptions.ConnectionError as e:
|
|
2583
|
+
logging.error(f"ā CONNECTION ERROR - Could not connect to {url}: {e}")
|
|
2584
|
+
results[url] = {'success': False, 'error': 'connection_error', 'validation': 'FAILED'}
|
|
2585
|
+
|
|
2586
|
+
except requests.exceptions.RequestException as e:
|
|
2587
|
+
logging.error(f"ā REQUEST ERROR - Error fetching {url}: {e}")
|
|
2588
|
+
results[url] = {'success': False, 'error': str(e), 'validation': 'FAILED'}
|
|
2589
|
+
|
|
2590
|
+
# Wait a bit between requests
|
|
2591
|
+
time.sleep(2)
|
|
2592
|
+
|
|
2593
|
+
# Summary
|
|
2594
|
+
logging.info("\n=== Test Results Summary ===")
|
|
2595
|
+
passed = 0
|
|
2596
|
+
total = len(test_urls)
|
|
2597
|
+
|
|
2598
|
+
for url, result in results.items():
|
|
2599
|
+
if result.get('success', False):
|
|
2600
|
+
validation = result.get('validation', 'UNKNOWN')
|
|
2601
|
+
if validation == 'PASSED':
|
|
2602
|
+
logging.info(f"ā
{url} - {validation}")
|
|
2603
|
+
passed += 1
|
|
2604
|
+
elif validation == 'PARTIAL':
|
|
2605
|
+
logging.warning(f"ā ļø {url} - {validation}")
|
|
2606
|
+
else:
|
|
2607
|
+
logging.error(f"ā {url} - {validation}")
|
|
2608
|
+
else:
|
|
2609
|
+
logging.error(f"ā {url} - FAILED ({result.get('error', 'unknown error')})")
|
|
2610
|
+
|
|
2611
|
+
success_rate = (passed / total) * 100
|
|
2612
|
+
logging.info(f"\nOverall Success Rate: {passed}/{total} ({success_rate:.1f}%)")
|
|
2613
|
+
|
|
2614
|
+
if success_rate >= 80:
|
|
2615
|
+
logging.info("š CDN is working well!")
|
|
2616
|
+
return True
|
|
2617
|
+
elif success_rate >= 50:
|
|
2618
|
+
logging.warning("ā ļø CDN is partially working - some issues detected")
|
|
2619
|
+
return False
|
|
2620
|
+
else:
|
|
2621
|
+
logging.error("ā CDN has significant issues")
|
|
2622
|
+
return False
|
|
2623
|
+
|
|
2624
|
+
def main():
|
|
2625
|
+
"""Main function to orchestrate the CDN setup.
|
|
2626
|
+
|
|
2627
|
+
Supports two modes:
|
|
2628
|
+
1. Subdomain mode: Setup CDN for subdomain.example.com
|
|
2629
|
+
2. Apex mode: Setup CDN for example.com with optional www redirect
|
|
2630
|
+
"""
|
|
2631
|
+
try:
|
|
2632
|
+
# Initialize DNS provider based on CLI argument
|
|
2633
|
+
logging.info(f"Using DNS provider: {DNS_PROVIDER_NAME}")
|
|
2634
|
+
dns_provider = get_dns_provider(DNS_PROVIDER_NAME)
|
|
2635
|
+
|
|
2636
|
+
# Get or create DNS zone
|
|
2637
|
+
if CREATE_ZONE:
|
|
2638
|
+
if not dns_provider.supports_zone_creation():
|
|
2639
|
+
logging.warning(
|
|
2640
|
+
f"DNS provider '{DNS_PROVIDER_NAME}' does not support automatic zone creation. "
|
|
2641
|
+
f"Please create the zone manually first."
|
|
2642
|
+
)
|
|
2643
|
+
zone_id = dns_provider.get_or_create_zone(CLOUDFLARE_ZONE_NAME, auto_create=True)
|
|
2644
|
+
else:
|
|
2645
|
+
zone_id = dns_provider.get_zone_id(CLOUDFLARE_ZONE_NAME)
|
|
2646
|
+
|
|
2647
|
+
# Check apex mode compatibility
|
|
2648
|
+
if APEX_MODE and not dns_provider.supports_apex_cname():
|
|
2649
|
+
logging.warning("Selected DNS provider does not fully support apex domains.")
|
|
2650
|
+
logging.warning("Proceeding with fallback approach (A records with CloudFront IPs).")
|
|
2651
|
+
|
|
2652
|
+
# 1. S3 Bucket
|
|
2653
|
+
create_s3_bucket(BUCKET_NAME)
|
|
2654
|
+
|
|
2655
|
+
# 2. Enable S3 Website Hosting
|
|
2656
|
+
enable_s3_website_hosting(BUCKET_NAME)
|
|
2657
|
+
|
|
2658
|
+
# 3. ACM Certificate handling
|
|
2659
|
+
cert_arn = None
|
|
2660
|
+
|
|
2661
|
+
if APEX_MODE:
|
|
2662
|
+
# For apex mode, we need a certificate that covers BOTH apex AND wildcard
|
|
2663
|
+
# because wildcard (*.example.com) does NOT cover the apex (example.com)
|
|
2664
|
+
logging.info("Apex mode: Checking for certificate covering apex + wildcard...")
|
|
2665
|
+
|
|
2666
|
+
# Required domains for apex mode with www redirect
|
|
2667
|
+
required_domains = [BUCKET_NAME] # apex domain
|
|
2668
|
+
if WWW_REDIRECT:
|
|
2669
|
+
required_domains.append(WWW_DOMAIN)
|
|
2670
|
+
|
|
2671
|
+
cert_arn = find_certificate_covering_domains(required_domains)
|
|
2672
|
+
|
|
2673
|
+
if not cert_arn:
|
|
2674
|
+
logging.info("No existing certificate covers required domains.")
|
|
2675
|
+
logging.info("Creating new ACM certificate for apex + wildcard...")
|
|
2676
|
+
# Request cert with apex as primary and wildcard as SAN
|
|
2677
|
+
# This covers: example.com, *.example.com (including www, staging, etc.)
|
|
2678
|
+
cert_arn, validation_options, cert_waiter = request_acm_certificate(
|
|
2679
|
+
BUCKET_NAME,
|
|
2680
|
+
san_domains=[BUCKET_NAME, f"*.{CLOUDFLARE_ZONE_NAME}"]
|
|
2681
|
+
)
|
|
2682
|
+
|
|
2683
|
+
# DNS Validation for all domains
|
|
2684
|
+
validation_dns_records = dns_provider.create_validation_records(zone_id, CLOUDFLARE_ZONE_NAME, validation_options)
|
|
2685
|
+
|
|
2686
|
+
# Wait for Validation
|
|
2687
|
+
wait_for_cert_validation(cert_waiter, cert_arn)
|
|
2688
|
+
|
|
2689
|
+
# Cleanup DNS validation records
|
|
2690
|
+
dns_provider.cleanup_validation_records(zone_id, validation_dns_records)
|
|
2691
|
+
|
|
2692
|
+
logging.info(f"Using ACM certificate: {cert_arn}")
|
|
2693
|
+
|
|
2694
|
+
elif not USE_CLOUDFLARE_SSL:
|
|
2695
|
+
# ACM SSL mode for subdomain
|
|
2696
|
+
cert_arn, validation_cname, cert_waiter = request_acm_certificate(BUCKET_NAME)
|
|
2697
|
+
|
|
2698
|
+
# DNS Validation
|
|
2699
|
+
validation_dns_record = dns_provider.create_validation_records(zone_id, CLOUDFLARE_ZONE_NAME, validation_cname)
|
|
2700
|
+
|
|
2701
|
+
# Wait for Validation
|
|
2702
|
+
wait_for_cert_validation(cert_waiter, cert_arn)
|
|
2703
|
+
|
|
2704
|
+
# Cleanup DNS validation record
|
|
2705
|
+
dns_provider.cleanup_validation_records(zone_id, validation_dns_record)
|
|
2706
|
+
else:
|
|
2707
|
+
# Cloudflare SSL mode for subdomain
|
|
2708
|
+
logging.info("Using Cloudflare for SSL, but checking for existing ACM certificates...")
|
|
2709
|
+
cert_arn = find_existing_acm_certificate(BUCKET_NAME)
|
|
2710
|
+
if cert_arn:
|
|
2711
|
+
logging.info(f"Found existing ACM certificate: {cert_arn}")
|
|
2712
|
+
else:
|
|
2713
|
+
logging.info("No existing ACM certificate found - creating wildcard certificate for aliases...")
|
|
2714
|
+
wildcard_domain = f"*.{CLOUDFLARE_ZONE_NAME}"
|
|
2715
|
+
logging.info(f"Creating ACM certificate for '{wildcard_domain}'...")
|
|
2716
|
+
cert_arn, validation_cname, cert_waiter = request_acm_certificate(wildcard_domain)
|
|
2717
|
+
|
|
2718
|
+
# DNS Validation
|
|
2719
|
+
validation_dns_record = dns_provider.create_validation_records(zone_id, CLOUDFLARE_ZONE_NAME, validation_cname)
|
|
2720
|
+
|
|
2721
|
+
# Wait for Validation
|
|
2722
|
+
wait_for_cert_validation(cert_waiter, cert_arn)
|
|
2723
|
+
|
|
2724
|
+
# Cleanup DNS validation record
|
|
2725
|
+
dns_provider.cleanup_validation_records(zone_id, validation_dns_record)
|
|
2726
|
+
|
|
2727
|
+
# 4. CloudFront Distribution
|
|
2728
|
+
distribution = create_cloudfront_distribution(BUCKET_NAME, cert_arn, None)
|
|
2729
|
+
distribution_domain = distribution['DomainName']
|
|
2730
|
+
distribution_id = distribution['Id']
|
|
2731
|
+
|
|
2732
|
+
# 5. DNS Configuration
|
|
2733
|
+
if APEX_MODE:
|
|
2734
|
+
# Create DNS records for apex + www
|
|
2735
|
+
dns_provider.create_apex_records(
|
|
2736
|
+
zone_id,
|
|
2737
|
+
BUCKET_NAME, WWW_DOMAIN,
|
|
2738
|
+
distribution_domain
|
|
2739
|
+
)
|
|
2740
|
+
|
|
2741
|
+
# 6. Setup CloudFront Function for www ā apex redirect
|
|
2742
|
+
if WWW_REDIRECT:
|
|
2743
|
+
logging.info("Setting up www ā apex redirect via CloudFront Function...")
|
|
2744
|
+
redirect_success = create_cloudfront_www_redirect_function(BUCKET_NAME, distribution_id)
|
|
2745
|
+
if not redirect_success:
|
|
2746
|
+
logging.warning("CloudFront Function setup failed - www will serve same content as apex")
|
|
2747
|
+
else:
|
|
2748
|
+
# Standard subdomain CNAME
|
|
2749
|
+
dns_provider.create_cname_record(zone_id, SUBDOMAIN, distribution_domain, CLOUDFLARE_ZONE_NAME)
|
|
2750
|
+
|
|
2751
|
+
# 7. Create test content
|
|
2752
|
+
create_test_content(BUCKET_NAME)
|
|
2753
|
+
|
|
2754
|
+
# Summary
|
|
2755
|
+
logging.info("\n" + "=" * 50)
|
|
2756
|
+
logging.info("CDN SETUP COMPLETE")
|
|
2757
|
+
logging.info("=" * 50)
|
|
2758
|
+
logging.info(f"S3 Bucket: {BUCKET_NAME}")
|
|
2759
|
+
logging.info(f"S3 Website Endpoint: {get_s3_website_endpoint(BUCKET_NAME, AWS_REGION)}")
|
|
2760
|
+
logging.info(f"CloudFront Domain: {distribution_domain}")
|
|
2761
|
+
logging.info(f"CloudFront Distribution ID: {distribution_id}")
|
|
2762
|
+
|
|
2763
|
+
if APEX_MODE:
|
|
2764
|
+
logging.info(f"Apex Domain: https://{BUCKET_NAME}")
|
|
2765
|
+
logging.info(f"WWW Domain: https://{WWW_DOMAIN}")
|
|
2766
|
+
if WWW_REDIRECT:
|
|
2767
|
+
logging.info(f"WWW Redirect: https://{WWW_DOMAIN} ā https://{BUCKET_NAME}")
|
|
2768
|
+
else:
|
|
2769
|
+
if USE_CLOUDFLARE_SSL:
|
|
2770
|
+
logging.info(f"Public URL: https://{BUCKET_NAME} (SSL via Cloudflare)")
|
|
2771
|
+
else:
|
|
2772
|
+
logging.info(f"Public URL: https://{BUCKET_NAME} (SSL via ACM)")
|
|
2773
|
+
|
|
2774
|
+
logging.info(f"SPA Mode: {SPA_MODE}")
|
|
2775
|
+
logging.info(f"Certificate: {cert_arn}")
|
|
2776
|
+
logging.info("=" * 50)
|
|
2777
|
+
logging.info("Test URLs:")
|
|
2778
|
+
logging.info(f" - https://{BUCKET_NAME}/index-test.html")
|
|
2779
|
+
logging.info(f" - https://{BUCKET_NAME}/test.txt")
|
|
2780
|
+
if APEX_MODE and WWW_REDIRECT:
|
|
2781
|
+
logging.info(f" - https://{WWW_DOMAIN}/ (should redirect to apex)")
|
|
2782
|
+
logging.info("=" * 50)
|
|
2783
|
+
|
|
2784
|
+
# 8. Test CDN functionality (unless skipped)
|
|
2785
|
+
if not SKIP_TESTS:
|
|
2786
|
+
logging.info("\nWaiting 30 seconds for DNS/CDN propagation before testing...")
|
|
2787
|
+
time.sleep(30)
|
|
2788
|
+
|
|
2789
|
+
test_success = test_cdn_functionality(BUCKET_NAME, TEST_TIMEOUT)
|
|
2790
|
+
|
|
2791
|
+
if test_success:
|
|
2792
|
+
logging.info("\nš CDN setup completed successfully and is working!")
|
|
2793
|
+
return 0
|
|
2794
|
+
else:
|
|
2795
|
+
logging.warning("\nā ļø CDN setup completed but tests indicate some issues. Check the logs above.")
|
|
2796
|
+
return 1
|
|
2797
|
+
else:
|
|
2798
|
+
logging.info("\nā
CDN setup completed successfully (tests skipped)")
|
|
2799
|
+
return 0
|
|
2800
|
+
|
|
2801
|
+
except Exception as e:
|
|
2802
|
+
logging.error(f"An error occurred: {e}")
|
|
2803
|
+
import traceback
|
|
2804
|
+
traceback.print_exc()
|
|
2805
|
+
return 1
|
|
2806
|
+
|
|
2807
|
+
if __name__ == "__main__":
|
|
2808
|
+
exit(main())
|