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