granny-devops 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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">&#127881; 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())