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.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. 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">&#127881; 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">&#127881; 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())