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,1899 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SSL Certificate Automation Tool
4
+
5
+ Automates SSL certificate creation, DNS validation, and configuration updates.
6
+
7
+ Certificate Providers:
8
+ - AWS ACM: Traditional AWS Certificate Manager with DNS validation
9
+ - Bunny CDN: Free SSL certificates for Bunny CDN pull zones
10
+
11
+ DNS Providers (for ACM validation):
12
+ - Cloudflare, Hetzner, deSEC
13
+
14
+ Supports both specific domain and wildcard certificate creation (ACM only).
15
+ """
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+ import time
21
+ from abc import ABC, abstractmethod
22
+ from typing import Optional, Tuple
23
+
24
+ import boto3
25
+ import requests
26
+ from botocore.exceptions import ClientError, WaiterError
27
+
28
+ from dotenv import load_dotenv
29
+ load_dotenv()
30
+ try:
31
+ from granny.credentials import load_secrets_into_env
32
+ load_secrets_into_env()
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ # =============================================================================
38
+ # DNS Provider Abstraction
39
+ # =============================================================================
40
+
41
+ class DNSProvider(ABC):
42
+ """Abstract base class for DNS providers."""
43
+
44
+ @abstractmethod
45
+ def get_zone_id(self, zone_name: str) -> str:
46
+ """Get zone ID by domain name."""
47
+ pass
48
+
49
+ @abstractmethod
50
+ def create_validation_record(self, zone_id: str, zone_name: str,
51
+ cname_name: str, cname_value: str) -> dict:
52
+ """Create DNS CNAME record for certificate validation.
53
+
54
+ Args:
55
+ zone_id: The zone ID
56
+ zone_name: The zone/domain name
57
+ cname_name: Full CNAME record name
58
+ cname_value: CNAME record value
59
+
60
+ Returns:
61
+ Dict with record details for cleanup
62
+ """
63
+ pass
64
+
65
+ @abstractmethod
66
+ def cleanup_validation_record(self, zone_id: str, record_info: dict) -> None:
67
+ """Remove DNS validation record after certificate issuance."""
68
+ pass
69
+
70
+ def supports_zone_creation(self) -> bool:
71
+ """Returns True if provider supports creating new zones via API."""
72
+ return False
73
+
74
+ def create_zone(self, zone_name: str) -> str:
75
+ """Create a new DNS zone."""
76
+ raise NotImplementedError(
77
+ f"{self.__class__.__name__} does not support zone creation via API. "
78
+ f"Please create the zone manually in the provider's dashboard."
79
+ )
80
+
81
+ def get_or_create_zone(self, zone_name: str, auto_create: bool = False) -> str:
82
+ """Get zone ID, optionally creating the zone if it doesn't exist."""
83
+ try:
84
+ return self.get_zone_id(zone_name)
85
+ except Exception as e:
86
+ if not auto_create:
87
+ raise
88
+ if not self.supports_zone_creation():
89
+ raise Exception(
90
+ f"Zone '{zone_name}' not found and {self.__class__.__name__} "
91
+ f"does not support automatic zone creation. "
92
+ f"Please create the zone manually first."
93
+ ) from e
94
+ print(f"Zone '{zone_name}' not found. Creating new zone...")
95
+ return self.create_zone(zone_name)
96
+
97
+
98
+ class CloudflareDNSProvider(DNSProvider):
99
+ """Cloudflare DNS API adapter."""
100
+
101
+ def __init__(self, api_token: str):
102
+ self.api_token = api_token
103
+ self.headers = {
104
+ "Authorization": f"Bearer {api_token}",
105
+ "Content-Type": "application/json",
106
+ }
107
+
108
+ def get_zone_id(self, zone_name: str) -> str:
109
+ """Get the Cloudflare zone ID for the domain."""
110
+ url = "https://api.cloudflare.com/client/v4/zones"
111
+ params = {"name": zone_name}
112
+
113
+ response = requests.get(url, headers=self.headers, params=params, timeout=30)
114
+ response.raise_for_status()
115
+
116
+ result = response.json()
117
+ zones = result.get("result", [])
118
+
119
+ if not zones:
120
+ raise Exception(
121
+ f"No Cloudflare zone found for domain '{zone_name}'. "
122
+ f"Please verify the domain is added to your Cloudflare account."
123
+ )
124
+
125
+ zone_id = zones[0].get("id")
126
+ zone_name_found = zones[0].get("name")
127
+ print(f"Found Cloudflare zone: {zone_name_found} (ID: {zone_id})")
128
+ return zone_id
129
+
130
+ def create_validation_record(self, zone_id: str, zone_name: str,
131
+ cname_name: str, cname_value: str) -> dict:
132
+ """Create or update DNS validation record in Cloudflare."""
133
+ print("Adding validation record to Cloudflare...")
134
+
135
+ # Extract record name (remove base domain)
136
+ record_name = cname_name.replace(f".{zone_name}", "").rstrip(".")
137
+
138
+ # Check if record exists
139
+ list_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"
140
+ params = {"type": "CNAME", "name": cname_name}
141
+
142
+ response = requests.get(list_url, headers=self.headers, params=params, timeout=30)
143
+ response.raise_for_status()
144
+
145
+ existing_records = response.json().get("result", [])
146
+ record_id = existing_records[0].get("id") if existing_records else None
147
+
148
+ # Prepare record data
149
+ record_data = {
150
+ "type": "CNAME",
151
+ "name": record_name,
152
+ "content": cname_value,
153
+ "ttl": 120,
154
+ "proxied": False,
155
+ }
156
+
157
+ if record_id:
158
+ print("Updating existing DNS record...")
159
+ update_url = f"{list_url}/{record_id}"
160
+ response = requests.put(
161
+ update_url, headers=self.headers, json=record_data, timeout=30
162
+ )
163
+ else:
164
+ print("Creating new DNS record...")
165
+ response = requests.post(
166
+ list_url, headers=self.headers, json=record_data, timeout=30
167
+ )
168
+
169
+ response.raise_for_status()
170
+ result = response.json()
171
+
172
+ if result.get("success"):
173
+ print("DNS record added/updated successfully")
174
+ record_id = result.get("result", {}).get("id", record_id)
175
+ return {"id": record_id, "name": cname_name, "zone_id": zone_id}
176
+ else:
177
+ raise RuntimeError(f"Failed to create/update DNS record: {result}")
178
+
179
+ def cleanup_validation_record(self, zone_id: str, record_info: dict) -> None:
180
+ """Remove DNS validation record from Cloudflare."""
181
+ record_id = record_info.get("id")
182
+ if not record_id:
183
+ return
184
+
185
+ print(f"Cleaning up validation record: {record_info.get('name')}")
186
+ url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}"
187
+
188
+ try:
189
+ response = requests.delete(url, headers=self.headers, timeout=30)
190
+ response.raise_for_status()
191
+ print("Validation record cleaned up successfully")
192
+ except Exception as e:
193
+ print(f"Warning: Could not clean up validation record: {e}")
194
+
195
+
196
+ class HetznerDNSProvider(DNSProvider):
197
+ """Hetzner DNS API adapter."""
198
+
199
+ API_BASE = "https://dns.hetzner.com/api/v1"
200
+
201
+ def __init__(self, api_token: str):
202
+ self.api_token = api_token
203
+ self.headers = {
204
+ "Auth-API-Token": api_token,
205
+ "Content-Type": "application/json",
206
+ }
207
+
208
+ def get_zone_id(self, zone_name: str) -> str:
209
+ """Get the Hetzner zone ID for the domain."""
210
+ print(f"Looking up Hetzner zone for '{zone_name}'...")
211
+ url = f"{self.API_BASE}/zones"
212
+
213
+ response = requests.get(url, headers=self.headers, timeout=30)
214
+ response.raise_for_status()
215
+
216
+ zones = response.json().get("zones", [])
217
+ for zone in zones:
218
+ if zone.get("name") == zone_name:
219
+ zone_id = zone.get("id")
220
+ print(f"Found Hetzner zone: {zone_name} (ID: {zone_id})")
221
+ return zone_id
222
+
223
+ raise Exception(f"Zone not found in Hetzner DNS: {zone_name}")
224
+
225
+ def create_validation_record(self, zone_id: str, zone_name: str,
226
+ cname_name: str, cname_value: str) -> dict:
227
+ """Create DNS validation record in Hetzner."""
228
+ print("Adding validation record to Hetzner DNS...")
229
+
230
+ # Extract record name (remove base domain and trailing dot)
231
+ clean_name = cname_name.rstrip(".")
232
+ if clean_name.endswith(f".{zone_name}"):
233
+ record_name = clean_name[:-len(f".{zone_name}")]
234
+ else:
235
+ record_name = clean_name
236
+
237
+ # Remove trailing dot from value
238
+ cname_value = cname_value.rstrip(".")
239
+
240
+ # Check for existing record
241
+ list_url = f"{self.API_BASE}/records"
242
+ params = {"zone_id": zone_id}
243
+ response = requests.get(list_url, headers=self.headers, params=params, timeout=30)
244
+ response.raise_for_status()
245
+
246
+ existing_record = None
247
+ for record in response.json().get("records", []):
248
+ if record.get("name") == record_name and record.get("type") == "CNAME":
249
+ existing_record = record
250
+ break
251
+
252
+ record_data = {
253
+ "zone_id": zone_id,
254
+ "type": "CNAME",
255
+ "name": record_name,
256
+ "value": cname_value,
257
+ "ttl": 300,
258
+ }
259
+
260
+ if existing_record:
261
+ print("Updating existing DNS record...")
262
+ url = f"{self.API_BASE}/records/{existing_record['id']}"
263
+ response = requests.put(url, headers=self.headers, json=record_data, timeout=30)
264
+ else:
265
+ print("Creating new DNS record...")
266
+ url = f"{self.API_BASE}/records"
267
+ response = requests.post(url, headers=self.headers, json=record_data, timeout=30)
268
+
269
+ response.raise_for_status()
270
+ result = response.json().get("record", {})
271
+ print("DNS record added/updated successfully")
272
+
273
+ return {
274
+ "id": result.get("id"),
275
+ "name": record_name,
276
+ "zone_id": zone_id
277
+ }
278
+
279
+ def cleanup_validation_record(self, zone_id: str, record_info: dict) -> None:
280
+ """Remove DNS validation record from Hetzner."""
281
+ record_id = record_info.get("id")
282
+ if not record_id:
283
+ return
284
+
285
+ print(f"Cleaning up validation record: {record_info.get('name')}")
286
+ url = f"{self.API_BASE}/records/{record_id}"
287
+
288
+ try:
289
+ response = requests.delete(url, headers=self.headers, timeout=30)
290
+ response.raise_for_status()
291
+ print("Validation record cleaned up successfully")
292
+ except Exception as e:
293
+ print(f"Warning: Could not clean up validation record: {e}")
294
+
295
+
296
+ class DeSECDNSProvider(DNSProvider):
297
+ """deSEC DNS API adapter with zone creation support."""
298
+
299
+ API_BASE = "https://desec.io/api/v1"
300
+ MIN_TTL = 3600 # deSEC requires minimum TTL of 3600 seconds
301
+
302
+ def __init__(self, api_token: str):
303
+ self.api_token = api_token
304
+ self.session = requests.Session()
305
+ self.session.headers.update({
306
+ "Authorization": f"Token {api_token}",
307
+ "Content-Type": "application/json"
308
+ })
309
+
310
+ def _api_request(self, method: str, endpoint: str, data: dict = None,
311
+ expected_errors: list = None) -> dict:
312
+ """Make an API request to deSEC.
313
+
314
+ Args:
315
+ method: HTTP method (GET, POST, PUT, DELETE)
316
+ endpoint: API endpoint path
317
+ data: Optional request body
318
+ expected_errors: List of status codes that are expected (won't log as errors)
319
+ """
320
+ url = f"{self.API_BASE}{endpoint}"
321
+ expected_errors = expected_errors or []
322
+
323
+ if method == "GET":
324
+ response = self.session.get(url, timeout=30)
325
+ elif method == "POST":
326
+ response = self.session.post(url, json=data, timeout=30)
327
+ elif method == "PUT":
328
+ response = self.session.put(url, json=data, timeout=30)
329
+ elif method == "DELETE":
330
+ response = self.session.delete(url, timeout=30)
331
+ else:
332
+ raise ValueError(f"Unsupported HTTP method: {method}")
333
+
334
+ # Handle rate limiting
335
+ if response.status_code == 429:
336
+ retry_after = int(response.headers.get("Retry-After", 5))
337
+ print(f"Rate limited by deSEC API. Waiting {retry_after}s...")
338
+ time.sleep(retry_after)
339
+ return self._api_request(method, endpoint, data, expected_errors)
340
+
341
+ if response.status_code >= 400:
342
+ # Only log if not an expected error
343
+ if response.status_code not in expected_errors:
344
+ print(f"deSEC API error: {response.status_code} - {response.text}")
345
+ response.raise_for_status()
346
+
347
+ if response.status_code == 204:
348
+ return {}
349
+
350
+ return response.json() if response.text else {}
351
+
352
+ def get_zone_id(self, zone_name: str) -> str:
353
+ """Get zone info - deSEC uses domain name as identifier."""
354
+ print(f"Looking up deSEC zone for '{zone_name}'...")
355
+ try:
356
+ result = self._api_request("GET", f"/domains/{zone_name}/")
357
+ if result and result.get("name") == zone_name:
358
+ print(f"Found deSEC zone: {zone_name}")
359
+ return zone_name
360
+ raise Exception(f"Zone not found: {zone_name}")
361
+ except requests.HTTPError as e:
362
+ if e.response.status_code == 404:
363
+ raise Exception(f"Zone not found in deSEC: {zone_name}")
364
+ raise
365
+
366
+ def supports_zone_creation(self) -> bool:
367
+ """deSEC supports creating zones via API."""
368
+ return True
369
+
370
+ def create_zone(self, zone_name: str) -> str:
371
+ """Create a new DNS zone in deSEC."""
372
+ print(f"Creating new deSEC zone for '{zone_name}'...")
373
+ try:
374
+ result = self._api_request("POST", "/domains/", {"name": zone_name})
375
+ if result and result.get("name") == zone_name:
376
+ print(f"Successfully created deSEC zone: {zone_name}")
377
+ if "keys" in result:
378
+ print("Zone created with DNSSEC enabled (deSEC default)")
379
+ print("=" * 60)
380
+ print("IMPORTANT: Configure these nameservers at your domain registrar:")
381
+ print(" ns1.desec.io")
382
+ print(" ns2.desec.org")
383
+ print("=" * 60)
384
+ return zone_name
385
+ raise Exception(f"Unexpected response when creating zone: {result}")
386
+ except requests.HTTPError as e:
387
+ if e.response.status_code == 400:
388
+ error_detail = e.response.json() if e.response.text else {}
389
+ if "name" in error_detail:
390
+ name_errors = error_detail["name"]
391
+ if any("already exists" in str(err).lower() for err in name_errors):
392
+ print(f"Zone '{zone_name}' already exists in deSEC")
393
+ return zone_name
394
+ raise Exception(f"Failed to create zone: {name_errors}")
395
+ raise Exception(f"Failed to create deSEC zone: {e}")
396
+
397
+ def create_validation_record(self, zone_id: str, zone_name: str,
398
+ cname_name: str, cname_value: str) -> dict:
399
+ """Create DNS validation record in deSEC."""
400
+ print("Adding validation record to deSEC...")
401
+
402
+ # Extract subname (remove zone name suffix)
403
+ full_name = cname_name.rstrip(".")
404
+ if full_name.endswith(f".{zone_name}"):
405
+ subname = full_name[:-len(f".{zone_name}")]
406
+ else:
407
+ subname = full_name
408
+
409
+ # Ensure value ends with dot
410
+ if not cname_value.endswith("."):
411
+ cname_value = f"{cname_value}."
412
+
413
+ # Check for existing record (404 is expected if record doesn't exist)
414
+ try:
415
+ endpoint = f"/domains/{zone_name}/rrsets/{subname}/CNAME/"
416
+ existing = self._api_request("GET", endpoint, expected_errors=[404])
417
+ except requests.HTTPError as e:
418
+ if e.response.status_code == 404:
419
+ existing = None
420
+ else:
421
+ raise
422
+
423
+ data = {
424
+ "subname": subname,
425
+ "type": "CNAME",
426
+ "records": [cname_value],
427
+ "ttl": self.MIN_TTL
428
+ }
429
+
430
+ if existing:
431
+ print("Updating existing DNS record...")
432
+ endpoint = f"/domains/{zone_name}/rrsets/{subname}/CNAME/"
433
+ self._api_request("PUT", endpoint, data)
434
+ else:
435
+ print("Creating new DNS record...")
436
+ endpoint = f"/domains/{zone_name}/rrsets/"
437
+ self._api_request("POST", endpoint, data)
438
+
439
+ print("DNS record added/updated successfully")
440
+ return {
441
+ "subname": subname,
442
+ "type": "CNAME",
443
+ "zone_name": zone_name
444
+ }
445
+
446
+ def cleanup_validation_record(self, zone_id: str, record_info: dict) -> None:
447
+ """Remove DNS validation record from deSEC."""
448
+ subname = record_info.get("subname")
449
+ zone_name = record_info.get("zone_name", zone_id)
450
+
451
+ if not subname:
452
+ return
453
+
454
+ print(f"Cleaning up validation record: {subname}")
455
+ try:
456
+ endpoint = f"/domains/{zone_name}/rrsets/{subname}/CNAME/"
457
+ self._api_request("DELETE", endpoint)
458
+ print("Validation record cleaned up successfully")
459
+ except Exception as e:
460
+ print(f"Warning: Could not clean up validation record: {e}")
461
+
462
+
463
+ class BunnyDNSProvider(DNSProvider):
464
+ """Bunny DNS API adapter with zone creation support.
465
+
466
+ Bunny DNS is a European DNS provider with:
467
+ - CNAME flattening at apex (supports root domain CNAMEs)
468
+ - Global anycast network
469
+ - Built-in DDoS protection
470
+ - Zone creation via API
471
+
472
+ Environment Variable: BUNNY_API_KEY
473
+ """
474
+ API_BASE = "https://api.bunny.net"
475
+ MIN_TTL = 60 # Bunny DNS allows lower TTL than deSEC
476
+
477
+ def __init__(self, api_key: str):
478
+ self.api_key = api_key
479
+ self.session = requests.Session()
480
+ self.session.headers.update({
481
+ "AccessKey": api_key,
482
+ "Content-Type": "application/json"
483
+ })
484
+ self._zone_cache = {} # Cache zone_name -> zone_id mapping
485
+
486
+ def _api_request(self, method: str, endpoint: str, data: dict = None,
487
+ expected_errors: list = None) -> dict:
488
+ """Make an API request to Bunny DNS."""
489
+ url = f"{self.API_BASE}{endpoint}"
490
+ expected_errors = expected_errors or []
491
+
492
+ try:
493
+ if method == "GET":
494
+ response = self.session.get(url, timeout=30)
495
+ elif method == "POST":
496
+ response = self.session.post(url, json=data, timeout=30)
497
+ elif method == "PUT":
498
+ response = self.session.put(url, json=data, timeout=30)
499
+ elif method == "DELETE":
500
+ response = self.session.delete(url, timeout=30)
501
+ else:
502
+ raise ValueError(f"Unsupported HTTP method: {method}")
503
+
504
+ if response.status_code >= 400:
505
+ if response.status_code not in expected_errors:
506
+ print(f"Bunny DNS API error: {response.status_code} - {response.text}")
507
+ response.raise_for_status()
508
+
509
+ if response.status_code == 204:
510
+ return {}
511
+
512
+ return response.json() if response.text else {}
513
+
514
+ except requests.RequestException as e:
515
+ raise Exception(f"Bunny DNS API request failed: {e}")
516
+
517
+ def get_zone_id(self, zone_name: str) -> str:
518
+ """Get the Bunny DNS zone ID for the domain."""
519
+ if zone_name in self._zone_cache:
520
+ return self._zone_cache[zone_name]
521
+
522
+ print(f"Looking up Bunny DNS zone for '{zone_name}'...")
523
+
524
+ result = self._api_request("GET", "/dnszone")
525
+
526
+ if isinstance(result, dict) and "Items" in result:
527
+ zones = result["Items"]
528
+ elif isinstance(result, list):
529
+ zones = result
530
+ else:
531
+ zones = []
532
+
533
+ for zone in zones:
534
+ if zone.get("Domain") == zone_name:
535
+ zone_id = str(zone.get("Id"))
536
+ print(f"Found Bunny DNS zone: {zone_name} (ID: {zone_id})")
537
+ self._zone_cache[zone_name] = zone_id
538
+ return zone_id
539
+
540
+ raise Exception(f"Zone not found in Bunny DNS: {zone_name}")
541
+
542
+ def supports_zone_creation(self) -> bool:
543
+ """Bunny DNS supports creating zones via API."""
544
+ return True
545
+
546
+ def create_zone(self, zone_name: str) -> str:
547
+ """Create a new DNS zone in Bunny DNS."""
548
+ print(f"Creating new Bunny DNS zone for '{zone_name}'...")
549
+
550
+ try:
551
+ result = self._api_request("POST", "/dnszone", {"Domain": zone_name})
552
+
553
+ if result and result.get("Id"):
554
+ zone_id = str(result["Id"])
555
+ print(f"Successfully created Bunny DNS zone: {zone_name} (ID: {zone_id})")
556
+ self._zone_cache[zone_name] = zone_id
557
+
558
+ nameservers = result.get("Nameservers", [])
559
+ if nameservers:
560
+ print("=" * 60)
561
+ print("IMPORTANT: Configure these nameservers at your domain registrar:")
562
+ for ns in nameservers:
563
+ print(f" {ns}")
564
+ print("=" * 60)
565
+
566
+ return zone_id
567
+
568
+ raise Exception(f"Unexpected response when creating zone: {result}")
569
+
570
+ except requests.HTTPError as e:
571
+ if e.response.status_code == 400:
572
+ try:
573
+ return self.get_zone_id(zone_name)
574
+ except Exception:
575
+ pass
576
+ raise Exception(f"Failed to create Bunny DNS zone: {e}")
577
+
578
+ def create_validation_record(self, zone_id: str, zone_name: str,
579
+ cname_name: str, cname_value: str) -> dict:
580
+ """Create DNS validation record in Bunny DNS."""
581
+ print("Adding validation record to Bunny DNS...")
582
+
583
+ full_name = cname_name.rstrip(".")
584
+ if full_name.endswith(f".{zone_name}"):
585
+ record_name = full_name[:-len(f".{zone_name}")]
586
+ else:
587
+ record_name = full_name
588
+
589
+ cname_value = cname_value.rstrip(".")
590
+
591
+ try:
592
+ records_result = self._api_request("GET", f"/dnszone/{zone_id}")
593
+ existing_records = records_result.get("Records", [])
594
+
595
+ existing_record = None
596
+ for record in existing_records:
597
+ if record.get("Name") == record_name and record.get("Type") == 5:
598
+ existing_record = record
599
+ break
600
+ except Exception:
601
+ existing_records = []
602
+ existing_record = None
603
+
604
+ record_data = {
605
+ "Type": 5,
606
+ "Name": record_name,
607
+ "Value": cname_value,
608
+ "Ttl": self.MIN_TTL
609
+ }
610
+
611
+ if existing_record:
612
+ print("Updating existing DNS record...")
613
+ record_id = existing_record.get("Id")
614
+ self._api_request("POST", f"/dnszone/{zone_id}/records/{record_id}", record_data)
615
+ else:
616
+ print("Creating new DNS record...")
617
+ result = self._api_request("PUT", f"/dnszone/{zone_id}/records", record_data)
618
+ record_id = result.get("Id") if result else None
619
+
620
+ print("DNS record added/updated successfully")
621
+ return {
622
+ "id": record_id,
623
+ "name": record_name,
624
+ "zone_id": zone_id,
625
+ "zone_name": zone_name
626
+ }
627
+
628
+ def cleanup_validation_record(self, zone_id: str, record_info: dict) -> None:
629
+ """Remove DNS validation record from Bunny DNS."""
630
+ record_id = record_info.get("id")
631
+ record_name = record_info.get("name")
632
+
633
+ if not record_id:
634
+ if record_name:
635
+ try:
636
+ records_result = self._api_request("GET", f"/dnszone/{zone_id}")
637
+ for record in records_result.get("Records", []):
638
+ if record.get("Name") == record_name and record.get("Type") == 5:
639
+ record_id = record.get("Id")
640
+ break
641
+ except Exception:
642
+ pass
643
+
644
+ if not record_id:
645
+ print(f"Warning: Could not find record to clean up: {record_name}")
646
+ return
647
+
648
+ print(f"Cleaning up validation record: {record_name}")
649
+ try:
650
+ self._api_request("DELETE", f"/dnszone/{zone_id}/records/{record_id}")
651
+ print("Validation record cleaned up successfully")
652
+ except Exception as e:
653
+ print(f"Warning: Could not clean up validation record: {e}")
654
+
655
+
656
+ class ClouDNSDNSProvider(DNSProvider):
657
+ """ClouDNS API adapter with zone creation support.
658
+
659
+ ClouDNS is a European DNS provider with:
660
+ - Affordable DDoS protection plans
661
+ - Global anycast network
662
+ - Zone creation via API
663
+ - Supports both master and slave zones
664
+
665
+ Environment Variables:
666
+ CLOUDNS_AUTH_ID: ClouDNS API auth-id
667
+ CLOUDNS_AUTH_PASSWORD: ClouDNS API auth-password
668
+
669
+ Alternative (sub-user):
670
+ CLOUDNS_SUB_AUTH_ID: ClouDNS sub-user auth-id
671
+ CLOUDNS_SUB_AUTH_USER: ClouDNS sub-user username
672
+ """
673
+ API_BASE = "https://api.cloudns.net"
674
+ VALID_TTLS = [60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600, 2592000]
675
+ MIN_TTL = 60
676
+
677
+ def __init__(self, auth_id: str = None, auth_password: str = None,
678
+ sub_auth_id: str = None, sub_auth_user: str = None):
679
+ """Initialize ClouDNS client.
680
+
681
+ Args:
682
+ auth_id: Main API user ID
683
+ auth_password: API password
684
+ sub_auth_id: Sub-user ID (alternative to auth_id)
685
+ sub_auth_user: Sub-user username (alternative to auth_id)
686
+ """
687
+ self.auth_password = auth_password or os.environ.get("CLOUDNS_AUTH_PASSWORD")
688
+
689
+ # Determine auth method
690
+ self.auth_id = auth_id or os.environ.get("CLOUDNS_AUTH_ID")
691
+ self.sub_auth_id = sub_auth_id or os.environ.get("CLOUDNS_SUB_AUTH_ID")
692
+ self.sub_auth_user = sub_auth_user or os.environ.get("CLOUDNS_SUB_AUTH_USER")
693
+
694
+ if not self.auth_password:
695
+ raise ValueError("CLOUDNS_AUTH_PASSWORD environment variable not set.")
696
+
697
+ if not any([self.auth_id, self.sub_auth_id, self.sub_auth_user]):
698
+ raise ValueError(
699
+ "ClouDNS authentication required. Set one of: "
700
+ "CLOUDNS_AUTH_ID, CLOUDNS_SUB_AUTH_ID, or CLOUDNS_SUB_AUTH_USER"
701
+ )
702
+
703
+ self.session = requests.Session()
704
+
705
+ def _get_auth_params(self) -> dict:
706
+ """Get authentication parameters for API requests."""
707
+ params = {"auth-password": self.auth_password}
708
+
709
+ if self.auth_id:
710
+ params["auth-id"] = self.auth_id
711
+ elif self.sub_auth_id:
712
+ params["sub-auth-id"] = self.sub_auth_id
713
+ elif self.sub_auth_user:
714
+ params["sub-auth-user"] = self.sub_auth_user
715
+
716
+ return params
717
+
718
+ def _api_request(self, endpoint: str, params: dict = None) -> dict:
719
+ """Make an API request to ClouDNS."""
720
+ url = f"{self.API_BASE}{endpoint}"
721
+ request_params = self._get_auth_params()
722
+ if params:
723
+ request_params.update(params)
724
+
725
+ try:
726
+ response = self.session.post(url, data=request_params, timeout=30)
727
+ response.raise_for_status()
728
+
729
+ result = response.json()
730
+
731
+ # Check for API-level errors
732
+ if isinstance(result, dict) and result.get("status") == "Failed":
733
+ error_msg = result.get("statusDescription", "Unknown error")
734
+ raise Exception(f"ClouDNS API error: {error_msg}")
735
+
736
+ return result
737
+
738
+ except requests.RequestException as e:
739
+ raise Exception(f"ClouDNS API request failed: {e}")
740
+
741
+ def get_zone_id(self, zone_name: str) -> str:
742
+ """Get zone info - ClouDNS uses domain name as identifier.
743
+
744
+ Note: ClouDNS doesn't use numeric zone IDs, just domain names.
745
+ """
746
+ print(f"Looking up ClouDNS zone for '{zone_name}'...")
747
+
748
+ # List zones and find our domain
749
+ result = self._api_request("/dns/list-zones.json", {
750
+ "page": 1,
751
+ "rows-per-page": 100,
752
+ "search": zone_name
753
+ })
754
+
755
+ # Result is a dict with zone names as keys
756
+ if isinstance(result, dict):
757
+ for domain, zone_info in result.items():
758
+ if domain == zone_name:
759
+ print(f"Found ClouDNS zone: {zone_name}")
760
+ return zone_name
761
+
762
+ raise Exception(f"Zone not found in ClouDNS: {zone_name}")
763
+
764
+ def supports_zone_creation(self) -> bool:
765
+ """ClouDNS supports creating zones via API."""
766
+ return True
767
+
768
+ def create_zone(self, zone_name: str) -> str:
769
+ """Create a new DNS zone in ClouDNS."""
770
+ print(f"Creating new ClouDNS zone for '{zone_name}'...")
771
+
772
+ try:
773
+ result = self._api_request("/dns/register.json", {
774
+ "domain-name": zone_name,
775
+ "zone-type": "master"
776
+ })
777
+
778
+ if result.get("status") == "Success":
779
+ print(f"Successfully created ClouDNS zone: {zone_name}")
780
+ print("=" * 60)
781
+ print("IMPORTANT: Configure these nameservers at your domain registrar:")
782
+ print(" ns1.cloudns.net")
783
+ print(" ns2.cloudns.net")
784
+ print(" ns3.cloudns.net")
785
+ print(" ns4.cloudns.net")
786
+ print("=" * 60)
787
+ return zone_name
788
+
789
+ raise Exception(f"Unexpected response when creating zone: {result}")
790
+
791
+ except Exception as e:
792
+ # Zone might already exist
793
+ if "already exists" in str(e).lower():
794
+ print(f"Zone '{zone_name}' already exists in ClouDNS")
795
+ return zone_name
796
+ raise Exception(f"Failed to create ClouDNS zone: {e}")
797
+
798
+ def _get_nearest_ttl(self, ttl: int) -> int:
799
+ """Get the nearest valid TTL value."""
800
+ for valid_ttl in self.VALID_TTLS:
801
+ if ttl <= valid_ttl:
802
+ return valid_ttl
803
+ return self.VALID_TTLS[-1]
804
+
805
+ def _find_record(self, zone_name: str, record_name: str, record_type: str = "CNAME") -> dict:
806
+ """Find an existing DNS record."""
807
+ result = self._api_request("/dns/records.json", {
808
+ "domain-name": zone_name,
809
+ "host": record_name,
810
+ "type": record_type
811
+ })
812
+
813
+ if isinstance(result, dict):
814
+ for record_id, record_info in result.items():
815
+ if record_info.get("host") == record_name and record_info.get("type") == record_type:
816
+ return {"id": record_id, **record_info}
817
+
818
+ return None
819
+
820
+ def create_validation_record(self, zone_id: str, zone_name: str,
821
+ cname_name: str, cname_value: str) -> dict:
822
+ """Create DNS validation record in ClouDNS."""
823
+ print("Adding validation record to ClouDNS...")
824
+
825
+ # Extract record name (remove zone name suffix)
826
+ full_name = cname_name.rstrip(".")
827
+ if full_name.endswith(f".{zone_name}"):
828
+ record_name = full_name[:-len(f".{zone_name}")]
829
+ else:
830
+ record_name = full_name
831
+
832
+ # Remove trailing dot from value
833
+ cname_value = cname_value.rstrip(".")
834
+
835
+ # Check for existing record
836
+ existing = self._find_record(zone_name, record_name, "CNAME")
837
+
838
+ if existing:
839
+ print("Updating existing DNS record...")
840
+ # Delete existing record first (ClouDNS doesn't have update, need delete+add)
841
+ self._api_request("/dns/delete-record.json", {
842
+ "domain-name": zone_name,
843
+ "record-id": existing["id"]
844
+ })
845
+
846
+ print("Creating new DNS record...")
847
+ result = self._api_request("/dns/add-record.json", {
848
+ "domain-name": zone_name,
849
+ "record-type": "CNAME",
850
+ "host": record_name,
851
+ "record": cname_value,
852
+ "ttl": self.MIN_TTL
853
+ })
854
+
855
+ if result.get("status") == "Success":
856
+ record_id = result.get("data", {}).get("id")
857
+ print("DNS record added successfully")
858
+ return {
859
+ "id": record_id,
860
+ "name": record_name,
861
+ "zone_name": zone_name
862
+ }
863
+
864
+ raise Exception(f"Failed to create DNS record: {result}")
865
+
866
+ def cleanup_validation_record(self, zone_id: str, record_info: dict) -> None:
867
+ """Remove DNS validation record from ClouDNS."""
868
+ record_id = record_info.get("id")
869
+ record_name = record_info.get("name")
870
+ zone_name = record_info.get("zone_name", zone_id)
871
+
872
+ if not record_id:
873
+ # Try to find record by name
874
+ if record_name:
875
+ existing = self._find_record(zone_name, record_name, "CNAME")
876
+ if existing:
877
+ record_id = existing.get("id")
878
+
879
+ if not record_id:
880
+ print(f"Warning: Could not find record to clean up: {record_name}")
881
+ return
882
+
883
+ print(f"Cleaning up validation record: {record_name}")
884
+ try:
885
+ self._api_request("/dns/delete-record.json", {
886
+ "domain-name": zone_name,
887
+ "record-id": record_id
888
+ })
889
+ print("Validation record cleaned up successfully")
890
+ except Exception as e:
891
+ print(f"Warning: Could not clean up validation record: {e}")
892
+
893
+
894
+
895
+
896
+ def get_dns_provider(provider_name: str, api_token: str = None) -> DNSProvider:
897
+ """Factory function to create DNS provider instance.
898
+
899
+ Args:
900
+ provider_name: Name of the provider ('cloudflare', 'hetzner', 'desec', 'bunny', 'cloudns')
901
+ api_token: Optional API token (will use env var if not provided)
902
+
903
+ Returns:
904
+ DNSProvider instance
905
+ """
906
+ providers = {
907
+ "cloudflare": (CloudflareDNSProvider, "CLOUDFLARE_API_TOKEN"),
908
+ "hetzner": (HetznerDNSProvider, "HETZNER_DNS_API_TOKEN"),
909
+ "desec": (DeSECDNSProvider, "DESEC_API_TOKEN"),
910
+ "bunny": (BunnyDNSProvider, "BUNNY_API_KEY"),
911
+ "cloudns": (ClouDNSDNSProvider, "CLOUDNS_AUTH_PASSWORD"),
912
+ }
913
+
914
+ if provider_name not in providers:
915
+ raise ValueError(f"Unknown DNS provider: {provider_name}. Supported: {list(providers.keys())}")
916
+
917
+ provider_class, env_var = providers[provider_name]
918
+ token = api_token or os.environ.get(env_var)
919
+
920
+ if not token:
921
+ raise ValueError(f"{env_var} environment variable not set for {provider_name} provider.")
922
+
923
+ return provider_class(token)
924
+
925
+
926
+ # =============================================================================
927
+ # Bunny CDN Certificate Provider
928
+ # =============================================================================
929
+
930
+ class BunnyCDNCertificateProvider:
931
+ """Handles SSL certificate requests for Bunny CDN pull zones."""
932
+
933
+ API_BASE = "https://api.bunny.net"
934
+
935
+ # DNS providers that support CNAME flattening at apex
936
+ CNAME_FLATTENING_PROVIDERS = ["cloudflare", "bunny"]
937
+
938
+ def __init__(self, api_key: str = None):
939
+ """Initialize Bunny CDN client.
940
+
941
+ Args:
942
+ api_key: Bunny.net API key (uses BUNNY_API_KEY env var if not provided)
943
+ """
944
+ self.api_key = api_key or os.environ.get("BUNNY_API_KEY")
945
+ if not self.api_key:
946
+ raise ValueError("BUNNY_API_KEY environment variable not set.")
947
+
948
+ self.session = requests.Session()
949
+ self.session.headers.update({
950
+ "AccessKey": self.api_key,
951
+ "Content-Type": "application/json"
952
+ })
953
+
954
+ @staticmethod
955
+ def is_apex_domain(hostname: str) -> bool:
956
+ """Check if hostname is an apex/root domain (e.g., example.com vs www.example.com)."""
957
+ parts = hostname.split('.')
958
+ # Simple check: apex domains typically have 2 parts (example.com)
959
+ # or 3 parts for country codes (example.co.uk)
960
+ if len(parts) == 2:
961
+ return True
962
+ # Check for common country-code TLDs
963
+ if len(parts) == 3 and parts[-2] in ['co', 'com', 'org', 'net', 'ac', 'gov']:
964
+ return True
965
+ return False
966
+
967
+ def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
968
+ """Make an API request to Bunny CDN."""
969
+ url = f"{self.API_BASE}{endpoint}"
970
+
971
+ try:
972
+ if method == "GET":
973
+ response = self.session.get(url, timeout=30)
974
+ elif method == "POST":
975
+ response = self.session.post(url, json=data, timeout=30)
976
+ else:
977
+ raise ValueError(f"Unsupported method: {method}")
978
+
979
+ if response.status_code >= 400:
980
+ return {"error": True, "status": response.status_code, "message": response.text}
981
+
982
+ if response.status_code == 204:
983
+ return {"success": True}
984
+
985
+ return response.json() if response.text else {"success": True}
986
+
987
+ except requests.RequestException as e:
988
+ return {"error": True, "message": str(e)}
989
+
990
+ def list_pull_zones(self) -> list:
991
+ """List all pull zones."""
992
+ result = self._api_request("GET", "/pullzone")
993
+ if isinstance(result, list):
994
+ return result
995
+ return []
996
+
997
+ def find_pull_zone_by_hostname(self, hostname: str) -> Optional[dict]:
998
+ """Find a pull zone that has a specific hostname configured."""
999
+ zones = self.list_pull_zones()
1000
+ for zone in zones:
1001
+ hostnames = zone.get('Hostnames', [])
1002
+ for h in hostnames:
1003
+ if h.get('Value') == hostname:
1004
+ return zone
1005
+ return None
1006
+
1007
+ def find_pull_zone_by_name(self, name: str) -> Optional[dict]:
1008
+ """Find a pull zone by its name."""
1009
+ zones = self.list_pull_zones()
1010
+ for zone in zones:
1011
+ if zone.get('Name') == name:
1012
+ return zone
1013
+ return None
1014
+
1015
+ def request_certificate(self, pull_zone_id: int, hostname: str,
1016
+ max_retries: int = 3, retry_delay: int = 30,
1017
+ use_dns01: bool = False) -> bool:
1018
+ """Request a free SSL certificate for a hostname.
1019
+
1020
+ The API endpoint is /pullzone/loadFreeCertificate (without pull zone ID
1021
+ in the path). The pull_zone_id parameter is kept for backwards
1022
+ compatibility but is not used in the API call.
1023
+
1024
+ Args:
1025
+ pull_zone_id: Bunny CDN pull zone ID (unused, kept for compat)
1026
+ hostname: The hostname to get certificate for
1027
+ max_retries: Number of retry attempts
1028
+ retry_delay: Delay between retries in seconds
1029
+ use_dns01: Use DNS01 validation instead of HTTP01. Required when
1030
+ the domain uses Bunny DNS or when HTTP01 validation
1031
+ fails (e.g., due to edge caching).
1032
+
1033
+ Returns:
1034
+ True if certificate was requested successfully
1035
+ """
1036
+ print(f"Requesting SSL certificate for '{hostname}'...")
1037
+ if use_dns01:
1038
+ print(" Using DNS01 validation (useOnlyHttp01=false)")
1039
+
1040
+ for attempt in range(1, max_retries + 1):
1041
+ params = f"hostname={hostname}"
1042
+ if use_dns01:
1043
+ params += "&useOnlyHttp01=false"
1044
+ url = f"/pullzone/loadFreeCertificate?{params}"
1045
+ result = self._api_request("GET", url)
1046
+
1047
+ if result.get("error"):
1048
+ status = result.get("status", "unknown")
1049
+ message = result.get("message", "Unknown error")
1050
+
1051
+ if status == 401:
1052
+ print(f" Attempt {attempt}/{max_retries}: Authorization denied...")
1053
+ elif status == 404:
1054
+ print(f" Attempt {attempt}/{max_retries}: DNS not propagated yet...")
1055
+ elif status == 500:
1056
+ print(f" Attempt {attempt}/{max_retries}: Server error, retrying...")
1057
+ else:
1058
+ print(f" Attempt {attempt}/{max_retries}: HTTP {status} - {message}")
1059
+
1060
+ if attempt < max_retries:
1061
+ print(f" Waiting {retry_delay}s before retry...")
1062
+ time.sleep(retry_delay)
1063
+ continue
1064
+
1065
+ print(f" SSL certificate requested successfully for '{hostname}'!")
1066
+ return True
1067
+
1068
+ print(f" Failed to request certificate after {max_retries} attempts.")
1069
+ return False
1070
+
1071
+ def check_certificate_status(self, pull_zone_id: int, hostname: str) -> dict:
1072
+ """Check SSL certificate status for a hostname.
1073
+
1074
+ Returns:
1075
+ Dict with certificate status information
1076
+ """
1077
+ zones = self.list_pull_zones()
1078
+ for zone in zones:
1079
+ if zone.get('Id') == pull_zone_id:
1080
+ for h in zone.get('Hostnames', []):
1081
+ if h.get('Value') == hostname:
1082
+ return {
1083
+ "hostname": hostname,
1084
+ "has_certificate": h.get('HasCertificate', False),
1085
+ "force_ssl": h.get('ForceSSL', False),
1086
+ "certificate_key": h.get('CertificateKey', ''),
1087
+ }
1088
+ return {"hostname": hostname, "has_certificate": False}
1089
+
1090
+ def enable_force_ssl(self, pull_zone_id: int, hostname: str) -> bool:
1091
+ """Enable Force SSL for a hostname."""
1092
+ print(f"Enabling Force SSL for '{hostname}'...")
1093
+
1094
+ result = self._api_request("POST", f"/pullzone/{pull_zone_id}/setForceSSL", {
1095
+ "Hostname": hostname,
1096
+ "ForceSSL": True
1097
+ })
1098
+
1099
+ if result.get("error"):
1100
+ print(f" Warning: Could not enable Force SSL: {result.get('message')}")
1101
+ return False
1102
+
1103
+ print(" Force SSL enabled successfully.")
1104
+ return True
1105
+
1106
+ def create_apex_redirect_edge_rule(self, pull_zone_id: int, apex_domain: str,
1107
+ www_domain: str = None) -> bool:
1108
+ """Create an edge rule to redirect apex domain to www.
1109
+
1110
+ This is needed when the DNS provider doesn't support CNAME flattening.
1111
+ The apex domain can't get an SSL certificate, so we redirect to www.
1112
+
1113
+ Args:
1114
+ pull_zone_id: Bunny CDN pull zone ID
1115
+ apex_domain: The apex domain (e.g., example.com)
1116
+ www_domain: The www domain (default: www.{apex_domain})
1117
+
1118
+ Returns:
1119
+ True if edge rule was created successfully
1120
+ """
1121
+ if www_domain is None:
1122
+ www_domain = f"www.{apex_domain}"
1123
+
1124
+ print(f"Creating edge rule to redirect '{apex_domain}' --> '{www_domain}'...")
1125
+
1126
+ data = {
1127
+ "ActionType": 2, # Redirect
1128
+ "ActionParameter1": f"https://{www_domain}/",
1129
+ "Triggers": [{
1130
+ "Type": 0, # URL match
1131
+ "PatternMatchingType": 0, # Match any
1132
+ "PatternMatches": [apex_domain]
1133
+ }],
1134
+ "TriggerMatchingType": 0, # Match all triggers
1135
+ "Description": f"Redirect apex ({apex_domain}) to www",
1136
+ "Enabled": True
1137
+ }
1138
+
1139
+ result = self._api_request("POST", f"/pullzone/{pull_zone_id}/edgerules/addOrUpdate", data)
1140
+
1141
+ if result.get("error"):
1142
+ print(f" Warning: Could not create edge rule: {result.get('message')}")
1143
+ return False
1144
+
1145
+ print(" Edge rule created successfully.")
1146
+ print(f" Note: HTTP traffic to {apex_domain} will redirect to https://{www_domain}")
1147
+ return True
1148
+
1149
+ def check_apex_redirect_exists(self, pull_zone_id: int, apex_domain: str) -> bool:
1150
+ """Check if an apex redirect edge rule already exists."""
1151
+ result = self._api_request("GET", f"/pullzone/{pull_zone_id}")
1152
+ if result.get("error"):
1153
+ return False
1154
+
1155
+ edge_rules = result.get("EdgeRules", [])
1156
+ for rule in edge_rules:
1157
+ description = rule.get("Description", "").lower()
1158
+ if "apex" in description and apex_domain.lower() in description:
1159
+ return True
1160
+ # Also check triggers
1161
+ triggers = rule.get("Triggers", [])
1162
+ for trigger in triggers:
1163
+ patterns = trigger.get("PatternMatches", [])
1164
+ if apex_domain in patterns:
1165
+ return True
1166
+ return False
1167
+
1168
+
1169
+ class BunnyCertificateAutomation:
1170
+ """Automates SSL certificate creation for Bunny CDN."""
1171
+
1172
+ def __init__(
1173
+ self,
1174
+ hostname: str,
1175
+ pull_zone_name: str = None,
1176
+ bunny_api_key: str = None,
1177
+ force_ssl: bool = True,
1178
+ retry_count: int = 5,
1179
+ retry_delay: int = 30,
1180
+ dns_provider: str = None,
1181
+ setup_apex_redirect: bool = True,
1182
+ ):
1183
+ """Initialize Bunny certificate automation.
1184
+
1185
+ Args:
1186
+ hostname: The hostname to get certificate for (e.g., mist-kibl.at)
1187
+ pull_zone_name: Pull zone name (default: hostname with dots replaced by hyphens)
1188
+ bunny_api_key: Bunny API key (uses env var if not provided)
1189
+ force_ssl: Enable Force SSL after certificate is issued
1190
+ retry_count: Number of retry attempts for certificate request
1191
+ retry_delay: Delay between retries in seconds
1192
+ dns_provider: DNS provider name (for apex domain handling)
1193
+ setup_apex_redirect: Auto-create edge rule for apex --> www redirect
1194
+ """
1195
+ self.hostname = hostname
1196
+ self.pull_zone_name = pull_zone_name or hostname.replace('.', '-')
1197
+ self.force_ssl = force_ssl
1198
+ self.retry_count = retry_count
1199
+ self.retry_delay = retry_delay
1200
+ self.dns_provider = dns_provider
1201
+ self.setup_apex_redirect = setup_apex_redirect
1202
+
1203
+ self.bunny = BunnyCDNCertificateProvider(bunny_api_key)
1204
+ self.pull_zone = None
1205
+ self.pull_zone_id = None
1206
+ self.is_apex = self.bunny.is_apex_domain(hostname)
1207
+
1208
+ def find_pull_zone(self) -> bool:
1209
+ """Find the pull zone for the hostname."""
1210
+ print(f"Looking for pull zone '{self.pull_zone_name}'...")
1211
+
1212
+ # First try by name
1213
+ self.pull_zone = self.bunny.find_pull_zone_by_name(self.pull_zone_name)
1214
+
1215
+ # Then try by hostname
1216
+ if not self.pull_zone:
1217
+ self.pull_zone = self.bunny.find_pull_zone_by_hostname(self.hostname)
1218
+
1219
+ if self.pull_zone:
1220
+ self.pull_zone_id = self.pull_zone.get('Id')
1221
+ print(f"Found pull zone: {self.pull_zone.get('Name')} (ID: {self.pull_zone_id})")
1222
+ return True
1223
+
1224
+ print(f"Error: Pull zone not found for '{self.pull_zone_name}' or '{self.hostname}'")
1225
+ return False
1226
+
1227
+ def check_hostname_configured(self) -> bool:
1228
+ """Check if hostname is configured on the pull zone."""
1229
+ if not self.pull_zone:
1230
+ return False
1231
+
1232
+ hostnames = self.pull_zone.get('Hostnames', [])
1233
+ for h in hostnames:
1234
+ if h.get('Value') == self.hostname:
1235
+ print(f"Hostname '{self.hostname}' is configured on pull zone.")
1236
+ return True
1237
+
1238
+ print(f"Warning: Hostname '{self.hostname}' not configured on pull zone.")
1239
+ return False
1240
+
1241
+ def run(self) -> bool:
1242
+ """Execute the certificate automation workflow.
1243
+
1244
+ Returns:
1245
+ True if successful, False otherwise
1246
+ """
1247
+ print(f"\n{'='*60}")
1248
+ print("Bunny CDN SSL Certificate Automation")
1249
+ print(f"{'='*60}")
1250
+ print(f"Hostname: {self.hostname}")
1251
+ print(f"Pull Zone: {self.pull_zone_name}")
1252
+ print(f"Is Apex Domain: {self.is_apex}")
1253
+ if self.dns_provider:
1254
+ print(f"DNS Provider: {self.dns_provider}")
1255
+ print(f"{'='*60}\n")
1256
+
1257
+ # Find pull zone
1258
+ if not self.find_pull_zone():
1259
+ return False
1260
+
1261
+ # Check hostname is configured
1262
+ if not self.check_hostname_configured():
1263
+ print("Please add the hostname to the pull zone first.")
1264
+ return False
1265
+
1266
+ # Handle apex domain limitations
1267
+ if self.is_apex:
1268
+ supports_cname_flattening = (
1269
+ self.dns_provider and
1270
+ self.dns_provider.lower() in BunnyCDNCertificateProvider.CNAME_FLATTENING_PROVIDERS
1271
+ )
1272
+
1273
+ if not supports_cname_flattening:
1274
+ print("\n[!] APEX DOMAIN LIMITATION DETECTED")
1275
+ print(f" '{self.hostname}' is an apex/root domain.")
1276
+ if self.dns_provider:
1277
+ print(f" DNS provider '{self.dns_provider}' does not support CNAME flattening.")
1278
+ else:
1279
+ print(" Most DNS providers don't support CNAME flattening at apex.")
1280
+ print(" Bunny CDN cannot validate SSL for apex domains without CNAME flattening.")
1281
+ print()
1282
+
1283
+ if self.setup_apex_redirect:
1284
+ www_domain = f"www.{self.hostname}"
1285
+ print(f" Setting up redirect: {self.hostname} --> {www_domain}")
1286
+
1287
+ # Check if redirect already exists
1288
+ if not self.bunny.check_apex_redirect_exists(self.pull_zone_id, self.hostname):
1289
+ self.bunny.create_apex_redirect_edge_rule(
1290
+ self.pull_zone_id, self.hostname, www_domain
1291
+ )
1292
+ else:
1293
+ print(" Apex redirect edge rule already exists.")
1294
+
1295
+ print()
1296
+ print(f" [OK] HTTP traffic to {self.hostname} will redirect to https://{www_domain}")
1297
+ print(f" [i] Use '{www_domain}' as your primary domain for SSL.")
1298
+ print()
1299
+ return True # Apex handled via redirect
1300
+ else:
1301
+ print(" To fix this, either:")
1302
+ print(" 1. Use Cloudflare as DNS provider (supports CNAME flattening)")
1303
+ print(f" 2. Use www.{self.hostname} as your primary domain")
1304
+ print(" 3. Run with --setup-apex-redirect to auto-create redirect rule")
1305
+ print()
1306
+ return False
1307
+
1308
+ # Check current certificate status
1309
+ status = self.bunny.check_certificate_status(self.pull_zone_id, self.hostname)
1310
+ if status.get('has_certificate'):
1311
+ print(f"Certificate already exists for '{self.hostname}'!")
1312
+ if self.force_ssl and not status.get('force_ssl'):
1313
+ self.bunny.enable_force_ssl(self.pull_zone_id, self.hostname)
1314
+ return True
1315
+
1316
+ # Request certificate
1317
+ # Use DNS01 validation when Bunny DNS is the provider, as this allows
1318
+ # Bunny to validate via its own DNS zone without HTTP challenges.
1319
+ use_dns01 = self.dns_provider and self.dns_provider.lower() == "bunny"
1320
+ success = self.bunny.request_certificate(
1321
+ self.pull_zone_id,
1322
+ self.hostname,
1323
+ max_retries=self.retry_count,
1324
+ retry_delay=self.retry_delay,
1325
+ use_dns01=use_dns01,
1326
+ )
1327
+
1328
+ if not success:
1329
+ print("\nCertificate request failed.")
1330
+ print("Make sure DNS is properly configured and propagated.")
1331
+ if self.is_apex:
1332
+ print(f"\nNote: This is an apex domain. Consider using www.{self.hostname} instead.")
1333
+ return False
1334
+
1335
+ # Enable Force SSL if requested
1336
+ if self.force_ssl:
1337
+ time.sleep(5) # Wait a bit for certificate to be processed
1338
+ self.bunny.enable_force_ssl(self.pull_zone_id, self.hostname)
1339
+
1340
+ # Verify certificate
1341
+ print("\nVerifying certificate status...")
1342
+ time.sleep(5)
1343
+ status = self.bunny.check_certificate_status(self.pull_zone_id, self.hostname)
1344
+
1345
+ if status.get('has_certificate'):
1346
+ print(f"\n{'='*60}")
1347
+ print("Certificate created successfully!")
1348
+ print(f"Hostname: {self.hostname}")
1349
+ print(f"Force SSL: {'Enabled' if status.get('force_ssl') else 'Disabled'}")
1350
+ print(f"{'='*60}\n")
1351
+ return True
1352
+ else:
1353
+ print("\nCertificate may still be processing. Check Bunny dashboard.")
1354
+ return True # Request was accepted, just may take time
1355
+
1356
+ class CertificateAutomation:
1357
+ """Handles SSL certificate creation and validation."""
1358
+
1359
+ def __init__(
1360
+ self,
1361
+ domain_name: str,
1362
+ stage: str,
1363
+ dns_provider: DNSProvider,
1364
+ aws_profile: Optional[str] = None,
1365
+ aws_region: str = "us-east-1",
1366
+ use_wildcard: bool = False,
1367
+ config_file: Optional[str] = None,
1368
+ skip_config_update: bool = False,
1369
+ create_zone: bool = False,
1370
+ ):
1371
+ """
1372
+ Initialize the certificate automation tool.
1373
+
1374
+ Args:
1375
+ domain_name: The domain name for the certificate
1376
+ stage: Deployment stage (e.g., staging, production)
1377
+ dns_provider: DNS provider instance for validation records
1378
+ aws_profile: AWS profile name (optional, uses env vars in CI)
1379
+ aws_region: AWS region for ACM (default: us-east-1)
1380
+ use_wildcard: Create wildcard certificate
1381
+ config_file: Path to config file to update (default: stages.yml)
1382
+ skip_config_update: Skip updating config file
1383
+ create_zone: Create DNS zone if it doesn't exist (provider must support it)
1384
+ """
1385
+ self.domain_name = domain_name
1386
+ self.stage = stage
1387
+ self.dns_provider = dns_provider
1388
+ self.aws_region = aws_region
1389
+ self.use_wildcard = use_wildcard
1390
+ self.config_file = config_file or "stages.yml"
1391
+ self.skip_config_update = skip_config_update
1392
+ self.create_zone = create_zone
1393
+ self.validation_record_info = None # For cleanup
1394
+
1395
+ # Determine if running in CI environment
1396
+ self.is_ci = bool(os.environ.get("CI_JOB_ID"))
1397
+
1398
+ # Setup AWS session
1399
+ if self.is_ci:
1400
+ print("Running in CI environment, using AWS environment variables")
1401
+ self.session = boto3.Session(region_name=aws_region)
1402
+ else:
1403
+ profile = aws_profile or os.environ.get("AWS_PROFILE", "pseekoo")
1404
+ print(f"Using AWS profile: {profile}")
1405
+ self.session = boto3.Session(profile_name=profile, region_name=aws_region)
1406
+
1407
+ self.acm_client = self.session.client("acm")
1408
+
1409
+ # Extract base domain
1410
+ self.base_domain = self._extract_base_domain(domain_name)
1411
+
1412
+ # Get zone ID (auto-create if requested and supported)
1413
+ if create_zone:
1414
+ if not dns_provider.supports_zone_creation():
1415
+ print("Warning: DNS provider does not support automatic zone creation.")
1416
+ self.zone_id = dns_provider.get_or_create_zone(self.base_domain, auto_create=True)
1417
+ else:
1418
+ self.zone_id = dns_provider.get_zone_id(self.base_domain)
1419
+
1420
+ # Determine certificate domain
1421
+ if use_wildcard:
1422
+ self.cert_domain = f"*.{self.base_domain}"
1423
+ print(f"Using wildcard certificate: {self.cert_domain}")
1424
+ else:
1425
+ self.cert_domain = domain_name
1426
+ print(f"Using specific domain certificate: {self.cert_domain}")
1427
+
1428
+ @staticmethod
1429
+ def _extract_base_domain(domain: str) -> str:
1430
+ """Extract base domain from a full domain name."""
1431
+ parts = domain.split(".")
1432
+ if len(parts) >= 2:
1433
+ return ".".join(parts[-2:])
1434
+ return domain
1435
+
1436
+ def find_existing_certificate(self) -> Optional[str]:
1437
+ """Check if certificate already exists for the domain."""
1438
+ print(f"Checking for existing certificate for {self.cert_domain}...")
1439
+ try:
1440
+ response = self.acm_client.list_certificates(
1441
+ CertificateStatuses=["ISSUED", "PENDING_VALIDATION"]
1442
+ )
1443
+
1444
+ for cert in response.get("CertificateSummaryList", []):
1445
+ if cert.get("DomainName") == self.cert_domain:
1446
+ arn = cert.get("CertificateArn")
1447
+ print(f"Found existing certificate: {arn}")
1448
+ return arn
1449
+
1450
+ print("No existing certificate found")
1451
+ return None
1452
+
1453
+ except ClientError as e:
1454
+ print(f"Error checking for existing certificates: {e}")
1455
+ raise
1456
+
1457
+ def request_certificate(self) -> str:
1458
+ """Request a new SSL certificate from ACM."""
1459
+ print(f"Requesting new certificate for {self.cert_domain}...")
1460
+ try:
1461
+ response = self.acm_client.request_certificate(
1462
+ DomainName=self.cert_domain, ValidationMethod="DNS"
1463
+ )
1464
+
1465
+ cert_arn = response["CertificateArn"]
1466
+ print(f"Certificate requested successfully: {cert_arn}")
1467
+ return cert_arn
1468
+
1469
+ except ClientError as e:
1470
+ print(f"Error requesting certificate: {e}")
1471
+ raise
1472
+
1473
+ def get_validation_records(
1474
+ self, cert_arn: str, max_attempts: int = 12, wait_time: int = 10
1475
+ ) -> Tuple[str, str]:
1476
+ """
1477
+ Get DNS validation records for the certificate.
1478
+
1479
+ Args:
1480
+ cert_arn: Certificate ARN
1481
+ max_attempts: Maximum number of retry attempts
1482
+ wait_time: Wait time between attempts in seconds
1483
+
1484
+ Returns:
1485
+ Tuple of (CNAME name, CNAME value)
1486
+ """
1487
+ print("Fetching validation records...")
1488
+
1489
+ for attempt in range(1, max_attempts + 1):
1490
+ try:
1491
+ response = self.acm_client.describe_certificate(
1492
+ CertificateArn=cert_arn
1493
+ )
1494
+
1495
+ validation_options = response.get("Certificate", {}).get(
1496
+ "DomainValidationOptions", []
1497
+ )
1498
+
1499
+ if validation_options:
1500
+ resource_record = validation_options[0].get("ResourceRecord")
1501
+ if resource_record:
1502
+ cname_name = resource_record.get("Name", "").rstrip(".")
1503
+ cname_value = resource_record.get("Value", "").rstrip(".")
1504
+
1505
+ if cname_name and cname_value:
1506
+ print("Found validation records:")
1507
+ print(f" CNAME Name: {cname_name}")
1508
+ print(f" CNAME Value: {cname_value}")
1509
+ return cname_name, cname_value
1510
+
1511
+ print(
1512
+ f"Validation records not available yet. Waiting... (Attempt {attempt}/{max_attempts})"
1513
+ )
1514
+ time.sleep(wait_time)
1515
+
1516
+ except ClientError as e:
1517
+ print(f"Error fetching validation records: {e}")
1518
+ if attempt == max_attempts:
1519
+ raise
1520
+
1521
+ raise RuntimeError(
1522
+ "Could not fetch validation records after multiple attempts"
1523
+ )
1524
+
1525
+ def create_dns_validation_record(self, cname_name: str, cname_value: str) -> dict:
1526
+ """
1527
+ Create DNS validation record using the configured DNS provider.
1528
+
1529
+ Args:
1530
+ cname_name: CNAME record name
1531
+ cname_value: CNAME record value
1532
+
1533
+ Returns:
1534
+ Record info dict for cleanup
1535
+ """
1536
+ return self.dns_provider.create_validation_record(
1537
+ self.zone_id, self.base_domain, cname_name, cname_value
1538
+ )
1539
+
1540
+ def cleanup_dns_validation_record(self) -> None:
1541
+ """Clean up DNS validation record if one was created."""
1542
+ if self.validation_record_info:
1543
+ self.dns_provider.cleanup_validation_record(
1544
+ self.zone_id, self.validation_record_info
1545
+ )
1546
+
1547
+ def wait_for_validation(
1548
+ self, cert_arn: str, timeout: int = 1800
1549
+ ) -> bool:
1550
+ """
1551
+ Wait for certificate validation to complete.
1552
+
1553
+ Args:
1554
+ cert_arn: Certificate ARN
1555
+ timeout: Maximum wait time in seconds (default: 30 minutes)
1556
+
1557
+ Returns:
1558
+ True if validation successful, False otherwise
1559
+ """
1560
+ print(
1561
+ f"Waiting for certificate validation (timeout: {timeout//60} minutes)..."
1562
+ )
1563
+ try:
1564
+ waiter = self.acm_client.get_waiter("certificate_validated")
1565
+ waiter.wait(
1566
+ CertificateArn=cert_arn,
1567
+ WaiterConfig={"Delay": 30, "MaxAttempts": timeout // 30},
1568
+ )
1569
+ print("Certificate validated successfully!")
1570
+ return True
1571
+
1572
+ except WaiterError as e:
1573
+ print(f"Certificate validation timed out or failed: {e}")
1574
+ return False
1575
+
1576
+ def update_config_file(self, cert_arn: str) -> None:
1577
+ """
1578
+ Update configuration file with certificate ARN.
1579
+
1580
+ Args:
1581
+ cert_arn: Certificate ARN to add to config
1582
+ """
1583
+ if self.skip_config_update:
1584
+ print("Skipping config file update (--skip-config-update flag set)")
1585
+ return
1586
+
1587
+ if not os.path.exists(self.config_file):
1588
+ print(f"Warning: Config file {self.config_file} not found. Skipping update.")
1589
+ return
1590
+
1591
+ print(f"Updating {self.config_file} with certificate ARN...")
1592
+
1593
+ try:
1594
+ with open(self.config_file, "r", encoding="utf-8") as f:
1595
+ content = f.read()
1596
+
1597
+ # Replace certificate placeholder
1598
+ updated_content = content.replace(
1599
+ "certificateArn: 'certificate-placeholder'",
1600
+ f"certificateArn: '{cert_arn}'",
1601
+ )
1602
+
1603
+ # Create backup
1604
+ backup_file = f"{self.config_file}.bak"
1605
+ with open(backup_file, "w", encoding="utf-8") as f:
1606
+ f.write(content)
1607
+
1608
+ # Write updated content
1609
+ with open(self.config_file, "w", encoding="utf-8") as f:
1610
+ f.write(updated_content)
1611
+
1612
+ print(f"Updated {self.config_file} successfully (backup: {backup_file})")
1613
+
1614
+ except Exception as e:
1615
+ print(f"Error updating config file: {e}")
1616
+ raise
1617
+
1618
+ def run(self) -> str:
1619
+ """
1620
+ Execute the full certificate automation workflow.
1621
+
1622
+ Returns:
1623
+ Certificate ARN
1624
+ """
1625
+ print(f"\n{'='*60}")
1626
+ print("SSL Certificate Automation")
1627
+ print(f"{'='*60}")
1628
+ print(f"Domain: {self.cert_domain}")
1629
+ print(f"Original domain: {self.domain_name}")
1630
+ print(f"Base domain: {self.base_domain}")
1631
+ print(f"Stage: {self.stage}")
1632
+ print(f"{'='*60}\n")
1633
+
1634
+ # Check for existing certificate
1635
+ cert_arn = self.find_existing_certificate()
1636
+
1637
+ if not cert_arn:
1638
+ # Request new certificate
1639
+ cert_arn = self.request_certificate()
1640
+
1641
+ # Wait for validation records to be available
1642
+ print("Waiting for validation records to be available...")
1643
+ time.sleep(10)
1644
+
1645
+ # Get validation records
1646
+ cname_name, cname_value = self.get_validation_records(cert_arn)
1647
+
1648
+ # Create/update DNS record using configured provider
1649
+ self.validation_record_info = self.create_dns_validation_record(cname_name, cname_value)
1650
+
1651
+ # Wait for validation
1652
+ if not self.wait_for_validation(cert_arn):
1653
+ print("\nCertificate validation timed out.")
1654
+ print("Please check AWS Certificate Manager console for status.")
1655
+ sys.exit(1)
1656
+
1657
+ # Update config file
1658
+ self.update_config_file(cert_arn)
1659
+
1660
+ print(f"\n{'='*60}")
1661
+ print("Certificate creation and validation completed successfully!")
1662
+ print(f"Certificate ARN: {cert_arn}")
1663
+ print(f"{'='*60}\n")
1664
+
1665
+ # Print deployment instructions
1666
+ print("Next steps:")
1667
+ if self.is_ci:
1668
+ print(f" npx serverless deploy --stage {self.stage} --verbose")
1669
+ else:
1670
+ profile = os.environ.get("AWS_PROFILE", "pseekoo")
1671
+ print(
1672
+ f" npx serverless deploy --stage {self.stage} --verbose --aws-profile {profile}"
1673
+ )
1674
+
1675
+ return cert_arn
1676
+
1677
+
1678
+ def main():
1679
+ """Main CLI entry point."""
1680
+ parser = argparse.ArgumentParser(
1681
+ description="Automate SSL certificate creation and DNS validation",
1682
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1683
+ epilog="""
1684
+ Examples:
1685
+ # Bunny CDN certificate for www subdomain (recommended)
1686
+ %(prog)s www.mist-kibl.at --cert-provider bunny
1687
+
1688
+ # Bunny CDN with apex domain + deSEC (auto-creates redirect)
1689
+ %(prog)s mist-kibl.at --cert-provider bunny --bunny-dns-provider desec
1690
+ # This will auto-create an edge rule: mist-kibl.at --> www.mist-kibl.at
1691
+
1692
+ # Bunny CDN with multiple hostnames
1693
+ %(prog)s www.mist-kibl.at api.mist-kibl.at --cert-provider bunny
1694
+
1695
+ # Bunny CDN with custom pull zone name
1696
+ %(prog)s www.mist-kibl.at --cert-provider bunny --pull-zone mist-kibl-at
1697
+
1698
+ # Bunny CDN apex with Cloudflare DNS (supports CNAME flattening)
1699
+ %(prog)s mist-kibl.at --cert-provider bunny --bunny-dns-provider cloudflare
1700
+
1701
+ # Bunny CDN apex with Bunny DNS (European DNS, supports CNAME flattening)
1702
+ %(prog)s mist-kibl.at --cert-provider bunny --bunny-dns-provider bunny
1703
+
1704
+ # AWS ACM with Bunny DNS
1705
+ %(prog)s app.example.com production --cert-provider acm --dns-provider bunny
1706
+
1707
+ # AWS ACM with ClouDNS (European, affordable DDoS protection)
1708
+ %(prog)s app.example.com production --cert-provider acm --dns-provider cloudns
1709
+
1710
+ # AWS ACM wildcard certificate with Cloudflare DNS
1711
+ %(prog)s app-dev.lularge.com staging --cert-provider acm --wildcard
1712
+
1713
+ # AWS ACM with deSEC DNS provider
1714
+ %(prog)s app.example.com production --cert-provider acm --dns-provider desec
1715
+
1716
+ # AWS ACM with Hetzner DNS
1717
+ %(prog)s app.example.com staging --cert-provider acm --dns-provider hetzner
1718
+
1719
+ Apex Domain Notes:
1720
+ Apex domains (e.g., example.com without www) require CNAME flattening for
1721
+ Bunny CDN SSL certificates. Cloudflare and Bunny DNS support this. For other
1722
+ DNS providers (deSEC, Hetzner, etc.), the script will:
1723
+ 1. Auto-create an edge rule to redirect apex --> www
1724
+ 2. You should request certificate for www subdomain instead
1725
+
1726
+ Environment Variables:
1727
+ BUNNY_API_KEY - Bunny.net API key (for bunny provider)
1728
+ AWS_PROFILE - AWS profile name (default: pseekoo)
1729
+ AWS_REGION - AWS region (default: us-east-1)
1730
+ CLOUDFLARE_API_TOKEN - Cloudflare API token (for cloudflare DNS)
1731
+ HETZNER_DNS_API_TOKEN - Hetzner DNS API token (for hetzner DNS)
1732
+ DESEC_API_TOKEN - deSEC API token (for desec DNS)
1733
+ CLOUDNS_AUTH_ID - ClouDNS API auth-id (for cloudns DNS)
1734
+ CLOUDNS_AUTH_PASSWORD - ClouDNS API auth-password (for cloudns DNS)
1735
+ """,
1736
+ )
1737
+
1738
+ parser.add_argument("domain", nargs="+", help="Domain name(s) for the certificate")
1739
+ parser.add_argument(
1740
+ "stage",
1741
+ nargs="?",
1742
+ default="production",
1743
+ help="Deployment stage (default: production, only used for ACM)",
1744
+ )
1745
+
1746
+ # Certificate provider selection
1747
+ parser.add_argument(
1748
+ "--cert-provider",
1749
+ choices=["bunny", "acm"],
1750
+ default="bunny",
1751
+ help="Certificate provider: bunny (Bunny CDN) or acm (AWS ACM). Default: bunny",
1752
+ )
1753
+
1754
+ # Bunny CDN options
1755
+ parser.add_argument(
1756
+ "--pull-zone",
1757
+ help="Bunny CDN pull zone name (default: domain with dots replaced by hyphens)",
1758
+ )
1759
+ parser.add_argument(
1760
+ "--no-force-ssl",
1761
+ action="store_true",
1762
+ help="Don't enable Force SSL after certificate is issued (Bunny only)",
1763
+ )
1764
+ parser.add_argument(
1765
+ "--retry-count",
1766
+ type=int,
1767
+ default=5,
1768
+ help="Number of retry attempts for certificate request (default: 5)",
1769
+ )
1770
+ parser.add_argument(
1771
+ "--retry-delay",
1772
+ type=int,
1773
+ default=30,
1774
+ help="Delay between retries in seconds (default: 30)",
1775
+ )
1776
+ parser.add_argument(
1777
+ "--bunny-dns-provider",
1778
+ choices=["cloudflare", "hetzner", "desec", "bunny", "cloudns", "other"],
1779
+ help="DNS provider used with Bunny CDN (for apex domain handling)",
1780
+ )
1781
+ parser.add_argument(
1782
+ "--no-apex-redirect",
1783
+ action="store_true",
1784
+ help="Don't auto-create apex to www redirect edge rule for apex domains",
1785
+ )
1786
+
1787
+ # AWS ACM options
1788
+ parser.add_argument(
1789
+ "--aws-profile",
1790
+ help="AWS profile name (default: env AWS_PROFILE or 'pseekoo')",
1791
+ default=None,
1792
+ )
1793
+ parser.add_argument(
1794
+ "--aws-region",
1795
+ help="AWS region for ACM (default: us-east-1)",
1796
+ default="us-east-1",
1797
+ )
1798
+ parser.add_argument(
1799
+ "--dns-provider",
1800
+ choices=["cloudflare", "hetzner", "desec", "bunny", "cloudns"],
1801
+ default="cloudflare",
1802
+ help="DNS provider for ACM validation (default: cloudflare)",
1803
+ )
1804
+ parser.add_argument(
1805
+ "--create-zone",
1806
+ action="store_true",
1807
+ help="Create DNS zone if it does not exist (deSEC only)",
1808
+ )
1809
+ parser.add_argument(
1810
+ "--wildcard",
1811
+ action="store_true",
1812
+ help="Create wildcard certificate (ACM only)",
1813
+ )
1814
+ parser.add_argument(
1815
+ "--config-file",
1816
+ help="Path to config file to update (default: stages.yml)",
1817
+ default="stages.yml",
1818
+ )
1819
+ parser.add_argument(
1820
+ "--skip-config-update",
1821
+ action="store_true",
1822
+ help="Skip updating config file with certificate ARN (ACM only)",
1823
+ )
1824
+ parser.add_argument(
1825
+ "--validation-timeout",
1826
+ type=int,
1827
+ help="Certificate validation timeout in seconds (default: 1800)",
1828
+ default=1800,
1829
+ )
1830
+
1831
+ args = parser.parse_args()
1832
+
1833
+ try:
1834
+ if args.cert_provider == "bunny":
1835
+ # Bunny CDN certificate automation
1836
+ print("Using Bunny CDN certificate provider")
1837
+ if args.bunny_dns_provider:
1838
+ print(f"DNS provider: {args.bunny_dns_provider}")
1839
+
1840
+ success = True
1841
+ for hostname in args.domain:
1842
+ automation = BunnyCertificateAutomation(
1843
+ hostname=hostname,
1844
+ pull_zone_name=args.pull_zone,
1845
+ force_ssl=not args.no_force_ssl,
1846
+ retry_count=args.retry_count,
1847
+ retry_delay=args.retry_delay,
1848
+ dns_provider=args.bunny_dns_provider,
1849
+ setup_apex_redirect=not args.no_apex_redirect,
1850
+ )
1851
+
1852
+ if not automation.run():
1853
+ success = False
1854
+
1855
+ if success:
1856
+ print("\nAll certificates processed successfully!")
1857
+ sys.exit(0)
1858
+ else:
1859
+ print("\nSome certificates failed. Check output above.")
1860
+ sys.exit(1)
1861
+
1862
+ else:
1863
+ # AWS ACM certificate automation
1864
+ print("Using AWS ACM certificate provider")
1865
+ print(f"DNS provider: {args.dns_provider}")
1866
+
1867
+ dns_provider = get_dns_provider(args.dns_provider)
1868
+
1869
+ # ACM only supports single domain (or wildcard)
1870
+ domain = args.domain[0]
1871
+
1872
+ automation = CertificateAutomation(
1873
+ domain_name=domain,
1874
+ stage=args.stage,
1875
+ dns_provider=dns_provider,
1876
+ aws_profile=args.aws_profile,
1877
+ aws_region=args.aws_region,
1878
+ use_wildcard=args.wildcard,
1879
+ config_file=args.config_file,
1880
+ skip_config_update=args.skip_config_update,
1881
+ create_zone=args.create_zone,
1882
+ )
1883
+
1884
+ cert_arn = automation.run()
1885
+ print(f"\nSuccess! Certificate ARN: {cert_arn}")
1886
+ sys.exit(0)
1887
+
1888
+ except KeyboardInterrupt:
1889
+ print("\n\nOperation cancelled by user")
1890
+ sys.exit(130)
1891
+ except Exception as e:
1892
+ print(f"\nError: {e}", file=sys.stderr)
1893
+ import traceback
1894
+ traceback.print_exc()
1895
+ sys.exit(1)
1896
+
1897
+
1898
+ if __name__ == "__main__":
1899
+ main()