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,547 @@
1
+ #!/usr/bin/env python3
2
+ """Private CDN Setup: Hetzner S3 + Bunny CDN with Token Authentication.
3
+
4
+ Creates a private file storage setup where all access requires signed URLs:
5
+ 1. Hetzner S3 bucket (private, no public access)
6
+ 2. Bunny CDN pull zone with Token Authentication
7
+ 3. DNS CNAME via Cloudflare
8
+ 4. Free SSL certificate
9
+
10
+ Files are only accessible via time-limited signed URLs generated server-side.
11
+ This is ideal for invoices, user uploads, private documents, etc.
12
+
13
+ Architecture:
14
+ App (JWT auth) → generate signed URL → user browser
15
+ Browser → https://cdn.example.com/path?token=<hash>&expires=<ts>
16
+ Bunny CDN (validates token) → Hetzner S3 (private origin)
17
+
18
+ Environment Variables (from Vaultwarden or .env):
19
+ HETZNER_S3_ACCESS_KEY - Hetzner S3 access key
20
+ HETZNER_S3_SECRET_KEY - Hetzner S3 secret key
21
+ BUNNY_API_KEY - Bunny.net API key
22
+ CLOUDFLARE_API_TOKEN - Cloudflare DNS token (optional, for auto DNS)
23
+
24
+ Usage:
25
+ # Dry-run: show what would be created
26
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn --dry-run
27
+
28
+ # Create everything
29
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn
30
+
31
+ # Use existing bucket
32
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn --existing-bucket
33
+
34
+ # Custom CORS origins
35
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn \\
36
+ --cors-origins "https://app.example.com,http://localhost:3000"
37
+
38
+ # Export Vaultwarden CSV for secret import
39
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn \\
40
+ --vault-csv --vault-folder "myproject/infra"
41
+
42
+ Output:
43
+ - Hetzner S3 bucket (private, CORS configured)
44
+ - Bunny CDN pull zone with token auth + origin shield
45
+ - DNS CNAME record (if Cloudflare token provided)
46
+ - SSL certificate (free, via Bunny)
47
+ - Token key for signed URL generation
48
+ - Optional: Vaultwarden CSV for import
49
+ """
50
+
51
+ import argparse
52
+ import csv
53
+ import io
54
+ import logging
55
+ import os
56
+ import sys
57
+ import time
58
+
59
+ try:
60
+ from dotenv import load_dotenv
61
+ load_dotenv()
62
+ except ImportError:
63
+ pass
64
+
65
+ # Load secrets via granny's resolution chain (no-op if already in env).
66
+ try:
67
+ from granny.credentials import load_secrets_into_env
68
+ load_secrets_into_env(["HETZNER_S3_ACCESS_KEY", "HETZNER_S3_SECRET_KEY",
69
+ "BUNNY_API_KEY", "CLOUDFLARE_API_TOKEN"])
70
+ except ImportError:
71
+ pass
72
+
73
+ logging.basicConfig(
74
+ level=logging.INFO,
75
+ format="%(asctime)s [%(levelname)8.8s] %(message)s",
76
+ )
77
+ logger = logging.getLogger(__name__)
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Constants
81
+ # ---------------------------------------------------------------------------
82
+
83
+ BUNNY_API = "https://api.bunny.net"
84
+
85
+ HETZNER_REGIONS = {
86
+ "fsn1": "fsn1.your-objectstorage.com",
87
+ "nbg1": "nbg1.your-objectstorage.com",
88
+ "hel1": "hel1.your-objectstorage.com",
89
+ }
90
+
91
+ DEFAULT_CORS_ORIGINS = ["http://localhost:3000", "http://localhost:5173"]
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Bunny CDN helpers
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def bunny_request(method: str, endpoint: str, data: dict = None) -> dict:
99
+ """Make a Bunny CDN API request."""
100
+ import requests
101
+ headers = {
102
+ "AccessKey": os.environ["BUNNY_API_KEY"],
103
+ "Content-Type": "application/json",
104
+ }
105
+ url = f"{BUNNY_API}{endpoint}"
106
+ resp = requests.request(method, url, headers=headers, json=data)
107
+ if resp.status_code >= 400:
108
+ raise Exception(f"Bunny API {method} {endpoint}: {resp.status_code} - {resp.text}")
109
+ return resp.json() if resp.text and resp.status_code != 204 else {}
110
+
111
+
112
+ def find_pull_zone(name: str = None, hostname: str = None) -> dict | None:
113
+ """Find existing pull zone by name or hostname."""
114
+ zones = bunny_request("GET", "/pullzone")
115
+ for z in zones:
116
+ if name and z.get("Name") == name:
117
+ return z
118
+ if hostname:
119
+ for h in z.get("Hostnames", []):
120
+ if h.get("Value") == hostname:
121
+ return z
122
+ return None
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Steps
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def check_env() -> bool:
130
+ """Verify required env vars are set."""
131
+ required = {
132
+ "HETZNER_S3_ACCESS_KEY": "Hetzner S3 access key",
133
+ "HETZNER_S3_SECRET_KEY": "Hetzner S3 secret key",
134
+ "BUNNY_API_KEY": "Bunny.net API key",
135
+ }
136
+ missing = [f" {k}: {v}" for k, v in required.items() if not os.getenv(k)]
137
+ if missing:
138
+ logger.error("Missing required environment variables:")
139
+ for m in missing:
140
+ logger.error(m)
141
+ logger.error("\nSet them in .env or via Vaultwarden.")
142
+ return False
143
+ return True
144
+
145
+
146
+ def step_create_bucket(bucket: str, region: str, cors_origins: list[str],
147
+ dry_run: bool = False) -> bool:
148
+ """Create private Hetzner S3 bucket with CORS."""
149
+ import boto3
150
+ from botocore.config import Config as BotoConfig
151
+
152
+ endpoint = f"https://{HETZNER_REGIONS[region]}"
153
+ client = boto3.client(
154
+ "s3",
155
+ endpoint_url=endpoint,
156
+ aws_access_key_id=os.environ["HETZNER_S3_ACCESS_KEY"],
157
+ aws_secret_access_key=os.environ["HETZNER_S3_SECRET_KEY"],
158
+ region_name=region,
159
+ config=BotoConfig(signature_version="s3v4"),
160
+ )
161
+
162
+ try:
163
+ client.head_bucket(Bucket=bucket)
164
+ logger.info(f"Bucket '{bucket}' already exists.")
165
+ return True
166
+ except Exception:
167
+ pass
168
+
169
+ if dry_run:
170
+ logger.info(f"[DRY RUN] Would create private bucket '{bucket}' in {region}")
171
+ logger.info(f"[DRY RUN] CORS origins: {cors_origins}")
172
+ return False
173
+
174
+ logger.info(f"Creating bucket '{bucket}' in {region}...")
175
+ client.create_bucket(Bucket=bucket)
176
+ time.sleep(3)
177
+
178
+ client.put_bucket_cors(
179
+ Bucket=bucket,
180
+ CORSConfiguration={
181
+ "CORSRules": [{
182
+ "AllowedHeaders": ["*"],
183
+ "AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
184
+ "AllowedOrigins": cors_origins,
185
+ "ExposeHeaders": ["ETag", "x-amz-request-id"],
186
+ "MaxAgeSeconds": 3600,
187
+ }]
188
+ },
189
+ )
190
+ logger.info(f"Bucket '{bucket}' created (private, CORS for {len(cors_origins)} origins).")
191
+ return True
192
+
193
+
194
+ def step_create_pull_zone(bucket: str, region: str, hostname: str,
195
+ dry_run: bool = False) -> tuple[int | None, str | None]:
196
+ """Create Bunny CDN pull zone with token authentication."""
197
+ origin_url = f"https://{bucket}.{HETZNER_REGIONS[region]}"
198
+ pull_zone_name = bucket
199
+
200
+ existing = find_pull_zone(name=pull_zone_name) or find_pull_zone(hostname=hostname)
201
+ if existing:
202
+ pz_id = existing["Id"]
203
+ logger.info(f"Pull zone exists: {pull_zone_name} (ID: {pz_id})")
204
+ elif dry_run:
205
+ logger.info(f"[DRY RUN] Would create pull zone '{pull_zone_name}' -> {origin_url}")
206
+ logger.info("[DRY RUN] Would enable token authentication")
207
+ logger.info(f"[DRY RUN] Would add hostname '{hostname}'")
208
+ return None, None
209
+ else:
210
+ logger.info(f"Creating pull zone '{pull_zone_name}' -> {origin_url}")
211
+ result = bunny_request("POST", "/pullzone", {
212
+ "Name": pull_zone_name,
213
+ "OriginUrl": origin_url,
214
+ "Type": 0,
215
+ "EnableGeoZoneUS": True,
216
+ "EnableGeoZoneEU": True,
217
+ "EnableGeoZoneASIA": True,
218
+ })
219
+ pz_id = result["Id"]
220
+ logger.info(f"Pull zone created (ID: {pz_id})")
221
+
222
+ if dry_run:
223
+ return pz_id, None
224
+
225
+ # Add custom hostname
226
+ try:
227
+ bunny_request("POST", f"/pullzone/{pz_id}/addHostname", {"Hostname": hostname})
228
+ logger.info(f"Hostname '{hostname}' added.")
229
+ except Exception as e:
230
+ if "already" in str(e).lower():
231
+ logger.info(f"Hostname '{hostname}' already registered.")
232
+ else:
233
+ raise
234
+
235
+ # Enable token authentication
236
+ logger.info("Enabling token authentication...")
237
+ bunny_request("POST", f"/pullzone/{pz_id}", {
238
+ "ZoneSecurityEnabled": True,
239
+ "ZoneSecurityIncludeHashRemoteIP": False,
240
+ })
241
+
242
+ # Enable origin shield (block direct S3 access)
243
+ logger.info("Enabling origin shield...")
244
+ bunny_request("POST", f"/pullzone/{pz_id}", {
245
+ "EnableOriginShield": True,
246
+ "OriginShieldZoneCode": "DE",
247
+ })
248
+
249
+ # Fetch token key
250
+ details = bunny_request("GET", f"/pullzone/{pz_id}")
251
+ token_key = details.get("ZoneSecurityKey", "")
252
+ logger.info(f"Token auth enabled. Key: {token_key[:8]}...{token_key[-4:]}")
253
+ return pz_id, token_key
254
+
255
+
256
+ def step_setup_dns(hostname: str, domain: str, pull_zone_name: str,
257
+ dry_run: bool = False) -> bool:
258
+ """Create Cloudflare CNAME for CDN hostname."""
259
+ import requests
260
+
261
+ token = os.getenv("CLOUDFLARE_API_TOKEN")
262
+ if not token:
263
+ logger.warning("CLOUDFLARE_API_TOKEN not set — skipping DNS.")
264
+ logger.warning(f"Create CNAME manually: {hostname} -> {pull_zone_name}.b-cdn.net")
265
+ return False
266
+
267
+ cf = "https://api.cloudflare.com/client/v4"
268
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
269
+
270
+ resp = requests.get(f"{cf}/zones?name={domain}", headers=headers)
271
+ zones = resp.json().get("result", [])
272
+ if not zones:
273
+ logger.warning(f"Cloudflare zone '{domain}' not found.")
274
+ return False
275
+
276
+ zone_id = zones[0]["id"]
277
+ target = f"{pull_zone_name}.b-cdn.net"
278
+
279
+ if dry_run:
280
+ logger.info(f"[DRY RUN] Would create CNAME: {hostname} -> {target}")
281
+ return True
282
+
283
+ resp = requests.get(
284
+ f"{cf}/zones/{zone_id}/dns_records?type=CNAME&name={hostname}", headers=headers
285
+ )
286
+ existing = resp.json().get("result", [])
287
+ data = {"type": "CNAME", "name": hostname, "content": target, "proxied": False, "ttl": 1}
288
+
289
+ if existing:
290
+ requests.put(f"{cf}/zones/{zone_id}/dns_records/{existing[0]['id']}",
291
+ headers=headers, json=data)
292
+ logger.info(f"DNS updated: {hostname} -> {target}")
293
+ else:
294
+ requests.post(f"{cf}/zones/{zone_id}/dns_records", headers=headers, json=data)
295
+ logger.info(f"DNS created: {hostname} -> {target}")
296
+ return True
297
+
298
+
299
+ def step_request_ssl(pull_zone_id: int, hostname: str, dry_run: bool = False) -> bool:
300
+ """Request free SSL certificate from Bunny CDN."""
301
+ if dry_run:
302
+ logger.info(f"[DRY RUN] Would request SSL for {hostname}")
303
+ return True
304
+ import requests
305
+ headers = {"AccessKey": os.environ["BUNNY_API_KEY"]}
306
+ resp = requests.get(
307
+ f"{BUNNY_API}/pullzone/{pull_zone_id}/loadFreeCertificate?hostname={hostname}",
308
+ headers=headers,
309
+ )
310
+ if resp.status_code in (200, 204):
311
+ logger.info(f"SSL certificate requested for {hostname}")
312
+ return True
313
+ logger.warning(f"SSL request returned {resp.status_code}")
314
+ return False
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Output helpers
319
+ # ---------------------------------------------------------------------------
320
+
321
+ def generate_vault_csv(settings: list[tuple[str, str, str]],
322
+ vault_folder: str, output_path: str) -> None:
323
+ """Generate Vaultwarden-importable Bitwarden CSV."""
324
+ output = io.StringIO()
325
+ writer = csv.writer(output)
326
+ writer.writerow(["folder", "favorite", "type", "name", "notes", "fields",
327
+ "reprompt", "login_uri", "login_username", "login_password", "login_totp"])
328
+ for name, value, desc in settings:
329
+ writer.writerow([
330
+ vault_folder, "", "login", f"{vault_folder}/{name}", desc,
331
+ "", "", "", "", value, "",
332
+ ])
333
+ with open(output_path, "w", newline="") as f:
334
+ f.write(output.getvalue())
335
+ logger.info(f"Vaultwarden CSV written: {output_path}")
336
+
337
+
338
+ def print_results(args, endpoint: str, pull_zone_id: int, token_key: str) -> None:
339
+ """Print all output values."""
340
+ hostname = args.hostname
341
+
342
+ logger.info("")
343
+ logger.info("Environment variables:")
344
+ logger.info(f" HETZNER_S3_ENDPOINT={endpoint}")
345
+ logger.info(f" HETZNER_S3_BUCKET={args.bucket}")
346
+ logger.info(f" HETZNER_S3_REGION={args.region}")
347
+ logger.info(f" BUNNY_CDN_TOKEN_KEY={token_key}")
348
+ logger.info(f" BUNNY_CDN_PULL_ZONE_ID={pull_zone_id}")
349
+ logger.info(f" BUNNY_CDN_HOSTNAME={hostname}")
350
+
351
+ logger.info("")
352
+ logger.info("Signed URL pattern:")
353
+ logger.info(" hash = base64url(sha256(token_key + url_path + expires))")
354
+ logger.info(f" url = https://{hostname}/path?token={{hash}}&expires={{ts}}")
355
+
356
+ logger.info("")
357
+ logger.info("Python example:")
358
+ logger.info(' import hashlib, base64, time')
359
+ logger.info(' expires = int(time.time()) + 3600')
360
+ logger.info(' path = "/tenant/export/file.pdf"')
361
+ logger.info(' raw = hashlib.sha256(f"{token_key}{path}{expires}".encode()).digest()')
362
+ logger.info(' token = base64.b64encode(raw).decode().replace("+","-").replace("/","_").rstrip("=")')
363
+ logger.info(f' url = f"https://{hostname}{{path}}?token={{token}}&expires={{expires}}"')
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Main
368
+ # ---------------------------------------------------------------------------
369
+
370
+ def parse_arguments():
371
+ parser = argparse.ArgumentParser(
372
+ description="Set up private CDN: Hetzner S3 + Bunny CDN with token authentication",
373
+ formatter_class=argparse.RawDescriptionHelpFormatter,
374
+ epilog="""
375
+ Examples:
376
+ # Basic setup
377
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn
378
+
379
+ # Dry-run
380
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn --dry-run
381
+
382
+ # With Vaultwarden CSV export
383
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn \\
384
+ --vault-csv --vault-folder "myproject/infra"
385
+
386
+ # Custom CORS
387
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn \\
388
+ --cors-origins "https://app.example.com,http://localhost:3000"
389
+
390
+ # Skip DNS (configure manually)
391
+ python setup_private_cdn.py --bucket my-files --domain example.com --subdomain cdn --skip-dns
392
+
393
+ Environment Variables:
394
+ HETZNER_S3_ACCESS_KEY Hetzner S3 access key (from Vaultwarden or .env)
395
+ HETZNER_S3_SECRET_KEY Hetzner S3 secret key
396
+ BUNNY_API_KEY Bunny.net API key
397
+ CLOUDFLARE_API_TOKEN Cloudflare DNS token (optional)
398
+ """,
399
+ )
400
+ parser.add_argument("--bucket", required=True, help="S3 bucket name (e.g., my-files)")
401
+ parser.add_argument("--domain", required=True, help="Root domain (e.g., example.com)")
402
+ parser.add_argument("--subdomain", required=True, help="CDN subdomain (e.g., cdn)")
403
+ parser.add_argument("--region", default="fsn1", choices=list(HETZNER_REGIONS.keys()),
404
+ help="Hetzner region (default: fsn1)")
405
+ parser.add_argument("--existing-bucket", action="store_true",
406
+ help="Skip bucket creation (already exists)")
407
+ parser.add_argument("--cors-origins", default=None,
408
+ help="Comma-separated CORS origins (default: localhost:3000,5173)")
409
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
410
+ parser.add_argument("--skip-dns", action="store_true", help="Skip DNS setup")
411
+ parser.add_argument("--skip-ssl", action="store_true", help="Skip SSL certificate request")
412
+ parser.add_argument("--vault-csv", action="store_true",
413
+ help="Generate Vaultwarden CSV for import")
414
+ parser.add_argument("--csv-only", action="store_true",
415
+ help="Only generate Vaultwarden CSV from env vars (no infra changes)")
416
+ parser.add_argument("--vault-folder", default=None,
417
+ help="Vaultwarden folder path (default: <bucket>/infra)")
418
+ parser.add_argument("--output-csv", default=None,
419
+ help="Vaultwarden CSV output path (default: vaultwarden-import-<bucket>.csv)")
420
+ parser.add_argument("--extra-secrets", default=None,
421
+ help="Extra env vars to include in CSV (comma-separated KEY=vault-name pairs)")
422
+ return parser.parse_args()
423
+
424
+
425
+ def main():
426
+ args = parse_arguments()
427
+
428
+ hostname = f"{args.subdomain}.{args.domain}"
429
+ args.hostname = hostname
430
+
431
+ cors_origins = (
432
+ [o.strip() for o in args.cors_origins.split(",")]
433
+ if args.cors_origins
434
+ else DEFAULT_CORS_ORIGINS + [f"https://{args.domain}", f"https://app.{args.domain}"]
435
+ )
436
+
437
+ vault_folder = args.vault_folder or f"{args.bucket}/infra"
438
+ csv_path = args.output_csv or f"vaultwarden-import-{args.bucket}.csv"
439
+
440
+ # --csv-only: just generate CSV from current env vars, no infra changes
441
+ if args.csv_only:
442
+ endpoint = f"https://{HETZNER_REGIONS.get(args.region, args.region)}"
443
+ settings = [
444
+ ("hetzner-s3-endpoint", os.getenv("HETZNER_S3_ENDPOINT", endpoint), "Hetzner S3 endpoint URL"),
445
+ ("hetzner-s3-access-key", os.getenv("HETZNER_S3_ACCESS_KEY", ""), "Hetzner S3 access key"),
446
+ ("hetzner-s3-secret-key", os.getenv("HETZNER_S3_SECRET_KEY", ""), "Hetzner S3 secret key"),
447
+ ("hetzner-s3-bucket", os.getenv("HETZNER_S3_BUCKET", args.bucket), "Hetzner S3 bucket name"),
448
+ ("hetzner-s3-region", os.getenv("HETZNER_S3_REGION", args.region), "Hetzner S3 region"),
449
+ ("hetzner-s3-prefix", os.getenv("HETZNER_S3_PREFIX", ""), "Hetzner S3 key prefix"),
450
+ ("bunny-cdn-token-key", os.getenv("BUNNY_CDN_TOKEN_KEY", ""), "Bunny CDN token auth key (for signed URLs)"),
451
+ ("bunny-cdn-hostname", hostname, "Bunny CDN hostname"),
452
+ ]
453
+ # Add extra secrets from --extra-secrets
454
+ if args.extra_secrets:
455
+ for pair in args.extra_secrets.split(","):
456
+ env_var, _, vault_name = pair.partition("=")
457
+ vault_name = vault_name or env_var.lower().replace("_", "-")
458
+ settings.append((vault_name, os.getenv(env_var, ""), f"{env_var} (extra)"))
459
+
460
+ generate_vault_csv(settings, vault_folder, csv_path)
461
+ filled = sum(1 for _, v, _ in settings if v)
462
+ empty = sum(1 for _, v, _ in settings if not v)
463
+ logger.info(f" {filled} values from env, {empty} empty (fill before import)")
464
+ logger.info("\nImport: Vaultwarden UI -> Tools -> Import Data -> Bitwarden (csv)")
465
+ logger.info(f"DELETE {csv_path} after import!")
466
+ return
467
+
468
+ logger.info("=" * 60)
469
+ logger.info("PRIVATE CDN SETUP")
470
+ logger.info(" Hetzner S3 (private) + Bunny CDN (token auth)")
471
+ logger.info("=" * 60)
472
+ logger.info(f" Bucket: {args.bucket}")
473
+ logger.info(f" Hostname: {hostname}")
474
+ logger.info(f" Region: {args.region}")
475
+ logger.info(f" CORS: {', '.join(cors_origins)}")
476
+ logger.info(f" Dry run: {args.dry_run}")
477
+ logger.info("=" * 60)
478
+
479
+ if not check_env():
480
+ sys.exit(1)
481
+
482
+ # Step 1: Hetzner S3 bucket
483
+ logger.info("\n--- Step 1: Hetzner S3 Bucket (private) ---")
484
+ if not args.existing_bucket:
485
+ step_create_bucket(args.bucket, args.region, cors_origins, dry_run=args.dry_run)
486
+ else:
487
+ logger.info(f"Using existing bucket: {args.bucket}")
488
+
489
+ # Step 2: Bunny CDN pull zone + token auth
490
+ logger.info("\n--- Step 2: Bunny CDN Pull Zone + Token Auth ---")
491
+ pull_zone_id, token_key = step_create_pull_zone(
492
+ args.bucket, args.region, hostname, dry_run=args.dry_run
493
+ )
494
+
495
+ # Step 3: DNS
496
+ if not args.skip_dns:
497
+ logger.info("\n--- Step 3: DNS (Cloudflare) ---")
498
+ step_setup_dns(hostname, args.domain, args.bucket, dry_run=args.dry_run)
499
+ else:
500
+ logger.info("\n--- Step 3: DNS (skipped) ---")
501
+ logger.info(f" Create CNAME manually: {hostname} -> {args.bucket}.b-cdn.net")
502
+
503
+ # Step 4: SSL
504
+ if not args.skip_ssl and pull_zone_id and not args.dry_run:
505
+ logger.info("\n--- Step 4: SSL Certificate ---")
506
+ logger.info("Waiting 30s for DNS propagation...")
507
+ time.sleep(30)
508
+ step_request_ssl(pull_zone_id, hostname, dry_run=args.dry_run)
509
+ elif args.dry_run:
510
+ logger.info("\n--- Step 4: SSL Certificate ---")
511
+ step_request_ssl(0, hostname, dry_run=True)
512
+
513
+ # Results
514
+ endpoint = f"https://{HETZNER_REGIONS[args.region]}"
515
+ logger.info("\n" + "=" * 60)
516
+ logger.info("SETUP COMPLETE")
517
+ logger.info("=" * 60)
518
+
519
+ if token_key:
520
+ print_results(args, endpoint, pull_zone_id, token_key)
521
+
522
+ # Vaultwarden CSV
523
+ if args.vault_csv:
524
+ settings = [
525
+ ("hetzner-s3-endpoint", endpoint, "Hetzner S3 endpoint URL"),
526
+ ("hetzner-s3-access-key", os.environ.get("HETZNER_S3_ACCESS_KEY", ""), "Hetzner S3 access key"),
527
+ ("hetzner-s3-secret-key", os.environ.get("HETZNER_S3_SECRET_KEY", ""), "Hetzner S3 secret key"),
528
+ ("hetzner-s3-bucket", args.bucket, "Hetzner S3 bucket name"),
529
+ ("hetzner-s3-region", args.region, "Hetzner S3 region"),
530
+ ("bunny-cdn-api-key", os.environ.get("BUNNY_API_KEY", ""), "Bunny CDN API key"),
531
+ ("bunny-cdn-token-key", token_key, "Bunny CDN token auth key (for signed URLs)"),
532
+ ("bunny-cdn-pull-zone-id", str(pull_zone_id), "Bunny CDN pull zone ID"),
533
+ ("bunny-cdn-hostname", hostname, "Bunny CDN hostname"),
534
+ ]
535
+ generate_vault_csv(settings, vault_folder, csv_path)
536
+ logger.info(f"\nVaultwarden CSV: {csv_path}")
537
+ logger.info(" Import: Vaultwarden UI -> Tools -> Import Data -> Bitwarden (csv)")
538
+ logger.info(" DELETE the CSV after import!")
539
+ elif args.dry_run:
540
+ logger.info("\nDry run complete. Re-run without --dry-run to execute.")
541
+ else:
542
+ logger.error("No token key returned — check Bunny CDN setup.")
543
+ sys.exit(1)
544
+
545
+
546
+ if __name__ == "__main__":
547
+ main()