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,1482 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hetzner S3 + Bunny CDN Website Setup Script
|
|
4
|
+
|
|
5
|
+
This script sets up a CDN for static content using Hetzner Object Storage (S3-compatible)
|
|
6
|
+
and Bunny CDN. Supports multiple DNS providers for domain configuration.
|
|
7
|
+
|
|
8
|
+
For AWS S3 + CloudFront setup, use: setup_aws_cloudfront.py
|
|
9
|
+
|
|
10
|
+
FEATURES:
|
|
11
|
+
---------
|
|
12
|
+
- Hetzner Object Storage (S3-compatible) for file storage
|
|
13
|
+
- Bunny CDN for global content delivery
|
|
14
|
+
- Free SSL certificates via Bunny CDN
|
|
15
|
+
- SPA (Single Page Application) support
|
|
16
|
+
- Multiple DNS providers: Cloudflare, Hetzner DNS, deSEC
|
|
17
|
+
- Private mode with Bunny CDN Token Authentication (signed URLs)
|
|
18
|
+
|
|
19
|
+
MODES:
|
|
20
|
+
------
|
|
21
|
+
1. Subdomain Mode (default):
|
|
22
|
+
- Setup CDN for subdomain.example.com
|
|
23
|
+
- Use: python setup_hetzner_bunny.py --domain example.com --subdomain cdn
|
|
24
|
+
|
|
25
|
+
2. Apex Mode (production):
|
|
26
|
+
- Setup CDN for example.com with optional www redirect
|
|
27
|
+
- Use: python setup_hetzner_bunny.py --domain example.com --apex --www-redirect
|
|
28
|
+
|
|
29
|
+
Environment Variables:
|
|
30
|
+
HETZNER_S3_ACCESS_KEY: Required - Hetzner S3 access key
|
|
31
|
+
HETZNER_S3_SECRET_KEY: Required - Hetzner S3 secret key
|
|
32
|
+
BUNNY_API_KEY: Required - Bunny.net API key
|
|
33
|
+
CLOUDFLARE_API_TOKEN: Required for Cloudflare DNS
|
|
34
|
+
HETZNER_DNS_API_TOKEN: Required for Hetzner DNS
|
|
35
|
+
DESEC_API_TOKEN: Required for deSEC DNS
|
|
36
|
+
|
|
37
|
+
Usage Examples:
|
|
38
|
+
# Subdomain: Basic static site (public)
|
|
39
|
+
python setup_hetzner_bunny.py --domain example.com --subdomain cdn
|
|
40
|
+
|
|
41
|
+
# Apex: Production website with www redirect
|
|
42
|
+
python setup_hetzner_bunny.py --domain example.com --apex --www-redirect --spa-mode
|
|
43
|
+
|
|
44
|
+
# Private storage with signed URLs (e.g., invoices, user files)
|
|
45
|
+
python setup_hetzner_bunny.py --domain m3rp.ai --subdomain cdn --private
|
|
46
|
+
|
|
47
|
+
# Token auth only (public bucket, signed CDN URLs)
|
|
48
|
+
python setup_hetzner_bunny.py --domain example.com --subdomain cdn --token-auth
|
|
49
|
+
|
|
50
|
+
# Specify Hetzner region
|
|
51
|
+
python setup_hetzner_bunny.py --domain example.com --subdomain cdn --region nbg1
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import boto3
|
|
55
|
+
import os
|
|
56
|
+
import json
|
|
57
|
+
import time
|
|
58
|
+
import logging
|
|
59
|
+
import argparse
|
|
60
|
+
import requests
|
|
61
|
+
from abc import ABC, abstractmethod
|
|
62
|
+
from typing import Optional, List
|
|
63
|
+
from botocore.config import Config
|
|
64
|
+
from dotenv import load_dotenv
|
|
65
|
+
load_dotenv()
|
|
66
|
+
try:
|
|
67
|
+
from granny.credentials import load_secrets_into_env
|
|
68
|
+
load_secrets_into_env()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# Optional imports for DNS providers
|
|
73
|
+
Cloudflare = None
|
|
74
|
+
try:
|
|
75
|
+
from cloudflare import Cloudflare
|
|
76
|
+
except ImportError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
HetznerDnsZone = None
|
|
80
|
+
HetznerDnsRecord = None
|
|
81
|
+
try:
|
|
82
|
+
from hetzner_dns_api import DnsZone as HetznerDnsZone, DnsRecord as HetznerDnsRecord
|
|
83
|
+
except ImportError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# Hetzner S3 Configuration
|
|
89
|
+
# =============================================================================
|
|
90
|
+
|
|
91
|
+
HETZNER_S3_REGIONS = {
|
|
92
|
+
'fsn1': 'fsn1.your-objectstorage.com',
|
|
93
|
+
'nbg1': 'nbg1.your-objectstorage.com',
|
|
94
|
+
'hel1': 'hel1.your-objectstorage.com',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
HETZNER_S3_REGION_NAMES = {
|
|
98
|
+
'fsn1': 'Falkenstein, Germany',
|
|
99
|
+
'nbg1': 'Nuremberg, Germany',
|
|
100
|
+
'hel1': 'Helsinki, Finland',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# =============================================================================
|
|
105
|
+
# Bunny CDN Configuration
|
|
106
|
+
# =============================================================================
|
|
107
|
+
|
|
108
|
+
BUNNY_API_BASE = "https://api.bunny.net"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# =============================================================================
|
|
112
|
+
# DNS Provider Abstraction Layer
|
|
113
|
+
# =============================================================================
|
|
114
|
+
|
|
115
|
+
class DNSProvider(ABC):
|
|
116
|
+
"""Abstract base class for DNS providers."""
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
120
|
+
"""Get zone ID by domain name."""
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
125
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
126
|
+
"""Create or update a CNAME record."""
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
@abstractmethod
|
|
130
|
+
def supports_apex_cname(self) -> bool:
|
|
131
|
+
"""Returns True if provider supports CNAME-like records at apex."""
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
136
|
+
cdn_domain: str) -> None:
|
|
137
|
+
"""Create DNS records for apex domain setup."""
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def supports_zone_creation(self) -> bool:
|
|
141
|
+
"""Returns True if provider supports creating new zones via API."""
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
def create_zone(self, zone_name: str) -> str:
|
|
145
|
+
"""Create a new DNS zone."""
|
|
146
|
+
raise NotImplementedError(
|
|
147
|
+
f"{self.__class__.__name__} does not support zone creation via API."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def get_or_create_zone(self, zone_name: str, auto_create: bool = False) -> str:
|
|
151
|
+
"""Get zone ID, optionally creating the zone if it doesn't exist."""
|
|
152
|
+
try:
|
|
153
|
+
return self.get_zone_id(zone_name)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
if not auto_create:
|
|
156
|
+
raise
|
|
157
|
+
if not self.supports_zone_creation():
|
|
158
|
+
raise Exception(
|
|
159
|
+
f"Zone '{zone_name}' not found and {self.__class__.__name__} "
|
|
160
|
+
f"does not support automatic zone creation."
|
|
161
|
+
) from e
|
|
162
|
+
logging.info(f"Zone '{zone_name}' not found. Creating new zone...")
|
|
163
|
+
return self.create_zone(zone_name)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class CloudflareDNSProvider(DNSProvider):
|
|
167
|
+
"""Cloudflare DNS API adapter."""
|
|
168
|
+
|
|
169
|
+
def __init__(self):
|
|
170
|
+
if Cloudflare is None:
|
|
171
|
+
raise ImportError(
|
|
172
|
+
"Cloudflare library not found. Install it: pip install cloudflare"
|
|
173
|
+
)
|
|
174
|
+
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
|
175
|
+
if not token:
|
|
176
|
+
raise ValueError("CLOUDFLARE_API_TOKEN environment variable not set.")
|
|
177
|
+
self.client = Cloudflare(api_token=token)
|
|
178
|
+
|
|
179
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
180
|
+
zones = self.client.zones.list(name=zone_name)
|
|
181
|
+
if not zones.result:
|
|
182
|
+
raise Exception(f"Zone not found in Cloudflare: {zone_name}")
|
|
183
|
+
return zones.result[0].id
|
|
184
|
+
|
|
185
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
186
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
187
|
+
full_domain_name = f"{name}.{zone_name}"
|
|
188
|
+
logging.info(f"Creating/updating CNAME: '{full_domain_name}' -> '{target}'...")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
existing_records = self.client.dns.records.list(zone_id=zone_id, name=full_domain_name)
|
|
192
|
+
if not existing_records.result:
|
|
193
|
+
existing_records = self.client.dns.records.list(zone_id=zone_id, name=name)
|
|
194
|
+
|
|
195
|
+
dns_record = {
|
|
196
|
+
'name': name,
|
|
197
|
+
'type': 'CNAME',
|
|
198
|
+
'content': target,
|
|
199
|
+
'proxied': False
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
cname_exists = False
|
|
203
|
+
records_to_delete = []
|
|
204
|
+
|
|
205
|
+
if existing_records.result:
|
|
206
|
+
for record in existing_records.result:
|
|
207
|
+
if record.type == 'CNAME':
|
|
208
|
+
cname_exists = True
|
|
209
|
+
if record.content != target or record.proxied:
|
|
210
|
+
self.client.dns.records.update(
|
|
211
|
+
zone_id=zone_id, dns_record_id=record.id, **dns_record
|
|
212
|
+
)
|
|
213
|
+
logging.info("CNAME record updated.")
|
|
214
|
+
else:
|
|
215
|
+
logging.info("CNAME record already correct.")
|
|
216
|
+
elif record.type in ['A', 'AAAA']:
|
|
217
|
+
records_to_delete.append((record.id, record.type))
|
|
218
|
+
|
|
219
|
+
for record_id, record_type in records_to_delete:
|
|
220
|
+
logging.info(f"Deleting conflicting {record_type} record")
|
|
221
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
|
222
|
+
|
|
223
|
+
if not cname_exists:
|
|
224
|
+
self.client.dns.records.create(zone_id=zone_id, **dns_record)
|
|
225
|
+
logging.info("CNAME record created.")
|
|
226
|
+
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logging.error(f"Error creating CNAME record: {e}")
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
def supports_apex_cname(self) -> bool:
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
235
|
+
cdn_domain: str) -> None:
|
|
236
|
+
logging.info("Configuring Cloudflare DNS for apex domain...")
|
|
237
|
+
|
|
238
|
+
def create_or_update_record(name: str, content: str, proxied: bool = False):
|
|
239
|
+
full_name = name if name == apex_domain else f"{name}.{apex_domain}" if '.' not in name else name
|
|
240
|
+
|
|
241
|
+
# Delete conflicting A/AAAA records
|
|
242
|
+
try:
|
|
243
|
+
existing = self.client.dns.records.list(zone_id=zone_id, name=full_name)
|
|
244
|
+
if existing.result:
|
|
245
|
+
for rec in existing.result:
|
|
246
|
+
if rec.type in ['A', 'AAAA']:
|
|
247
|
+
self.client.dns.records.delete(zone_id=zone_id, dns_record_id=rec.id)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logging.warning(f"Error deleting records: {e}")
|
|
250
|
+
|
|
251
|
+
existing = self.client.dns.records.list(zone_id=zone_id, name=full_name)
|
|
252
|
+
record_data = {'name': name, 'type': 'CNAME', 'content': content, 'proxied': proxied}
|
|
253
|
+
|
|
254
|
+
if existing.result:
|
|
255
|
+
for rec in existing.result:
|
|
256
|
+
if rec.type == 'CNAME':
|
|
257
|
+
if rec.content != content or rec.proxied != proxied:
|
|
258
|
+
self.client.dns.records.update(zone_id=zone_id, dns_record_id=rec.id, **record_data)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
self.client.dns.records.create(zone_id=zone_id, **record_data)
|
|
262
|
+
|
|
263
|
+
create_or_update_record(apex_domain, cdn_domain, proxied=False)
|
|
264
|
+
logging.info(f"Apex configured: {apex_domain} -> {cdn_domain}")
|
|
265
|
+
|
|
266
|
+
create_or_update_record('www', cdn_domain, proxied=False)
|
|
267
|
+
logging.info(f"WWW configured: {www_domain} -> {cdn_domain}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class HetznerDNSProvider(DNSProvider):
|
|
271
|
+
"""Hetzner DNS API adapter."""
|
|
272
|
+
|
|
273
|
+
def __init__(self):
|
|
274
|
+
if HetznerDnsZone is None or HetznerDnsRecord is None:
|
|
275
|
+
raise ImportError(
|
|
276
|
+
"hetzner-dns-api library not found. Install it: pip install hetzner-dns-api"
|
|
277
|
+
)
|
|
278
|
+
token = os.environ.get("HETZNER_DNS_API_TOKEN")
|
|
279
|
+
if not token:
|
|
280
|
+
raise ValueError("HETZNER_DNS_API_TOKEN environment variable not set.")
|
|
281
|
+
self.zone_api = HetznerDnsZone(token)
|
|
282
|
+
self.record_api = HetznerDnsRecord(token)
|
|
283
|
+
|
|
284
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
285
|
+
for zone in self.zone_api.all(name=zone_name):
|
|
286
|
+
if zone.name == zone_name:
|
|
287
|
+
return zone.id
|
|
288
|
+
raise Exception(f"Zone not found in Hetzner DNS: {zone_name}")
|
|
289
|
+
|
|
290
|
+
def _get_records_by_name(self, zone_id: str, name: str, record_type: Optional[str] = None) -> list:
|
|
291
|
+
matching = []
|
|
292
|
+
for record in self.record_api.all(zone_id=zone_id):
|
|
293
|
+
if record.name == name:
|
|
294
|
+
if record_type is None or record.type == record_type:
|
|
295
|
+
matching.append(record)
|
|
296
|
+
return matching
|
|
297
|
+
|
|
298
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
299
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
300
|
+
logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
|
|
301
|
+
target = target.rstrip('.')
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
existing_cname = self._get_records_by_name(zone_id, name, 'CNAME')
|
|
305
|
+
existing_a = self._get_records_by_name(zone_id, name, 'A')
|
|
306
|
+
existing_aaaa = self._get_records_by_name(zone_id, name, 'AAAA')
|
|
307
|
+
|
|
308
|
+
for record in existing_a + existing_aaaa:
|
|
309
|
+
logging.info(f"Deleting conflicting {record.type} record")
|
|
310
|
+
self.record_api.delete(record.id)
|
|
311
|
+
|
|
312
|
+
if existing_cname:
|
|
313
|
+
record = existing_cname[0]
|
|
314
|
+
if record.value != target:
|
|
315
|
+
self.record_api.update(
|
|
316
|
+
record_id=record.id, zone_id=zone_id,
|
|
317
|
+
name=name, record_type='CNAME', value=target, ttl=ttl
|
|
318
|
+
)
|
|
319
|
+
logging.info("CNAME record updated.")
|
|
320
|
+
else:
|
|
321
|
+
logging.info("CNAME record already correct.")
|
|
322
|
+
else:
|
|
323
|
+
self.record_api.create(
|
|
324
|
+
zone_id=zone_id, name=name,
|
|
325
|
+
record_type='CNAME', value=target, ttl=ttl
|
|
326
|
+
)
|
|
327
|
+
logging.info("CNAME record created.")
|
|
328
|
+
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logging.error(f"Error creating CNAME record: {e}")
|
|
331
|
+
raise
|
|
332
|
+
|
|
333
|
+
def supports_apex_cname(self) -> bool:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
337
|
+
cdn_domain: str) -> None:
|
|
338
|
+
logging.warning("=" * 60)
|
|
339
|
+
logging.warning(" HETZNER DNS APEX DOMAIN LIMITATION")
|
|
340
|
+
logging.warning("=" * 60)
|
|
341
|
+
logging.warning("Hetzner DNS does not support CNAME records at apex.")
|
|
342
|
+
logging.warning("Using Bunny CDN's Anycast IP for A record.")
|
|
343
|
+
logging.warning("=" * 60)
|
|
344
|
+
|
|
345
|
+
# Bunny CDN Anycast IP
|
|
346
|
+
bunny_anycast_ip = "185.206.224.1"
|
|
347
|
+
|
|
348
|
+
# Delete existing A/AAAA records at apex
|
|
349
|
+
existing_a = self._get_records_by_name(zone_id, '@', 'A')
|
|
350
|
+
existing_aaaa = self._get_records_by_name(zone_id, '@', 'AAAA')
|
|
351
|
+
for record in existing_a + existing_aaaa:
|
|
352
|
+
logging.info(f"Deleting existing {record.type} record at apex")
|
|
353
|
+
self.record_api.delete(record.id)
|
|
354
|
+
|
|
355
|
+
# Create A record for Bunny CDN
|
|
356
|
+
logging.info(f"Creating A record: @ -> {bunny_anycast_ip}")
|
|
357
|
+
self.record_api.create(
|
|
358
|
+
zone_id=zone_id, name='@',
|
|
359
|
+
record_type='A', value=bunny_anycast_ip, ttl=300
|
|
360
|
+
)
|
|
361
|
+
logging.info(f"Apex A record created: {apex_domain} -> {bunny_anycast_ip}")
|
|
362
|
+
|
|
363
|
+
# Create www CNAME
|
|
364
|
+
self.create_cname_record(zone_id, 'www', cdn_domain, apex_domain)
|
|
365
|
+
logging.info(f"WWW configured: {www_domain} -> {cdn_domain}")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class DeSECDNSProvider(DNSProvider):
|
|
369
|
+
"""deSEC DNS API adapter."""
|
|
370
|
+
|
|
371
|
+
API_BASE = "https://desec.io/api/v1"
|
|
372
|
+
MIN_TTL = 3600
|
|
373
|
+
|
|
374
|
+
def __init__(self):
|
|
375
|
+
token = os.environ.get("DESEC_API_TOKEN")
|
|
376
|
+
if not token:
|
|
377
|
+
raise ValueError("DESEC_API_TOKEN environment variable not set.")
|
|
378
|
+
self.token = token
|
|
379
|
+
self.session = requests.Session()
|
|
380
|
+
self.session.headers.update({
|
|
381
|
+
"Authorization": f"Token {token}",
|
|
382
|
+
"Content-Type": "application/json"
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
386
|
+
url = f"{self.API_BASE}{endpoint}"
|
|
387
|
+
try:
|
|
388
|
+
if method == "GET":
|
|
389
|
+
response = self.session.get(url)
|
|
390
|
+
elif method == "POST":
|
|
391
|
+
response = self.session.post(url, json=data)
|
|
392
|
+
elif method == "PUT":
|
|
393
|
+
response = self.session.put(url, json=data)
|
|
394
|
+
elif method == "PATCH":
|
|
395
|
+
response = self.session.patch(url, json=data)
|
|
396
|
+
elif method == "DELETE":
|
|
397
|
+
response = self.session.delete(url)
|
|
398
|
+
else:
|
|
399
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
400
|
+
|
|
401
|
+
if response.status_code == 429:
|
|
402
|
+
retry_after = int(response.headers.get("Retry-After", 5))
|
|
403
|
+
logging.warning(f"Rate limited. Waiting {retry_after}s...")
|
|
404
|
+
time.sleep(retry_after)
|
|
405
|
+
return self._api_request(method, endpoint, data)
|
|
406
|
+
|
|
407
|
+
if response.status_code >= 400:
|
|
408
|
+
raise Exception(f"API error: {response.status_code} - {response.text}")
|
|
409
|
+
|
|
410
|
+
if response.status_code == 204:
|
|
411
|
+
return {}
|
|
412
|
+
return response.json() if response.text else {}
|
|
413
|
+
|
|
414
|
+
except requests.RequestException as e:
|
|
415
|
+
raise Exception(f"Request failed: {e}")
|
|
416
|
+
|
|
417
|
+
def get_zone_id(self, zone_name: str) -> str:
|
|
418
|
+
try:
|
|
419
|
+
self._api_request("GET", f"/domains/{zone_name}/")
|
|
420
|
+
return zone_name
|
|
421
|
+
except Exception:
|
|
422
|
+
raise Exception(f"Zone not found in deSEC: {zone_name}")
|
|
423
|
+
|
|
424
|
+
def _create_or_update_rrset(self, domain: str, subname: str, record_type: str,
|
|
425
|
+
records: list, ttl: int = 3600) -> None:
|
|
426
|
+
ttl = max(ttl, self.MIN_TTL)
|
|
427
|
+
endpoint = f"/domains/{domain}/rrsets/"
|
|
428
|
+
rrset_url = f"{endpoint}{subname}.../{record_type}/"
|
|
429
|
+
|
|
430
|
+
# Check if record exists
|
|
431
|
+
try:
|
|
432
|
+
self._api_request("GET", rrset_url)
|
|
433
|
+
# Record exists, update it
|
|
434
|
+
self._api_request("PATCH", rrset_url, {
|
|
435
|
+
"records": records, "ttl": ttl
|
|
436
|
+
})
|
|
437
|
+
logging.info(f"Updated {record_type} record for {subname or '@'}.{domain}")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
if "404" in str(e):
|
|
440
|
+
# Record doesn't exist, create it
|
|
441
|
+
self._api_request("POST", endpoint, {
|
|
442
|
+
"subname": subname, "type": record_type,
|
|
443
|
+
"records": records, "ttl": ttl
|
|
444
|
+
})
|
|
445
|
+
logging.info(f"Created {record_type} record for {subname or '@'}.{domain}")
|
|
446
|
+
else:
|
|
447
|
+
raise
|
|
448
|
+
|
|
449
|
+
def _delete_rrset(self, domain: str, subname: str, record_type: str) -> None:
|
|
450
|
+
try:
|
|
451
|
+
self._api_request("DELETE", f"/domains/{domain}/rrsets/{subname}.../{record_type}/")
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
def create_cname_record(self, zone_id: str, name: str, target: str,
|
|
456
|
+
zone_name: str, ttl: int = 300) -> None:
|
|
457
|
+
logging.info(f"Creating/updating CNAME: '{name}.{zone_name}' -> '{target}'...")
|
|
458
|
+
target_with_dot = target if target.endswith('.') else f"{target}."
|
|
459
|
+
|
|
460
|
+
self._delete_rrset(zone_id, name, 'A')
|
|
461
|
+
self._delete_rrset(zone_id, name, 'AAAA')
|
|
462
|
+
self._create_or_update_rrset(zone_id, name, 'CNAME', [target_with_dot], ttl)
|
|
463
|
+
logging.info("CNAME record created/updated.")
|
|
464
|
+
|
|
465
|
+
def supports_apex_cname(self) -> bool:
|
|
466
|
+
return False # deSEC can't have CNAME at apex (conflicts with NS)
|
|
467
|
+
|
|
468
|
+
def supports_zone_creation(self) -> bool:
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
def create_zone(self, zone_name: str) -> str:
|
|
472
|
+
logging.info(f"Creating deSEC zone for '{zone_name}'...")
|
|
473
|
+
self._api_request("POST", "/domains/", {"name": zone_name})
|
|
474
|
+
logging.info("Zone created. Configure nameservers: ns1.desec.io, ns2.desec.org")
|
|
475
|
+
return zone_name
|
|
476
|
+
|
|
477
|
+
def create_apex_records(self, zone_id: str, apex_domain: str, www_domain: str,
|
|
478
|
+
cdn_domain: str) -> None:
|
|
479
|
+
logging.info("Configuring deSEC DNS for apex domain...")
|
|
480
|
+
|
|
481
|
+
# deSEC can't have CNAME at apex (conflicts with NS records)
|
|
482
|
+
# Use Bunny CDN's anycast IP for A record instead
|
|
483
|
+
bunny_anycast_ip = "185.206.224.1"
|
|
484
|
+
|
|
485
|
+
logging.info(f"Using Bunny CDN anycast IP: {bunny_anycast_ip}")
|
|
486
|
+
|
|
487
|
+
self._delete_rrset(apex_domain, '', 'CNAME')
|
|
488
|
+
self._delete_rrset(apex_domain, '', 'AAAA')
|
|
489
|
+
|
|
490
|
+
# Create A record pointing to Bunny CDN
|
|
491
|
+
self._create_or_update_rrset(apex_domain, '', 'A', [bunny_anycast_ip], 300)
|
|
492
|
+
logging.info(f"Apex A record created: {apex_domain} -> {bunny_anycast_ip}")
|
|
493
|
+
|
|
494
|
+
# WWW as CNAME to CDN hostname
|
|
495
|
+
self.create_cname_record(apex_domain, 'www', cdn_domain, apex_domain)
|
|
496
|
+
logging.info(f"WWW configured: {www_domain} -> {cdn_domain}")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def get_dns_provider(provider_name: str) -> DNSProvider:
|
|
500
|
+
"""Factory function to get the appropriate DNS provider."""
|
|
501
|
+
providers = {
|
|
502
|
+
'cloudflare': CloudflareDNSProvider,
|
|
503
|
+
'hetzner': HetznerDNSProvider,
|
|
504
|
+
'desec': DeSECDNSProvider,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if provider_name not in providers:
|
|
508
|
+
raise ValueError(f"Unknown DNS provider: {provider_name}")
|
|
509
|
+
|
|
510
|
+
return providers[provider_name]()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# =============================================================================
|
|
514
|
+
# Hetzner S3 Client
|
|
515
|
+
# =============================================================================
|
|
516
|
+
|
|
517
|
+
class HetznerS3Client:
|
|
518
|
+
"""Client for Hetzner Object Storage (S3-compatible)."""
|
|
519
|
+
|
|
520
|
+
def __init__(self, region: str = 'fsn1'):
|
|
521
|
+
self.region = region
|
|
522
|
+
self.endpoint = f"https://{HETZNER_S3_REGIONS[region]}"
|
|
523
|
+
|
|
524
|
+
access_key = os.environ.get("HETZNER_S3_ACCESS_KEY")
|
|
525
|
+
secret_key = os.environ.get("HETZNER_S3_SECRET_KEY")
|
|
526
|
+
|
|
527
|
+
if not access_key or not secret_key:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
"HETZNER_S3_ACCESS_KEY and HETZNER_S3_SECRET_KEY environment variables required."
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
self.client = boto3.client(
|
|
533
|
+
's3',
|
|
534
|
+
endpoint_url=self.endpoint,
|
|
535
|
+
aws_access_key_id=access_key,
|
|
536
|
+
aws_secret_access_key=secret_key,
|
|
537
|
+
region_name=region,
|
|
538
|
+
config=Config(signature_version='s3v4')
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def bucket_exists(self, bucket_name: str) -> bool:
|
|
542
|
+
"""Check if bucket exists."""
|
|
543
|
+
try:
|
|
544
|
+
self.client.head_bucket(Bucket=bucket_name)
|
|
545
|
+
return True
|
|
546
|
+
except Exception:
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
def create_bucket(self, bucket_name: str) -> None:
|
|
550
|
+
"""Create a bucket if it doesn't exist."""
|
|
551
|
+
if self.bucket_exists(bucket_name):
|
|
552
|
+
logging.info(f"Bucket '{bucket_name}' already exists.")
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
logging.info(f"Creating bucket '{bucket_name}' in {self.region}...")
|
|
556
|
+
# Hetzner S3 uses the endpoint URL for region, no LocationConstraint needed
|
|
557
|
+
self.client.create_bucket(Bucket=bucket_name)
|
|
558
|
+
logging.info(f"Bucket '{bucket_name}' created.")
|
|
559
|
+
# Wait for bucket to be fully available (Hetzner S3 eventual consistency)
|
|
560
|
+
logging.info("Waiting for bucket to be available...")
|
|
561
|
+
time.sleep(3)
|
|
562
|
+
|
|
563
|
+
def set_bucket_policy_public(self, bucket_name: str) -> None:
|
|
564
|
+
"""Set bucket policy for public read access."""
|
|
565
|
+
logging.info(f"Setting public read policy on '{bucket_name}'...")
|
|
566
|
+
|
|
567
|
+
policy = {
|
|
568
|
+
"Version": "2012-10-17",
|
|
569
|
+
"Statement": [
|
|
570
|
+
{
|
|
571
|
+
"Sid": "PublicReadGetObject",
|
|
572
|
+
"Effect": "Allow",
|
|
573
|
+
"Principal": "*",
|
|
574
|
+
"Action": "s3:GetObject",
|
|
575
|
+
"Resource": f"arn:aws:s3:::{bucket_name}/*"
|
|
576
|
+
}
|
|
577
|
+
]
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
self.client.put_bucket_policy(
|
|
581
|
+
Bucket=bucket_name,
|
|
582
|
+
Policy=json.dumps(policy)
|
|
583
|
+
)
|
|
584
|
+
logging.info("Bucket policy set.")
|
|
585
|
+
|
|
586
|
+
def set_cors_config(self, bucket_name: str) -> None:
|
|
587
|
+
"""Set CORS configuration for web access."""
|
|
588
|
+
logging.info(f"Setting CORS configuration on '{bucket_name}'...")
|
|
589
|
+
|
|
590
|
+
cors_config = {
|
|
591
|
+
'CORSRules': [
|
|
592
|
+
{
|
|
593
|
+
'AllowedHeaders': ['*'],
|
|
594
|
+
'AllowedMethods': ['GET', 'HEAD'],
|
|
595
|
+
'AllowedOrigins': ['*'],
|
|
596
|
+
'MaxAgeSeconds': 3600
|
|
597
|
+
}
|
|
598
|
+
]
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
self.client.put_bucket_cors(
|
|
602
|
+
Bucket=bucket_name,
|
|
603
|
+
CORSConfiguration=cors_config
|
|
604
|
+
)
|
|
605
|
+
logging.info("CORS configuration set.")
|
|
606
|
+
|
|
607
|
+
def upload_file(self, bucket_name: str, key: str, content: str,
|
|
608
|
+
content_type: str = 'text/html', cache_control: str = 'max-age=300') -> None:
|
|
609
|
+
"""Upload a file to the bucket."""
|
|
610
|
+
self.client.put_object(
|
|
611
|
+
Bucket=bucket_name,
|
|
612
|
+
Key=key,
|
|
613
|
+
Body=content.encode('utf-8'),
|
|
614
|
+
ContentType=content_type,
|
|
615
|
+
CacheControl=cache_control
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def get_bucket_url(self, bucket_name: str) -> str:
|
|
619
|
+
"""Get the public URL for the bucket."""
|
|
620
|
+
return f"https://{bucket_name}.{HETZNER_S3_REGIONS[self.region]}"
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# =============================================================================
|
|
624
|
+
# Bunny CDN Client
|
|
625
|
+
# =============================================================================
|
|
626
|
+
|
|
627
|
+
class BunnyCDNClient:
|
|
628
|
+
"""Client for Bunny CDN API."""
|
|
629
|
+
|
|
630
|
+
def __init__(self):
|
|
631
|
+
self.api_key = os.environ.get("BUNNY_API_KEY")
|
|
632
|
+
if not self.api_key:
|
|
633
|
+
raise ValueError("BUNNY_API_KEY environment variable not set.")
|
|
634
|
+
|
|
635
|
+
self.session = requests.Session()
|
|
636
|
+
self.session.headers.update({
|
|
637
|
+
"AccessKey": self.api_key,
|
|
638
|
+
"Content-Type": "application/json"
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
def _api_request(self, method: str, endpoint: str, data: dict = None) -> dict:
|
|
642
|
+
"""Make an API request to Bunny CDN."""
|
|
643
|
+
url = f"{BUNNY_API_BASE}{endpoint}"
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
if method == "GET":
|
|
647
|
+
response = self.session.get(url)
|
|
648
|
+
elif method == "POST":
|
|
649
|
+
response = self.session.post(url, json=data)
|
|
650
|
+
elif method == "DELETE":
|
|
651
|
+
response = self.session.delete(url)
|
|
652
|
+
else:
|
|
653
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
654
|
+
|
|
655
|
+
if response.status_code >= 400:
|
|
656
|
+
raise Exception(f"Bunny API error: {response.status_code} - {response.text}")
|
|
657
|
+
|
|
658
|
+
if response.status_code == 204:
|
|
659
|
+
return {}
|
|
660
|
+
|
|
661
|
+
return response.json() if response.text else {}
|
|
662
|
+
|
|
663
|
+
except requests.RequestException as e:
|
|
664
|
+
raise Exception(f"Bunny API request failed: {e}")
|
|
665
|
+
|
|
666
|
+
def list_pull_zones(self) -> List[dict]:
|
|
667
|
+
"""List all pull zones."""
|
|
668
|
+
return self._api_request("GET", "/pullzone")
|
|
669
|
+
|
|
670
|
+
def find_pull_zone_by_name(self, name: str) -> Optional[dict]:
|
|
671
|
+
"""Find a pull zone by name."""
|
|
672
|
+
zones = self.list_pull_zones()
|
|
673
|
+
for zone in zones:
|
|
674
|
+
if zone.get('Name') == name:
|
|
675
|
+
return zone
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
def find_pull_zone_by_hostname(self, hostname: str) -> Optional[dict]:
|
|
679
|
+
"""Find a pull zone that has a specific hostname registered."""
|
|
680
|
+
zones = self.list_pull_zones()
|
|
681
|
+
for zone in zones:
|
|
682
|
+
for h in zone.get('Hostnames', []):
|
|
683
|
+
if h.get('Value') == hostname:
|
|
684
|
+
return zone
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
def get_pull_zone_details(self, pull_zone_id: int) -> dict:
|
|
688
|
+
"""Get detailed information about a pull zone."""
|
|
689
|
+
return self._api_request("GET", f"/pullzone/{pull_zone_id}")
|
|
690
|
+
|
|
691
|
+
def create_pull_zone(self, name: str, origin_url: str,
|
|
692
|
+
enable_geo_zones: bool = True) -> dict:
|
|
693
|
+
"""Create a new pull zone."""
|
|
694
|
+
logging.info(f"Creating Bunny CDN pull zone: {name}")
|
|
695
|
+
logging.info(f"Origin URL: {origin_url}")
|
|
696
|
+
|
|
697
|
+
data = {
|
|
698
|
+
"Name": name,
|
|
699
|
+
"OriginUrl": origin_url,
|
|
700
|
+
"Type": 0, # Standard pull zone
|
|
701
|
+
"EnableGeoZoneUS": enable_geo_zones,
|
|
702
|
+
"EnableGeoZoneEU": enable_geo_zones,
|
|
703
|
+
"EnableGeoZoneASIA": enable_geo_zones,
|
|
704
|
+
"EnableGeoZoneSA": enable_geo_zones,
|
|
705
|
+
"EnableGeoZoneAF": enable_geo_zones,
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
result = self._api_request("POST", "/pullzone", data)
|
|
709
|
+
logging.info(f"Pull zone created. ID: {result.get('Id')}")
|
|
710
|
+
return result
|
|
711
|
+
|
|
712
|
+
def add_hostname(self, pull_zone_id: int, hostname: str) -> dict:
|
|
713
|
+
"""Add a custom hostname to a pull zone.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
dict with 'added' (bool) and 'message' (str)
|
|
717
|
+
"""
|
|
718
|
+
logging.info(f"Adding hostname '{hostname}' to pull zone {pull_zone_id}")
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
self._api_request("POST", f"/pullzone/{pull_zone_id}/addHostname", {
|
|
722
|
+
"Hostname": hostname
|
|
723
|
+
})
|
|
724
|
+
logging.info(f"Hostname '{hostname}' added.")
|
|
725
|
+
return {"added": True, "message": "Hostname added successfully"}
|
|
726
|
+
except Exception as e:
|
|
727
|
+
error_str = str(e)
|
|
728
|
+
if "hostname_already_registered" in error_str:
|
|
729
|
+
logging.info(f"Hostname '{hostname}' is already registered.")
|
|
730
|
+
return {"added": False, "message": "Hostname already registered"}
|
|
731
|
+
raise
|
|
732
|
+
|
|
733
|
+
def request_ssl_certificate(self, pull_zone_id: int, hostname: str) -> bool:
|
|
734
|
+
"""Request a free SSL certificate for a hostname."""
|
|
735
|
+
logging.info(f"Requesting SSL certificate for '{hostname}'...")
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
url = f"{BUNNY_API_BASE}/pullzone/{pull_zone_id}/loadFreeCertificate?hostname={hostname}"
|
|
739
|
+
response = self.session.get(url)
|
|
740
|
+
|
|
741
|
+
if response.status_code in [200, 204]:
|
|
742
|
+
logging.info("SSL certificate requested successfully.")
|
|
743
|
+
return True
|
|
744
|
+
else:
|
|
745
|
+
logging.warning(f"SSL certificate request returned: {response.status_code}")
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
except Exception as e:
|
|
749
|
+
logging.warning(f"SSL certificate request failed: {e}")
|
|
750
|
+
return False
|
|
751
|
+
|
|
752
|
+
def configure_error_pages(self, pull_zone_id: int, spa_mode: bool = False) -> None:
|
|
753
|
+
"""Configure error page handling for SPA support."""
|
|
754
|
+
if not spa_mode:
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
logging.info("Configuring SPA error handling...")
|
|
758
|
+
|
|
759
|
+
# Enable custom error page that redirects to index
|
|
760
|
+
data = {
|
|
761
|
+
"ErrorPageEnableCustomCode": True,
|
|
762
|
+
"ErrorPageCustomCode": '<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=/"></head><body></body></html>',
|
|
763
|
+
"ErrorPageWhitelabel": True
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
self._api_request("POST", f"/pullzone/{pull_zone_id}", data)
|
|
767
|
+
logging.info("SPA error handling configured.")
|
|
768
|
+
|
|
769
|
+
def enable_token_authentication(self, pull_zone_id: int, token_key: str = None) -> dict:
|
|
770
|
+
"""Enable Bunny CDN Token Authentication on a pull zone.
|
|
771
|
+
|
|
772
|
+
When enabled, all requests must include a valid ``token`` query
|
|
773
|
+
parameter (SHA256 HMAC signature) and an ``expires`` timestamp.
|
|
774
|
+
This makes the pull zone **private** — no public access without
|
|
775
|
+
a signed URL.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
pull_zone_id: The pull zone ID.
|
|
779
|
+
token_key: Custom security token key. If None, Bunny generates
|
|
780
|
+
one automatically and returns it in the response.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
dict with pull zone details including ``ZoneSecurityKey``.
|
|
784
|
+
"""
|
|
785
|
+
logging.info(f"Enabling token authentication on pull zone {pull_zone_id}...")
|
|
786
|
+
|
|
787
|
+
data = {
|
|
788
|
+
"ZoneSecurityEnabled": True,
|
|
789
|
+
"ZoneSecurityIncludeHashRemoteIP": False, # Don't lock to IP (users on mobile etc)
|
|
790
|
+
}
|
|
791
|
+
if token_key:
|
|
792
|
+
data["ZoneSecurityKey"] = token_key
|
|
793
|
+
|
|
794
|
+
self._api_request("POST", f"/pullzone/{pull_zone_id}", data)
|
|
795
|
+
logging.info("Token authentication enabled.")
|
|
796
|
+
|
|
797
|
+
# Fetch updated details to get the security key
|
|
798
|
+
details = self.get_pull_zone_details(pull_zone_id)
|
|
799
|
+
security_key = details.get("ZoneSecurityKey", "")
|
|
800
|
+
if security_key:
|
|
801
|
+
logging.info(f"Token key (ZoneSecurityKey): {security_key[:8]}...{security_key[-4:]}")
|
|
802
|
+
return details
|
|
803
|
+
|
|
804
|
+
def disable_direct_origin_access(self, pull_zone_id: int) -> None:
|
|
805
|
+
"""Block direct access to the S3 origin, forcing all requests through CDN.
|
|
806
|
+
|
|
807
|
+
Adds an origin header so only Bunny CDN can read from the S3 bucket.
|
|
808
|
+
"""
|
|
809
|
+
logging.info(f"Configuring origin shield on pull zone {pull_zone_id}...")
|
|
810
|
+
data = {
|
|
811
|
+
"EnableOriginShield": True,
|
|
812
|
+
"OriginShieldZoneCode": "DE", # Frankfurt shield
|
|
813
|
+
}
|
|
814
|
+
self._api_request("POST", f"/pullzone/{pull_zone_id}", data)
|
|
815
|
+
logging.info("Origin shield enabled.")
|
|
816
|
+
|
|
817
|
+
def purge_cache(self, pull_zone_id: int) -> None:
|
|
818
|
+
"""Purge the entire cache for a pull zone."""
|
|
819
|
+
logging.info(f"Purging cache for pull zone {pull_zone_id}...")
|
|
820
|
+
|
|
821
|
+
self._api_request("POST", f"/pullzone/{pull_zone_id}/purgeCache")
|
|
822
|
+
logging.info("Cache purged.")
|
|
823
|
+
|
|
824
|
+
def get_pull_zone_hostname(self, pull_zone: dict) -> str:
|
|
825
|
+
"""Get the default hostname for a pull zone."""
|
|
826
|
+
hostnames = pull_zone.get('Hostnames', [])
|
|
827
|
+
for hostname in hostnames:
|
|
828
|
+
if hostname.get('Value', '').endswith('.b-cdn.net'):
|
|
829
|
+
return hostname['Value']
|
|
830
|
+
return f"{pull_zone['Name']}.b-cdn.net"
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
# =============================================================================
|
|
834
|
+
# Setup Functions
|
|
835
|
+
# =============================================================================
|
|
836
|
+
|
|
837
|
+
def create_test_content(s3_client: HetznerS3Client, bucket_name: str,
|
|
838
|
+
region: str, spa_mode: bool) -> None:
|
|
839
|
+
"""Create test content in the S3 bucket."""
|
|
840
|
+
logging.info("Creating test content...")
|
|
841
|
+
|
|
842
|
+
bucket_url = s3_client.get_bucket_url(bucket_name)
|
|
843
|
+
|
|
844
|
+
test_html = f"""<!DOCTYPE html>
|
|
845
|
+
<html>
|
|
846
|
+
<head>
|
|
847
|
+
<meta charset="UTF-8">
|
|
848
|
+
<title>CDN Test Page</title>
|
|
849
|
+
<style>
|
|
850
|
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
|
851
|
+
.container {{ max-width: 600px; margin: 0 auto; text-align: center; }}
|
|
852
|
+
.success {{ color: #28a745; }}
|
|
853
|
+
.info {{ background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }}
|
|
854
|
+
</style>
|
|
855
|
+
</head>
|
|
856
|
+
<body>
|
|
857
|
+
<div class="container">
|
|
858
|
+
<h1 class="success">🎉 CDN Setup Successful!</h1>
|
|
859
|
+
<p>Your Hetzner S3 + Bunny CDN is working correctly.</p>
|
|
860
|
+
<div class="info">
|
|
861
|
+
<h3>Configuration</h3>
|
|
862
|
+
<p><strong>Storage:</strong> Hetzner Object Storage ({HETZNER_S3_REGION_NAMES.get(region, region)})</p>
|
|
863
|
+
<p><strong>CDN:</strong> Bunny CDN</p>
|
|
864
|
+
<p><strong>Bucket:</strong> {bucket_name}</p>
|
|
865
|
+
<p><strong>Origin:</strong> {bucket_url}</p>
|
|
866
|
+
<p><strong>SPA Mode:</strong> {'Enabled' if spa_mode else 'Disabled'}</p>
|
|
867
|
+
</div>
|
|
868
|
+
<p><small>Generated by setup_hetzner_bunny.py</small></p>
|
|
869
|
+
</div>
|
|
870
|
+
</body>
|
|
871
|
+
</html>"""
|
|
872
|
+
|
|
873
|
+
error_html = """<!DOCTYPE html>
|
|
874
|
+
<html>
|
|
875
|
+
<head>
|
|
876
|
+
<meta charset="UTF-8">
|
|
877
|
+
<title>404 - Page Not Found</title>
|
|
878
|
+
<style>
|
|
879
|
+
body { font-family: Arial, sans-serif; margin: 40px; text-align: center; }
|
|
880
|
+
.error { color: #dc3545; }
|
|
881
|
+
</style>
|
|
882
|
+
</head>
|
|
883
|
+
<body>
|
|
884
|
+
<h1 class="error">404 - Page Not Found</h1>
|
|
885
|
+
<p>The page you're looking for doesn't exist.</p>
|
|
886
|
+
<p><a href="/">Back to Home</a></p>
|
|
887
|
+
</body>
|
|
888
|
+
</html>"""
|
|
889
|
+
|
|
890
|
+
# Upload test files
|
|
891
|
+
s3_client.upload_file(bucket_name, 'index.html', test_html)
|
|
892
|
+
logging.info("index.html uploaded.")
|
|
893
|
+
|
|
894
|
+
s3_client.upload_file(bucket_name, 'index-test.html', test_html)
|
|
895
|
+
logging.info("index-test.html uploaded.")
|
|
896
|
+
|
|
897
|
+
s3_client.upload_file(bucket_name, 'error.html', error_html)
|
|
898
|
+
logging.info("error.html uploaded.")
|
|
899
|
+
|
|
900
|
+
test_txt = f"CDN test - {bucket_name} - {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}"
|
|
901
|
+
s3_client.upload_file(bucket_name, 'test.txt', test_txt, content_type='text/plain')
|
|
902
|
+
logging.info("test.txt uploaded.")
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def test_cdn_functionality(url: str, timeout: int = 60) -> bool:
|
|
906
|
+
"""Test if the CDN is working correctly."""
|
|
907
|
+
logging.info(f"Testing CDN at: {url}")
|
|
908
|
+
|
|
909
|
+
test_url = f"{url}/test.txt"
|
|
910
|
+
start_time = time.time()
|
|
911
|
+
|
|
912
|
+
while time.time() - start_time < timeout:
|
|
913
|
+
try:
|
|
914
|
+
response = requests.get(test_url, timeout=10)
|
|
915
|
+
if response.status_code == 200:
|
|
916
|
+
logging.info(f"CDN test passed! Status: {response.status_code}")
|
|
917
|
+
return True
|
|
918
|
+
else:
|
|
919
|
+
logging.info(f"Got status {response.status_code}, waiting...")
|
|
920
|
+
except requests.RequestException as e:
|
|
921
|
+
logging.info(f"Request failed: {e}, retrying...")
|
|
922
|
+
|
|
923
|
+
time.sleep(5)
|
|
924
|
+
|
|
925
|
+
logging.warning("CDN test timed out.")
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
# =============================================================================
|
|
930
|
+
# Setup Report Generation
|
|
931
|
+
# =============================================================================
|
|
932
|
+
|
|
933
|
+
class SetupReport:
|
|
934
|
+
"""Track and report CDN setup status."""
|
|
935
|
+
|
|
936
|
+
def __init__(self, domain: str, mode: str, dns_provider: str, region: str):
|
|
937
|
+
self.timestamp = time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())
|
|
938
|
+
self.domain = domain
|
|
939
|
+
self.mode = mode # 'apex' or 'subdomain'
|
|
940
|
+
self.dns_provider = dns_provider
|
|
941
|
+
self.region = region
|
|
942
|
+
self.region_name = HETZNER_S3_REGION_NAMES.get(region, region)
|
|
943
|
+
|
|
944
|
+
# Component status tracking
|
|
945
|
+
self.components = {
|
|
946
|
+
's3_bucket': {'status': 'pending', 'details': {}},
|
|
947
|
+
'pull_zone': {'status': 'pending', 'details': {}},
|
|
948
|
+
'hostnames': {'status': 'pending', 'details': {}},
|
|
949
|
+
'dns_records': {'status': 'pending', 'details': {}},
|
|
950
|
+
'ssl_certificates': {'status': 'pending', 'details': {}},
|
|
951
|
+
'spa_config': {'status': 'pending', 'details': {}},
|
|
952
|
+
'test_content': {'status': 'pending', 'details': {}},
|
|
953
|
+
'cdn_test': {'status': 'pending', 'details': {}},
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
# URLs and endpoints
|
|
957
|
+
self.urls = {}
|
|
958
|
+
|
|
959
|
+
def set_component(self, name: str, status: str, **details):
|
|
960
|
+
"""Update component status."""
|
|
961
|
+
if name in self.components:
|
|
962
|
+
self.components[name]['status'] = status
|
|
963
|
+
self.components[name]['details'].update(details)
|
|
964
|
+
|
|
965
|
+
def set_url(self, name: str, url: str):
|
|
966
|
+
"""Set a URL endpoint."""
|
|
967
|
+
self.urls[name] = url
|
|
968
|
+
|
|
969
|
+
def generate_markdown(self) -> str:
|
|
970
|
+
"""Generate markdown report content."""
|
|
971
|
+
lines = []
|
|
972
|
+
lines.append(f"# CDN Setup Report: {self.domain}")
|
|
973
|
+
lines.append("")
|
|
974
|
+
lines.append(f"**Generated:** {self.timestamp}")
|
|
975
|
+
lines.append("**Script:** setup_hetzner_bunny.py")
|
|
976
|
+
lines.append("")
|
|
977
|
+
lines.append("---")
|
|
978
|
+
lines.append("")
|
|
979
|
+
|
|
980
|
+
# Configuration Summary
|
|
981
|
+
lines.append("## Configuration Summary")
|
|
982
|
+
lines.append("")
|
|
983
|
+
lines.append("| Setting | Value |")
|
|
984
|
+
lines.append("|---------|-------|")
|
|
985
|
+
lines.append(f"| Domain | `{self.domain}` |")
|
|
986
|
+
lines.append(f"| Mode | {self.mode.title()} |")
|
|
987
|
+
lines.append(f"| DNS Provider | {self.dns_provider.title()} |")
|
|
988
|
+
lines.append(f"| Storage Region | {self.region} ({self.region_name}) |")
|
|
989
|
+
lines.append("")
|
|
990
|
+
|
|
991
|
+
# Component Status
|
|
992
|
+
lines.append("## Component Status")
|
|
993
|
+
lines.append("")
|
|
994
|
+
|
|
995
|
+
status_icons = {
|
|
996
|
+
'created': '[NEW]',
|
|
997
|
+
'exists': '[OK]',
|
|
998
|
+
'configured': '[OK]',
|
|
999
|
+
'requested': '[OK]',
|
|
1000
|
+
'uploaded': '[OK]',
|
|
1001
|
+
'passed': '[OK]',
|
|
1002
|
+
'skipped': '[SKIP]',
|
|
1003
|
+
'failed': '[FAIL]',
|
|
1004
|
+
'pending': '[ ]',
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
component_names = {
|
|
1008
|
+
's3_bucket': 'S3 Bucket',
|
|
1009
|
+
'pull_zone': 'Bunny CDN Pull Zone',
|
|
1010
|
+
'hostnames': 'Custom Hostnames',
|
|
1011
|
+
'dns_records': 'DNS Records',
|
|
1012
|
+
'ssl_certificates': 'SSL Certificates',
|
|
1013
|
+
'spa_config': 'SPA Configuration',
|
|
1014
|
+
'test_content': 'Test Content',
|
|
1015
|
+
'cdn_test': 'CDN Functionality Test',
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
for comp_key, comp_name in component_names.items():
|
|
1019
|
+
comp = self.components[comp_key]
|
|
1020
|
+
status = comp['status']
|
|
1021
|
+
icon = status_icons.get(status, '[ ]')
|
|
1022
|
+
details = comp['details']
|
|
1023
|
+
|
|
1024
|
+
lines.append(f"### {icon} {comp_name}")
|
|
1025
|
+
lines.append("")
|
|
1026
|
+
lines.append(f"**Status:** {status.replace('_', ' ').title()}")
|
|
1027
|
+
|
|
1028
|
+
if details:
|
|
1029
|
+
for key, value in details.items():
|
|
1030
|
+
display_key = key.replace('_', ' ').title()
|
|
1031
|
+
if isinstance(value, list):
|
|
1032
|
+
lines.append(f"- **{display_key}:**")
|
|
1033
|
+
for item in value:
|
|
1034
|
+
lines.append(f" - `{item}`")
|
|
1035
|
+
else:
|
|
1036
|
+
lines.append(f"- **{display_key}:** `{value}`")
|
|
1037
|
+
|
|
1038
|
+
lines.append("")
|
|
1039
|
+
|
|
1040
|
+
# URLs and Endpoints
|
|
1041
|
+
if self.urls:
|
|
1042
|
+
lines.append("## URLs and Endpoints")
|
|
1043
|
+
lines.append("")
|
|
1044
|
+
lines.append("| Name | URL |")
|
|
1045
|
+
lines.append("|------|-----|")
|
|
1046
|
+
for name, url in self.urls.items():
|
|
1047
|
+
display_name = name.replace('_', ' ').title()
|
|
1048
|
+
lines.append(f"| {display_name} | {url} |")
|
|
1049
|
+
lines.append("")
|
|
1050
|
+
|
|
1051
|
+
# Quick test links
|
|
1052
|
+
lines.append("### Quick Test Links")
|
|
1053
|
+
lines.append("")
|
|
1054
|
+
if 'public_url' in self.urls:
|
|
1055
|
+
base_url = self.urls['public_url']
|
|
1056
|
+
lines.append(f"- Homepage: {base_url}/")
|
|
1057
|
+
lines.append(f"- Test page: {base_url}/index-test.html")
|
|
1058
|
+
lines.append(f"- Text file: {base_url}/test.txt")
|
|
1059
|
+
lines.append("")
|
|
1060
|
+
|
|
1061
|
+
# Next Steps
|
|
1062
|
+
lines.append("## Next Steps")
|
|
1063
|
+
lines.append("")
|
|
1064
|
+
lines.append("1. **Upload your website files** to the S3 bucket")
|
|
1065
|
+
if 's3_bucket' in self.components and 'bucket_name' in self.components['s3_bucket']['details']:
|
|
1066
|
+
bucket = self.components['s3_bucket']['details']['bucket_name']
|
|
1067
|
+
lines.append(" ```bash")
|
|
1068
|
+
lines.append(" # Using AWS CLI or S3 tool")
|
|
1069
|
+
lines.append(f" aws s3 sync ./dist s3://{bucket}/ --endpoint-url https://{HETZNER_S3_REGIONS.get(self.region, 'fsn1.your-objectstorage.com')}")
|
|
1070
|
+
lines.append(" ```")
|
|
1071
|
+
lines.append("")
|
|
1072
|
+
lines.append("2. **Purge CDN cache** after uploading new content")
|
|
1073
|
+
lines.append("")
|
|
1074
|
+
lines.append("3. **Configure SSL** if certificate request is pending")
|
|
1075
|
+
lines.append(" - SSL certificates typically activate within 5-15 minutes")
|
|
1076
|
+
lines.append(" - Check Bunny CDN dashboard for certificate status")
|
|
1077
|
+
lines.append("")
|
|
1078
|
+
|
|
1079
|
+
# Footer
|
|
1080
|
+
lines.append("---")
|
|
1081
|
+
lines.append("")
|
|
1082
|
+
lines.append("*This report was generated automatically by setup_hetzner_bunny.py*")
|
|
1083
|
+
lines.append("")
|
|
1084
|
+
|
|
1085
|
+
return '\n'.join(lines)
|
|
1086
|
+
|
|
1087
|
+
def save_report(self, output_dir: str = '.') -> str:
|
|
1088
|
+
"""Save report to a markdown file."""
|
|
1089
|
+
# Ensure output directory exists
|
|
1090
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1091
|
+
|
|
1092
|
+
# Create filename with timestamp
|
|
1093
|
+
safe_domain = self.domain.replace('.', '-')
|
|
1094
|
+
timestamp = time.strftime('%Y%m%d-%H%M%S', time.gmtime())
|
|
1095
|
+
filename = f"cdn-setup-{safe_domain}-{timestamp}.md"
|
|
1096
|
+
filepath = os.path.join(output_dir, filename)
|
|
1097
|
+
|
|
1098
|
+
content = self.generate_markdown()
|
|
1099
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
1100
|
+
f.write(content)
|
|
1101
|
+
|
|
1102
|
+
logging.info(f"Setup report saved to: {filepath}")
|
|
1103
|
+
return filepath
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
# =============================================================================
|
|
1107
|
+
# Main Setup
|
|
1108
|
+
# =============================================================================
|
|
1109
|
+
|
|
1110
|
+
def parse_arguments():
|
|
1111
|
+
"""Parse command line arguments."""
|
|
1112
|
+
parser = argparse.ArgumentParser(
|
|
1113
|
+
description='Setup CDN with Hetzner S3 + Bunny CDN',
|
|
1114
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1115
|
+
epilog="""
|
|
1116
|
+
Examples:
|
|
1117
|
+
# Subdomain setup
|
|
1118
|
+
python setup_hetzner_bunny.py --domain example.com --subdomain cdn
|
|
1119
|
+
|
|
1120
|
+
# Apex domain setup with www redirect
|
|
1121
|
+
python setup_hetzner_bunny.py --domain example.com --apex --www-redirect
|
|
1122
|
+
|
|
1123
|
+
# SPA application
|
|
1124
|
+
python setup_hetzner_bunny.py --domain example.com --subdomain app --spa-mode
|
|
1125
|
+
|
|
1126
|
+
# Different Hetzner region
|
|
1127
|
+
python setup_hetzner_bunny.py --domain example.com --subdomain cdn --region nbg1
|
|
1128
|
+
|
|
1129
|
+
Environment Variables:
|
|
1130
|
+
HETZNER_S3_ACCESS_KEY: Hetzner S3 access key
|
|
1131
|
+
HETZNER_S3_SECRET_KEY: Hetzner S3 secret key
|
|
1132
|
+
BUNNY_API_KEY: Bunny.net API key
|
|
1133
|
+
CLOUDFLARE_API_TOKEN: For Cloudflare DNS
|
|
1134
|
+
HETZNER_DNS_API_TOKEN: For Hetzner DNS
|
|
1135
|
+
DESEC_API_TOKEN: For deSEC DNS
|
|
1136
|
+
"""
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
parser.add_argument('--domain', required=True,
|
|
1140
|
+
help='The root domain (e.g., example.com)')
|
|
1141
|
+
parser.add_argument('--subdomain', default=None,
|
|
1142
|
+
help='The subdomain for the CDN (e.g., cdn)')
|
|
1143
|
+
parser.add_argument('--apex', action='store_true',
|
|
1144
|
+
help='Setup for apex domain (no subdomain)')
|
|
1145
|
+
parser.add_argument('--www-redirect', action='store_true',
|
|
1146
|
+
help='Redirect www to apex domain (only with --apex)')
|
|
1147
|
+
parser.add_argument('--region', default='fsn1',
|
|
1148
|
+
choices=list(HETZNER_S3_REGIONS.keys()),
|
|
1149
|
+
help='Hetzner region (default: fsn1)')
|
|
1150
|
+
parser.add_argument('--dns-provider', choices=['cloudflare', 'hetzner', 'desec'],
|
|
1151
|
+
default='cloudflare',
|
|
1152
|
+
help='DNS provider (default: cloudflare)')
|
|
1153
|
+
parser.add_argument('--create-zone', action='store_true',
|
|
1154
|
+
help='Create DNS zone if it does not exist')
|
|
1155
|
+
parser.add_argument('--spa-mode', action='store_true',
|
|
1156
|
+
help='Configure for Single Page Application')
|
|
1157
|
+
parser.add_argument('--skip-tests', action='store_true',
|
|
1158
|
+
help='Skip CDN functionality tests')
|
|
1159
|
+
parser.add_argument('--test-timeout', type=int, default=60,
|
|
1160
|
+
help='Timeout for CDN tests (default: 60)')
|
|
1161
|
+
parser.add_argument('--force', action='store_true',
|
|
1162
|
+
help='Force recreate pull zone even if it exists')
|
|
1163
|
+
parser.add_argument('--private', action='store_true',
|
|
1164
|
+
help='Private mode: skip public bucket policy, enable Bunny token auth')
|
|
1165
|
+
parser.add_argument('--token-auth', action='store_true',
|
|
1166
|
+
help='Enable Bunny CDN token authentication (signed URLs required)')
|
|
1167
|
+
parser.add_argument('--token-key', default=None,
|
|
1168
|
+
help='Custom token key for Bunny token auth (auto-generated if omitted)')
|
|
1169
|
+
parser.add_argument('--report-dir', default='.',
|
|
1170
|
+
help='Directory to save the setup report (default: current directory)')
|
|
1171
|
+
|
|
1172
|
+
return parser.parse_args()
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def main():
|
|
1176
|
+
"""Main function to orchestrate the CDN setup."""
|
|
1177
|
+
args = parse_arguments()
|
|
1178
|
+
|
|
1179
|
+
# Setup logging
|
|
1180
|
+
logging.basicConfig(
|
|
1181
|
+
level=logging.INFO,
|
|
1182
|
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
# Determine full domain and bucket name
|
|
1186
|
+
if args.apex:
|
|
1187
|
+
full_domain = args.domain
|
|
1188
|
+
www_domain = f"www.{args.domain}"
|
|
1189
|
+
mode = 'apex'
|
|
1190
|
+
elif args.subdomain:
|
|
1191
|
+
full_domain = f"{args.subdomain}.{args.domain}"
|
|
1192
|
+
www_domain = None
|
|
1193
|
+
mode = 'subdomain'
|
|
1194
|
+
else:
|
|
1195
|
+
logging.error("Either --subdomain or --apex must be specified")
|
|
1196
|
+
return 1
|
|
1197
|
+
|
|
1198
|
+
# Sanitize bucket name (Hetzner S3 doesn't allow dots)
|
|
1199
|
+
bucket_name = full_domain.replace('.', '-')
|
|
1200
|
+
# Pull zone name same as bucket name
|
|
1201
|
+
pull_zone_name = bucket_name
|
|
1202
|
+
|
|
1203
|
+
# Initialize report tracker
|
|
1204
|
+
report = SetupReport(
|
|
1205
|
+
domain=full_domain,
|
|
1206
|
+
mode=mode,
|
|
1207
|
+
dns_provider=args.dns_provider,
|
|
1208
|
+
region=args.region
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
# Log configuration
|
|
1212
|
+
logging.info("=" * 50)
|
|
1213
|
+
logging.info("HETZNER S3 + BUNNY CDN SETUP")
|
|
1214
|
+
logging.info("=" * 50)
|
|
1215
|
+
logging.info(f"Domain: {args.domain}")
|
|
1216
|
+
logging.info(f"Mode: {'Apex' if args.apex else 'Subdomain'}")
|
|
1217
|
+
logging.info(f"Full Domain: {full_domain}")
|
|
1218
|
+
logging.info(f"Bucket Name: {bucket_name}")
|
|
1219
|
+
logging.info(f"Pull Zone Name: {pull_zone_name}")
|
|
1220
|
+
logging.info(f"Hetzner Region: {args.region} ({HETZNER_S3_REGION_NAMES.get(args.region, '')})")
|
|
1221
|
+
logging.info(f"DNS Provider: {args.dns_provider}")
|
|
1222
|
+
logging.info(f"SPA Mode: {args.spa_mode}")
|
|
1223
|
+
logging.info(f"Private Mode: {args.private}")
|
|
1224
|
+
logging.info(f"Token Auth: {args.token_auth or args.private}")
|
|
1225
|
+
if args.apex:
|
|
1226
|
+
logging.info(f"WWW Redirect: {args.www_redirect}")
|
|
1227
|
+
logging.info("=" * 50)
|
|
1228
|
+
|
|
1229
|
+
# --private implies --token-auth
|
|
1230
|
+
if args.private:
|
|
1231
|
+
args.token_auth = True
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
# Initialize clients
|
|
1235
|
+
logging.info("Initializing clients...")
|
|
1236
|
+
s3_client = HetznerS3Client(region=args.region)
|
|
1237
|
+
bunny_client = BunnyCDNClient()
|
|
1238
|
+
dns_provider = get_dns_provider(args.dns_provider)
|
|
1239
|
+
|
|
1240
|
+
# Get DNS zone
|
|
1241
|
+
if args.create_zone:
|
|
1242
|
+
zone_id = dns_provider.get_or_create_zone(args.domain, auto_create=True)
|
|
1243
|
+
else:
|
|
1244
|
+
zone_id = dns_provider.get_zone_id(args.domain)
|
|
1245
|
+
|
|
1246
|
+
# 1. Create S3 bucket
|
|
1247
|
+
logging.info("\n--- Step 1: S3 Bucket ---")
|
|
1248
|
+
bucket_existed = s3_client.bucket_exists(bucket_name)
|
|
1249
|
+
s3_client.create_bucket(bucket_name)
|
|
1250
|
+
if not args.private:
|
|
1251
|
+
s3_client.set_bucket_policy_public(bucket_name)
|
|
1252
|
+
else:
|
|
1253
|
+
logging.info("Private mode: skipping public bucket policy (origin access only)")
|
|
1254
|
+
s3_client.set_cors_config(bucket_name)
|
|
1255
|
+
|
|
1256
|
+
origin_url = s3_client.get_bucket_url(bucket_name)
|
|
1257
|
+
logging.info(f"S3 Origin URL: {origin_url}")
|
|
1258
|
+
|
|
1259
|
+
report.set_component('s3_bucket',
|
|
1260
|
+
'exists' if bucket_existed else 'created',
|
|
1261
|
+
bucket_name=bucket_name,
|
|
1262
|
+
origin_url=origin_url,
|
|
1263
|
+
region=args.region
|
|
1264
|
+
)
|
|
1265
|
+
report.set_url('s3_origin', origin_url)
|
|
1266
|
+
|
|
1267
|
+
# 2. Create or get Bunny CDN pull zone
|
|
1268
|
+
logging.info("\n--- Step 2: Bunny CDN Pull Zone ---")
|
|
1269
|
+
|
|
1270
|
+
# First, check if the hostname is already registered to any pull zone
|
|
1271
|
+
existing_zone_by_hostname = bunny_client.find_pull_zone_by_hostname(full_domain)
|
|
1272
|
+
existing_zone_by_name = bunny_client.find_pull_zone_by_name(pull_zone_name)
|
|
1273
|
+
|
|
1274
|
+
pull_zone_existed = False
|
|
1275
|
+
pull_zone = None
|
|
1276
|
+
|
|
1277
|
+
if existing_zone_by_hostname:
|
|
1278
|
+
# Hostname already registered - use that pull zone
|
|
1279
|
+
logging.info(f"Hostname '{full_domain}' already registered to pull zone {existing_zone_by_hostname['Id']}.")
|
|
1280
|
+
pull_zone = existing_zone_by_hostname
|
|
1281
|
+
pull_zone_existed = True
|
|
1282
|
+
elif existing_zone_by_name and not args.force:
|
|
1283
|
+
# Pull zone with same name exists
|
|
1284
|
+
logging.info(f"Pull zone '{pull_zone_name}' already exists.")
|
|
1285
|
+
pull_zone = existing_zone_by_name
|
|
1286
|
+
pull_zone_existed = True
|
|
1287
|
+
elif existing_zone_by_name and args.force:
|
|
1288
|
+
logging.info("Force flag set. Pull zone will be reused.")
|
|
1289
|
+
pull_zone = existing_zone_by_name
|
|
1290
|
+
pull_zone_existed = True
|
|
1291
|
+
else:
|
|
1292
|
+
# Create new pull zone
|
|
1293
|
+
pull_zone = bunny_client.create_pull_zone(pull_zone_name, origin_url)
|
|
1294
|
+
|
|
1295
|
+
pull_zone_id = pull_zone['Id']
|
|
1296
|
+
cdn_hostname = bunny_client.get_pull_zone_hostname(pull_zone)
|
|
1297
|
+
logging.info(f"CDN Hostname: {cdn_hostname}")
|
|
1298
|
+
|
|
1299
|
+
report.set_component('pull_zone',
|
|
1300
|
+
'exists' if pull_zone_existed else 'created',
|
|
1301
|
+
pull_zone_id=pull_zone_id,
|
|
1302
|
+
pull_zone_name=pull_zone_name,
|
|
1303
|
+
cdn_hostname=cdn_hostname
|
|
1304
|
+
)
|
|
1305
|
+
report.set_url('cdn_hostname', f"https://{cdn_hostname}")
|
|
1306
|
+
|
|
1307
|
+
# 3. Add custom hostnames
|
|
1308
|
+
logging.info("\n--- Step 3: Custom Hostnames ---")
|
|
1309
|
+
|
|
1310
|
+
# Check existing hostnames
|
|
1311
|
+
existing_hostnames = [h.get('Value', '') for h in pull_zone.get('Hostnames', [])]
|
|
1312
|
+
hostname_details = {'configured_hostnames': []}
|
|
1313
|
+
|
|
1314
|
+
if full_domain not in existing_hostnames:
|
|
1315
|
+
bunny_client.add_hostname(pull_zone_id, full_domain)
|
|
1316
|
+
hostname_details['configured_hostnames'].append(f"{full_domain} (added)")
|
|
1317
|
+
else:
|
|
1318
|
+
logging.info(f"Hostname '{full_domain}' already configured.")
|
|
1319
|
+
hostname_details['configured_hostnames'].append(f"{full_domain} (existed)")
|
|
1320
|
+
|
|
1321
|
+
if args.apex and args.www_redirect:
|
|
1322
|
+
if www_domain not in existing_hostnames:
|
|
1323
|
+
bunny_client.add_hostname(pull_zone_id, www_domain)
|
|
1324
|
+
hostname_details['configured_hostnames'].append(f"{www_domain} (added)")
|
|
1325
|
+
else:
|
|
1326
|
+
logging.info(f"Hostname '{www_domain}' already configured.")
|
|
1327
|
+
hostname_details['configured_hostnames'].append(f"{www_domain} (existed)")
|
|
1328
|
+
|
|
1329
|
+
report.set_component('hostnames', 'configured', **hostname_details)
|
|
1330
|
+
|
|
1331
|
+
# 3b. Token Authentication (if enabled)
|
|
1332
|
+
token_security_key = None
|
|
1333
|
+
if args.token_auth:
|
|
1334
|
+
logging.info("\n--- Step 3b: Token Authentication ---")
|
|
1335
|
+
details = bunny_client.enable_token_authentication(
|
|
1336
|
+
pull_zone_id, token_key=args.token_key
|
|
1337
|
+
)
|
|
1338
|
+
token_security_key = details.get("ZoneSecurityKey", "")
|
|
1339
|
+
report.set_component('token_auth', 'enabled',
|
|
1340
|
+
security_key_preview=f"{token_security_key[:8]}...{token_security_key[-4:]}" if token_security_key else "N/A",
|
|
1341
|
+
note="All requests require ?token=<hash>&expires=<ts> signed URLs"
|
|
1342
|
+
)
|
|
1343
|
+
if args.private:
|
|
1344
|
+
bunny_client.disable_direct_origin_access(pull_zone_id)
|
|
1345
|
+
report.set_component('origin_shield', 'enabled')
|
|
1346
|
+
else:
|
|
1347
|
+
report.set_component('token_auth', 'disabled')
|
|
1348
|
+
|
|
1349
|
+
# 4. Configure SPA mode if enabled
|
|
1350
|
+
if args.spa_mode:
|
|
1351
|
+
logging.info("\n--- Step 4: SPA Configuration ---")
|
|
1352
|
+
bunny_client.configure_error_pages(pull_zone_id, spa_mode=True)
|
|
1353
|
+
report.set_component('spa_config', 'configured',
|
|
1354
|
+
mode='enabled',
|
|
1355
|
+
behavior='404 errors redirect to index.html'
|
|
1356
|
+
)
|
|
1357
|
+
else:
|
|
1358
|
+
report.set_component('spa_config', 'skipped', mode='disabled')
|
|
1359
|
+
|
|
1360
|
+
# 5. Configure DNS
|
|
1361
|
+
logging.info("\n--- Step 5: DNS Configuration ---")
|
|
1362
|
+
dns_details = {'provider': args.dns_provider, 'records': []}
|
|
1363
|
+
|
|
1364
|
+
if args.apex:
|
|
1365
|
+
dns_provider.create_apex_records(
|
|
1366
|
+
zone_id, args.domain, www_domain, cdn_hostname
|
|
1367
|
+
)
|
|
1368
|
+
if dns_provider.supports_apex_cname():
|
|
1369
|
+
dns_details['records'].append(f"CNAME {args.domain} -> {cdn_hostname}")
|
|
1370
|
+
else:
|
|
1371
|
+
dns_details['records'].append(f"A {args.domain} -> 185.206.224.1 (Bunny anycast)")
|
|
1372
|
+
dns_details['records'].append(f"CNAME www.{args.domain} -> {cdn_hostname}")
|
|
1373
|
+
else:
|
|
1374
|
+
dns_provider.create_cname_record(
|
|
1375
|
+
zone_id, args.subdomain, cdn_hostname, args.domain
|
|
1376
|
+
)
|
|
1377
|
+
dns_details['records'].append(f"CNAME {full_domain} -> {cdn_hostname}")
|
|
1378
|
+
|
|
1379
|
+
report.set_component('dns_records', 'configured', **dns_details)
|
|
1380
|
+
|
|
1381
|
+
# 6. Request SSL certificates
|
|
1382
|
+
logging.info("\n--- Step 6: SSL Certificates ---")
|
|
1383
|
+
logging.info("Waiting 30 seconds for DNS propagation before requesting SSL...")
|
|
1384
|
+
time.sleep(30)
|
|
1385
|
+
|
|
1386
|
+
ssl_details = {'certificates': []}
|
|
1387
|
+
|
|
1388
|
+
ssl_success = bunny_client.request_ssl_certificate(pull_zone_id, full_domain)
|
|
1389
|
+
ssl_details['certificates'].append(f"{full_domain}: {'requested' if ssl_success else 'pending'}")
|
|
1390
|
+
|
|
1391
|
+
if args.apex and args.www_redirect:
|
|
1392
|
+
www_ssl_success = bunny_client.request_ssl_certificate(pull_zone_id, www_domain)
|
|
1393
|
+
ssl_details['certificates'].append(f"{www_domain}: {'requested' if www_ssl_success else 'pending'}")
|
|
1394
|
+
|
|
1395
|
+
report.set_component('ssl_certificates', 'requested', **ssl_details)
|
|
1396
|
+
|
|
1397
|
+
# 7. Create test content
|
|
1398
|
+
logging.info("\n--- Step 7: Test Content ---")
|
|
1399
|
+
create_test_content(s3_client, bucket_name, args.region, args.spa_mode)
|
|
1400
|
+
report.set_component('test_content', 'uploaded',
|
|
1401
|
+
files=['index.html', 'index-test.html', 'error.html', 'test.txt']
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
# Set public URLs
|
|
1405
|
+
report.set_url('public_url', f"https://{full_domain}")
|
|
1406
|
+
if args.apex and args.www_redirect:
|
|
1407
|
+
report.set_url('www_url', f"https://{www_domain}")
|
|
1408
|
+
|
|
1409
|
+
# Summary
|
|
1410
|
+
logging.info("\n" + "=" * 50)
|
|
1411
|
+
logging.info("CDN SETUP COMPLETE")
|
|
1412
|
+
logging.info("=" * 50)
|
|
1413
|
+
logging.info(f"S3 Bucket: {bucket_name}")
|
|
1414
|
+
logging.info(f"S3 Origin: {origin_url}")
|
|
1415
|
+
logging.info(f"Bunny Pull Zone ID: {pull_zone_id}")
|
|
1416
|
+
logging.info(f"CDN Hostname: {cdn_hostname}")
|
|
1417
|
+
logging.info(f"Public URL: https://{full_domain}")
|
|
1418
|
+
if args.apex and args.www_redirect:
|
|
1419
|
+
logging.info(f"WWW URL: https://{www_domain}")
|
|
1420
|
+
logging.info(f"SPA Mode: {args.spa_mode}")
|
|
1421
|
+
logging.info(f"Token Auth: {args.token_auth}")
|
|
1422
|
+
if token_security_key:
|
|
1423
|
+
logging.info(f"Token Key: {token_security_key}")
|
|
1424
|
+
logging.info("")
|
|
1425
|
+
logging.info("SAVE THIS TOKEN KEY — needed for signed URL generation:")
|
|
1426
|
+
logging.info(f" BUNNY_CDN_TOKEN_KEY={token_security_key}")
|
|
1427
|
+
logging.info("=" * 50)
|
|
1428
|
+
if not args.token_auth:
|
|
1429
|
+
logging.info("Test URLs:")
|
|
1430
|
+
logging.info(f" - https://{full_domain}/index-test.html")
|
|
1431
|
+
logging.info(f" - https://{full_domain}/test.txt")
|
|
1432
|
+
else:
|
|
1433
|
+
logging.info("Token auth enabled — files require signed URLs to access.")
|
|
1434
|
+
logging.info("Generate signed URLs in your application using the token key above.")
|
|
1435
|
+
logging.info("=" * 50)
|
|
1436
|
+
|
|
1437
|
+
# 8. Test CDN (skip if token auth — public URLs won't work)
|
|
1438
|
+
test_success = True
|
|
1439
|
+
if args.token_auth and not args.skip_tests:
|
|
1440
|
+
logging.info("\nSkipping CDN tests (token auth enabled — public URLs blocked).")
|
|
1441
|
+
args.skip_tests = True
|
|
1442
|
+
if not args.skip_tests:
|
|
1443
|
+
logging.info("\nWaiting 30 seconds for CDN propagation before testing...")
|
|
1444
|
+
time.sleep(30)
|
|
1445
|
+
|
|
1446
|
+
test_success = test_cdn_functionality(f"https://{full_domain}", args.test_timeout)
|
|
1447
|
+
report.set_component('cdn_test',
|
|
1448
|
+
'passed' if test_success else 'failed',
|
|
1449
|
+
test_url=f"https://{full_domain}/test.txt"
|
|
1450
|
+
)
|
|
1451
|
+
else:
|
|
1452
|
+
report.set_component('cdn_test', 'skipped')
|
|
1453
|
+
|
|
1454
|
+
# Generate and save report
|
|
1455
|
+
report_path = report.save_report(args.report_dir)
|
|
1456
|
+
|
|
1457
|
+
if test_success or args.skip_tests:
|
|
1458
|
+
logging.info("\n[OK] CDN setup completed successfully!")
|
|
1459
|
+
logging.info(f"[i] Setup report: {report_path}")
|
|
1460
|
+
return 0
|
|
1461
|
+
else:
|
|
1462
|
+
logging.warning("\n[!] CDN setup completed but tests indicate issues.")
|
|
1463
|
+
logging.info(f"[i] Setup report: {report_path}")
|
|
1464
|
+
return 1
|
|
1465
|
+
|
|
1466
|
+
except Exception as e:
|
|
1467
|
+
logging.error(f"An error occurred: {e}")
|
|
1468
|
+
import traceback
|
|
1469
|
+
traceback.print_exc()
|
|
1470
|
+
|
|
1471
|
+
# Try to save partial report on error
|
|
1472
|
+
try:
|
|
1473
|
+
report_path = report.save_report(args.report_dir)
|
|
1474
|
+
logging.info(f"[i] Partial setup report saved: {report_path}")
|
|
1475
|
+
except Exception:
|
|
1476
|
+
pass
|
|
1477
|
+
|
|
1478
|
+
return 1
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
if __name__ == "__main__":
|
|
1482
|
+
exit(main())
|