granny-devops 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- granny/__init__.py +19 -0
- granny/analyze/__init__.py +6 -0
- granny/analyze/lambdas.py +59 -0
- granny/analyze/vpcs.py +57 -0
- granny/cdn/__init__.py +9 -0
- granny/cdn/bunny.py +231 -0
- granny/cli/__init__.py +0 -0
- granny/cli/analyze.py +66 -0
- granny/cli/cdn.py +210 -0
- granny/cli/create.py +94 -0
- granny/cli/credentials.py +99 -0
- granny/cli/dns.py +290 -0
- granny/cli/docker.py +165 -0
- granny/cli/edge.py +106 -0
- granny/cli/email.py +224 -0
- granny/cli/main.py +98 -0
- granny/cli/serverless.py +278 -0
- granny/cli/storage.py +249 -0
- granny/create/__init__.py +4 -0
- granny/create/auto_certificate.py +1899 -0
- granny/create/cloudfront-security-headers.js +53 -0
- granny/create/manage-dns.sh +321 -0
- granny/create/manage_mailjet_contacts.py +619 -0
- granny/create/registrars.py +363 -0
- granny/create/setup_aws_cloudfront.py +2808 -0
- granny/create/setup_bunny_edge_script.py +923 -0
- granny/create/setup_bunny_storage.py +1719 -0
- granny/create/setup_cognito_identity_pool.py +740 -0
- granny/create/setup_hetzner_bunny.py +1482 -0
- granny/create/setup_mailjet_dns.py +1103 -0
- granny/create/setup_private_cdn.py +547 -0
- granny/create/setup_s3_website.py +1512 -0
- granny/create/setup_scaleway_faas.py +1165 -0
- granny/create/setup_workmail.py +1217 -0
- granny/create/www-redirect-function.js +17 -0
- granny/credentials/__init__.py +15 -0
- granny/credentials/secrets.py +403 -0
- granny/dns/__init__.py +22 -0
- granny/dns/base.py +113 -0
- granny/dns/bunny.py +150 -0
- granny/dns/cloudflare.py +192 -0
- granny/dns/cloudns.py +162 -0
- granny/dns/desec.py +152 -0
- granny/dns/factory.py +72 -0
- granny/dns/hetzner.py +165 -0
- granny/dns/manual.py +64 -0
- granny/dns/records.py +29 -0
- granny/docker/__init__.py +5 -0
- granny/docker/build_base.py +204 -0
- granny/edge/__init__.py +5 -0
- granny/edge/bunny.py +147 -0
- granny/email/__init__.py +7 -0
- granny/email/mailjet.py +119 -0
- granny/email/mailjet_contacts.py +115 -0
- granny/email/ses_forwarding.py +281 -0
- granny/email/workmail.py +145 -0
- granny/report.py +128 -0
- granny/serverless/__init__.py +5 -0
- granny/serverless/scaleway.py +264 -0
- granny/storage/__init__.py +7 -0
- granny/storage/aws.py +113 -0
- granny/storage/bunny.py +98 -0
- granny/storage/hetzner.py +118 -0
- granny_devops-0.4.0.dist-info/METADATA +445 -0
- granny_devops-0.4.0.dist-info/RECORD +68 -0
- granny_devops-0.4.0.dist-info/WHEEL +4 -0
- granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
- granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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()
|