studio-console 1.3.4__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.
@@ -0,0 +1,1549 @@
1
+ # studio_console/cloudflare/cf_wizard.py
2
+ """Cloudflare PAT-driven setup wizard.
3
+
4
+ Covers:
5
+ 1. API token collection + account discovery
6
+ 2. Tunnel create/reuse
7
+ 3. Published application routes (ingress rules) — nginx-LB aware
8
+ 4. DNS CNAME records
9
+ 5. Zero Trust Access application
10
+ 6. IP bypass policy
11
+ 7. Update IP rules (standalone, callable from submenu)
12
+ 8. Update domain (with stale DNS + Access app cleanup)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import getpass
18
+ import os
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse
21
+
22
+ from ..env import detect_shape, read_env, run_quiet, set_env_value
23
+ from ..tui import (
24
+ NavBack,
25
+ _bold,
26
+ _cyan,
27
+ _dim,
28
+ _green,
29
+ _red,
30
+ _yellow,
31
+ error,
32
+ fatal,
33
+ info,
34
+ ok,
35
+ warn,
36
+ _interactive_single,
37
+ _interactive_yn,
38
+ _prompt,
39
+ )
40
+ from .cf_api import CloudflareAPI, CloudflareError
41
+
42
+ # Set by cf_full_setup(non_interactive=True) — skips prompts that have a
43
+ # sensible default. Requested values still come from env / .env as usual.
44
+ _NON_INTERACTIVE = False
45
+
46
+
47
+ def _ingress_target(env_file: Path, hostname: str = "") -> str:
48
+ """Tunnel ingress origin. Every shape targets the nginx front door, which
49
+ path-routes api/ws vs ui. Split: nginx service. Core/full: localhost (nginx
50
+ shares the container with cloudflared)."""
51
+ nginx_port = read_env(env_file).get("SHS_NGINX_PORT", "80")
52
+ if detect_shape(env_file) in ("core", "full"):
53
+ return f"http://localhost:{nginx_port}"
54
+ return f"http://nginx:{nginx_port}"
55
+
56
+
57
+ def _is_non_interactive() -> bool:
58
+ return _NON_INTERACTIVE
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Helpers
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _load_api(env_file: Path) -> CloudflareAPI | None:
66
+ """Load a CloudflareAPI instance from .env. Returns None if token missing."""
67
+ env_data = read_env(env_file)
68
+ token = env_data.get("CLOUDFLARE_API_TOKEN", "")
69
+ account_id = env_data.get("CLOUDFLARE_ACCOUNT_ID", "")
70
+ if not token:
71
+ if _is_non_interactive():
72
+ return None
73
+ info("No Cloudflare API token configured — let's set one up.")
74
+ return _step_token(env_file)
75
+ return CloudflareAPI(token, account_id)
76
+
77
+
78
+ def _detect_home_ip() -> str:
79
+ """Best-effort home IP detection via ifconfig.me."""
80
+ rc, out = run_quiet(["curl", "-sf", "--max-time", "5", "https://ifconfig.me"])
81
+ if rc == 0 and out.strip():
82
+ return out.strip()
83
+ # Fallback
84
+ rc, out = run_quiet(["curl", "-sf", "--max-time", "5", "https://api.ipify.org"])
85
+ if rc == 0 and out.strip():
86
+ return out.strip()
87
+ return ""
88
+
89
+
90
+ def _normalise_cidr(raw: str) -> str:
91
+ """Append /32 to a bare IPv4 address. Pass CIDRs through unchanged."""
92
+ raw = raw.strip()
93
+ if raw and "/" not in raw:
94
+ return f"{raw}/32"
95
+ return raw
96
+
97
+
98
+ def _warn_broad_cidr(cidr: str) -> None:
99
+ """Warn if a CIDR prefix is broad enough to cover a large IP range."""
100
+ if "/" not in cidr:
101
+ return
102
+ try:
103
+ prefix = int(cidr.split("/")[1])
104
+ except (ValueError, IndexError):
105
+ return
106
+ if prefix < 24:
107
+ from ..tui import warn
108
+ warn(f"{cidr} covers {2 ** (32 - prefix):,} IPs — double-check this is intentional")
109
+
110
+
111
+ def _parse_domain(public_url: str) -> tuple[str, str, str]:
112
+ """Return (hostname, subdomain, root_domain) from a public URL.
113
+
114
+ e.g. "https://studio.example.com" → ("studio.example.com", "studio", "example.com")
115
+ """
116
+ parsed = urlparse(public_url)
117
+ hostname = parsed.hostname or ""
118
+ parts = hostname.split(".", 1)
119
+ if len(parts) == 2:
120
+ return hostname, parts[0], parts[1]
121
+ return hostname, "", hostname
122
+
123
+
124
+ def _resolve_zone(cf: CloudflareAPI, root_domain: str) -> str | None:
125
+ """Find the Cloudflare zone ID matching root_domain. Returns None if not found.
126
+
127
+ Distinguishes a permissions failure (403 — token lacks Zone:Read) from a
128
+ genuine absence, so the operator isn't wrongly told their zone doesn't exist
129
+ when the real fix is a token scope.
130
+ """
131
+ try:
132
+ zones = cf.list_zones()
133
+ except CloudflareError as e:
134
+ if e.status_code == 403:
135
+ warn(
136
+ "Could not list zones (403 — token likely lacks Zone:Read on "
137
+ "this zone). Add the scope or enter the zone ID manually."
138
+ )
139
+ else:
140
+ warn(f"Could not list zones: {e}")
141
+ return None
142
+ for zone in zones:
143
+ if zone.get("name") == root_domain:
144
+ return zone.get("id", "")
145
+ return None
146
+
147
+
148
+ def _upsert_ip_policy(
149
+ cf: CloudflareAPI,
150
+ app_id: str,
151
+ policy_name: str,
152
+ ip_ranges: list[str],
153
+ existing_policy_id: str = "",
154
+ ) -> None:
155
+ """Create or update a named IP bypass policy on an Access app."""
156
+ if existing_policy_id:
157
+ info("Updating IP bypass policy...")
158
+ try:
159
+ cf.update_ip_bypass_policy(app_id, existing_policy_id, policy_name, ip_ranges)
160
+ ok(f"Bypass policy updated: {', '.join(ip_ranges)}")
161
+ except CloudflareError as e:
162
+ error(f"Failed to update policy: {e}")
163
+ else:
164
+ info("Creating IP bypass policy...")
165
+ try:
166
+ cf.create_ip_bypass_policy(app_id, policy_name, ip_ranges)
167
+ ok(f"Bypass policy created: {', '.join(ip_ranges)}")
168
+ except CloudflareError as e:
169
+ error(f"Failed to create policy: {e}")
170
+
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Step 1 — API token + account
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def _print_token_instructions() -> None:
179
+ """Print step-by-step Cloudflare API token creation instructions."""
180
+ print()
181
+ print(f" {_bold('Before you start — grab your Account ID:')}")
182
+ print(f" Go to {_cyan('https://dash.cloudflare.com')} (Account home)")
183
+ print(f" - In the URL: dash.cloudflare.com/{_bold('<account-id>')}/...")
184
+ print(f" - Or: click the {_bold('⋮')} menu (top right) → {_bold('Copy account ID')}")
185
+ print()
186
+ try:
187
+ input(f" Press Enter when you have your Account ID ready...")
188
+ except (EOFError, KeyboardInterrupt):
189
+ print()
190
+ return
191
+ print()
192
+ print(f" {_bold('Now create a Cloudflare API token:')}")
193
+ print()
194
+ print(f" 1. Go to: {_cyan('https://dash.cloudflare.com/profile/api-tokens')}")
195
+ print(f" 2. Click {_bold('Create Token')} → {_bold('Custom token')} → {_bold('Get started')}")
196
+ print(f" 3. Give it a name, e.g. {_bold('studio-console')}")
197
+ print(f" 4. Under {_bold('Permissions')}, add these three:")
198
+ print(f" Account → Cloudflare Tunnel → Edit")
199
+ print(f" Account → Access: Apps and Policies → Edit")
200
+ print(f" Zone → DNS → Edit")
201
+ print(f" 5. Under {_bold('Account Resources')}: Include → All accounts {_dim('(or your specific account)')}")
202
+ print(f" 6. Under {_bold('Zone Resources')}: Include → Specific zone → your domain")
203
+ print(f" 7. {_dim('Client IP filtering and TTL — leave blank (console sets up IP rules separately)')}")
204
+ print(f" 8. Click {_bold('Continue to summary')} → {_bold('Create Token')}")
205
+ print(f" 9. Copy the token — {_bold('it is only shown once')}")
206
+ print()
207
+ try:
208
+ input(f" Press Enter when you have your token ready...")
209
+ except (EOFError, KeyboardInterrupt):
210
+ print()
211
+ return
212
+ print()
213
+
214
+
215
+ def _step_token(env_file: Path) -> CloudflareAPI | None:
216
+ """Collect account ID + API token, validate, save to .env."""
217
+ env_data = read_env(env_file)
218
+ existing_token = env_data.get("CLOUDFLARE_API_TOKEN", "")
219
+ existing_account_id = env_data.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
220
+
221
+ print()
222
+ if existing_token and _is_non_interactive():
223
+ # Non-interactive: trust the values that env / .env supplied and move on.
224
+ token = existing_token
225
+ account_id = existing_account_id
226
+ if not account_id:
227
+ error("Account ID is required (set CLOUDFLARE_ACCOUNT_ID)")
228
+ return None
229
+ elif existing_token:
230
+ # Re-run: just confirm or replace existing credentials
231
+ hint = f"{_dim('[set]')} (enter=keep, paste new to replace)"
232
+ raw = _prompt(f"Cloudflare API token {hint}", "").strip()
233
+ token = raw if raw else existing_token
234
+
235
+ account_id = existing_account_id
236
+ if not account_id:
237
+ account_id = _prompt("Account ID", "").strip()
238
+ if not account_id:
239
+ error("Account ID is required")
240
+ return None
241
+ else:
242
+ # First run: show instructions or proceed if they're ready
243
+ idx = _interactive_single(
244
+ "Cloudflare API token",
245
+ ["I have a token and my Account ID ready", "Show me how to create a token"],
246
+ default=0,
247
+ )
248
+ if idx == 1:
249
+ _print_token_instructions()
250
+
251
+ # Account ID first — they already have it from the instructions
252
+ print()
253
+ print(f" {_dim('Find your Account ID:')}")
254
+ print(f" - In the URL: dash.cloudflare.com/{_bold('<account-id>')}/...")
255
+ print(f" - Or: Account home → click the {_bold('⋮')} menu (top right) → {_bold('Copy account ID')}")
256
+ print()
257
+ account_id = _prompt("Account ID", existing_account_id).strip()
258
+ if not account_id:
259
+ error("Account ID is required")
260
+ return None
261
+
262
+ try:
263
+ token = getpass.getpass(f"▸ Cloudflare API token: ").strip()
264
+ except (EOFError, KeyboardInterrupt):
265
+ print()
266
+ return None
267
+ if not token:
268
+ warn("No token entered")
269
+ return None
270
+
271
+ info("Validating token...")
272
+ cf = CloudflareAPI(token, account_id)
273
+ try:
274
+ valid = cf.verify_token()
275
+ except CloudflareError as e:
276
+ if e.status_code in (401, 403):
277
+ error(
278
+ "Token rejected (401/403) — it's invalid, expired, or missing "
279
+ "scopes. Tunnel setup needs: Account:Cloudflare Tunnel, "
280
+ "Account:Access: Apps and Policies, Zone:DNS, Zone:Zone (Read)."
281
+ )
282
+ else:
283
+ error(f"Could not validate token: {e}")
284
+ return None
285
+ if not valid:
286
+ error("Token validation failed — check that the token is correct and not expired")
287
+ return None
288
+
289
+ set_env_value(env_file, "CLOUDFLARE_API_TOKEN", token)
290
+ set_env_value(env_file, "CLOUDFLARE_ACCOUNT_ID", account_id)
291
+ ok(f"Token valid (account: {account_id})")
292
+ return cf
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Step 2 — Tunnel create/reuse
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ def _fetch_tunnel_token(cf: CloudflareAPI, tunnel_id: str) -> str | None:
301
+ """Fetch tunnel token, return None on failure."""
302
+ info("Fetching tunnel token...")
303
+ try:
304
+ return cf.get_tunnel_token(tunnel_id)
305
+ except CloudflareError as e:
306
+ error(f"Failed to get tunnel token: {e}")
307
+ return None
308
+
309
+
310
+ def _save_tunnel(env_file: Path, tunnel_id: str, tunnel_token: str) -> None:
311
+ """Persist tunnel ID, token, and cloudflared compose profile to .env."""
312
+ set_env_value(env_file, "CLOUDFLARE_TUNNEL_ID", tunnel_id)
313
+ set_env_value(env_file, "CLOUDFLARE_TUNNEL_TOKEN", tunnel_token)
314
+ env_data = read_env(env_file)
315
+ profiles = env_data.get("COMPOSE_PROFILES", "")
316
+ if "cloudflared" not in profiles:
317
+ set_env_value(env_file, "COMPOSE_PROFILES", (profiles + ",cloudflared").strip(","))
318
+
319
+
320
+ def _confirm_tunnel_overwrite(cf: CloudflareAPI, tunnel: dict) -> bool:
321
+ """Warn before reusing a tunnel that already has ingress for other hostnames."""
322
+ try:
323
+ existing = cf.get_tunnel_config(tunnel["id"])
324
+ except CloudflareError as e:
325
+ warn(f"Could not read tunnel config: {e} — proceeding anyway")
326
+ return True
327
+ if not existing:
328
+ return True
329
+
330
+ print()
331
+ warn(f"Tunnel '{tunnel['name']}' currently routes:")
332
+ for rule in existing:
333
+ host = rule.get("hostname", "?")
334
+ svc = rule.get("service", "?")
335
+ print(f" - {host} → {svc}")
336
+ print(f" {_dim('Continuing replaces these rules with the hostnames you configure now.')}")
337
+ return _interactive_yn("Replace existing ingress?", default=False)
338
+
339
+
340
+ def _step_tunnel(cf: CloudflareAPI, env_file: Path) -> tuple[str, str] | None:
341
+ """Select or create a tunnel. Returns (tunnel_id, tunnel_token) or None on abort."""
342
+ env_data = read_env(env_file)
343
+ existing_id = env_data.get("CLOUDFLARE_TUNNEL_ID", "")
344
+ existing_token = env_data.get("CLOUDFLARE_TUNNEL_TOKEN", "")
345
+
346
+ print()
347
+ info("Fetching tunnels...")
348
+ try:
349
+ tunnels = cf.list_tunnels()
350
+ except CloudflareError as e:
351
+ warn(f"Could not list tunnels: {e}")
352
+ tunnels = []
353
+
354
+ # If stored tunnel still exists, offer to keep it
355
+ if existing_id and any(t.get("id") == existing_id for t in tunnels):
356
+ name = next((t["name"] for t in tunnels if t["id"] == existing_id), existing_id)
357
+ ok(f"Existing tunnel: {name} ({existing_id})")
358
+ if _is_non_interactive() or not _interactive_yn(
359
+ "Reconfigure? (No = keep current)", default=False
360
+ ):
361
+ return existing_id, existing_token
362
+
363
+ # Non-interactive: jump straight to "create new tunnel" with default name.
364
+ if _is_non_interactive():
365
+ idx = len(tunnels) # = "Create new tunnel"
366
+ else:
367
+ # Build selection list
368
+ options = [
369
+ f"{t['name']} {(_green if t.get('status') == 'healthy' else _dim)(t.get('status', ''))} {_dim(t['id'][:8] + '...')}"
370
+ for t in tunnels
371
+ ]
372
+ options.append("Create new tunnel")
373
+ idx = _interactive_single("Select tunnel to use", options, default=len(tunnels))
374
+
375
+ tunnel_map: dict[int, dict] = {i: t for i, t in enumerate(tunnels)}
376
+ create_idx = len(tunnels)
377
+
378
+ if idx < create_idx:
379
+ # Reuse selected tunnel
380
+ tunnel = tunnel_map[idx]
381
+ tunnel_id = tunnel["id"]
382
+ if tunnel_id != existing_id and not _confirm_tunnel_overwrite(cf, tunnel):
383
+ return None
384
+ tunnel_token = _fetch_tunnel_token(cf, tunnel_id)
385
+ if not tunnel_token:
386
+ return None
387
+ ok(f"Using tunnel: {tunnel['name']} ({tunnel_id})")
388
+ _save_tunnel(env_file, tunnel_id, tunnel_token)
389
+ return tunnel_id, tunnel_token
390
+
391
+ # Create new tunnel — loop to handle name collisions
392
+ if _is_non_interactive():
393
+ name = _tunnel_name(env_file)
394
+ else:
395
+ preset = read_env(env_file).get("CLOUDFLARE_TUNNEL_NAME", "") or "studio"
396
+ name = _prompt("Tunnel name", preset)
397
+ while True:
398
+ info(f"Creating tunnel '{name}'...")
399
+ try:
400
+ tunnel = cf.create_tunnel(name)
401
+ tunnel_id = tunnel["id"]
402
+ tunnel_token = _fetch_tunnel_token(cf, tunnel_id)
403
+ if not tunnel_token:
404
+ return None
405
+ ok(f"Tunnel created: {name} ({tunnel_id})")
406
+ _save_tunnel(env_file, tunnel_id, tunnel_token)
407
+ return tunnel_id, tunnel_token
408
+ except CloudflareError as e:
409
+ if "already have a tunnel" in str(e).lower() or "already exists" in str(e).lower():
410
+ warn(f"A tunnel named '{name}' already exists.")
411
+ if _is_non_interactive():
412
+ # Non-interactive: silently reuse the existing tunnel.
413
+ match = next((t for t in tunnels if t.get("name") == name), None)
414
+ if not match:
415
+ error(f"Could not find '{name}' in tunnel list")
416
+ return None
417
+ tunnel_id = match["id"]
418
+ tunnel_token = _fetch_tunnel_token(cf, tunnel_id)
419
+ if not tunnel_token:
420
+ return None
421
+ ok(f"Reusing tunnel: {name} ({tunnel_id})")
422
+ _save_tunnel(env_file, tunnel_id, tunnel_token)
423
+ return tunnel_id, tunnel_token
424
+ action = _interactive_single(
425
+ "What would you like to do?",
426
+ [f"Use the existing '{name}' tunnel", "Choose a different name", "Cancel"],
427
+ default=0,
428
+ )
429
+ if action == 0:
430
+ match = next((t for t in tunnels if t.get("name") == name), None)
431
+ if not match:
432
+ error(f"Could not find '{name}' in tunnel list")
433
+ return None
434
+ tunnel_id = match["id"]
435
+ tunnel_token = _fetch_tunnel_token(cf, tunnel_id)
436
+ if not tunnel_token:
437
+ return None
438
+ ok(f"Using tunnel: {name} ({tunnel_id})")
439
+ _save_tunnel(env_file, tunnel_id, tunnel_token)
440
+ return tunnel_id, tunnel_token
441
+ elif action == 1:
442
+ name = _prompt("New tunnel name", "")
443
+ if not name:
444
+ return None
445
+ else:
446
+ return None
447
+ else:
448
+ error(f"Failed to create tunnel: {e}")
449
+ return None
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Step 3 — Public domain + zone
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ def _step_domain(cf: CloudflareAPI, env_file: Path) -> tuple[str, str, str, str] | None:
458
+ """Collect/confirm public domain and resolve zone ID.
459
+
460
+ Returns (public_url, hostname, zone_id, root_domain) or None on abort.
461
+ """
462
+ env_data = read_env(env_file)
463
+ current_url = env_data.get("SHS_PUBLIC_BASE_URL", "")
464
+
465
+ print()
466
+ domain_url = _prompt(
467
+ "Public domain (e.g. https://studio.example.com)",
468
+ current_url if current_url.startswith("https://") else "",
469
+ ).strip().rstrip("/")
470
+
471
+ if not domain_url.startswith("https://"):
472
+ warn("Must start with https://")
473
+ return None
474
+
475
+ hostname, subdomain, root_domain = _parse_domain(domain_url)
476
+
477
+ # Resolve zone
478
+ info(f"Looking up zone for {root_domain}...")
479
+ zone_id = _resolve_zone(cf, root_domain)
480
+ if not zone_id:
481
+ warn(f"Zone '{root_domain}' not found in your Cloudflare account")
482
+ zone_id = _prompt("Enter zone ID manually (or leave blank to skip DNS setup)", "").strip()
483
+ if not zone_id:
484
+ warn("DNS records will not be created automatically")
485
+
486
+ if zone_id:
487
+ set_env_value(env_file, "CLOUDFLARE_ZONE_ID", zone_id)
488
+
489
+ set_env_value(env_file, "SHS_PUBLIC_BASE_URL", domain_url)
490
+ return domain_url, hostname, zone_id, root_domain
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Step 4 — Routes + ingress config
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ def _confirm_dns_overwrite(
499
+ cf: CloudflareAPI, zone_id: str, hostname: str, tunnel_id: str
500
+ ) -> bool:
501
+ """Return True if it's safe to upsert the tunnel CNAME at `hostname`.
502
+
503
+ Silent when there's no existing record, or when the existing record is
504
+ already pointing at this exact tunnel (re-running the wizard). Prompts the
505
+ operator only when a conflicting record exists — anything that isn't our
506
+ tunnel CNAME — so they can decide whether to overwrite.
507
+ """
508
+ try:
509
+ records = cf.list_dns_records(zone_id, name=hostname)
510
+ except CloudflareError as e:
511
+ warn(f"Could not check existing DNS for {hostname}: {e}")
512
+ return True # don't block — let upsert_dns_record surface the real error
513
+
514
+ if not records:
515
+ return True
516
+
517
+ expected = f"{tunnel_id}.cfargotunnel.com"
518
+ matches_us = all(
519
+ r.get("type") == "CNAME" and r.get("content") == expected
520
+ for r in records
521
+ )
522
+ if matches_us:
523
+ return True # already ours — no-op overwrite, don't prompt
524
+
525
+ # Surface the conflict in operator-readable form.
526
+ warn(f"{hostname} already has DNS records that aren't pointing at this tunnel:")
527
+ for r in records:
528
+ rtype = r.get("type", "?")
529
+ content = r.get("content", "?")
530
+ print(f" {rtype:5s} {hostname} → {content}")
531
+ if _is_non_interactive():
532
+ # Don't silently stomp existing records when running scripted; skip and warn.
533
+ warn(f"Refusing to overwrite {hostname} in non-interactive mode")
534
+ return False
535
+ return _interactive_yn(
536
+ f"Overwrite with tunnel CNAME ({expected})?",
537
+ default=False,
538
+ )
539
+
540
+
541
+ def _step_routes(
542
+ cf: CloudflareAPI,
543
+ env_file: Path,
544
+ tunnel_id: str,
545
+ hostnames: list[str],
546
+ zone_id: str,
547
+ root_domain: str,
548
+ ) -> list[str] | None:
549
+ """Configure tunnel ingress rules and DNS records for one or more hostnames.
550
+
551
+ nginx is the sole entry point — every public hostname maps to the nginx
552
+ front door and nginx splits internally by path.
553
+ """
554
+ print()
555
+ ingress: list[dict] = [
556
+ {"hostname": h, "service": _ingress_target(env_file, h)} for h in hostnames
557
+ ]
558
+
559
+ info("Pushing tunnel ingress config...")
560
+ try:
561
+ cf.put_tunnel_config(tunnel_id, ingress)
562
+ for rule in ingress:
563
+ ok(f"Ingress: {rule['hostname']} → {rule['service']}")
564
+ except CloudflareError as e:
565
+ error(f"Failed to configure ingress: {e}")
566
+ return None
567
+
568
+ # DNS records — one CNAME per hostname. Existing non-tunnel records
569
+ # trigger a confirm prompt before overwrite (operators may have other
570
+ # services on the same subdomain we shouldn't silently stomp).
571
+ # Track each hostname's outcome so we can report a summary — a half-applied
572
+ # route set with no report of what landed is exactly the silent dead-end
573
+ # (hostname resolves nowhere → 404/DNS error the operator can't diagnose).
574
+ dns_ok: list[str] = []
575
+ dns_failed: list[str] = []
576
+ dns_manual: list[str] = [] # declined overwrite or no zone — operator must act
577
+ for h in hostnames:
578
+ if zone_id:
579
+ if not _confirm_dns_overwrite(cf, zone_id, h, tunnel_id):
580
+ warn(f"Skipping DNS for {h} — operator declined overwrite")
581
+ dns_manual.append(h)
582
+ continue
583
+ info(f"Creating DNS record: {h} → tunnel")
584
+ try:
585
+ cf.upsert_dns_record(zone_id, h, tunnel_id)
586
+ ok(f"DNS: {h}")
587
+ dns_ok.append(h)
588
+ except CloudflareError as e:
589
+ warn(f"DNS record failed for {h}: {e}")
590
+ dns_failed.append(h)
591
+ else:
592
+ warn(f"Skipping DNS for {h} — create this CNAME manually:")
593
+ print(f" {h} → {tunnel_id}.cfargotunnel.com (proxied)")
594
+ dns_manual.append(h)
595
+
596
+ # Summary — surface exactly which hostnames are live vs. need action.
597
+ if dns_failed or dns_manual:
598
+ print()
599
+ warn("DNS not fully applied — these hostnames will not resolve yet:")
600
+ for h in dns_failed:
601
+ print(f" {h} (FAILED — retry 'Update domain' or check Zone:DNS scope)")
602
+ for h in dns_manual:
603
+ print(f" {h} → {tunnel_id}.cfargotunnel.com (create this CNAME, proxied)")
604
+ if dns_ok:
605
+ print(f" {_dim('Applied OK: ' + ', '.join(dns_ok))}")
606
+
607
+ # A genuine API failure (not an operator-declined/manual skip) means the
608
+ # route set is partially applied — signal it so the caller warns and the
609
+ # operator knows to re-run rather than assuming success.
610
+ if dns_failed:
611
+ return None
612
+
613
+ return hostnames
614
+
615
+
616
+ # ---------------------------------------------------------------------------
617
+ # Step 5 — Zero Trust Access application
618
+ # ---------------------------------------------------------------------------
619
+
620
+
621
+ # Env vars storing each Access app id, keyed by role.
622
+ _ACCESS_APP_ENV_VAR = {
623
+ "ui": "CLOUDFLARE_ACCESS_APP_ID",
624
+ "api": "CLOUDFLARE_ACCESS_API_APP_ID",
625
+ }
626
+
627
+
628
+ def _default_access_app_name(hostname: str) -> str:
629
+ """Suggest a CF Access app name based on the public hostname.
630
+
631
+ "app-mac.self-hoststudio.com" -> "Studio - app-mac"
632
+ "api.example.com" -> "Studio - api"
633
+ "example.com" -> "Studio - example.com" (rare, apex)
634
+ """
635
+ parts = hostname.split(".")
636
+ if len(parts) >= 3:
637
+ return f"Studio - {parts[0]}"
638
+ return f"Studio - {hostname}"
639
+
640
+
641
+ def _ensure_access_app(
642
+ cf: CloudflareAPI,
643
+ env_file: Path,
644
+ role: str,
645
+ hostnames: list[str],
646
+ apps: list[dict] | None = None,
647
+ ) -> str | None:
648
+ """Create or reuse a Zero Trust Access app for the given role ("ui" or "api").
649
+
650
+ Stores the resulting app id in the role-specific env var. If an app already
651
+ exists for this role and is still present in Cloudflare, its domains are
652
+ updated to match `hostnames`. The interactive selection menu is only shown
653
+ on first setup for this role.
654
+ """
655
+ env_var = _ACCESS_APP_ENV_VAR[role]
656
+ primary_host = hostnames[0] if hostnames else ""
657
+ default_name = _default_access_app_name(primary_host)
658
+
659
+ env_data = read_env(env_file)
660
+ existing_app_id = env_data.get(env_var, "")
661
+
662
+ if apps is None:
663
+ info("Fetching Access applications...")
664
+ try:
665
+ apps = cf.list_access_apps()
666
+ except CloudflareError as e:
667
+ warn(f"Could not list Access apps: {e}")
668
+ apps = []
669
+
670
+ # If stored app still exists, offer to keep it as-is.
671
+ if existing_app_id and any(a.get("id") == existing_app_id for a in apps):
672
+ name = next((a["name"] for a in apps if a["id"] == existing_app_id), existing_app_id)
673
+ ok(f"Existing {role.upper()} Access app: {name} ({existing_app_id})")
674
+ if _is_non_interactive() or not _interactive_yn(
675
+ f"Reconfigure {role.upper()} Access app? (No = keep current)", default=False
676
+ ):
677
+ info(f"Updating domains on {role.upper()} app...")
678
+ try:
679
+ cf.update_access_app_domains(existing_app_id, hostnames)
680
+ ok(f"{role.upper()} Access app domains updated")
681
+ except CloudflareError as e:
682
+ warn(f"Could not update domains: {e}")
683
+ return existing_app_id
684
+
685
+ # Non-interactive: jump straight to "create new" with the suggested default name.
686
+ if _is_non_interactive():
687
+ idx = len(apps) # = create new
688
+ else:
689
+ # Build selection list — let the operator reuse an existing app if they want.
690
+ options = [f"{a['name']} {_dim(a['id'][:8] + '...')}" for a in apps]
691
+ options.append(f"Create new Access app ({default_name})")
692
+ idx = _interactive_single(
693
+ f"Select Access application for the {role.upper()} hostname",
694
+ options,
695
+ default=len(apps),
696
+ )
697
+
698
+ app_map: dict[int, dict] = {i: a for i, a in enumerate(apps)}
699
+ create_idx = len(apps)
700
+
701
+ if idx < create_idx:
702
+ app = app_map[idx]
703
+ app_id = app["id"]
704
+ info(f"Updating domains on existing app...")
705
+ try:
706
+ cf.update_access_app_domains(app_id, hostnames)
707
+ ok(f"Using Access app: {app['name']} ({app_id})")
708
+ except CloudflareError as e:
709
+ warn(f"Could not update domains: {e}")
710
+ set_env_value(env_file, env_var, app_id)
711
+ return app_id
712
+
713
+ # Create new — but first handle name collisions gracefully so the operator
714
+ # gets a 3-way choice (reuse / rename / cancel) instead of a raw API error.
715
+ app_name = (
716
+ default_name
717
+ if _is_non_interactive()
718
+ else _prompt("Access application name", default_name).strip()
719
+ )
720
+ while True:
721
+ existing = next((a for a in apps if a.get("name") == app_name), None)
722
+ if existing:
723
+ warn(f"An Access app named '{app_name}' already exists.")
724
+ if _is_non_interactive():
725
+ # Reuse silently — same tunnel-collision policy.
726
+ app_id = existing["id"]
727
+ info(f"Updating domains on existing app...")
728
+ try:
729
+ cf.update_access_app_domains(app_id, hostnames)
730
+ ok(f"Using Access app: {app_name} ({app_id})")
731
+ except CloudflareError as e:
732
+ warn(f"Could not update domains: {e}")
733
+ set_env_value(env_file, env_var, app_id)
734
+ return app_id
735
+ choice = _interactive_single(
736
+ "What would you like to do?",
737
+ [
738
+ f"Use the existing '{app_name}' app",
739
+ "Pick a different name",
740
+ "Cancel",
741
+ ],
742
+ default=0,
743
+ )
744
+ if choice == 0:
745
+ app_id = existing["id"]
746
+ info(f"Updating domains on existing app...")
747
+ try:
748
+ cf.update_access_app_domains(app_id, hostnames)
749
+ ok(f"Using Access app: {app_name} ({app_id})")
750
+ except CloudflareError as e:
751
+ warn(f"Could not update domains: {e}")
752
+ set_env_value(env_file, env_var, app_id)
753
+ return app_id
754
+ if choice == 1:
755
+ app_name = _prompt("Access application name", default_name).strip()
756
+ continue
757
+ return None
758
+
759
+ info(f"Creating {role.upper()} Access application...")
760
+ try:
761
+ app = cf.create_access_app(app_name, hostnames)
762
+ app_id = app.get("id", "") if isinstance(app, dict) else ""
763
+ if not app_id:
764
+ error("Access app created but no ID returned")
765
+ return None
766
+ set_env_value(env_file, env_var, app_id)
767
+ ok(f"Access app created: {app_name} ({app_id})")
768
+ return app_id
769
+ except CloudflareError as e:
770
+ error(f"Failed to create Access app: {e}")
771
+ return None
772
+
773
+
774
+ # ---------------------------------------------------------------------------
775
+ # Step 6 — IP bypass policy
776
+ # ---------------------------------------------------------------------------
777
+
778
+
779
+ _IP_POLICY_NAME = "Studio Console - IP Bypass"
780
+
781
+
782
+ def _apply_ip_policy(cf: CloudflareAPI, app_id: str, ip_ranges: list[str], role: str) -> None:
783
+ """Upsert console's IP bypass policy on a single Access app."""
784
+ existing_policy_id = ""
785
+ try:
786
+ policies = cf.list_access_policies(app_id)
787
+ for p in policies:
788
+ if p.get("name") == _IP_POLICY_NAME:
789
+ existing_policy_id = p.get("id", "")
790
+ break
791
+ except CloudflareError:
792
+ pass
793
+ info(f"Applying IP policy to {role.upper()} app...")
794
+ _upsert_ip_policy(cf, app_id, _IP_POLICY_NAME, ip_ranges, existing_policy_id)
795
+
796
+
797
+ def _remove_ip_policy(cf: CloudflareAPI, app_id: str, role: str) -> None:
798
+ """Delete console's IP bypass policy from an Access app, if present.
799
+
800
+ Used when the operator changes ip_restrict_mode such that this app should
801
+ no longer be gated. We only ever touch policies we created (matched by name).
802
+ """
803
+ try:
804
+ policies = cf.list_access_policies(app_id)
805
+ except CloudflareError as e:
806
+ warn(f"Could not list policies on {role.upper()} app: {e}")
807
+ return
808
+ for p in policies:
809
+ if p.get("name") == _IP_POLICY_NAME:
810
+ try:
811
+ cf.delete_access_policy(app_id, p["id"])
812
+ ok(f"Removed IP policy from {role.upper()} app")
813
+ except CloudflareError as e:
814
+ warn(f"Could not remove IP policy from {role.upper()} app: {e}")
815
+ return
816
+
817
+
818
+ def _prompt_ip_restrict_mode(current: str, is_split: bool) -> str:
819
+ """Ask which roles to gate by IP. Returns 'none', 'ui', or 'both'."""
820
+ if _is_non_interactive():
821
+ return current
822
+ both_label = "UI + API" if is_split else "UI"
823
+ modes = ["none", "ui", "both"] if is_split else ["none", "both"]
824
+ labels = (
825
+ [
826
+ "None — public, no IP gating",
827
+ "UI only — gate UI, leave API public",
828
+ f"{both_label} — gate all hostnames",
829
+ ]
830
+ if is_split
831
+ else ["None — public, no IP gating", f"{both_label} — gate with IP bypass"]
832
+ )
833
+ default = modes.index(current) if current in modes else 0
834
+ idx = _interactive_single("Restrict access by IP?", labels, default=default)
835
+ return modes[idx]
836
+
837
+
838
+ def _step_ip_policy(
839
+ cf: CloudflareAPI,
840
+ env_file: Path,
841
+ gated_apps: list[tuple[str, str]],
842
+ ungated_apps: list[tuple[str, str]],
843
+ ) -> None:
844
+ """Create/update the IP bypass policy on every gated app; remove from ungated.
845
+
846
+ `gated_apps` and `ungated_apps` are each lists of (role, app_id) tuples.
847
+ The same IP set is applied to all gated apps. Ungated apps have any
848
+ console-managed policy removed so they remain public.
849
+ """
850
+ if not gated_apps and not ungated_apps:
851
+ return
852
+
853
+ if not gated_apps:
854
+ # ip_restrict_mode == "none" — make sure no stale policy remains.
855
+ for role, app_id in ungated_apps:
856
+ _remove_ip_policy(cf, app_id, role)
857
+ return
858
+
859
+ print()
860
+ if len(gated_apps) > 1:
861
+ info(f"Configuring IP allowlist for: {', '.join(r.upper() for r, _ in gated_apps)}")
862
+
863
+ # Pre-set allowlist via env / .env? Skip the prompts entirely when present.
864
+ # Comma-separated CIDRs; bare IPs auto-promoted to /32 by _normalise_cidr.
865
+ env_data = read_env(env_file)
866
+ preset_raw = (
867
+ os.environ.get("CONSOLE_IP_ALLOWLIST", "") or env_data.get("CONSOLE_IP_ALLOWLIST", "")
868
+ ).strip()
869
+ if preset_raw:
870
+ ip_ranges = [
871
+ _normalise_cidr(p) for p in preset_raw.split(",") if p.strip()
872
+ ]
873
+ for cidr in ip_ranges:
874
+ _warn_broad_cidr(cidr)
875
+ info(f"Using CONSOLE_IP_ALLOWLIST: {', '.join(ip_ranges)}")
876
+ for role, app_id in gated_apps:
877
+ _apply_ip_policy(cf, app_id, ip_ranges, role)
878
+ for role, app_id in ungated_apps:
879
+ _remove_ip_policy(cf, app_id, role)
880
+ return
881
+
882
+ # Prompt path — used when CONSOLE_IP_ALLOWLIST isn't set (the preset_raw branch
883
+ # above), even in non-interactive mode. Catch-all for "operator forgot,"
884
+ # VPN-but-secrets-file-has-old-IP, new location, etc. The fully-specified
885
+ # CONSOLE_IP_ALLOWLIST path stays prompt-free.
886
+ detected_ip = _detect_home_ip()
887
+ if detected_ip:
888
+ info(f"Detected your IP: {detected_ip}")
889
+ print(f" {_dim('If your ISP assigns dynamic IPs, this rule will break when your IP changes.')}")
890
+ print(f" {_dim('Use a CIDR range or update the rule after each IP change (Cloudflare → Update IP rules).')}")
891
+ print(f" {_dim('Press enter to use detected IP, or type skip to skip.')}")
892
+ else:
893
+ warn("Could not detect your IP — enter it manually or type skip to skip")
894
+
895
+ home_ip_raw = _prompt(
896
+ "Your IP for bypass",
897
+ detected_ip,
898
+ ).strip()
899
+
900
+ if not home_ip_raw or home_ip_raw.lower() == "skip":
901
+ warn("Skipping bypass policy")
902
+ return
903
+
904
+ ip_ranges: list[str] = [_normalise_cidr(home_ip_raw)]
905
+ _warn_broad_cidr(ip_ranges[0])
906
+
907
+ extra = _prompt("Additional IPs? (comma-separated CIDRs, leave blank to skip)", "").strip()
908
+ if extra:
909
+ for part in extra.split(","):
910
+ cidr = _normalise_cidr(part)
911
+ if cidr and cidr not in ip_ranges:
912
+ _warn_broad_cidr(cidr)
913
+ ip_ranges.append(cidr)
914
+
915
+ for role, app_id in gated_apps:
916
+ _apply_ip_policy(cf, app_id, ip_ranges, role)
917
+ for role, app_id in ungated_apps:
918
+ _remove_ip_policy(cf, app_id, role)
919
+
920
+
921
+ # ---------------------------------------------------------------------------
922
+ # Update IP rules (standalone, called from submenu option 4)
923
+ # ---------------------------------------------------------------------------
924
+
925
+
926
+ def _read_bypass_ips(cf: CloudflareAPI, app_id: str) -> tuple[list[str], dict | None]:
927
+ """Return (ips, policy_dict) for the console-managed bypass policy on app_id.
928
+
929
+ Returns ([], None) if the policy doesn't exist or the API call fails.
930
+ """
931
+ try:
932
+ policies = cf.list_access_policies(app_id)
933
+ except CloudflareError as e:
934
+ error(f"Could not fetch policies: {e}")
935
+ return [], None
936
+ bypass = next((p for p in policies if p.get("name") == _IP_POLICY_NAME), None)
937
+ if not bypass:
938
+ return [], None
939
+ ips: list[str] = []
940
+ for rule in bypass.get("include", []):
941
+ ip_rule = rule.get("ip", {})
942
+ if ip_rule.get("ip"):
943
+ ips.append(ip_rule["ip"])
944
+ return ips, bypass
945
+
946
+
947
+ def _hostname_for_role(env_file: Path, role: str) -> str:
948
+ """Bare hostname for an Access role, derived from .env URLs."""
949
+ env_data = read_env(env_file)
950
+ url_var = "SHS_PUBLIC_BASE_URL" if role == "ui" else "CONSOLE_PUBLIC_API_BASE_URL"
951
+ url = env_data.get(url_var, "")
952
+ if not url.startswith("https://"):
953
+ return ""
954
+ return url[len("https://") :].split("/", 1)[0]
955
+
956
+
957
+ def update_ip_rules(env_file: Path) -> None:
958
+ """Interactively add/remove/replace IPs on the bypass policy.
959
+
960
+ Lazy-creates an Access app for any role the operator wants to gate but
961
+ doesn't yet have an app for. Conversely, removing all IPs from a role
962
+ deletes that role's Access app so the hostname becomes public again
963
+ (an Access app with no policy blocks everyone).
964
+ """
965
+ cf = _load_api(env_file)
966
+ if not cf:
967
+ warn("Cloudflare API token required — cannot update IP rules.")
968
+ return
969
+
970
+ env_data = read_env(env_file)
971
+ ui_app_id = env_data.get("CLOUDFLARE_ACCESS_APP_ID", "")
972
+ api_app_id = env_data.get("CLOUDFLARE_ACCESS_API_APP_ID", "")
973
+ has_ui_host = bool(env_data.get("SHS_PUBLIC_BASE_URL", "").startswith("https://"))
974
+ has_api_host = bool(env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "").startswith("https://"))
975
+
976
+ if not has_ui_host:
977
+ warn("No public UI domain configured. Run 'Full setup (API)' first.")
978
+ return
979
+
980
+ # Decide which role(s) to operate on.
981
+ available_roles: list[str] = ["ui"]
982
+ if has_api_host:
983
+ available_roles.append("api")
984
+
985
+ if len(available_roles) == 1:
986
+ target_roles = ["ui"]
987
+ else:
988
+ labels = [
989
+ f"UI {_dim('gates the UI hostname (browser sessions)')}",
990
+ f"API {_dim('gates the API hostname (webhooks, OAuth callbacks)')}",
991
+ f"Both {_dim('apply same edit to UI and API — keeps them in sync')}",
992
+ ]
993
+ idx = _interactive_single("Which hostname's IP rule do you want to edit?", labels, default=2)
994
+ target_roles = {0: ["ui"], 1: ["api"], 2: ["ui", "api"]}[idx]
995
+
996
+ # Resolve current state per role: existing app (or empty) + current IPs.
997
+ role_app: dict[str, str] = {"ui": ui_app_id, "api": api_app_id}
998
+ info("Fetching current policies...")
999
+ per_role_state: list[tuple[str, str, list[str], dict | None]] = []
1000
+ for role in target_roles:
1001
+ app_id = role_app[role]
1002
+ if app_id:
1003
+ ips, policy = _read_bypass_ips(cf, app_id)
1004
+ else:
1005
+ ips, policy = [], None
1006
+ per_role_state.append((role, app_id, ips, policy))
1007
+
1008
+ union_ips: list[str] = []
1009
+ for _, _, ips, _ in per_role_state:
1010
+ for ip in ips:
1011
+ if ip not in union_ips:
1012
+ union_ips.append(ip)
1013
+
1014
+ print()
1015
+ if union_ips:
1016
+ print(f" {_bold('Current bypass IPs:')}")
1017
+ for ip in union_ips:
1018
+ print(f" {_green('●')} {ip}")
1019
+ else:
1020
+ warn("No bypass policy found — will create one on each selected hostname")
1021
+
1022
+ print()
1023
+ idx = _interactive_single(
1024
+ "What would you like to do?",
1025
+ ["Add IP(s)", "Remove IP(s)", "Replace all IPs"],
1026
+ default=0,
1027
+ )
1028
+
1029
+ if idx == 0:
1030
+ raw = _prompt("IP(s) to add (comma-separated)").strip()
1031
+ new_ips = [_normalise_cidr(p) for p in raw.split(",") if p.strip()]
1032
+ for cidr in new_ips:
1033
+ _warn_broad_cidr(cidr)
1034
+ updated = list(dict.fromkeys(union_ips + new_ips))
1035
+ elif idx == 1:
1036
+ if not union_ips:
1037
+ warn("No IPs to remove")
1038
+ return
1039
+ pick = _interactive_single("Remove which IP?", union_ips, default=0)
1040
+ removed = union_ips[pick]
1041
+ updated = [ip for ip in union_ips if ip != removed]
1042
+ else:
1043
+ raw = _prompt("New IP list (comma-separated CIDRs)").strip()
1044
+ updated = [_normalise_cidr(p) for p in raw.split(",") if p.strip()]
1045
+ for cidr in updated:
1046
+ _warn_broad_cidr(cidr)
1047
+
1048
+ # Empty IP list — refuse to apply, warn the operator that they need to
1049
+ # delete the Access app manually if they want a truly public hostname.
1050
+ # An app with no policy blocks everyone (Cloudflare login code page),
1051
+ # so silently leaving the policy empty would break the hostname.
1052
+ if not updated:
1053
+ warn("Refusing to apply an empty IP list — an Access app with no rule blocks everyone.")
1054
+ print()
1055
+ print(f" {_dim('To make the hostname public, delete the Access app in the Cloudflare')}")
1056
+ print(f" {_dim('dashboard manually. Then re-run studio-console init to refresh state.')}")
1057
+ return
1058
+
1059
+ # Apply the IPs. Lazy-create the Access app for any role missing one.
1060
+ fresh_apps: list[dict] | None = None
1061
+ for role, app_id, _, policy in per_role_state:
1062
+ if not app_id:
1063
+ host = _hostname_for_role(env_file, role)
1064
+ if not host:
1065
+ warn(f"Skipping {role.upper()}: no hostname configured for it")
1066
+ continue
1067
+ if fresh_apps is None:
1068
+ try:
1069
+ fresh_apps = cf.list_access_apps()
1070
+ except CloudflareError:
1071
+ fresh_apps = []
1072
+ info(f"No {role.upper()} Access app yet — creating one...")
1073
+ app_id = _ensure_access_app(cf, env_file, role, [host], apps=fresh_apps) or ""
1074
+ if not app_id:
1075
+ warn(f"Could not create {role.upper()} Access app — skipping")
1076
+ continue
1077
+ policy = None # new app, no existing policy
1078
+
1079
+ policy_id = policy["id"] if policy else ""
1080
+ info(f"Updating IP policy on {role.upper()} app...")
1081
+ _upsert_ip_policy(cf, app_id, _IP_POLICY_NAME, updated, policy_id)
1082
+
1083
+ # Reflect newly-gated apps in CONSOLE_IP_RESTRICT_MODE so it stays consistent
1084
+ # with what's deployed. Lazy-create above may have added apps; this catches
1085
+ # those. We never downgrade the mode here — that would require deleting an
1086
+ # app, which we don't do automatically.
1087
+ after = read_env(env_file)
1088
+ has_ui_app = bool(after.get("CLOUDFLARE_ACCESS_APP_ID", ""))
1089
+ has_api_app = bool(after.get("CLOUDFLARE_ACCESS_API_APP_ID", ""))
1090
+ if has_ui_app and has_api_app:
1091
+ set_env_value(env_file, "CONSOLE_IP_RESTRICT_MODE", "both")
1092
+ elif has_ui_app:
1093
+ set_env_value(env_file, "CONSOLE_IP_RESTRICT_MODE", "both" if not has_api_host else "ui")
1094
+
1095
+
1096
+ # ---------------------------------------------------------------------------
1097
+ # Update domain (standalone, called from submenu option 6)
1098
+ # ---------------------------------------------------------------------------
1099
+
1100
+
1101
+ _DOMAIN_ROLE_CONFIG = {
1102
+ "ui": {
1103
+ "label": "UI",
1104
+ "url_var": "SHS_PUBLIC_BASE_URL",
1105
+ "app_var": "CLOUDFLARE_ACCESS_APP_ID",
1106
+ },
1107
+ "api": {
1108
+ "label": "API",
1109
+ "url_var": "CONSOLE_PUBLIC_API_BASE_URL",
1110
+ "app_var": "CLOUDFLARE_ACCESS_API_APP_ID",
1111
+ },
1112
+ }
1113
+
1114
+
1115
+ def _push_tunnel_ingress(
1116
+ cf: CloudflareAPI, env_file: Path, tunnel_id: str
1117
+ ) -> None:
1118
+ """Re-push the tunnel ingress config based on current SHS_PUBLIC_*_BASE_URL.
1119
+
1120
+ Always called after a domain change so the tunnel rules match what the
1121
+ rest of the system thinks the public hostnames are.
1122
+ """
1123
+ env_data = read_env(env_file)
1124
+
1125
+ hostnames: list[str] = []
1126
+ ui_url = env_data.get("SHS_PUBLIC_BASE_URL", "").rstrip("/")
1127
+ api_url = env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "").rstrip("/")
1128
+ if ui_url.startswith("https://"):
1129
+ hostnames.append(_parse_domain(ui_url)[0])
1130
+ if api_url.startswith("https://"):
1131
+ api_host = _parse_domain(api_url)[0]
1132
+ if api_host not in hostnames:
1133
+ hostnames.append(api_host)
1134
+
1135
+ if not hostnames:
1136
+ warn("No public hostnames configured — skipping tunnel ingress update")
1137
+ return
1138
+
1139
+ ingress = [{"hostname": h, "service": _ingress_target(env_file, h)} for h in hostnames]
1140
+ info("Pushing tunnel ingress config...")
1141
+ try:
1142
+ cf.put_tunnel_config(tunnel_id, ingress)
1143
+ for rule in ingress:
1144
+ ok(f"Ingress: {rule['hostname']} → {rule['service']}")
1145
+ except CloudflareError as e:
1146
+ warn(f"Failed to push ingress: {e}")
1147
+
1148
+
1149
+ def _update_one_domain(cf: CloudflareAPI, env_file: Path, role: str) -> bool:
1150
+ """Change the public hostname for a single role ("ui" or "api"). Returns
1151
+ True iff something changed (so the caller knows to re-push tunnel ingress).
1152
+ """
1153
+ cfg = _DOMAIN_ROLE_CONFIG[role]
1154
+ label = cfg["label"]
1155
+ url_var = cfg["url_var"]
1156
+ app_var = cfg["app_var"]
1157
+
1158
+ env_data = read_env(env_file)
1159
+ old_url = env_data.get(url_var, "")
1160
+ zone_id = env_data.get("CLOUDFLARE_ZONE_ID", "")
1161
+ tunnel_id = env_data.get("CLOUDFLARE_TUNNEL_ID", "")
1162
+ app_id = env_data.get(app_var, "")
1163
+
1164
+ print()
1165
+ new_url = _prompt(
1166
+ f"New {label} domain (e.g. https://{role}.example.com)",
1167
+ old_url if old_url.startswith("https://") else "",
1168
+ ).strip().rstrip("/")
1169
+
1170
+ if not new_url.startswith("https://"):
1171
+ warn("Must start with https://")
1172
+ return False
1173
+
1174
+ if new_url == old_url:
1175
+ info(f"{label} domain unchanged")
1176
+ return False
1177
+
1178
+ new_hostname, _, new_root = _parse_domain(new_url)
1179
+ old_hostname = _parse_domain(old_url)[0] if old_url else ""
1180
+
1181
+ if zone_id and old_hostname and old_hostname != new_hostname:
1182
+ if _interactive_yn(f"Delete old DNS records for {old_hostname}?", default=True):
1183
+ info(f"Removing DNS records for {old_hostname}...")
1184
+ try:
1185
+ records = cf.list_dns_records(zone_id, name=old_hostname)
1186
+ for rec in records:
1187
+ if rec.get("type") == "CNAME":
1188
+ cf.delete_dns_record(zone_id, rec["id"])
1189
+ ok(f"Deleted: {rec.get('name')}")
1190
+ except CloudflareError as e:
1191
+ warn(f"DNS cleanup error: {e}")
1192
+
1193
+ new_zone_id = zone_id
1194
+ old_root = _parse_domain(old_url)[2] if old_url else ""
1195
+ if not old_root or new_root != old_root:
1196
+ info(f"Looking up zone for {new_root}...")
1197
+ resolved = _resolve_zone(cf, new_root)
1198
+ if resolved:
1199
+ new_zone_id = resolved
1200
+ set_env_value(env_file, "CLOUDFLARE_ZONE_ID", new_zone_id)
1201
+ else:
1202
+ warn(f"Zone '{new_root}' not found — DNS records must be created manually")
1203
+ new_zone_id = ""
1204
+
1205
+ set_env_value(env_file, url_var, new_url)
1206
+ ok(f"{label} domain updated to {new_url}")
1207
+
1208
+ if new_zone_id and tunnel_id:
1209
+ info(f"Creating DNS record for {new_hostname}...")
1210
+ try:
1211
+ cf.upsert_dns_record(new_zone_id, new_hostname, tunnel_id)
1212
+ ok(f"DNS: {new_hostname} → tunnel")
1213
+ except CloudflareError as e:
1214
+ warn(f"DNS record failed: {e}")
1215
+
1216
+ if app_id and new_hostname:
1217
+ info(f"Updating {label} Access app domain...")
1218
+ try:
1219
+ cf.update_access_app_domains(app_id, [new_hostname])
1220
+ ok(f"{label} Access app updated: {new_hostname}")
1221
+ except CloudflareError as e:
1222
+ warn(f"Access app update failed: {e}")
1223
+
1224
+ return True
1225
+
1226
+
1227
+ def update_domain(env_file: Path) -> None:
1228
+ """Change one or both public domains — updates DNS, Access apps, and ingress."""
1229
+ cf = _load_api(env_file)
1230
+ if not cf:
1231
+ warn("Cloudflare API token required — cannot update domain.")
1232
+ return
1233
+
1234
+ env_data = read_env(env_file)
1235
+ has_api = bool(env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "").startswith("https://"))
1236
+ tunnel_id = env_data.get("CLOUDFLARE_TUNNEL_ID", "")
1237
+
1238
+ if has_api:
1239
+ idx = _interactive_single(
1240
+ "Which domain do you want to change?",
1241
+ [
1242
+ f"UI domain {_dim('the UI hostname (e.g. app.example.com)')}",
1243
+ f"API domain {_dim('the API hostname (e.g. api.example.com)')}",
1244
+ f"Both {_dim('change UI, then API')}",
1245
+ ],
1246
+ default=0,
1247
+ )
1248
+ roles = {0: ["ui"], 1: ["api"], 2: ["ui", "api"]}[idx]
1249
+ else:
1250
+ roles = ["ui"]
1251
+
1252
+ changed = False
1253
+ for role in roles:
1254
+ if _update_one_domain(cf, env_file, role):
1255
+ changed = True
1256
+
1257
+ if changed:
1258
+ # Recompute derived URL vars after domain change.
1259
+ from ..commands_container import _sync_derived_urls
1260
+
1261
+ _sync_derived_urls(env_file)
1262
+
1263
+ if changed and tunnel_id:
1264
+ # Re-push ingress so new hostnames take effect — old hostnames are
1265
+ # implicitly removed because put_tunnel_config replaces the whole list.
1266
+ _push_tunnel_ingress(cf, env_file, tunnel_id)
1267
+
1268
+ print()
1269
+ if changed:
1270
+ warn("Restart the cloudflared container to pick up the new domain:")
1271
+ print(f" studio-console → Cloudflare → Restart tunnel")
1272
+
1273
+
1274
+ # ---------------------------------------------------------------------------
1275
+ # Full setup wizard entry point
1276
+ # ---------------------------------------------------------------------------
1277
+
1278
+
1279
+ def cf_full_setup(env_file: Path, non_interactive: bool = False) -> bool:
1280
+ """Run the full PAT-driven Cloudflare setup wizard.
1281
+
1282
+ Returns True on success, False on any abort (preflight conflict,
1283
+ unresolvable zone, missing creds, operator cancel). Callers should treat
1284
+ False as a hard stop — the install is in an indeterminate state and
1285
+ should not proceed.
1286
+
1287
+ With non_interactive=True, prompts that have a sensible default (tunnel
1288
+ name, Access app name, DNS overwrite confirmation, etc.) are skipped and
1289
+ the default is used. Required values still come from env / .env.
1290
+ """
1291
+ global _NON_INTERACTIVE
1292
+ prev = _NON_INTERACTIVE
1293
+ _NON_INTERACTIVE = non_interactive
1294
+ try:
1295
+ return _cf_full_setup_impl(env_file)
1296
+ finally:
1297
+ _NON_INTERACTIVE = prev
1298
+
1299
+
1300
+ def _tunnel_name(env_file: Path) -> str:
1301
+ """Resolve the required tunnel name from CLOUDFLARE_TUNNEL_NAME (env or .env).
1302
+
1303
+ Both tunnel creation and preflight must agree on this, or preflight checks a
1304
+ different name than the one we create. No default — non-interactive callers
1305
+ must set it explicitly.
1306
+ """
1307
+ name = (
1308
+ os.environ.get("CLOUDFLARE_TUNNEL_NAME", "")
1309
+ or read_env(env_file).get("CLOUDFLARE_TUNNEL_NAME", "")
1310
+ )
1311
+ if not name:
1312
+ fatal("CLOUDFLARE_TUNNEL_NAME is not set — set it before running CF setup.")
1313
+ return name
1314
+
1315
+
1316
+ def _preflight_conflicts(
1317
+ cf: CloudflareAPI,
1318
+ env_file: Path,
1319
+ hostnames: list[str],
1320
+ zone_id: str,
1321
+ gated_roles: list[str],
1322
+ ui_host: str,
1323
+ api_host: str,
1324
+ ) -> bool:
1325
+ """Check for Cloudflare resources that already exist before we touch anything.
1326
+
1327
+ Returns True iff it's safe to proceed. On any conflict, prints the full
1328
+ list and returns False — non-interactive mode aborts so the operator
1329
+ cleans up in one pass instead of stumbling into conflicts piecemeal.
1330
+
1331
+ Skipped entirely outside non-interactive mode; the existing prompts in
1332
+ each step handle conflicts there.
1333
+ """
1334
+ if not _is_non_interactive():
1335
+ return True
1336
+
1337
+ env_data = read_env(env_file)
1338
+ conflicts: list[str] = []
1339
+ tunnel_name = _tunnel_name(env_file)
1340
+
1341
+ # 1. Tunnel name — only a conflict if operator hasn't pinned a tunnel ID.
1342
+ if not env_data.get("CLOUDFLARE_TUNNEL_ID", ""):
1343
+ try:
1344
+ tunnels = cf.list_tunnels()
1345
+ except CloudflareError as e:
1346
+ warn(f"Could not list tunnels for preflight: {e}")
1347
+ tunnels = []
1348
+ if any(t.get("name") == tunnel_name for t in tunnels):
1349
+ conflicts.append(f" Tunnel: {tunnel_name}")
1350
+
1351
+ # 2. Access app names — one per gated role, default name format.
1352
+ try:
1353
+ apps = cf.list_access_apps()
1354
+ except CloudflareError as e:
1355
+ warn(f"Could not list Access apps for preflight: {e}")
1356
+ apps = []
1357
+ pinned_ui_app = env_data.get("CLOUDFLARE_ACCESS_APP_ID", "")
1358
+ pinned_api_app = env_data.get("CLOUDFLARE_ACCESS_API_APP_ID", "")
1359
+ app_conflicts: list[str] = []
1360
+ for role in gated_roles:
1361
+ if role == "ui" and pinned_ui_app:
1362
+ continue
1363
+ if role == "api" and pinned_api_app:
1364
+ continue
1365
+ host = ui_host if role == "ui" else api_host
1366
+ wanted = _default_access_app_name(host)
1367
+ if any(a.get("name") == wanted for a in apps):
1368
+ app_conflicts.append(f" {wanted}")
1369
+ if app_conflicts:
1370
+ app_conflicts[0] = " Access apps: " + app_conflicts[0].lstrip()
1371
+ conflicts.extend(app_conflicts)
1372
+
1373
+ # 3. DNS — non-tunnel records on any of our planned hostnames.
1374
+ if zone_id:
1375
+ expected_tunnel = env_data.get("CLOUDFLARE_TUNNEL_ID", "")
1376
+ # Tunnel ID isn't known yet (we run before tunnel creation) so any
1377
+ # CNAME pointing at *.cfargotunnel.com is treated as "ours" for the
1378
+ # purposes of preflight — we'd reuse it in non-interactive mode.
1379
+ dns_conflicts: list[str] = []
1380
+ for h in hostnames:
1381
+ try:
1382
+ records = cf.list_dns_records(zone_id, name=h)
1383
+ except CloudflareError:
1384
+ records = []
1385
+ for r in records:
1386
+ content = r.get("content", "")
1387
+ rtype = r.get("type", "")
1388
+ if rtype == "CNAME" and content.endswith(".cfargotunnel.com"):
1389
+ continue
1390
+ dns_conflicts.append(f" {h} ({rtype} → {content})")
1391
+ if dns_conflicts:
1392
+ dns_conflicts[0] = " DNS records: " + dns_conflicts[0].lstrip()
1393
+ conflicts.extend(dns_conflicts)
1394
+
1395
+ if not conflicts:
1396
+ return True
1397
+
1398
+ print()
1399
+ error("Cannot bootstrap — Cloudflare resources already exist:")
1400
+ print()
1401
+ for line in conflicts:
1402
+ print(line)
1403
+ print()
1404
+ print(f" {_dim('Delete these in the Cloudflare dashboard before re-running, or set')}")
1405
+ print(f" {_dim('CLOUDFLARE_TUNNEL_ID / CLOUDFLARE_ACCESS_APP_ID / CLOUDFLARE_ACCESS_API_APP_ID')}")
1406
+ print(f" {_dim('to reuse them intentionally.')}")
1407
+ return False
1408
+
1409
+
1410
+ def _cf_full_setup_impl(env_file: Path) -> bool:
1411
+ from ..tui import heading
1412
+ heading("Cloudflare Setup")
1413
+
1414
+ # Step 1: API token + account ID
1415
+ cf = _step_token(env_file)
1416
+ if not cf:
1417
+ return False
1418
+
1419
+ # Step 2: Resolve public domains + zone. Domains are written by the wizard's
1420
+ # network section before this runs. Split mode is enabled iff a separate
1421
+ # API hostname is set and differs from the UI hostname. We resolve before
1422
+ # creating anything so preflight can check all planned hostnames at once.
1423
+ env_data = read_env(env_file)
1424
+ ui_url = env_data.get("SHS_PUBLIC_BASE_URL", "").rstrip("/")
1425
+ api_url = env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "").rstrip("/")
1426
+ ip_mode = env_data.get("CONSOLE_IP_RESTRICT_MODE", "none")
1427
+
1428
+ if not ui_url.startswith("https://"):
1429
+ error("No public HTTPS domain set — configure it in Settings → Public access first")
1430
+ return False
1431
+
1432
+ ui_host, _, ui_root = _parse_domain(ui_url)
1433
+ api_host = ""
1434
+ api_root = ""
1435
+ if api_url.startswith("https://"):
1436
+ api_host, _, api_root = _parse_domain(api_url)
1437
+ if api_host == ui_host:
1438
+ api_host = "" # not really split if hostnames coincide
1439
+
1440
+ is_split = bool(api_host)
1441
+ hostnames = [ui_host, api_host] if is_split else [ui_host]
1442
+
1443
+ ok(f"UI domain: {ui_url}")
1444
+ if is_split:
1445
+ ok(f"API domain: {api_url}")
1446
+
1447
+ # Resolve zone(s). For now we keep one CLOUDFLARE_ZONE_ID — both hostnames
1448
+ # are expected to live in the same zone for the common case (same root
1449
+ # domain). If the API hostname is in a different zone, surface a warning
1450
+ # and fall back to manual DNS for it.
1451
+ info(f"Looking up zone for {ui_root}...")
1452
+ zone_id = _resolve_zone(cf, ui_root) or ""
1453
+ if zone_id:
1454
+ set_env_value(env_file, "CLOUDFLARE_ZONE_ID", zone_id)
1455
+ else:
1456
+ warn(f"Zone '{ui_root}' not found — DNS records must be created manually")
1457
+
1458
+ if is_split and api_root != ui_root:
1459
+ warn(
1460
+ f"API hostname is in a different root domain ({api_root}) — "
1461
+ "DNS for it must be set up separately."
1462
+ )
1463
+
1464
+ # Ask which roles to gate by IP. Default to the stored mode; interactive so a
1465
+ # stale "none" can't silently skip Access-app creation.
1466
+ ip_mode = _prompt_ip_restrict_mode(ip_mode, is_split)
1467
+ set_env_value(env_file, "CONSOLE_IP_RESTRICT_MODE", ip_mode)
1468
+ if ip_mode == "both":
1469
+ gated_roles = ["ui", "api"] if is_split else ["ui"]
1470
+ elif ip_mode == "ui":
1471
+ gated_roles = ["ui"]
1472
+ else:
1473
+ gated_roles = []
1474
+
1475
+ # Step 3: Preflight conflict check (non-interactive only). Aborts before
1476
+ # any creation so the operator gets one comprehensive list to clean up.
1477
+ if not _preflight_conflicts(
1478
+ cf, env_file, hostnames, zone_id, gated_roles, ui_host, api_host
1479
+ ):
1480
+ return False
1481
+
1482
+ # Step 4: Tunnel — create or reuse.
1483
+ result = _step_tunnel(cf, env_file)
1484
+ if not result:
1485
+ return False
1486
+ tunnel_id, _ = result
1487
+
1488
+ # Step 5: Ingress rules + DNS records (one per hostname).
1489
+ if not _step_routes(cf, env_file, tunnel_id, hostnames, zone_id, ui_root):
1490
+ return False
1491
+
1492
+ # Step 6: Cloudflare Access apps + IP policy. Only create apps for roles
1493
+ # that need gating — an Access app with no policy blocks everyone, so
1494
+ # creating one for a role that should be public would defeat the purpose.
1495
+ # Operators can add gating later via "Update IP rules".
1496
+ if not gated_roles:
1497
+ info("Skipping Access app creation — ip_restrict_mode=none")
1498
+ else:
1499
+ info("Fetching Access applications...")
1500
+ try:
1501
+ existing_apps = cf.list_access_apps()
1502
+ except CloudflareError as e:
1503
+ warn(f"Could not list Access apps: {e}")
1504
+ existing_apps = []
1505
+
1506
+ gated: list[tuple[str, str]] = []
1507
+ failed_roles: list[str] = []
1508
+ for role in gated_roles:
1509
+ host = ui_host if role == "ui" else api_host
1510
+ app_id = _ensure_access_app(cf, env_file, role, [host], apps=existing_apps)
1511
+ if app_id:
1512
+ gated.append((role, app_id))
1513
+ else:
1514
+ failed_roles.append(role)
1515
+
1516
+ if gated:
1517
+ _step_ip_policy(cf, env_file, gated, [])
1518
+
1519
+ # A role meant to be gated but with no Access app is left PUBLIC — surface
1520
+ # it loudly (likely a 403/scope issue) instead of silently exposing it.
1521
+ if failed_roles:
1522
+ warn(
1523
+ "Access app NOT created for: "
1524
+ + ", ".join(failed_roles)
1525
+ + " — these are currently PUBLIC. Check the token has "
1526
+ "Account:Access: Apps and Policies, then re-run 'Update IP rules'."
1527
+ )
1528
+ return False
1529
+
1530
+ # Summary
1531
+ env_data = read_env(env_file)
1532
+ print()
1533
+ print(f" {_bold('Cloudflare setup complete:')}")
1534
+ print(f" Tunnel ID: {env_data.get('CLOUDFLARE_TUNNEL_ID', '—')}")
1535
+ print(f" UI domain: {env_data.get('SHS_PUBLIC_BASE_URL', '—')}")
1536
+ if is_split:
1537
+ print(f" API domain: {env_data.get('CONSOLE_PUBLIC_API_BASE_URL', '—')}")
1538
+ print(f" UI app: {env_data.get('CLOUDFLARE_ACCESS_APP_ID', '—')}")
1539
+ if is_split:
1540
+ print(f" API app: {env_data.get('CLOUDFLARE_ACCESS_API_APP_ID', '—')}")
1541
+ print(f" IP restrict: {ip_mode}")
1542
+ print()
1543
+ if detect_shape(env_file) == "split":
1544
+ print(f" {_dim('cloudflared will start with Services → Start.')}")
1545
+ print()
1546
+ print(f" {_dim(f'Note: {ui_root} (the apex) is not configured — visitors there get a DNS')}")
1547
+ print(f" {_dim('or 404 error. To redirect it to your UI, create a Cloudflare Bulk')}")
1548
+ print(f" {_dim('Redirect manually in the dashboard. See docs/topology.md.')}")
1549
+ return True