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,1719 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Bunny Storage + Bunny CDN Website Setup Script
4
+
5
+ This script sets up a CDN for static content using Bunny Storage and Bunny CDN.
6
+ Bunny Storage has built-in static website hosting support, including automatic
7
+ index.html serving for directory requests.
8
+
9
+ ADVANTAGES OVER HETZNER S3:
10
+ ---------------------------
11
+ - Automatic index.html serving for / requests
12
+ - Built-in 404 handling with custom error pages
13
+ - Edge replication for faster global access
14
+ - Simpler setup (single provider)
15
+
16
+ MODES:
17
+ ------
18
+ 1. Subdomain Mode (default):
19
+ - Setup CDN for subdomain.example.com
20
+ - Use: python setup_bunny_storage.py --domain example.com --subdomain www
21
+
22
+ 2. Apex Mode (production):
23
+ - Setup CDN for example.com with optional www redirect
24
+ - Use: python setup_bunny_storage.py --domain example.com --apex --www-redirect
25
+
26
+ Environment Variables:
27
+ BUNNY_API_KEY: Required - Bunny.net API key
28
+ CLOUDFLARE_API_TOKEN: Required for Cloudflare DNS
29
+ HETZNER_DNS_API_TOKEN: Required for Hetzner DNS
30
+ DESEC_API_TOKEN: Required for deSEC DNS
31
+
32
+ Usage Examples:
33
+ # Subdomain: Basic static site
34
+ python setup_bunny_storage.py --domain example.com --subdomain www
35
+
36
+ # Apex: Production website with www redirect
37
+ python setup_bunny_storage.py --domain example.com --apex --www-redirect --spa-mode
38
+
39
+ # Different storage region
40
+ python setup_bunny_storage.py --domain example.com --subdomain www --region UK
41
+ """
42
+
43
+ import os
44
+ import time
45
+ import logging
46
+ import argparse
47
+ import requests
48
+ from abc import ABC, abstractmethod
49
+ from typing import Optional, List
50
+
51
+ from granny.create.registrars import get_registrar, registrar_choices, ManualRegistrar
52
+ from dotenv import load_dotenv
53
+ load_dotenv()
54
+ try:
55
+ from granny.credentials import load_secrets_into_env
56
+ load_secrets_into_env()
57
+ except Exception:
58
+ pass
59
+
60
+ # Optional imports for DNS providers
61
+ Cloudflare = None
62
+ try:
63
+ from cloudflare import Cloudflare
64
+ except ImportError:
65
+ pass
66
+
67
+ # Hetzner DNS is now part of the Hetzner Cloud API (dns.hetzner.com → api.hetzner.cloud).
68
+ # We call it directly with httpx instead of relying on the deprecated hetzner-dns-api lib.
69
+
70
+
71
+ # =============================================================================
72
+ # Bunny Storage Configuration
73
+ # =============================================================================
74
+
75
+ BUNNY_API_BASE = "https://api.bunny.net"
76
+
77
+ BUNNY_STORAGE_REGIONS = {
78
+ 'DE': {'name': 'Frankfurt, Germany', 'hostname': 'storage.bunnycdn.com'},
79
+ 'UK': {'name': 'London, UK', 'hostname': 'uk.storage.bunnycdn.com'},
80
+ 'NY': {'name': 'New York, US', 'hostname': 'ny.storage.bunnycdn.com'},
81
+ 'LA': {'name': 'Los Angeles, US', 'hostname': 'la.storage.bunnycdn.com'},
82
+ 'SG': {'name': 'Singapore', 'hostname': 'sg.storage.bunnycdn.com'},
83
+ 'SE': {'name': 'Stockholm, Sweden', 'hostname': 'se.storage.bunnycdn.com'},
84
+ 'BR': {'name': 'Sao Paulo, Brazil', 'hostname': 'br.storage.bunnycdn.com'},
85
+ 'JH': {'name': 'Johannesburg, SA', 'hostname': 'jh.storage.bunnycdn.com'},
86
+ 'SYD': {'name': 'Sydney, Australia', 'hostname': 'syd.storage.bunnycdn.com'},
87
+ }
88
+
89
+
90
+ # =============================================================================
91
+ # DNS Provider Abstraction Layer (same as setup_hetzner_bunny.py)
92
+ # =============================================================================
93
+
94
+ class DNSProvider(ABC):
95
+ """Abstract base class for DNS providers."""
96
+
97
+ @abstractmethod
98
+ def get_zone_id(self, zone_name: str) -> str:
99
+ pass
100
+
101
+ @abstractmethod
102
+ def create_cname_record(self, zone_id: str, name: str, target: str,
103
+ zone_name: str, ttl: int = 300) -> None:
104
+ pass
105
+
106
+ @abstractmethod
107
+ def supports_apex_cname(self) -> bool:
108
+ pass
109
+
110
+ @abstractmethod
111
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
112
+ cdn_domain: str) -> None:
113
+ pass
114
+
115
+
116
+ class CloudflareDNSProvider(DNSProvider):
117
+ """Cloudflare DNS API adapter."""
118
+
119
+ def __init__(self):
120
+ if Cloudflare is None:
121
+ raise ImportError("Cloudflare library not found. Install: pip install cloudflare")
122
+ token = os.environ.get("CLOUDFLARE_API_TOKEN")
123
+ if not token:
124
+ raise ValueError("CLOUDFLARE_API_TOKEN environment variable not set.")
125
+ self.client = Cloudflare(api_token=token)
126
+
127
+ def get_zone_id(self, zone_name: str) -> str:
128
+ zones = self.client.zones.list(name=zone_name)
129
+ if not zones.result:
130
+ raise Exception(f"Zone not found in Cloudflare: {zone_name}")
131
+ return zones.result[0].id
132
+
133
+ def create_cname_record(self, zone_id: str, name: str, target: str,
134
+ zone_name: str, ttl: int = 300) -> None:
135
+ full_domain_name = f"{name}.{zone_name}"
136
+ logging.info(f"Creating/updating CNAME: '{full_domain_name}' -> '{target}'...")
137
+
138
+ try:
139
+ existing_records = self.client.dns.records.list(zone_id=zone_id, name=full_domain_name)
140
+ dns_record = {'name': name, 'type': 'CNAME', 'content': target, 'proxied': False}
141
+
142
+ cname_exists = False
143
+ if existing_records.result:
144
+ for record in existing_records.result:
145
+ if record.type == 'CNAME':
146
+ cname_exists = True
147
+ if record.content != target:
148
+ self.client.dns.records.update(zone_id=zone_id, dns_record_id=record.id, **dns_record)
149
+ logging.info("CNAME record updated.")
150
+ else:
151
+ logging.info("CNAME record already correct.")
152
+ elif record.type in ['A', 'AAAA']:
153
+ self.client.dns.records.delete(zone_id=zone_id, dns_record_id=record.id)
154
+
155
+ if not cname_exists:
156
+ self.client.dns.records.create(zone_id=zone_id, **dns_record)
157
+ logging.info("CNAME record created.")
158
+ except Exception as e:
159
+ logging.error(f"Error creating CNAME record: {e}")
160
+ raise
161
+
162
+ def supports_apex_cname(self) -> bool:
163
+ return True
164
+
165
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
166
+ cdn_domain: str) -> None:
167
+ logging.info("Configuring Cloudflare DNS for apex domain...")
168
+
169
+ def create_or_update_record(name: str, content: str):
170
+ full_name = name if name == apex_domain else f"{name}.{apex_domain}"
171
+ existing = self.client.dns.records.list(zone_id=zone_id, name=full_name)
172
+ record_data = {'name': name, 'type': 'CNAME', 'content': content, 'proxied': False}
173
+
174
+ if existing.result:
175
+ for rec in existing.result:
176
+ if rec.type in ['A', 'AAAA']:
177
+ self.client.dns.records.delete(zone_id=zone_id, dns_record_id=rec.id)
178
+ elif rec.type == 'CNAME':
179
+ if rec.content != content:
180
+ self.client.dns.records.update(zone_id=zone_id, dns_record_id=rec.id, **record_data)
181
+ return
182
+ self.client.dns.records.create(zone_id=zone_id, **record_data)
183
+
184
+ create_or_update_record(apex_domain, cdn_domain)
185
+ logging.info(f"Apex configured: {apex_domain} -> {cdn_domain}")
186
+ create_or_update_record('www', cdn_domain)
187
+ logging.info(f"WWW configured: {www_domain} -> {cdn_domain}")
188
+
189
+
190
+ class HetznerDNSProvider(DNSProvider):
191
+ """Hetzner DNS API adapter using the new Cloud API (api.hetzner.cloud/v1/zones).
192
+
193
+ The old dns.hetzner.com API is being deprecated. This adapter uses the
194
+ Hetzner Cloud API directly (Bearer auth, rrset-based record management).
195
+ Zones are auto-created if they don't already exist.
196
+ """
197
+
198
+ API_BASE = "https://api.hetzner.cloud/v1"
199
+
200
+ def __init__(self):
201
+ token = os.environ.get("HETZNER_DNS_API_TOKEN")
202
+ if not token:
203
+ raise ValueError("HETZNER_DNS_API_TOKEN environment variable not set.")
204
+ self.session = requests.Session()
205
+ self.session.headers.update({
206
+ "Authorization": f"Bearer {token}",
207
+ "Content-Type": "application/json",
208
+ })
209
+
210
+ def _request(self, method: str, path: str, **kwargs) -> dict:
211
+ url = f"{self.API_BASE}{path}"
212
+ resp = self.session.request(method, url, **kwargs)
213
+ if resp.status_code >= 400:
214
+ raise Exception(f"Hetzner API {method} {path} failed: {resp.status_code} {resp.text}")
215
+ return resp.json() if resp.text else {}
216
+
217
+ def get_zone_id(self, zone_name: str) -> str:
218
+ data = self._request("GET", "/zones", params={"name": zone_name})
219
+ for zone in data.get("zones", []):
220
+ if zone["name"] == zone_name:
221
+ logging.info(f"Found Hetzner DNS zone: {zone_name} (id={zone['id']})")
222
+ return str(zone["id"])
223
+
224
+ # Auto-create zone if missing
225
+ logging.info(f"Zone '{zone_name}' not found. Creating new Hetzner DNS zone...")
226
+ data = self._request("POST", "/zones", json={"name": zone_name, "mode": "primary"})
227
+ zone = data["zone"]
228
+ ns = zone.get("authoritative_nameservers", {}).get("assigned", [])
229
+ logging.info("=" * 60)
230
+ logging.info("IMPORTANT: Configure these nameservers at your domain registrar:")
231
+ for n in ns:
232
+ logging.info(f" {n.rstrip('.')}")
233
+ logging.info("=" * 60)
234
+ return str(zone["id"])
235
+
236
+ def _get_rrset(self, zone_id: str, name: str, record_type: str) -> Optional[dict]:
237
+ rrset_id = f"{name}/{record_type}"
238
+ try:
239
+ data = self._request("GET", f"/zones/{zone_id}/rrsets/{rrset_id}")
240
+ return data.get("rrset")
241
+ except Exception:
242
+ return None
243
+
244
+ def _upsert_rrset(self, zone_id: str, name: str, record_type: str,
245
+ values: list, ttl: int = 300) -> None:
246
+ existing = self._get_rrset(zone_id, name, record_type)
247
+ records = [{"value": v} for v in values]
248
+ rrset_id = f"{name}/{record_type}"
249
+
250
+ if existing:
251
+ existing_values = sorted(r["value"] for r in existing.get("records", []))
252
+ if existing_values == sorted(values) and existing.get("ttl") == ttl:
253
+ logging.info(f"{record_type} rrset '{name}' already correct.")
254
+ return
255
+ self._request("PUT", f"/zones/{zone_id}/rrsets/{rrset_id}",
256
+ json={"ttl": ttl, "records": records})
257
+ logging.info(f"Updated {record_type} rrset: {name} -> {values}")
258
+ else:
259
+ self._request("POST", f"/zones/{zone_id}/rrsets",
260
+ json={"name": name, "type": record_type, "ttl": ttl, "records": records})
261
+ logging.info(f"Created {record_type} rrset: {name} -> {values}")
262
+
263
+ def _delete_rrset(self, zone_id: str, name: str, record_type: str) -> None:
264
+ rrset_id = f"{name}/{record_type}"
265
+ try:
266
+ self._request("DELETE", f"/zones/{zone_id}/rrsets/{rrset_id}")
267
+ logging.info(f"Deleted {record_type} rrset for '{name}'")
268
+ except Exception as e:
269
+ if "404" not in str(e):
270
+ raise
271
+
272
+ def create_cname_record(self, zone_id: str, name: str, target: str,
273
+ zone_name: str, ttl: int = 300) -> None:
274
+ logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
275
+ target = target if target.endswith('.') else f"{target}."
276
+ # CNAME conflicts with A/AAAA at the same name
277
+ self._delete_rrset(zone_id, name, 'A')
278
+ self._delete_rrset(zone_id, name, 'AAAA')
279
+ self._upsert_rrset(zone_id, name, 'CNAME', [target], ttl)
280
+
281
+ def supports_apex_cname(self) -> bool:
282
+ return False
283
+
284
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
285
+ cdn_domain: str) -> None:
286
+ logging.warning("Hetzner DNS does not support CNAME at apex. Using Bunny anycast IP.")
287
+ bunny_anycast_ip = "185.206.224.1"
288
+ self._delete_rrset(zone_id, '@', 'AAAA')
289
+ self._delete_rrset(zone_id, '@', 'CNAME')
290
+ self._upsert_rrset(zone_id, '@', 'A', [bunny_anycast_ip], 300)
291
+ logging.info(f"Apex A record set: {apex_domain} -> {bunny_anycast_ip}")
292
+ self.create_cname_record(zone_id, 'www', cdn_domain, apex_domain)
293
+
294
+
295
+ class DeSECDNSProvider(DNSProvider):
296
+ """deSEC DNS API adapter."""
297
+
298
+ API_BASE = "https://desec.io/api/v1"
299
+ MIN_TTL = 3600
300
+
301
+ def __init__(self):
302
+ token = os.environ.get("DESEC_API_TOKEN")
303
+ if not token:
304
+ raise ValueError("DESEC_API_TOKEN environment variable not set.")
305
+ self.token = token
306
+ self.session = requests.Session()
307
+ self.session.headers.update({
308
+ "Authorization": f"Token {token}",
309
+ "Content-Type": "application/json"
310
+ })
311
+
312
+ def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
313
+ url = f"{self.API_BASE}{endpoint}"
314
+ response = getattr(self.session, method.lower())(url, json=data)
315
+
316
+ if response.status_code == 429:
317
+ retry_after = int(response.headers.get("Retry-After", 5))
318
+ logging.warning(f"Rate limited. Waiting {retry_after}s...")
319
+ time.sleep(retry_after)
320
+ return self._api_request(method, endpoint, data)
321
+
322
+ if response.status_code >= 400:
323
+ raise Exception(f"API error: {response.status_code} - {response.text}")
324
+
325
+ return response.json() if response.text and response.status_code != 204 else {}
326
+
327
+ def get_zone_id(self, zone_name: str) -> str:
328
+ try:
329
+ self._api_request("GET", f"/domains/{zone_name}/")
330
+ return zone_name
331
+ except Exception as exc:
332
+ raise Exception(f"Zone not found in deSEC: {zone_name}") from exc
333
+
334
+ def _create_or_update_rrset(self, domain: str, subname: str, record_type: str,
335
+ records: list, ttl: int = 3600) -> None:
336
+ ttl = max(ttl, self.MIN_TTL)
337
+ endpoint = f"/domains/{domain}/rrsets/"
338
+
339
+ try:
340
+ self._api_request("PATCH", f"{endpoint}{subname}.../{record_type}/", {"records": records, "ttl": ttl})
341
+ logging.info(f"Updated {record_type} record for {subname or '@'}.{domain}")
342
+ except Exception as e:
343
+ if "404" in str(e):
344
+ self._api_request("POST", endpoint, {"subname": subname, "type": record_type, "records": records, "ttl": ttl})
345
+ logging.info(f"Created {record_type} record for {subname or '@'}.{domain}")
346
+ else:
347
+ raise
348
+
349
+ def _delete_rrset(self, domain: str, subname: str, record_type: str) -> None:
350
+ try:
351
+ self._api_request("DELETE", f"/domains/{domain}/rrsets/{subname}.../{record_type}/")
352
+ except Exception:
353
+ pass
354
+
355
+ def create_cname_record(self, zone_id: str, name: str, target: str,
356
+ zone_name: str, ttl: int = 300) -> None:
357
+ logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
358
+ target_with_dot = target if target.endswith('.') else f"{target}."
359
+ self._delete_rrset(zone_id, name, 'A')
360
+ self._delete_rrset(zone_id, name, 'AAAA')
361
+ self._create_or_update_rrset(zone_id, name, 'CNAME', [target_with_dot], ttl)
362
+
363
+ def supports_apex_cname(self) -> bool:
364
+ return False
365
+
366
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
367
+ cdn_domain: str) -> None:
368
+ logging.info("Configuring deSEC DNS for apex domain...")
369
+ bunny_anycast_ip = "185.206.224.1"
370
+
371
+ self._delete_rrset(apex_domain, '', 'CNAME')
372
+ self._delete_rrset(apex_domain, '', 'AAAA')
373
+ self._create_or_update_rrset(apex_domain, '', 'A', [bunny_anycast_ip], 300)
374
+ logging.info(f"Apex A record created: {apex_domain} -> {bunny_anycast_ip}")
375
+ self.create_cname_record(apex_domain, 'www', cdn_domain, apex_domain)
376
+
377
+
378
+
379
+ class BunnyDNSProvider(DNSProvider):
380
+ """Bunny DNS API adapter.
381
+
382
+ Bunny.net provides DNS hosting that integrates seamlessly with their CDN.
383
+ Uses the same API key as Bunny Storage/CDN (BUNNY_API_KEY).
384
+ """
385
+
386
+ API_BASE = "https://api.bunny.net"
387
+
388
+ # DNS Record Types in Bunny API
389
+ RECORD_TYPES = {
390
+ 'A': 0,
391
+ 'AAAA': 1,
392
+ 'CNAME': 2,
393
+ 'TXT': 3,
394
+ 'MX': 4,
395
+ 'Redirect': 5,
396
+ 'Flatten': 6,
397
+ 'PullZone': 7,
398
+ 'SRV': 8,
399
+ 'CAA': 9,
400
+ 'PTR': 10,
401
+ 'Script': 11,
402
+ 'NS': 12,
403
+ }
404
+
405
+ def __init__(self):
406
+ api_key = os.environ.get("BUNNY_API_KEY")
407
+ if not api_key:
408
+ raise ValueError("BUNNY_API_KEY environment variable not set.")
409
+ self.api_key = api_key
410
+ self.session = requests.Session()
411
+ self.session.headers.update({
412
+ "AccessKey": api_key,
413
+ "Content-Type": "application/json"
414
+ })
415
+ self._zone_cache = {}
416
+
417
+ def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
418
+ """Make an API request to Bunny.net API."""
419
+ url = f"{self.API_BASE}{endpoint}"
420
+ response = getattr(self.session, method.lower())(url, json=data)
421
+
422
+ if response.status_code >= 400:
423
+ raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
424
+
425
+ return response.json() if response.text and response.status_code not in [204] else {}
426
+
427
+ def list_dns_zones(self) -> List[dict]:
428
+ """List all DNS zones."""
429
+ result = self._api_request("GET", "/dnszone")
430
+ return result.get('Items', []) if isinstance(result, dict) else result
431
+
432
+ def find_zone_by_domain(self, domain: str) -> Optional[dict]:
433
+ """Find a DNS zone by domain name."""
434
+ zones = self.list_dns_zones()
435
+ for zone in zones:
436
+ if zone.get('Domain') == domain:
437
+ return zone
438
+ return None
439
+
440
+ def create_dns_zone(self, domain: str) -> dict:
441
+ """Create a new DNS zone."""
442
+ logging.info(f"Creating Bunny DNS zone for: {domain}")
443
+ result = self._api_request("POST", "/dnszone", {"Domain": domain})
444
+ logging.info(f"DNS Zone created. ID: {result.get('Id')}")
445
+
446
+ # Print nameservers
447
+ nameservers = result.get('NameServer1', ''), result.get('NameServer2', '')
448
+ if nameservers[0]:
449
+ logging.info("=" * 60)
450
+ logging.info("IMPORTANT: Configure these nameservers at your domain registrar:")
451
+ logging.info(f" {nameservers[0]}")
452
+ logging.info(f" {nameservers[1]}")
453
+ logging.info("=" * 60)
454
+
455
+ return result
456
+
457
+ def get_zone_id(self, zone_name: str) -> str:
458
+ """Get zone ID, creating the zone if it doesn't exist."""
459
+ # Check cache first
460
+ if zone_name in self._zone_cache:
461
+ return self._zone_cache[zone_name]
462
+
463
+ # Try to find existing zone
464
+ zone = self.find_zone_by_domain(zone_name)
465
+
466
+ if zone:
467
+ zone_id = str(zone.get('Id'))
468
+ self._zone_cache[zone_name] = zone_id
469
+ logging.info(f"Found existing Bunny DNS zone: {zone_name} (ID: {zone_id})")
470
+ return zone_id
471
+
472
+ # Create new zone
473
+ logging.info(f"Zone '{zone_name}' not found. Creating new Bunny DNS zone...")
474
+ zone = self.create_dns_zone(zone_name)
475
+ zone_id = str(zone.get('Id'))
476
+ self._zone_cache[zone_name] = zone_id
477
+ return zone_id
478
+
479
+ def get_zone_records(self, zone_id: str) -> List[dict]:
480
+ """Get all records for a zone."""
481
+ result = self._api_request("GET", f"/dnszone/{zone_id}")
482
+ return result.get('Records', [])
483
+
484
+ def find_record(self, zone_id: str, name: str, record_type: str) -> Optional[dict]:
485
+ """Find a specific DNS record."""
486
+ records = self.get_zone_records(zone_id)
487
+ type_num = self.RECORD_TYPES.get(record_type.upper())
488
+
489
+ for record in records:
490
+ if record.get('Name') == name and record.get('Type') == type_num:
491
+ return record
492
+ return None
493
+
494
+ def create_record(self, zone_id: str, name: str, record_type: str, value: str,
495
+ ttl: int = 300, priority: int = 0) -> dict:
496
+ """Create a DNS record."""
497
+ type_num = self.RECORD_TYPES.get(record_type.upper())
498
+ if type_num is None:
499
+ raise ValueError(f"Unknown record type: {record_type}")
500
+
501
+ data = {
502
+ "Type": type_num,
503
+ "Name": name,
504
+ "Value": value,
505
+ "Ttl": ttl,
506
+ "Priority": priority,
507
+ }
508
+
509
+ return self._api_request("PUT", f"/dnszone/{zone_id}/records", data)
510
+
511
+ def delete_record(self, zone_id: str, record_id: int) -> None:
512
+ """Delete a DNS record."""
513
+ self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record_id}")
514
+
515
+ def create_or_update_record(self, zone_id: str, name: str, record_type: str,
516
+ value: str, ttl: int = 300) -> None:
517
+ """Create or update a DNS record."""
518
+ existing = self.find_record(zone_id, name, record_type)
519
+
520
+ if existing:
521
+ if existing.get('Value') == value:
522
+ logging.info(f"{record_type} record '{name}' already correct.")
523
+ return
524
+ # Delete existing record
525
+ self.delete_record(zone_id, existing.get('Id'))
526
+ logging.info(f"Deleted old {record_type} record for '{name}'")
527
+
528
+ self.create_record(zone_id, name, record_type, value, ttl)
529
+ logging.info(f"Created {record_type} record: {name} -> {value}")
530
+
531
+ def delete_records_by_type(self, zone_id: str, name: str, record_types: List[str]) -> None:
532
+ """Delete records of specified types for a name."""
533
+ records = self.get_zone_records(zone_id)
534
+
535
+ for record in records:
536
+ if record.get('Name') == name:
537
+ for rtype in record_types:
538
+ type_num = self.RECORD_TYPES.get(rtype.upper())
539
+ if record.get('Type') == type_num:
540
+ self.delete_record(zone_id, record.get('Id'))
541
+ logging.info(f"Deleted {rtype} record for '{name}'")
542
+
543
+ def create_cname_record(self, zone_id: str, name: str, target: str,
544
+ zone_name: str, ttl: int = 300) -> None:
545
+ """Create a CNAME record, removing conflicting A/AAAA records first."""
546
+ logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
547
+
548
+ # Remove trailing dot from target if present
549
+ target = target.rstrip('.')
550
+
551
+ # Delete conflicting A/AAAA records
552
+ self.delete_records_by_type(zone_id, name, ['A', 'AAAA'])
553
+
554
+ # Create/update CNAME
555
+ self.create_or_update_record(zone_id, name, 'CNAME', target, ttl)
556
+
557
+ def supports_apex_cname(self) -> bool:
558
+ """Bunny DNS doesn't support CNAME at apex, use A record with anycast IP."""
559
+ return False
560
+
561
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
562
+ cdn_domain: str) -> None:
563
+ """Configure DNS for apex domain using Bunny's anycast IP.
564
+
565
+ Since Bunny DNS doesn't support CNAME at apex, we use Bunny's anycast IP
566
+ (185.206.224.1) which routes to the nearest Bunny edge server.
567
+ """
568
+ logging.info("Configuring Bunny DNS for apex domain...")
569
+ bunny_anycast_ip = "185.206.224.1"
570
+
571
+ # Delete any existing conflicting records at apex
572
+ self.delete_records_by_type(zone_id, '', ['AAAA', 'CNAME'])
573
+
574
+ # Create A record at apex pointing to Bunny anycast IP
575
+ self.create_or_update_record(zone_id, '', 'A', bunny_anycast_ip, 300)
576
+ logging.info(f"Apex A record created: {apex_domain} -> {bunny_anycast_ip}")
577
+
578
+ # Create www CNAME
579
+ self.create_cname_record(zone_id, 'www', cdn_domain, apex_domain)
580
+
581
+ def get_nameservers(self, zone_name: str) -> List[str]:
582
+ """Return the authoritative nameservers assigned to the Bunny DNS zone.
583
+
584
+ Used by the --registrar flag to flip the domain's delegation at the
585
+ registrar after the zone is provisioned. Returns an empty list if
586
+ the zone doesn't exist or Bunny hasn't populated the NS fields yet.
587
+ """
588
+ zone = self.find_zone_by_domain(zone_name)
589
+ if not zone:
590
+ return []
591
+ ns: List[str] = []
592
+ for key in ('NameServer1', 'NameServer2'):
593
+ value = zone.get(key)
594
+ if value:
595
+ ns.append(value)
596
+ return ns
597
+
598
+
599
+
600
+
601
+
602
+ class ClouDNSDNSProvider(DNSProvider):
603
+ """ClouDNS API adapter.
604
+
605
+ ClouDNS is a European DNS provider with affordable DDoS protection.
606
+ Uses auth-id + auth-password authentication.
607
+
608
+ Environment Variables:
609
+ CLOUDNS_AUTH_ID: ClouDNS auth ID
610
+ CLOUDNS_AUTH_PASSWORD: ClouDNS auth password
611
+ """
612
+ API_BASE = "https://api.cloudns.net"
613
+ VALID_TTLS = [60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600, 2592000]
614
+ MIN_TTL = 60
615
+
616
+ def __init__(self, auth_id: str = None, auth_password: str = None,
617
+ sub_auth_id: str = None, sub_auth_user: str = None):
618
+ self.auth_id = auth_id or os.environ.get("CLOUDNS_AUTH_ID")
619
+ self.auth_password = auth_password or os.environ.get("CLOUDNS_AUTH_PASSWORD")
620
+ self.sub_auth_id = sub_auth_id or os.environ.get("CLOUDNS_SUB_AUTH_ID")
621
+ self.sub_auth_user = sub_auth_user or os.environ.get("CLOUDNS_SUB_AUTH_USER")
622
+
623
+ if not self.auth_password:
624
+ raise ValueError("ClouDNS auth-password is required (CLOUDNS_AUTH_PASSWORD)")
625
+ if not self.auth_id and not self.sub_auth_id and not self.sub_auth_user:
626
+ raise ValueError("ClouDNS requires auth-id (CLOUDNS_AUTH_ID) or sub-auth credentials")
627
+
628
+ self._zone_cache = {}
629
+
630
+ def _get_auth_params(self) -> dict:
631
+ """Get authentication parameters for API requests."""
632
+ params = {"auth-password": self.auth_password}
633
+ if self.auth_id:
634
+ params["auth-id"] = self.auth_id
635
+ elif self.sub_auth_id:
636
+ params["sub-auth-id"] = self.sub_auth_id
637
+ elif self.sub_auth_user:
638
+ params["sub-auth-user"] = self.sub_auth_user
639
+ return params
640
+
641
+ def _api_request(self, endpoint: str, params: dict = None) -> dict:
642
+ """Make an API request to ClouDNS."""
643
+ url = f"{self.API_BASE}{endpoint}"
644
+ all_params = self._get_auth_params()
645
+ if params:
646
+ all_params.update(params)
647
+
648
+ response = requests.post(url, data=all_params, timeout=30)
649
+
650
+ if response.status_code >= 400:
651
+ raise Exception(f"ClouDNS API error: {response.status_code} - {response.text}")
652
+
653
+ result = response.json()
654
+ if isinstance(result, dict) and result.get("status") == "Failed":
655
+ raise Exception(f"ClouDNS API error: {result.get('statusDescription', 'Unknown error')}")
656
+
657
+ return result
658
+
659
+ def _get_valid_ttl(self, ttl: int) -> int:
660
+ """Get the closest valid TTL for ClouDNS."""
661
+ for valid_ttl in self.VALID_TTLS:
662
+ if ttl <= valid_ttl:
663
+ return valid_ttl
664
+ return self.VALID_TTLS[-1]
665
+
666
+ def get_zone_id(self, zone_name: str) -> str:
667
+ """Get the zone name (ClouDNS uses domain name as identifier)."""
668
+ if zone_name in self._zone_cache:
669
+ return self._zone_cache[zone_name]
670
+
671
+ logging.info(f"Looking up ClouDNS zone for '{zone_name}'...")
672
+
673
+ result = self._api_request("/dns/list-zones.json", {"page": 1, "rows-per-page": 100})
674
+
675
+ if isinstance(result, list):
676
+ zones = result
677
+ elif isinstance(result, dict):
678
+ zones = list(result.values()) if result else []
679
+ else:
680
+ zones = []
681
+
682
+ for zone in zones:
683
+ if isinstance(zone, dict) and zone.get("name") == zone_name:
684
+ logging.info(f"Found ClouDNS zone: {zone_name}")
685
+ self._zone_cache[zone_name] = zone_name
686
+ return zone_name
687
+
688
+ raise Exception(f"Zone not found in ClouDNS: {zone_name}")
689
+
690
+ def _get_records(self, zone_name: str) -> list:
691
+ """Get all records for a zone."""
692
+ result = self._api_request("/dns/records.json", {"domain-name": zone_name})
693
+
694
+ if isinstance(result, list):
695
+ return result
696
+ elif isinstance(result, dict):
697
+ return list(result.values()) if result else []
698
+ return []
699
+
700
+ def _find_record(self, zone_name: str, name: str, record_type: str) -> Optional[dict]:
701
+ """Find a specific DNS record."""
702
+ records = self._get_records(zone_name)
703
+
704
+ for record in records:
705
+ if not isinstance(record, dict):
706
+ continue
707
+ record_host = record.get("host", "")
708
+ if record_host == name and record.get("type") == record_type:
709
+ return record
710
+
711
+ return None
712
+
713
+ def _delete_record(self, zone_name: str, record_id: str) -> None:
714
+ """Delete a DNS record."""
715
+ self._api_request("/dns/delete-record.json", {
716
+ "domain-name": zone_name,
717
+ "record-id": record_id
718
+ })
719
+
720
+ def create_cname_record(self, zone_id: str, name: str, target: str,
721
+ zone_name: str, ttl: int = 300) -> None:
722
+ """Create or update a CNAME record."""
723
+ logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
724
+
725
+ target = target.rstrip(".")
726
+ valid_ttl = self._get_valid_ttl(ttl)
727
+
728
+ # Delete conflicting A/AAAA records
729
+ for rtype in ['A', 'AAAA']:
730
+ existing = self._find_record(zone_name, name, rtype)
731
+ if existing and existing.get("id"):
732
+ logging.info(f"Deleting conflicting {rtype} record...")
733
+ self._delete_record(zone_name, existing["id"])
734
+
735
+ # Check for existing CNAME
736
+ existing_cname = self._find_record(zone_name, name, "CNAME")
737
+ if existing_cname:
738
+ if existing_cname.get("record") == target:
739
+ logging.info("CNAME record already correct.")
740
+ return
741
+ # Delete and recreate
742
+ self._delete_record(zone_name, existing_cname["id"])
743
+ logging.info("Deleted old CNAME record.")
744
+
745
+ # Create new CNAME
746
+ self._api_request("/dns/add-record.json", {
747
+ "domain-name": zone_name,
748
+ "record-type": "CNAME",
749
+ "host": name,
750
+ "record": target,
751
+ "ttl": valid_ttl
752
+ })
753
+ logging.info("CNAME record created.")
754
+
755
+ def supports_apex_cname(self) -> bool:
756
+ """ClouDNS does not support CNAME at apex."""
757
+ return False
758
+
759
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
760
+ cdn_domain: str) -> None:
761
+ """Configure DNS for apex domain using Bunny's anycast IP."""
762
+ logging.info("Configuring ClouDNS for apex domain...")
763
+ bunny_anycast_ip = "185.206.224.1"
764
+
765
+ # Delete conflicting records at apex
766
+ for rtype in ['AAAA', 'CNAME']:
767
+ existing = self._find_record(apex_domain, "", rtype)
768
+ if existing and existing.get("id"):
769
+ self._delete_record(apex_domain, existing["id"])
770
+
771
+ # Create/update A record at apex
772
+ existing_a = self._find_record(apex_domain, "", "A")
773
+ if existing_a:
774
+ if existing_a.get("record") == bunny_anycast_ip:
775
+ logging.info("Apex A record already correct.")
776
+ else:
777
+ self._delete_record(apex_domain, existing_a["id"])
778
+ self._api_request("/dns/add-record.json", {
779
+ "domain-name": apex_domain,
780
+ "record-type": "A",
781
+ "host": "",
782
+ "record": bunny_anycast_ip,
783
+ "ttl": 300
784
+ })
785
+ logging.info(f"Apex A record updated: {apex_domain} -> {bunny_anycast_ip}")
786
+ else:
787
+ self._api_request("/dns/add-record.json", {
788
+ "domain-name": apex_domain,
789
+ "record-type": "A",
790
+ "host": "",
791
+ "record": bunny_anycast_ip,
792
+ "ttl": 300
793
+ })
794
+ logging.info(f"Apex A record created: {apex_domain} -> {bunny_anycast_ip}")
795
+
796
+ # Create www CNAME
797
+ self.create_cname_record(apex_domain, "www", cdn_domain, apex_domain)
798
+
799
+
800
+ class ManualDNSProvider(DNSProvider):
801
+ """No-op DNS provider.
802
+
803
+ Use when the authoritative DNS lives with a provider Granny does not yet
804
+ support (e.g. easyname.eu). The script will create the Bunny storage zone,
805
+ pull zone, and hostname binding as usual, but instead of touching DNS it
806
+ prints the exact record the operator must add manually.
807
+
808
+ SSL issuance via HTTP-01 will fail during the initial run because DNS has
809
+ not propagated yet. That is expected: Bunny retries certificate issuance
810
+ automatically once the CNAME resolves, typically within a few minutes of
811
+ the manual record being added. Pair this provider with ``--skip-tests``.
812
+ """
813
+
814
+ def __init__(self):
815
+ self._pending_records: list[str] = []
816
+
817
+ def get_zone_id(self, zone_name: str) -> str:
818
+ # Zone id is unused for the manual path but the main flow expects a value.
819
+ return "manual"
820
+
821
+ def create_cname_record(self, zone_id: str, name: str, target: str,
822
+ zone_name: str, ttl: int = 300) -> None:
823
+ fqdn = f"{name}.{zone_name}" if name and name != '@' else zone_name
824
+ record = f"CNAME {fqdn}. -> {target.rstrip('.')}. (TTL {ttl})"
825
+ self._pending_records.append(record)
826
+ logging.warning("=" * 70)
827
+ logging.warning("MANUAL DNS ACTION REQUIRED")
828
+ logging.warning("Add the following record at your DNS provider:")
829
+ logging.warning(" %s", record)
830
+ logging.warning("SSL will auto-issue once the record propagates.")
831
+ logging.warning("=" * 70)
832
+
833
+ def supports_apex_cname(self) -> bool:
834
+ # Claim support so the caller emits a single CNAME instruction for apex.
835
+ return True
836
+
837
+ def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
838
+ cdn_domain: str) -> None:
839
+ logging.warning("=" * 70)
840
+ logging.warning("MANUAL DNS ACTION REQUIRED (apex)")
841
+ logging.warning("Most registrars cannot CNAME the apex. Add either:")
842
+ logging.warning(" ALIAS/ANAME %s. -> %s.", apex_domain, cdn_domain.rstrip('.'))
843
+ logging.warning(" or A record %s. -> 185.206.224.1 (Bunny anycast)", apex_domain)
844
+ logging.warning("And for www:")
845
+ logging.warning(" CNAME %s. -> %s.", www_domain, cdn_domain.rstrip('.'))
846
+ logging.warning("SSL will auto-issue once the records propagate.")
847
+ logging.warning("=" * 70)
848
+ self._pending_records.extend([
849
+ f"ALIAS/A {apex_domain}. -> {cdn_domain.rstrip('.')}. (or 185.206.224.1)",
850
+ f"CNAME {www_domain}. -> {cdn_domain.rstrip('.')}.",
851
+ ])
852
+
853
+
854
+ def get_dns_provider(provider_name: str) -> DNSProvider:
855
+ """Factory function to get the appropriate DNS provider."""
856
+ providers = {
857
+ 'cloudflare': CloudflareDNSProvider,
858
+ 'hetzner': HetznerDNSProvider,
859
+ 'desec': DeSECDNSProvider,
860
+ 'bunny': BunnyDNSProvider,
861
+ 'cloudns': ClouDNSDNSProvider,
862
+ 'manual': ManualDNSProvider,
863
+ }
864
+ if provider_name not in providers:
865
+ raise ValueError(f"Unknown DNS provider: {provider_name}")
866
+ return providers[provider_name]()
867
+
868
+
869
+ # =============================================================================
870
+ # Bunny Storage Client
871
+ # =============================================================================
872
+
873
+ class BunnyStorageClient:
874
+ """Client for Bunny Storage API."""
875
+
876
+ def __init__(self, api_key: str):
877
+ self.api_key = api_key
878
+ self.session = requests.Session()
879
+ self.session.headers.update({
880
+ "AccessKey": api_key,
881
+ "Content-Type": "application/json"
882
+ })
883
+
884
+ def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
885
+ """Make an API request to Bunny.net API."""
886
+ url = f"{BUNNY_API_BASE}{endpoint}"
887
+ response = getattr(self.session, method.lower())(url, json=data)
888
+
889
+ if response.status_code >= 400:
890
+ raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
891
+
892
+ return response.json() if response.text and response.status_code not in [204] else {}
893
+
894
+ def list_storage_zones(self) -> List[dict]:
895
+ """List all storage zones."""
896
+ return self._api_request("GET", "/storagezone")
897
+
898
+ def find_storage_zone_by_name(self, name: str) -> Optional[dict]:
899
+ """Find a storage zone by name."""
900
+ for zone in self.list_storage_zones():
901
+ if zone.get('Name') == name:
902
+ return zone
903
+ return None
904
+
905
+ def create_storage_zone(self, name: str, region: str = 'DE') -> dict:
906
+ """Create a new storage zone."""
907
+ logging.info(f"Creating Bunny Storage Zone: {name} in {region}")
908
+
909
+ data = {
910
+ "Name": name,
911
+ "Region": region,
912
+ "ZoneTier": 0, # Standard tier
913
+ }
914
+
915
+ result = self._api_request("POST", "/storagezone", data)
916
+ logging.info(f"Storage Zone created. ID: {result.get('Id')}")
917
+ return result
918
+
919
+ def delete_storage_zone(self, zone_id: int) -> None:
920
+ """Delete a storage zone."""
921
+ self._api_request("DELETE", f"/storagezone/{zone_id}")
922
+
923
+ def upload_file(self, storage_hostname: str, storage_password: str,
924
+ zone_name: str, path: str, content: bytes, content_type: str = None) -> None:
925
+ """Upload a file to the storage zone."""
926
+ url = f"https://{storage_hostname}/{zone_name}/{path}"
927
+ headers = {"AccessKey": storage_password}
928
+ if content_type:
929
+ headers["Content-Type"] = content_type
930
+
931
+ response = requests.put(url, data=content, headers=headers)
932
+ if response.status_code not in [200, 201]:
933
+ raise Exception(f"Upload failed: {response.status_code} - {response.text}")
934
+
935
+
936
+ # =============================================================================
937
+ # Bunny CDN Client
938
+ # =============================================================================
939
+
940
+ class BunnyCDNClient:
941
+ """Client for Bunny CDN API."""
942
+
943
+ def __init__(self, api_key: str):
944
+ self.api_key = api_key
945
+ self.session = requests.Session()
946
+ self.session.headers.update({
947
+ "AccessKey": api_key,
948
+ "Content-Type": "application/json"
949
+ })
950
+
951
+ def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
952
+ url = f"{BUNNY_API_BASE}{endpoint}"
953
+ response = getattr(self.session, method.lower())(url, json=data)
954
+
955
+ if response.status_code >= 400:
956
+ raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
957
+
958
+ return response.json() if response.text and response.status_code not in [204] else {}
959
+
960
+ def list_pull_zones(self) -> List[dict]:
961
+ """List all pull zones."""
962
+ return self._api_request("GET", "/pullzone")
963
+
964
+ def find_pull_zone_by_name(self, name: str) -> Optional[dict]:
965
+ """Find a pull zone by name."""
966
+ for zone in self.list_pull_zones():
967
+ if zone.get('Name') == name:
968
+ return zone
969
+ return None
970
+
971
+ def find_pull_zone_by_hostname(self, hostname: str) -> Optional[dict]:
972
+ """Find a pull zone by hostname."""
973
+ for zone in self.list_pull_zones():
974
+ for h in zone.get('Hostnames', []):
975
+ if h.get('Value') == hostname:
976
+ return zone
977
+ return None
978
+
979
+ def create_pull_zone_for_storage(self, name: str, storage_zone_id: int) -> dict:
980
+ """Create a pull zone connected to a storage zone."""
981
+ logging.info(f"Creating Bunny CDN Pull Zone: {name}")
982
+
983
+ data = {
984
+ "Name": name,
985
+ "OriginType": 2, # Storage Zone
986
+ "StorageZoneId": storage_zone_id,
987
+ "EnableGeoZoneUS": True,
988
+ "EnableGeoZoneEU": True,
989
+ "EnableGeoZoneASIA": True,
990
+ "EnableGeoZoneSA": True,
991
+ "EnableGeoZoneAF": True,
992
+ }
993
+
994
+ result = self._api_request("POST", "/pullzone", data)
995
+ logging.info(f"Pull Zone created. ID: {result.get('Id')}")
996
+ return result
997
+
998
+ def add_hostname(self, pull_zone_id: int, hostname: str) -> dict:
999
+ """Add a custom hostname to a pull zone."""
1000
+ logging.info(f"Adding hostname '{hostname}' to pull zone {pull_zone_id}")
1001
+
1002
+ try:
1003
+ self._api_request("POST", f"/pullzone/{pull_zone_id}/addHostname", {"Hostname": hostname})
1004
+ logging.info(f"Hostname '{hostname}' added.")
1005
+ return {"added": True}
1006
+ except Exception as e:
1007
+ if "hostname_already_registered" in str(e):
1008
+ logging.info(f"Hostname '{hostname}' already registered.")
1009
+ return {"added": False, "exists": True}
1010
+ raise
1011
+
1012
+ def request_ssl_certificate(self, pull_zone_id: int, hostname: str,
1013
+ use_dns01: bool = False) -> bool:
1014
+ """Request a free SSL certificate for a hostname.
1015
+
1016
+ The API endpoint is /pullzone/loadFreeCertificate (without pull zone ID
1017
+ in the path).
1018
+
1019
+ Args:
1020
+ pull_zone_id: Unused, kept for backwards compatibility.
1021
+ hostname: The hostname to request a certificate for.
1022
+ use_dns01: Use DNS01 validation instead of HTTP01. Set to True when
1023
+ the domain uses Bunny DNS.
1024
+ """
1025
+ logging.info(f"Requesting SSL certificate for '{hostname}'...")
1026
+
1027
+ try:
1028
+ params = f"hostname={hostname}"
1029
+ if use_dns01:
1030
+ params += "&useOnlyHttp01=false"
1031
+ logging.info("Using DNS01 validation (useOnlyHttp01=false)")
1032
+ url = f"{BUNNY_API_BASE}/pullzone/loadFreeCertificate?{params}"
1033
+ response = self.session.get(url)
1034
+ if response.status_code in [200, 204]:
1035
+ logging.info("SSL certificate requested successfully.")
1036
+ return True
1037
+ else:
1038
+ logging.warning(f"SSL certificate request returned: {response.status_code}")
1039
+ return False
1040
+ except Exception as e:
1041
+ logging.warning(f"SSL certificate request failed: {e}")
1042
+ return False
1043
+
1044
+ def configure_error_pages(self, pull_zone_id: int, spa_mode: bool = False) -> None:
1045
+ """Configure error page handling (legacy approach, prefer configure_spa_edge_rules)."""
1046
+ if not spa_mode:
1047
+ return
1048
+
1049
+ logging.info("Configuring SPA error handling...")
1050
+ data = {
1051
+ "ErrorPageEnableCustomCode": True,
1052
+ "ErrorPageCustomCode": '<!DOCTYPE html><html><head><script>sessionStorage.redirect=location.pathname+location.search+location.hash;</script><meta http-equiv="refresh" content="0;url=/"></head></html>',
1053
+ "ErrorPageWhitelabel": True
1054
+ }
1055
+ self._api_request("POST", f"/pullzone/{pull_zone_id}", data)
1056
+ logging.info("SPA error handling configured.")
1057
+
1058
+ def get_edge_rules(self, pull_zone_id: int) -> List[dict]:
1059
+ """Get edge rules for a pull zone."""
1060
+ pull_zone = self._api_request("GET", f"/pullzone/{pull_zone_id}")
1061
+ return pull_zone.get('EdgeRules', [])
1062
+
1063
+ def add_or_update_edge_rule(self, pull_zone_id: int, rule: dict) -> dict:
1064
+ """Add or update an edge rule on a pull zone."""
1065
+ return self._api_request("POST", f"/pullzone/{pull_zone_id}/edgerules/addOrUpdate", rule)
1066
+
1067
+ def configure_spa_routing(self, storage_zone_id: int) -> None:
1068
+ """Configure SPA (Single Page Application) routing on a storage zone.
1069
+
1070
+ Sets the storage zone to serve /index.html for all 404 responses and
1071
+ rewrites the status code from 404 to 200. This enables client-side
1072
+ routing with clean URLs (no hash fragments).
1073
+
1074
+ This is the recommended approach for Bunny Storage-based pull zones,
1075
+ as edge rules cannot perform internal URL rewrites on storage origins.
1076
+ """
1077
+ logging.info("Configuring SPA routing on storage zone...")
1078
+
1079
+ data = {
1080
+ "Custom404FilePath": "/index.html",
1081
+ "Rewrite404To200": True,
1082
+ }
1083
+
1084
+ self._api_request("POST", f"/storagezone/{storage_zone_id}", data)
1085
+ logging.info("SPA routing configured: 404 -> /index.html (200)")
1086
+
1087
+ def get_pull_zone_hostname(self, pull_zone: dict) -> str:
1088
+ """Get the default hostname for a pull zone."""
1089
+ hostnames = pull_zone.get('Hostnames', [])
1090
+ for hostname in hostnames:
1091
+ if hostname.get('Value', '').endswith('.b-cdn.net'):
1092
+ return hostname['Value']
1093
+ return f"{pull_zone['Name']}.b-cdn.net"
1094
+
1095
+ def purge_cache(self, pull_zone_id: int) -> None:
1096
+ """Purge the CDN cache."""
1097
+ logging.info(f"Purging cache for pull zone {pull_zone_id}...")
1098
+ self._api_request("POST", f"/pullzone/{pull_zone_id}/purgeCache")
1099
+
1100
+
1101
+ # =============================================================================
1102
+ # Setup Report
1103
+ # =============================================================================
1104
+
1105
+ class SetupReport:
1106
+ """Track and report CDN setup status."""
1107
+
1108
+ def __init__(self, domain: str, mode: str, dns_provider: str, region: str):
1109
+ self.timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())
1110
+ self.domain = domain
1111
+ self.mode = mode
1112
+ self.dns_provider = dns_provider
1113
+ self.region = region
1114
+ self.region_name = BUNNY_STORAGE_REGIONS.get(region, {}).get('name', region)
1115
+ self.components = {
1116
+ 'storage_zone': {'status': 'pending', 'details': {}},
1117
+ 'pull_zone': {'status': 'pending', 'details': {}},
1118
+ 'hostnames': {'status': 'pending', 'details': {}},
1119
+ 'dns_records': {'status': 'pending', 'details': {}},
1120
+ 'ssl_certificates': {'status': 'pending', 'details': {}},
1121
+ 'spa_config': {'status': 'pending', 'details': {}},
1122
+ 'test_content': {'status': 'pending', 'details': {}},
1123
+ 'cdn_test': {'status': 'pending', 'details': {}},
1124
+ }
1125
+ self.urls = {}
1126
+
1127
+ def set_component(self, name: str, status: str, **details):
1128
+ if name in self.components:
1129
+ self.components[name]['status'] = status
1130
+ self.components[name]['details'].update(details)
1131
+
1132
+ def set_url(self, name: str, url: str):
1133
+ self.urls[name] = url
1134
+
1135
+ def generate_markdown(self) -> str:
1136
+ lines = [
1137
+ f"# CDN Setup Report: {self.domain}",
1138
+ "",
1139
+ f"**Generated:** {self.timestamp}",
1140
+ "**Script:** setup_bunny_storage.py",
1141
+ "**Infrastructure:** Bunny Storage + Bunny CDN",
1142
+ "",
1143
+ "---",
1144
+ "",
1145
+ "## Configuration Summary",
1146
+ "",
1147
+ "| Setting | Value |",
1148
+ "|---------|-------|",
1149
+ f"| Domain | `{self.domain}` |",
1150
+ f"| Mode | {self.mode.title()} |",
1151
+ f"| DNS Provider | {self.dns_provider.title()} |",
1152
+ f"| Storage Region | {self.region} ({self.region_name}) |",
1153
+ "",
1154
+ "## Component Status",
1155
+ "",
1156
+ ]
1157
+
1158
+ status_icons = {
1159
+ 'created': '[NEW]', 'exists': '[OK]', 'configured': '[OK]',
1160
+ 'requested': '[OK]', 'uploaded': '[OK]', 'passed': '[OK]',
1161
+ 'skipped': '[SKIP]', 'failed': '[FAIL]', 'pending': '[ ]',
1162
+ }
1163
+
1164
+ component_names = {
1165
+ 'storage_zone': 'Bunny Storage Zone',
1166
+ 'pull_zone': 'Bunny CDN Pull Zone',
1167
+ 'hostnames': 'Custom Hostnames',
1168
+ 'dns_records': 'DNS Records',
1169
+ 'ssl_certificates': 'SSL Certificates',
1170
+ 'spa_config': 'SPA Configuration',
1171
+ 'test_content': 'Test Content',
1172
+ 'cdn_test': 'CDN Functionality Test',
1173
+ }
1174
+
1175
+ for comp_key, comp_name in component_names.items():
1176
+ comp = self.components[comp_key]
1177
+ status = comp['status']
1178
+ icon = status_icons.get(status, '[ ]')
1179
+ lines.append(f"### {icon} {comp_name}")
1180
+ lines.append("")
1181
+ lines.append(f"**Status:** {status.replace('_', ' ').title()}")
1182
+
1183
+ for key, value in comp['details'].items():
1184
+ display_key = key.replace('_', ' ').title()
1185
+ if isinstance(value, list):
1186
+ lines.append(f"- **{display_key}:**")
1187
+ for item in value:
1188
+ lines.append(f" - `{item}`")
1189
+ else:
1190
+ lines.append(f"- **{display_key}:** `{value}`")
1191
+ lines.append("")
1192
+
1193
+ if self.urls:
1194
+ lines.extend([
1195
+ "## URLs and Endpoints",
1196
+ "",
1197
+ "| Name | URL |",
1198
+ "|------|-----|",
1199
+ ])
1200
+ for name, url in self.urls.items():
1201
+ lines.append(f"| {name.replace('_', ' ').title()} | {url} |")
1202
+ lines.extend([
1203
+ "",
1204
+ "### Quick Test Links",
1205
+ "",
1206
+ ])
1207
+ if 'public_url' in self.urls:
1208
+ base_url = self.urls['public_url']
1209
+ lines.append(f"- Homepage: {base_url}/")
1210
+ lines.append(f"- Test page: {base_url}/index-test.html")
1211
+ lines.append("")
1212
+
1213
+ lines.extend([
1214
+ "## Advantages of Bunny Storage",
1215
+ "",
1216
+ "- **Automatic index.html**: Root URL `/` automatically serves `index.html`",
1217
+ "- **Built-in 404 handling**: Custom error pages work correctly",
1218
+ "- **Edge replication**: Files replicated to edge locations globally",
1219
+ "- **Simple billing**: Single provider for storage and CDN",
1220
+ "",
1221
+ "---",
1222
+ "",
1223
+ "*Generated by setup_bunny_storage.py*",
1224
+ ])
1225
+
1226
+ return '\n'.join(lines)
1227
+
1228
+ def save_report(self, output_dir: str = '.') -> str:
1229
+ os.makedirs(output_dir, exist_ok=True)
1230
+ safe_domain = self.domain.replace('.', '-')
1231
+ timestamp = time.strftime('%Y%m%d-%H%M%S', time.gmtime())
1232
+ filename = f"cdn-setup-{safe_domain}-{timestamp}.md"
1233
+ filepath = os.path.join(output_dir, filename)
1234
+
1235
+ with open(filepath, 'w', encoding='utf-8') as f:
1236
+ f.write(self.generate_markdown())
1237
+
1238
+ logging.info(f"Setup report saved to: {filepath}")
1239
+ return filepath
1240
+
1241
+ def write_deploy_env(self, env_file: str = '.deploy.env') -> bool:
1242
+ """Write Bunny CDN configuration to .deploy.env file.
1243
+
1244
+ Appends or updates BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, and BUNNY_PULL_ZONE_ID.
1245
+ """
1246
+ storage_zone = self.components['storage_zone']['details'].get('storage_zone_name')
1247
+ pull_zone_id = self.components['pull_zone']['details'].get('pull_zone_id')
1248
+
1249
+ if not storage_zone:
1250
+ logging.warning("No storage zone name to write to deploy env")
1251
+ return False
1252
+
1253
+ # Read existing content
1254
+ existing_content = ""
1255
+ existing_vars = {}
1256
+ if os.path.exists(env_file):
1257
+ with open(env_file, 'r') as f:
1258
+ existing_content = f.read()
1259
+ for line in existing_content.split('\n'):
1260
+ if '=' in line and not line.strip().startswith('#'):
1261
+ key = line.split('=')[0].strip()
1262
+ existing_vars[key] = True
1263
+
1264
+ # Prepare new variables
1265
+ new_vars = []
1266
+
1267
+ # Add header comment if file is empty or doesn't exist
1268
+ if not existing_content.strip():
1269
+ new_vars.append("# Bunny CDN Configuration")
1270
+ new_vars.append(f"# Generated by setup_bunny_storage.py at {self.timestamp}")
1271
+ new_vars.append("")
1272
+
1273
+ # Get storage password from storage zone details or try to get from component
1274
+ storage_password = self.components['storage_zone']['details'].get('storage_password', '')
1275
+
1276
+ # Add/update variables
1277
+ bunny_vars = {
1278
+ 'BUNNY_STORAGE_ZONE': storage_zone,
1279
+ }
1280
+
1281
+ if storage_password:
1282
+ bunny_vars['BUNNY_STORAGE_PASSWORD'] = storage_password
1283
+
1284
+ if pull_zone_id:
1285
+ bunny_vars['BUNNY_PULL_ZONE_ID'] = str(pull_zone_id)
1286
+
1287
+ lines_to_add = []
1288
+ for key, value in bunny_vars.items():
1289
+ if key in existing_vars:
1290
+ # Update existing variable
1291
+ import re
1292
+ pattern = rf'^{key}=.*$'
1293
+ existing_content = re.sub(pattern, f'{key}={value}', existing_content, flags=re.MULTILINE)
1294
+ logging.info(f"Updated {key} in {env_file}")
1295
+ else:
1296
+ lines_to_add.append(f"{key}={value}")
1297
+
1298
+ # Write file
1299
+ with open(env_file, 'w') as f:
1300
+ # Write updated content
1301
+ if existing_content.strip():
1302
+ f.write(existing_content)
1303
+ if not existing_content.endswith('\n'):
1304
+ f.write('\n')
1305
+
1306
+ # Add new variables
1307
+ if lines_to_add:
1308
+ if existing_content.strip():
1309
+ f.write('\n# Bunny CDN (auto-generated)\n')
1310
+ for line in lines_to_add:
1311
+ f.write(f"{line}\n")
1312
+ key = line.split('=')[0]
1313
+ logging.info(f"Added {key} to {env_file}")
1314
+
1315
+ logging.info(f"[OK] Deploy env written to: {env_file}")
1316
+ return True
1317
+
1318
+
1319
+ # =============================================================================
1320
+ # Setup Functions
1321
+ # =============================================================================
1322
+
1323
+ def create_test_content(storage_client: BunnyStorageClient, storage_hostname: str,
1324
+ storage_password: str, zone_name: str, region: str, spa_mode: bool) -> None:
1325
+ """Create test content in the storage zone."""
1326
+ logging.info("Creating test content...")
1327
+
1328
+ test_html = f"""<!DOCTYPE html>
1329
+ <html>
1330
+ <head>
1331
+ <meta charset="UTF-8">
1332
+ <title>CDN Test Page</title>
1333
+ <style>
1334
+ body {{ font-family: Arial, sans-serif; margin: 40px; }}
1335
+ .container {{ max-width: 600px; margin: 0 auto; text-align: center; }}
1336
+ .success {{ color: #28a745; }}
1337
+ .info {{ background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
1338
+ </style>
1339
+ </head>
1340
+ <body>
1341
+ <div class="container">
1342
+ <h1 class="success">CDN Setup Successful!</h1>
1343
+ <p>Your Bunny Storage + Bunny CDN is working correctly.</p>
1344
+ <div class="info">
1345
+ <h3>Configuration</h3>
1346
+ <p><strong>Storage:</strong> Bunny Storage ({BUNNY_STORAGE_REGIONS.get(region, {}).get('name', region)})</p>
1347
+ <p><strong>CDN:</strong> Bunny CDN</p>
1348
+ <p><strong>Zone:</strong> {zone_name}</p>
1349
+ <p><strong>SPA Mode:</strong> {'Enabled' if spa_mode else 'Disabled'}</p>
1350
+ </div>
1351
+ <p><small>Generated by setup_bunny_storage.py</small></p>
1352
+ </div>
1353
+ </body>
1354
+ </html>"""
1355
+
1356
+ error_html = """<!DOCTYPE html>
1357
+ <html>
1358
+ <head>
1359
+ <meta charset="UTF-8">
1360
+ <title>404 - Page Not Found</title>
1361
+ <style>
1362
+ body { font-family: Arial, sans-serif; margin: 40px; text-align: center; }
1363
+ .error { color: #dc3545; }
1364
+ </style>
1365
+ </head>
1366
+ <body>
1367
+ <h1 class="error">404 - Page Not Found</h1>
1368
+ <p>The page you're looking for doesn't exist.</p>
1369
+ <p><a href="/">Back to Home</a></p>
1370
+ </body>
1371
+ </html>"""
1372
+
1373
+ files = [
1374
+ ('index.html', test_html, 'text/html'),
1375
+ ('index-test.html', test_html, 'text/html'),
1376
+ ('error.html', error_html, 'text/html'),
1377
+ ('test.txt', f"CDN test - {zone_name} - {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}", 'text/plain'),
1378
+ ]
1379
+
1380
+ for filename, content, content_type in files:
1381
+ content_bytes = content.encode('utf-8') if isinstance(content, str) else content
1382
+ storage_client.upload_file(storage_hostname, storage_password, zone_name, filename, content_bytes, content_type)
1383
+ logging.info(f"{filename} uploaded.")
1384
+
1385
+
1386
+ def test_cdn_functionality(url: str, timeout: int = 60) -> bool:
1387
+ """Test if the CDN is working correctly."""
1388
+ logging.info(f"Testing CDN at: {url}")
1389
+ test_url = f"{url}/test.txt"
1390
+ start_time = time.time()
1391
+
1392
+ while time.time() - start_time < timeout:
1393
+ try:
1394
+ response = requests.get(test_url, timeout=10)
1395
+ if response.status_code == 200:
1396
+ logging.info(f"CDN test passed! Status: {response.status_code}")
1397
+ return True
1398
+ logging.info(f"Got status {response.status_code}, waiting...")
1399
+ except requests.RequestException as e:
1400
+ logging.info(f"Request failed: {e}, retrying...")
1401
+ time.sleep(5)
1402
+
1403
+ logging.warning("CDN test timed out.")
1404
+ return False
1405
+
1406
+
1407
+ # =============================================================================
1408
+ # Main Setup
1409
+ # =============================================================================
1410
+
1411
+ def parse_arguments():
1412
+ parser = argparse.ArgumentParser(
1413
+ description='Setup CDN with Bunny Storage + Bunny CDN',
1414
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1415
+ epilog="""
1416
+ Examples:
1417
+ python setup_bunny_storage.py --domain example.com --subdomain www
1418
+ python setup_bunny_storage.py --domain example.com --apex --www-redirect
1419
+ python setup_bunny_storage.py --domain example.com --subdomain www --spa-mode
1420
+ python setup_bunny_storage.py --add-spa-rules 12345 # Configure SPA routing on existing storage zone
1421
+
1422
+ Environment Variables:
1423
+ BUNNY_API_KEY: Bunny.net API key
1424
+ CLOUDFLARE_API_TOKEN: For Cloudflare DNS
1425
+ HETZNER_DNS_API_TOKEN: For Hetzner DNS
1426
+ DESEC_API_TOKEN: For deSEC DNS
1427
+ """
1428
+ )
1429
+
1430
+ parser.add_argument('--domain', default=None, help='The root domain (e.g., example.com)')
1431
+ parser.add_argument('--subdomain', default=None, help='The subdomain (e.g., www)')
1432
+ parser.add_argument('--apex', action='store_true', help='Setup for apex domain')
1433
+ parser.add_argument('--www-redirect', action='store_true', help='Add www redirect (with --apex)')
1434
+ parser.add_argument('--region', default='DE', choices=list(BUNNY_STORAGE_REGIONS.keys()),
1435
+ help='Bunny Storage region (default: DE)')
1436
+ parser.add_argument('--dns-provider', choices=['cloudflare', 'hetzner', 'desec', 'bunny', 'cloudns', 'manual'],
1437
+ default='cloudflare', help='DNS provider (default: cloudflare)')
1438
+ parser.add_argument('--registrar', choices=registrar_choices(), default='manual',
1439
+ help='Domain registrar adapter used to flip nameservers after '
1440
+ 'Bunny DNS zone creation. Only meaningful with '
1441
+ '--dns-provider bunny. Default: manual (prints instructions).')
1442
+ parser.add_argument('--spa-mode', action='store_true', help='Configure for Single Page Application')
1443
+ parser.add_argument('--skip-tests', action='store_true', help='Skip CDN functionality tests')
1444
+ parser.add_argument('--test-timeout', type=int, default=60, help='Timeout for CDN tests')
1445
+ parser.add_argument('--report-dir', default='.', help='Directory to save the setup report')
1446
+ parser.add_argument('--deploy-env', default='.deploy.env',
1447
+ help='Path to .deploy.env file (default: .deploy.env)')
1448
+ parser.add_argument('--no-deploy-env', action='store_true',
1449
+ help='Skip writing to .deploy.env file')
1450
+ parser.add_argument('--add-spa-rules', type=int, metavar='STORAGE_ZONE_ID',
1451
+ help='Configure SPA routing on an existing storage zone and exit')
1452
+
1453
+ return parser.parse_args()
1454
+
1455
+
1456
+ def main():
1457
+ args = parse_arguments()
1458
+
1459
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
1460
+
1461
+ # Handle standalone SPA routing command
1462
+ if args.add_spa_rules:
1463
+ api_key = os.environ.get("BUNNY_API_KEY")
1464
+ if not api_key:
1465
+ logging.error("BUNNY_API_KEY environment variable not set.")
1466
+ return 1
1467
+ # --add-spa-rules takes a STORAGE ZONE ID (not pull zone ID)
1468
+ cdn_client = BunnyCDNClient(api_key)
1469
+ cdn_client.configure_spa_routing(args.add_spa_rules)
1470
+ return 0
1471
+
1472
+ if not args.domain:
1473
+ logging.error("--domain is required for full setup (or use --add-spa-rules PULL_ZONE_ID)")
1474
+ return 1
1475
+
1476
+ if args.apex:
1477
+ full_domain = args.domain
1478
+ www_domain = f"www.{args.domain}"
1479
+ mode = 'apex'
1480
+ elif args.subdomain:
1481
+ full_domain = f"{args.subdomain}.{args.domain}"
1482
+ www_domain = None
1483
+ mode = 'subdomain'
1484
+ else:
1485
+ logging.error("Either --subdomain or --apex must be specified")
1486
+ return 1
1487
+
1488
+ # Sanitize zone name
1489
+ zone_name = full_domain.replace('.', '-')
1490
+
1491
+ # Initialize report
1492
+ report = SetupReport(domain=full_domain, mode=mode, dns_provider=args.dns_provider, region=args.region)
1493
+
1494
+ logging.info("=" * 50)
1495
+ logging.info("BUNNY STORAGE + BUNNY CDN SETUP")
1496
+ logging.info("=" * 50)
1497
+ logging.info(f"Domain: {args.domain}")
1498
+ logging.info(f"Mode: {mode.title()}")
1499
+ logging.info(f"Full Domain: {full_domain}")
1500
+ logging.info(f"Storage Zone: {zone_name}")
1501
+ logging.info(f"Region: {args.region} ({BUNNY_STORAGE_REGIONS[args.region]['name']})")
1502
+ logging.info(f"DNS Provider: {args.dns_provider}")
1503
+ logging.info(f"SPA Mode: {args.spa_mode}")
1504
+ logging.info("=" * 50)
1505
+
1506
+ try:
1507
+ # Initialize clients
1508
+ api_key = os.environ.get("BUNNY_API_KEY")
1509
+ if not api_key:
1510
+ raise ValueError("BUNNY_API_KEY environment variable not set.")
1511
+
1512
+ storage_client = BunnyStorageClient(api_key)
1513
+ cdn_client = BunnyCDNClient(api_key)
1514
+ dns_provider = get_dns_provider(args.dns_provider)
1515
+ zone_id = dns_provider.get_zone_id(args.domain)
1516
+
1517
+ # --- Registrar nameserver flip ---
1518
+ # If the DNS zone lives at Bunny AND the user named a registrar
1519
+ # adapter, flip the domain's delegation to Bunny's auto-assigned
1520
+ # nameservers. ManualRegistrar (the default) just logs the NS values.
1521
+ # For any non-Bunny DNS provider the flip is a no-op — we only know
1522
+ # how to read NS from BunnyDNSProvider today.
1523
+ registrar = get_registrar(args.registrar)
1524
+ if isinstance(dns_provider, BunnyDNSProvider):
1525
+ ns_list = dns_provider.get_nameservers(args.domain)
1526
+ if ns_list:
1527
+ logging.info(f"Bunny DNS zone nameservers: {ns_list}")
1528
+ if isinstance(registrar, ManualRegistrar):
1529
+ # Still useful: prints the checklist even in manual mode
1530
+ registrar.set_nameservers(args.domain, ns_list)
1531
+ else:
1532
+ try:
1533
+ registrar.set_nameservers(args.domain, ns_list)
1534
+ except Exception as exc:
1535
+ logging.error(
1536
+ f"Registrar '{args.registrar}' failed to flip NS: {exc}. "
1537
+ "Continuing — the Bunny zone is in place; flip manually."
1538
+ )
1539
+ else:
1540
+ logging.warning(
1541
+ "Could not read nameservers from Bunny DNS zone — skipping registrar flip. "
1542
+ "Check the zone in the Bunny dashboard manually."
1543
+ )
1544
+ elif args.registrar != 'manual':
1545
+ logging.warning(
1546
+ f"--registrar {args.registrar} is only meaningful with --dns-provider bunny. "
1547
+ "Ignored."
1548
+ )
1549
+
1550
+ # 1. Create Storage Zone
1551
+ logging.info("\n--- Step 1: Bunny Storage Zone ---")
1552
+ existing_storage = storage_client.find_storage_zone_by_name(zone_name)
1553
+
1554
+ if existing_storage:
1555
+ logging.info(f"Storage zone '{zone_name}' already exists.")
1556
+ storage_zone = existing_storage
1557
+ report.set_component('storage_zone', 'exists',
1558
+ storage_zone_id=storage_zone['Id'],
1559
+ storage_zone_name=zone_name,
1560
+ storage_password=storage_zone['Password'])
1561
+ else:
1562
+ storage_zone = storage_client.create_storage_zone(zone_name, args.region)
1563
+ report.set_component('storage_zone', 'created',
1564
+ storage_zone_id=storage_zone['Id'],
1565
+ storage_zone_name=zone_name,
1566
+ storage_password=storage_zone['Password'])
1567
+
1568
+ storage_zone_id = storage_zone['Id']
1569
+ storage_hostname = storage_zone.get('StorageHostname', BUNNY_STORAGE_REGIONS[args.region]['hostname'])
1570
+ storage_password = storage_zone['Password']
1571
+
1572
+ # 2. Create Pull Zone
1573
+ logging.info("\n--- Step 2: Bunny CDN Pull Zone ---")
1574
+ existing_pull_zone = cdn_client.find_pull_zone_by_hostname(full_domain)
1575
+
1576
+ if not existing_pull_zone:
1577
+ existing_pull_zone = cdn_client.find_pull_zone_by_name(zone_name)
1578
+
1579
+ if existing_pull_zone:
1580
+ logging.info(f"Pull zone already exists (ID: {existing_pull_zone['Id']})")
1581
+ pull_zone = existing_pull_zone
1582
+ report.set_component('pull_zone', 'exists',
1583
+ pull_zone_id=pull_zone['Id'],
1584
+ pull_zone_name=pull_zone['Name'])
1585
+ else:
1586
+ pull_zone = cdn_client.create_pull_zone_for_storage(zone_name, storage_zone_id)
1587
+ report.set_component('pull_zone', 'created',
1588
+ pull_zone_id=pull_zone['Id'],
1589
+ pull_zone_name=zone_name)
1590
+
1591
+ pull_zone_id = pull_zone['Id']
1592
+ cdn_hostname = cdn_client.get_pull_zone_hostname(pull_zone)
1593
+ logging.info(f"CDN Hostname: {cdn_hostname}")
1594
+
1595
+ report.set_url('cdn_hostname', f"https://{cdn_hostname}")
1596
+
1597
+ # 3. Add Custom Hostnames
1598
+ logging.info("\n--- Step 3: Custom Hostnames ---")
1599
+ hostname_details = {'configured_hostnames': []}
1600
+
1601
+ existing_hostnames = [h.get('Value', '') for h in pull_zone.get('Hostnames', [])]
1602
+
1603
+ if full_domain not in existing_hostnames:
1604
+ cdn_client.add_hostname(pull_zone_id, full_domain)
1605
+ hostname_details['configured_hostnames'].append(f"{full_domain} (added)")
1606
+ else:
1607
+ hostname_details['configured_hostnames'].append(f"{full_domain} (existed)")
1608
+
1609
+ if args.apex and args.www_redirect and www_domain not in existing_hostnames:
1610
+ cdn_client.add_hostname(pull_zone_id, www_domain)
1611
+ hostname_details['configured_hostnames'].append(f"{www_domain} (added)")
1612
+
1613
+ report.set_component('hostnames', 'configured', **hostname_details)
1614
+
1615
+ # 4. Configure SPA Mode (Storage Zone 404 rewrite)
1616
+ if args.spa_mode:
1617
+ logging.info("\n--- Step 4: SPA Configuration ---")
1618
+ cdn_client.configure_spa_routing(storage_zone_id)
1619
+ report.set_component('spa_config', 'configured', mode='storage_404_rewrite',
1620
+ description='Storage zone serves /index.html for all 404s with status 200')
1621
+ else:
1622
+ report.set_component('spa_config', 'skipped', mode='disabled')
1623
+
1624
+ # 5. Configure DNS
1625
+ logging.info("\n--- Step 5: DNS Configuration ---")
1626
+ dns_details = {'provider': args.dns_provider, 'records': []}
1627
+
1628
+ if args.apex:
1629
+ dns_provider.create_apex_records(zone_id, args.domain, www_domain, cdn_hostname)
1630
+ if dns_provider.supports_apex_cname():
1631
+ dns_details['records'].append(f"CNAME {args.domain} -> {cdn_hostname}")
1632
+ else:
1633
+ dns_details['records'].append(f"A {args.domain} -> 185.206.224.1")
1634
+ dns_details['records'].append(f"CNAME www.{args.domain} -> {cdn_hostname}")
1635
+ else:
1636
+ dns_provider.create_cname_record(zone_id, args.subdomain, cdn_hostname, args.domain)
1637
+ dns_details['records'].append(f"CNAME {full_domain} -> {cdn_hostname}")
1638
+
1639
+ report.set_component('dns_records', 'configured', **dns_details)
1640
+
1641
+ # 6. Request SSL Certificates
1642
+ logging.info("\n--- Step 6: SSL Certificates ---")
1643
+ logging.info("Waiting 30 seconds for DNS propagation...")
1644
+ time.sleep(30)
1645
+
1646
+ ssl_details = {'certificates': []}
1647
+ use_dns01 = args.dns_provider == 'bunny'
1648
+ ssl_success = cdn_client.request_ssl_certificate(pull_zone_id, full_domain, use_dns01=use_dns01)
1649
+ ssl_details['certificates'].append(f"{full_domain}: {'requested' if ssl_success else 'pending'}")
1650
+
1651
+ if args.apex and args.www_redirect:
1652
+ www_ssl = cdn_client.request_ssl_certificate(pull_zone_id, www_domain, use_dns01=use_dns01)
1653
+ ssl_details['certificates'].append(f"{www_domain}: {'requested' if www_ssl else 'pending'}")
1654
+
1655
+ report.set_component('ssl_certificates', 'requested', **ssl_details)
1656
+
1657
+ # 7. Upload Test Content
1658
+ logging.info("\n--- Step 7: Test Content ---")
1659
+ create_test_content(storage_client, storage_hostname, storage_password, zone_name, args.region, args.spa_mode)
1660
+ report.set_component('test_content', 'uploaded', files=['index.html', 'index-test.html', 'error.html', 'test.txt'])
1661
+
1662
+ # Set URLs
1663
+ report.set_url('public_url', f"https://{full_domain}")
1664
+ if args.apex and args.www_redirect:
1665
+ report.set_url('www_url', f"https://{www_domain}")
1666
+
1667
+ # Summary
1668
+ logging.info("\n" + "=" * 50)
1669
+ logging.info("CDN SETUP COMPLETE")
1670
+ logging.info("=" * 50)
1671
+ logging.info(f"Storage Zone: {zone_name} (ID: {storage_zone_id})")
1672
+ logging.info(f"Pull Zone: {pull_zone_id}")
1673
+ logging.info(f"CDN Hostname: {cdn_hostname}")
1674
+ logging.info(f"Public URL: https://{full_domain}")
1675
+ logging.info("=" * 50)
1676
+
1677
+ # 8. Test CDN
1678
+ test_success = True
1679
+ if not args.skip_tests:
1680
+ logging.info("\nWaiting 30 seconds for CDN propagation...")
1681
+ time.sleep(30)
1682
+ test_success = test_cdn_functionality(f"https://{full_domain}", args.test_timeout)
1683
+ report.set_component('cdn_test', 'passed' if test_success else 'failed',
1684
+ test_url=f"https://{full_domain}/test.txt")
1685
+ else:
1686
+ report.set_component('cdn_test', 'skipped')
1687
+
1688
+ # Save report
1689
+ report_path = report.save_report(args.report_dir)
1690
+
1691
+ # Write to .deploy.env
1692
+ if not args.no_deploy_env:
1693
+ report.write_deploy_env(args.deploy_env)
1694
+
1695
+ if test_success or args.skip_tests:
1696
+ logging.info("\n[OK] CDN setup completed successfully!")
1697
+ logging.info(f"[i] Setup report: {report_path}")
1698
+ return 0
1699
+ else:
1700
+ logging.warning("\n[!] CDN setup completed but tests indicate issues.")
1701
+ logging.info(f"[i] Setup report: {report_path}")
1702
+ return 1
1703
+
1704
+ except Exception as e:
1705
+ logging.error(f"An error occurred: {e}")
1706
+ import traceback
1707
+ traceback.print_exc()
1708
+
1709
+ try:
1710
+ report_path = report.save_report(args.report_dir)
1711
+ logging.info(f"[i] Partial report saved: {report_path}")
1712
+ except Exception:
1713
+ pass
1714
+
1715
+ return 1
1716
+
1717
+
1718
+ if __name__ == "__main__":
1719
+ exit(main())