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.
- VERSION +1 -0
- docker-compose.yml +282 -0
- nginx/studio.conf.template +89 -0
- studio_console/__init__.py +17 -0
- studio_console/__main__.py +3 -0
- studio_console/cli.py +176 -0
- studio_console/cloudflare/__init__.py +1 -0
- studio_console/cloudflare/cf_api.py +285 -0
- studio_console/cloudflare/cf_wizard.py +1549 -0
- studio_console/commands.py +3001 -0
- studio_console/commands_container.py +526 -0
- studio_console/commands_launch.py +684 -0
- studio_console/constants.py +184 -0
- studio_console/data/README.md +37 -0
- studio_console/data/known_baselines.json +3 -0
- studio_console/env.py +600 -0
- studio_console/major_version.py +257 -0
- studio_console/tui.py +412 -0
- studio_console/wizard.py +1953 -0
- studio_console-1.3.4.dist-info/METADATA +235 -0
- studio_console-1.3.4.dist-info/RECORD +26 -0
- studio_console-1.3.4.dist-info/WHEEL +4 -0
- studio_console-1.3.4.dist-info/entry_points.txt +2 -0
- studio_console-1.3.4.dist-info/licenses/LEGAL.md +60 -0
- studio_console-1.3.4.dist-info/licenses/LICENSE +112 -0
- templates/.env.example +76 -0
|
@@ -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
|