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,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()
|