@mriekki/project-forge 4.8.1 → 4.9.0

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,834 @@
1
+ """Optional Cloudflare Tunnel public ingress for remote Project Forge targets.
2
+
3
+ Cloudflare is an ingress provider, not a deployment transport. The existing
4
+ remote-ssh target and trusted-LAN Caddy route remain authoritative. This
5
+ module owns an optional shared cloudflared runtime plus Project-scoped public
6
+ hostname/DNS registrations that forward to those existing Caddy routes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import re
15
+ import shlex
16
+ import subprocess
17
+ import urllib.parse
18
+ from copy import deepcopy
19
+ from typing import Any
20
+
21
+ from . import cloudflare_api
22
+ from .box_platform import platform_status
23
+ from .box_platform_assets import CADDY_CONTAINER, NETWORK_NAME, PLATFORM_ROOT
24
+ from .cloudflare_ingress_remote import REMOTE_SCRIPT
25
+ from .errors import DeploymentKitError
26
+ from .profiles import load_profile
27
+ from .route_registry import route_inventory
28
+ from .ssh import ssh_args, verify_access
29
+
30
+ INGRESS_SCHEMA_VERSION = 1
31
+ INGRESS_VERSION = "1"
32
+ INGRESS_DISTRIBUTION = "cloudflare-remote-managed-tunnel"
33
+ CLOUDFLARED_VERSION = "2026.10.4"
34
+ CLOUDFLARED_IMAGE = f"cloudflare/cloudflared:{CLOUDFLARED_VERSION}"
35
+ HOST_PUBLIC_INGRESS_WRITE_PHRASE = "[HOST PUBLIC INGRESS WRITE]"
36
+ PUBLIC_INGRESS_ROUTE_WRITE_PHRASE = "[PUBLIC INGRESS ROUTE WRITE]"
37
+ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE = "[PUBLIC INGRESS ROUTE REMOVE]"
38
+ CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
39
+
40
+ _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
41
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
42
+ _LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
43
+ _BOOL_STATUS_KEYS = {"markerPresent", "platformReady", "tokenPresent", "containerRunning", "networkAttached"}
44
+ _STATUS_KEYS = {
45
+ "state", "scope", "markerPresent", "markerSchemaVersion", "markerIngressVersion",
46
+ "markerDistribution", "platformReady", "tokenPresent", "containerRunning",
47
+ "networkAttached", "containerName", "cloudflaredImage", "accountId", "zoneId",
48
+ "zoneName", "tunnelId", "tunnelName",
49
+ }
50
+
51
+
52
+ def _slug(value: str, field: str) -> str:
53
+ if not isinstance(value, str) or _SLUG_RE.fullmatch(value) is None:
54
+ raise DeploymentKitError(f"{field} must use lowercase letters, digits, and internal hyphens.")
55
+ return value
56
+
57
+
58
+ def _owner_repo(value: str) -> str:
59
+ if not isinstance(value, str) or _REPO_RE.fullmatch(value) is None:
60
+ raise DeploymentKitError("ownerRepo must use GitHub owner/repository form without URL syntax.")
61
+ return value
62
+
63
+
64
+ def _hostname(value: str, field: str) -> str:
65
+ if not isinstance(value, str) or not value or len(value) > 253:
66
+ raise DeploymentKitError(f"{field} must be a non-empty DNS hostname.")
67
+ normalized = value.lower().rstrip(".")
68
+ labels = normalized.split(".")
69
+ if len(labels) < 2 or any(_LABEL_RE.fullmatch(label) is None for label in labels):
70
+ raise DeploymentKitError(f"Invalid {field}: {normalized}")
71
+ return normalized
72
+
73
+
74
+ def _first_level_public_hostname(value: str, zone_name: str) -> str:
75
+ host = _hostname(value, "public hostname")
76
+ zone = _hostname(zone_name, "Cloudflare zone")
77
+ suffix = "." + zone
78
+ if not host.endswith(suffix):
79
+ raise DeploymentKitError(f"Public hostname {host} must belong to Cloudflare zone {zone}.")
80
+ relative = host[: -len(suffix)]
81
+ if "." in relative or not relative:
82
+ raise DeploymentKitError(
83
+ "The current partial Cloudflare adapter supports one first-level subdomain per application "
84
+ "(for example app.example.com). Deeper names require an explicit certificate/support review."
85
+ )
86
+ return host
87
+
88
+
89
+ def _origin_hostname(value: str) -> str:
90
+ host = _hostname(value, "origin hostname")
91
+ if not host.endswith(".local"):
92
+ raise DeploymentKitError("Cloudflare origin hostname must be an existing trusted-LAN .local route.")
93
+ return host
94
+
95
+
96
+ def tunnel_name_for_scope(scope: str) -> str:
97
+ return f"project-forge-{_slug(scope, 'scope')}"
98
+
99
+
100
+ def build_remote_script(*, tunnel_token: str | None = None) -> str:
101
+ token_b64 = ""
102
+ if tunnel_token is not None:
103
+ if not isinstance(tunnel_token, str) or not tunnel_token.strip():
104
+ raise DeploymentKitError("Cloudflare tunnel token is empty.")
105
+ token_b64 = base64.b64encode(tunnel_token.strip().encode("utf-8")).decode("ascii")
106
+ replacements = {
107
+ "__PLATFORM_ROOT__": PLATFORM_ROOT,
108
+ "__NETWORK_NAME__": NETWORK_NAME,
109
+ "__CADDY_CONTAINER__": CADDY_CONTAINER,
110
+ "__CLOUDFLARED_IMAGE__": CLOUDFLARED_IMAGE,
111
+ "__SCHEMA_VERSION__": str(INGRESS_SCHEMA_VERSION),
112
+ "__INGRESS_VERSION__": INGRESS_VERSION,
113
+ "__DISTRIBUTION__": INGRESS_DISTRIBUTION,
114
+ "__TOKEN_B64__": token_b64,
115
+ }
116
+ script = REMOTE_SCRIPT
117
+ for placeholder, replacement in replacements.items():
118
+ script = script.replace(placeholder, replacement)
119
+ if "__" in script:
120
+ raise DeploymentKitError("Internal Cloudflare ingress script contains an unresolved placeholder.")
121
+ return script
122
+
123
+
124
+ def _parse_status(output: str) -> dict[str, Any]:
125
+ result: dict[str, Any] = {}
126
+ for raw in output.splitlines():
127
+ if not raw.strip():
128
+ continue
129
+ if "=" not in raw:
130
+ raise DeploymentKitError(f"Malformed Cloudflare ingress status line: {raw!r}")
131
+ key, value = raw.split("=", 1)
132
+ if key not in _STATUS_KEYS or key in result:
133
+ raise DeploymentKitError(f"Unexpected or duplicate Cloudflare ingress status key: {key!r}")
134
+ if key in _BOOL_STATUS_KEYS:
135
+ if value not in {"true", "false"}:
136
+ raise DeploymentKitError(f"Invalid boolean Cloudflare ingress status value for {key}.")
137
+ result[key] = value == "true"
138
+ else:
139
+ result[key] = value
140
+ missing = _STATUS_KEYS - result.keys()
141
+ if missing:
142
+ raise DeploymentKitError("Cloudflare ingress status omitted keys: " + ", ".join(sorted(missing)))
143
+ return result
144
+
145
+
146
+ def _metadata_tsv(values: dict[str, Any]) -> str:
147
+ return "\t".join(
148
+ str(values[key])
149
+ for key in ("accountId", "zoneId", "zoneName", "tunnelId", "tunnelName")
150
+ )
151
+
152
+
153
+ def _route_spec(
154
+ *,
155
+ owner_repo: str,
156
+ environment: str,
157
+ app_slug: str,
158
+ public_hostname: str,
159
+ origin_hostname: str,
160
+ ) -> dict[str, Any]:
161
+ owner_repo = _owner_repo(owner_repo)
162
+ environment = _slug(environment, "environment")
163
+ app_slug = _slug(app_slug, "appSlug")
164
+ public_hostname = _hostname(public_hostname, "public hostname")
165
+ if public_hostname.endswith(".local"):
166
+ raise DeploymentKitError("Public hostname must not use the .local suffix.")
167
+ origin_hostname = _origin_hostname(origin_hostname)
168
+ return {
169
+ "schemaVersion": INGRESS_SCHEMA_VERSION,
170
+ "ownerRepo": owner_repo,
171
+ "environment": environment,
172
+ "appSlug": app_slug,
173
+ "routeId": f"{app_slug}--{environment}",
174
+ "publicHostname": public_hostname,
175
+ "originHostname": origin_hostname,
176
+ }
177
+
178
+
179
+ def _route_tsv(spec: dict[str, Any]) -> str:
180
+ return "\t".join(
181
+ str(spec[key])
182
+ for key in (
183
+ "schemaVersion", "ownerRepo", "environment", "appSlug", "routeId",
184
+ "publicHostname", "originHostname",
185
+ )
186
+ )
187
+
188
+
189
+ def _run_remote(
190
+ profile: dict[str, Any],
191
+ address: str,
192
+ action: str,
193
+ *,
194
+ scope: str,
195
+ metadata: dict[str, Any] | None = None,
196
+ spec: dict[str, Any] | None = None,
197
+ expected: dict[str, Any] | None = None,
198
+ tunnel_token: str | None = None,
199
+ ) -> str:
200
+ if action not in {"status", "install", "upgrade", "route-inventory", "route-register", "route-remove"}:
201
+ raise DeploymentKitError(f"Unsupported Cloudflare ingress action: {action}")
202
+ args = [action, _slug(scope, "scope")]
203
+ args.append(_metadata_tsv(metadata) if metadata is not None else "")
204
+ args.append(_route_tsv(spec) if spec is not None else "")
205
+ if action == "route-register":
206
+ args.append(_route_tsv(expected) if expected is not None else "NONE")
207
+ elif action == "route-remove":
208
+ args.append("")
209
+ remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
210
+ completed = subprocess.run(
211
+ ssh_args(profile, address, command=remote_command),
212
+ input=build_remote_script(tunnel_token=tunnel_token).encode("utf-8"),
213
+ check=False,
214
+ capture_output=True,
215
+ )
216
+ stdout = completed.stdout.decode("utf-8", errors="replace")
217
+ stderr = completed.stderr.decode("utf-8", errors="replace")
218
+ if completed.returncode != 0:
219
+ detail = stderr.strip() or stdout.strip() or f"exit {completed.returncode}"
220
+ raise DeploymentKitError(f"Remote Cloudflare ingress {action} failed: {detail}")
221
+ return stdout
222
+
223
+
224
+ def cloudflare_ingress_status(name: str, scope: str) -> dict[str, Any]:
225
+ scope = _slug(scope, "scope")
226
+ profile = load_profile(name)
227
+ access = verify_access(profile)
228
+ status = _parse_status(
229
+ _run_remote(profile, access["address"], "status", scope=scope)
230
+ )
231
+ status["targetName"] = name
232
+ status["verifiedAddress"] = access["address"]
233
+ status["mutated"] = False
234
+ return status
235
+
236
+
237
+ def cloudflare_ingress_doctor(name: str, scope: str) -> dict[str, Any]:
238
+ status = cloudflare_ingress_status(name, scope)
239
+ status["nextAction"] = {
240
+ "installable": "configure-cloudflare-ingress",
241
+ "upgrade-required": "upgrade-cloudflare-ingress",
242
+ "drifted": "inspect-cloudflare-ingress-drift",
243
+ "blocked-platform": "prepare-shared-host-platform",
244
+ "incompatible": "manual-cloudflare-ingress-recovery",
245
+ "ready": "none",
246
+ }.get(status["state"], "manual-review")
247
+ return status
248
+
249
+
250
+ def _status_snapshot(status: dict[str, Any]) -> str:
251
+ selected = {key: status[key] for key in sorted(_STATUS_KEYS)}
252
+ return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
253
+
254
+
255
+ def _config_snapshot(config: dict[str, Any]) -> str:
256
+ return hashlib.sha256(json.dumps(config, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
257
+
258
+
259
+ def _tunnel_summary(tunnel: dict[str, Any] | None) -> dict[str, Any] | None:
260
+ if tunnel is None:
261
+ return None
262
+ return {
263
+ "id": tunnel.get("id"),
264
+ "name": tunnel.get("name"),
265
+ "configSrc": tunnel.get("config_src"),
266
+ "status": tunnel.get("status"),
267
+ "deletedAt": tunnel.get("deleted_at"),
268
+ }
269
+
270
+
271
+ def _empty_tunnel_config(config: dict[str, Any]) -> bool:
272
+ ingress = config.get("ingress")
273
+ return isinstance(ingress, list) and ingress == [{"service": "http_status:404"}]
274
+
275
+
276
+ def plan_cloudflare_ingress(
277
+ name: str,
278
+ *,
279
+ scope: str,
280
+ zone_name: str,
281
+ api_token: str,
282
+ tunnel_name: str | None = None,
283
+ ) -> dict[str, Any]:
284
+ """Plan creation/adoption of one shared remotely managed Cloudflare tunnel."""
285
+ scope = _slug(scope, "scope")
286
+ zone = cloudflare_api.resolve_zone(api_token, zone_name)
287
+ expected_tunnel_name = tunnel_name or tunnel_name_for_scope(scope)
288
+ if not isinstance(expected_tunnel_name, str) or not expected_tunnel_name.strip():
289
+ raise DeploymentKitError("Cloudflare tunnel name must be non-empty.")
290
+ expected_tunnel_name = expected_tunnel_name.strip()
291
+
292
+ platform = platform_status(name)
293
+ if platform["state"] != "ready":
294
+ raise DeploymentKitError(
295
+ f"Cloudflare ingress requires a ready shared Project Forge platform; target {name} is {platform['state']}."
296
+ )
297
+ host = cloudflare_ingress_status(name, scope)
298
+ if host["state"] not in {"installable", "upgrade-required", "ready"}:
299
+ raise DeploymentKitError(
300
+ f"Cloudflare ingress cannot be configured while host scope {scope} is {host['state']}."
301
+ )
302
+
303
+ tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], expected_tunnel_name)
304
+ tunnel_config: dict[str, Any] | None = None
305
+ if tunnel is not None:
306
+ if tunnel.get("config_src") not in {None, "cloudflare"}:
307
+ raise DeploymentKitError("Project Forge supports adoption only of remotely managed Cloudflare tunnels.")
308
+ tunnel_config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], str(tunnel["id"]))
309
+
310
+ if host["state"] in {"ready", "upgrade-required"}:
311
+ expected = {
312
+ "accountId": zone["accountId"],
313
+ "zoneId": zone["zoneId"],
314
+ "zoneName": zone["zoneName"],
315
+ "tunnelName": expected_tunnel_name,
316
+ }
317
+ for key, value in expected.items():
318
+ if host[key] != value:
319
+ raise DeploymentKitError(
320
+ f"Host Cloudflare ingress scope {scope} is already bound to different {key}; explicit migration is required."
321
+ )
322
+ if tunnel is None or host["tunnelId"] != str(tunnel.get("id")):
323
+ raise DeploymentKitError("Host Cloudflare tunnel identity no longer matches the provider; refusing implicit replacement.")
324
+ action = "upgrade" if host["state"] == "upgrade-required" else "refresh-token"
325
+ else:
326
+ action = "adopt-and-install" if tunnel is not None else "create-and-install"
327
+ if tunnel_config is not None and not _empty_tunnel_config(tunnel_config):
328
+ raise DeploymentKitError(
329
+ "An existing tunnel with the desired name already has ingress configuration. "
330
+ "Project Forge will not implicitly adopt or overwrite it."
331
+ )
332
+
333
+ return {
334
+ "targetName": name,
335
+ "scope": scope,
336
+ "action": action,
337
+ "zone": zone,
338
+ "tunnelName": expected_tunnel_name,
339
+ "existingTunnel": _tunnel_summary(tunnel),
340
+ "existingTunnelConfigSha256": _config_snapshot(tunnel_config) if tunnel_config is not None else None,
341
+ "hostState": host["state"],
342
+ "hostStatusSnapshot": _status_snapshot(host),
343
+ "cloudflaredImage": CLOUDFLARED_IMAGE,
344
+ "requiredAuthorization": HOST_PUBLIC_INGRESS_WRITE_PHRASE,
345
+ "secrets": {
346
+ "apiToken": "workstation-only; not persisted by Deployment Kit",
347
+ "tunnelToken": "written only to the selected host scope through verified SSH",
348
+ },
349
+ }
350
+
351
+
352
+ def _delete_tunnel(api_token: str, account_id: str, tunnel_id: str) -> None:
353
+ cloudflare_api._request( # package-internal bounded rollback helper
354
+ "DELETE",
355
+ f"/accounts/{urllib.parse.quote(account_id, safe='')}/cfd_tunnel/{urllib.parse.quote(tunnel_id, safe='')}",
356
+ token=api_token,
357
+ )
358
+
359
+
360
+ def apply_cloudflare_ingress(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
361
+ """Apply exactly one reviewed shared Cloudflare ingress plan."""
362
+ required = {
363
+ "targetName", "scope", "action", "zone", "tunnelName", "existingTunnel",
364
+ "existingTunnelConfigSha256", "hostState", "hostStatusSnapshot", "cloudflaredImage",
365
+ "requiredAuthorization", "secrets",
366
+ }
367
+ if not isinstance(plan, dict) or set(plan) != required:
368
+ raise DeploymentKitError("Malformed Cloudflare ingress plan.")
369
+ if plan["cloudflaredImage"] != CLOUDFLARED_IMAGE:
370
+ raise DeploymentKitError("Cloudflare ingress plan uses a different cloudflared image; re-plan with the current package.")
371
+
372
+ name = plan["targetName"]
373
+ scope = plan["scope"]
374
+ zone = cloudflare_api.resolve_zone(api_token, plan["zone"]["zoneName"])
375
+ if zone != plan["zone"]:
376
+ raise DeploymentKitError("Cloudflare zone identity changed after planning; create a fresh plan.")
377
+ current_host = cloudflare_ingress_status(name, scope)
378
+ if _status_snapshot(current_host) != plan["hostStatusSnapshot"]:
379
+ raise DeploymentKitError("Host Cloudflare ingress state changed after planning; create a fresh plan.")
380
+
381
+ current_tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], plan["tunnelName"])
382
+ if _tunnel_summary(current_tunnel) != plan["existingTunnel"]:
383
+ raise DeploymentKitError("Cloudflare tunnel state changed after planning; create a fresh plan.")
384
+ if current_tunnel is not None:
385
+ current_config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], str(current_tunnel["id"]))
386
+ if _config_snapshot(current_config) != plan["existingTunnelConfigSha256"]:
387
+ raise DeploymentKitError("Cloudflare tunnel configuration changed after planning; create a fresh plan.")
388
+
389
+ created = False
390
+ tunnel = current_tunnel
391
+ if tunnel is None:
392
+ tunnel = cloudflare_api.create_tunnel(api_token, zone["accountId"], plan["tunnelName"])
393
+ created = True
394
+ tunnel_id = str(tunnel["id"])
395
+ metadata = {
396
+ **zone,
397
+ "tunnelId": tunnel_id,
398
+ "tunnelName": plan["tunnelName"],
399
+ }
400
+
401
+ try:
402
+ config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], tunnel_id)
403
+ if not _empty_tunnel_config(config):
404
+ raise DeploymentKitError(
405
+ "Cloudflare tunnel acquired non-empty ingress configuration during apply; refusing overwrite."
406
+ )
407
+ token = cloudflare_api.get_tunnel_token(api_token, zone["accountId"], tunnel_id)
408
+ profile = load_profile(name)
409
+ access = verify_access(profile)
410
+ remote_action = "upgrade" if plan["hostState"] == "upgrade-required" else (
411
+ "upgrade" if plan["hostState"] == "ready" else "install"
412
+ )
413
+ _run_remote(
414
+ profile,
415
+ access["address"],
416
+ remote_action,
417
+ scope=scope,
418
+ metadata=metadata,
419
+ tunnel_token=token,
420
+ )
421
+ final = cloudflare_ingress_status(name, scope)
422
+ if final["state"] != "ready":
423
+ raise DeploymentKitError(f"Cloudflare ingress apply completed but host readiness is {final['state']}.")
424
+ for key in ("accountId", "zoneId", "zoneName", "tunnelId", "tunnelName"):
425
+ if final[key] != str(metadata[key]):
426
+ raise DeploymentKitError(f"Host Cloudflare ingress returned mismatched {key} after apply.")
427
+ final["mutated"] = True
428
+ final["providerTunnel"] = _tunnel_summary(tunnel)
429
+ return final
430
+ except Exception:
431
+ if created:
432
+ try:
433
+ _delete_tunnel(api_token, zone["accountId"], tunnel_id)
434
+ except Exception:
435
+ pass
436
+ raise
437
+
438
+
439
+ def _parse_route_inventory(output: str) -> dict[str, Any]:
440
+ summary: dict[str, str] = {}
441
+ routes: list[dict[str, Any]] = []
442
+ for raw in output.splitlines():
443
+ if not raw:
444
+ continue
445
+ parts = raw.split("\t")
446
+ if parts[0] == "SUMMARY":
447
+ if len(parts) != 3 or parts[1] in summary:
448
+ raise DeploymentKitError("Malformed Cloudflare public-route inventory summary.")
449
+ summary[parts[1]] = parts[2]
450
+ elif parts[0] == "ROUTE":
451
+ if len(parts) != 8:
452
+ raise DeploymentKitError("Malformed Cloudflare public-route inventory record.")
453
+ try:
454
+ schema = int(parts[1])
455
+ except ValueError as error:
456
+ raise DeploymentKitError("Malformed Cloudflare public-route schema.") from error
457
+ spec = _route_spec(
458
+ owner_repo=parts[2],
459
+ environment=parts[3],
460
+ app_slug=parts[4],
461
+ public_hostname=parts[6],
462
+ origin_hostname=parts[7],
463
+ )
464
+ if schema != INGRESS_SCHEMA_VERSION or spec["routeId"] != parts[5]:
465
+ raise DeploymentKitError("Cloudflare public-route schema or route ID mismatch.")
466
+ routes.append(spec)
467
+ else:
468
+ raise DeploymentKitError("Unexpected Cloudflare public-route inventory record.")
469
+ if set(summary) != {"scope", "managedRouteCount"}:
470
+ raise DeploymentKitError("Cloudflare public-route inventory omitted summary fields.")
471
+ try:
472
+ count = int(summary["managedRouteCount"])
473
+ except ValueError as error:
474
+ raise DeploymentKitError("Malformed Cloudflare managed route count.") from error
475
+ if count != len(routes):
476
+ raise DeploymentKitError("Cloudflare managed route count does not match inventory.")
477
+ return {"scope": summary["scope"], "managedRoutes": sorted(routes, key=lambda item: item["routeId"])}
478
+
479
+
480
+ def cloudflare_route_inventory(name: str, scope: str) -> dict[str, Any]:
481
+ profile = load_profile(name)
482
+ access = verify_access(profile)
483
+ result = _parse_route_inventory(
484
+ _run_remote(profile, access["address"], "route-inventory", scope=scope)
485
+ )
486
+ result["targetName"] = name
487
+ result["verifiedAddress"] = access["address"]
488
+ result["mutated"] = False
489
+ return result
490
+
491
+
492
+ def _route_inventory_snapshot(value: dict[str, Any]) -> str:
493
+ selected = {"scope": value["scope"], "managedRoutes": value["managedRoutes"]}
494
+ return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
495
+
496
+
497
+ def _find_route(inventory: dict[str, Any], route_id: str) -> dict[str, Any] | None:
498
+ return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
499
+
500
+
501
+ def _lan_route(name: str, spec: dict[str, Any]) -> dict[str, Any]:
502
+ inventory = route_inventory(name)
503
+ route = next((item for item in inventory["managedRoutes"] if item["routeId"] == spec["routeId"]), None)
504
+ if route is None:
505
+ raise DeploymentKitError(
506
+ f"Trusted-LAN Caddy route {spec['routeId']} must exist before public ingress can be registered."
507
+ )
508
+ for key in ("ownerRepo", "environment", "appSlug"):
509
+ if route[key] != spec[key]:
510
+ raise DeploymentKitError("Trusted-LAN route ownership does not match the requested public-ingress route.")
511
+ if spec["originHostname"] not in route["hostnames"]:
512
+ raise DeploymentKitError(
513
+ f"Origin hostname {spec['originHostname']} is not owned by trusted-LAN route {spec['routeId']}."
514
+ )
515
+ return route
516
+
517
+
518
+ def _cloudflare_rule(spec: dict[str, Any]) -> dict[str, Any]:
519
+ return {
520
+ "hostname": spec["publicHostname"],
521
+ "service": f"http://{CADDY_CONTAINER}:80",
522
+ "originRequest": {"httpHostHeader": spec["originHostname"]},
523
+ }
524
+
525
+
526
+ def _split_ingress(config: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
527
+ ingress = config.get("ingress")
528
+ if not isinstance(ingress, list) or not ingress:
529
+ raise DeploymentKitError("Cloudflare tunnel configuration must contain ingress rules.")
530
+ if ingress[-1] != {"service": "http_status:404"}:
531
+ raise DeploymentKitError("Cloudflare tunnel must retain the Project Forge fail-closed final 404 ingress rule.")
532
+ rules = ingress[:-1]
533
+ if any(not isinstance(item, dict) for item in rules):
534
+ raise DeploymentKitError("Cloudflare tunnel contains a malformed ingress rule.")
535
+ return [deepcopy(item) for item in rules], deepcopy(ingress[-1])
536
+
537
+
538
+ def _dns_marker(spec: dict[str, Any]) -> str:
539
+ identity = "|".join((spec["ownerRepo"], spec["environment"], spec["appSlug"])).encode("utf-8")
540
+ return "Project Forge " + hashlib.sha256(identity).hexdigest()[:20]
541
+
542
+
543
+ def _exact_dns_record(records: list[dict[str, Any]], *, spec: dict[str, Any], tunnel_id: str) -> dict[str, Any] | None:
544
+ if not records:
545
+ return None
546
+ if len(records) != 1:
547
+ raise DeploymentKitError(f"Public hostname {spec['publicHostname']} has multiple Cloudflare DNS records; refusing takeover.")
548
+ record = records[0]
549
+ expected_content = f"{tunnel_id}.cfargotunnel.com"
550
+ if (
551
+ record.get("type") != "CNAME"
552
+ or str(record.get("content", "")).lower().rstrip(".") != expected_content
553
+ or record.get("proxied") is not True
554
+ or record.get("comment") != _dns_marker(spec)
555
+ ):
556
+ raise DeploymentKitError(
557
+ f"Public hostname {spec['publicHostname']} already has DNS state not owned by this Project Forge route."
558
+ )
559
+ return record
560
+
561
+
562
+ def plan_public_route(
563
+ name: str,
564
+ *,
565
+ scope: str,
566
+ owner_repo: str,
567
+ environment: str,
568
+ app_slug: str,
569
+ public_hostname: str,
570
+ origin_hostname: str,
571
+ api_token: str,
572
+ ) -> dict[str, Any]:
573
+ host = cloudflare_ingress_status(name, scope)
574
+ if host["state"] != "ready":
575
+ raise DeploymentKitError(f"Cloudflare ingress scope {scope} is not ready on target {name}.")
576
+ public_hostname = _first_level_public_hostname(public_hostname, host["zoneName"])
577
+ spec = _route_spec(
578
+ owner_repo=owner_repo,
579
+ environment=environment,
580
+ app_slug=app_slug,
581
+ public_hostname=public_hostname,
582
+ origin_hostname=origin_hostname,
583
+ )
584
+ _lan_route(name, spec)
585
+
586
+ inventory = cloudflare_route_inventory(name, scope)
587
+ existing = _find_route(inventory, spec["routeId"])
588
+ if existing is not None and existing != spec:
589
+ raise DeploymentKitError(
590
+ "This Project/environment already owns a different public-ingress route. Remove it explicitly before rebinding."
591
+ )
592
+ for other in inventory["managedRoutes"]:
593
+ if other["routeId"] != spec["routeId"] and other["publicHostname"] == spec["publicHostname"]:
594
+ raise DeploymentKitError("Public hostname is already owned by another Project Forge route on this ingress scope.")
595
+
596
+ config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
597
+ rules, final_rule = _split_ingress(config)
598
+ desired_rule = _cloudflare_rule(spec)
599
+ conflicting = [item for item in rules if item.get("hostname") == spec["publicHostname"] and item != desired_rule]
600
+ if conflicting:
601
+ raise DeploymentKitError("Cloudflare tunnel already has a conflicting ingress rule for this hostname.")
602
+ exact_rules = [item for item in rules if item == desired_rule]
603
+ if len(exact_rules) > 1:
604
+ raise DeploymentKitError("Cloudflare tunnel contains duplicate exact Project Forge ingress rules.")
605
+
606
+ records = cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"])
607
+ dns = _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
608
+ return {
609
+ "targetName": name,
610
+ "scope": scope,
611
+ "action": "already-configured" if existing == spec and exact_rules and dns is not None else "register",
612
+ "route": spec,
613
+ "previousRoute": existing,
614
+ "routeInventorySnapshot": _route_inventory_snapshot(inventory),
615
+ "hostStatusSnapshot": _status_snapshot(host),
616
+ "tunnelConfig": config,
617
+ "tunnelConfigSha256": _config_snapshot(config),
618
+ "desiredTunnelConfig": {**config, "ingress": rules + ([] if exact_rules else [desired_rule]) + [final_rule]},
619
+ "dnsRecord": deepcopy(dns),
620
+ "dnsMarker": _dns_marker(spec),
621
+ "requiredAuthorization": PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
622
+ }
623
+
624
+
625
+ def apply_public_route(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
626
+ if plan.get("action") == "already-configured":
627
+ return {**plan, "mutated": False}
628
+ required = {
629
+ "targetName", "scope", "action", "route", "previousRoute", "routeInventorySnapshot",
630
+ "hostStatusSnapshot", "tunnelConfig", "tunnelConfigSha256", "desiredTunnelConfig",
631
+ "dnsRecord", "dnsMarker", "requiredAuthorization",
632
+ }
633
+ if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "register":
634
+ raise DeploymentKitError("Malformed Cloudflare public-route plan.")
635
+ name = plan["targetName"]
636
+ scope = plan["scope"]
637
+ spec = plan["route"]
638
+ host = cloudflare_ingress_status(name, scope)
639
+ if _status_snapshot(host) != plan["hostStatusSnapshot"]:
640
+ raise DeploymentKitError("Cloudflare host ingress changed after planning; create a fresh route plan.")
641
+ inventory = cloudflare_route_inventory(name, scope)
642
+ if _route_inventory_snapshot(inventory) != plan["routeInventorySnapshot"]:
643
+ raise DeploymentKitError("Cloudflare public-route inventory changed after planning; create a fresh plan.")
644
+ _lan_route(name, spec)
645
+ config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
646
+ if _config_snapshot(config) != plan["tunnelConfigSha256"]:
647
+ raise DeploymentKitError("Cloudflare tunnel configuration changed after planning; create a fresh route plan.")
648
+ records = cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"])
649
+ current_dns = _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
650
+ if current_dns != plan["dnsRecord"]:
651
+ raise DeploymentKitError("Cloudflare DNS state changed after planning; create a fresh route plan.")
652
+
653
+ profile = load_profile(name)
654
+ access = verify_access(profile)
655
+ host_written = False
656
+ config_written = False
657
+ dns_created: dict[str, Any] | None = None
658
+ try:
659
+ _run_remote(
660
+ profile,
661
+ access["address"],
662
+ "route-register",
663
+ scope=scope,
664
+ spec=spec,
665
+ expected=plan["previousRoute"],
666
+ )
667
+ host_written = True
668
+ if plan["desiredTunnelConfig"] != plan["tunnelConfig"]:
669
+ cloudflare_api.update_tunnel_configuration(
670
+ api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
671
+ )
672
+ config_written = True
673
+ if current_dns is None:
674
+ dns_created = cloudflare_api.create_dns_cname(
675
+ api_token,
676
+ host["zoneId"],
677
+ name=spec["publicHostname"],
678
+ content=f"{host['tunnelId']}.cfargotunnel.com",
679
+ comment=plan["dnsMarker"],
680
+ )
681
+ except Exception:
682
+ if dns_created is not None:
683
+ try:
684
+ cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns_created["id"]))
685
+ except Exception:
686
+ pass
687
+ if config_written:
688
+ try:
689
+ cloudflare_api.update_tunnel_configuration(
690
+ api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
691
+ )
692
+ except Exception:
693
+ pass
694
+ if host_written and plan["previousRoute"] is None:
695
+ try:
696
+ _run_remote(profile, access["address"], "route-remove", scope=scope, spec=spec)
697
+ except Exception:
698
+ pass
699
+ raise
700
+
701
+ final_inventory = cloudflare_route_inventory(name, scope)
702
+ if _find_route(final_inventory, spec["routeId"]) != spec:
703
+ raise DeploymentKitError("Public route registration returned without exact host ownership metadata.")
704
+ final_config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
705
+ if final_config != plan["desiredTunnelConfig"]:
706
+ raise DeploymentKitError("Cloudflare tunnel configuration does not match the reviewed route plan after apply.")
707
+ final_dns = _exact_dns_record(
708
+ cloudflare_api.list_dns_records(api_token, host["zoneId"], name=spec["publicHostname"]),
709
+ spec=spec,
710
+ tunnel_id=host["tunnelId"],
711
+ )
712
+ if final_dns is None:
713
+ raise DeploymentKitError("Cloudflare DNS record is missing after public-route apply.")
714
+ return {
715
+ "targetName": name,
716
+ "scope": scope,
717
+ "route": spec,
718
+ "dnsRecordId": str(final_dns["id"]),
719
+ "publicUrl": f"https://{spec['publicHostname']}",
720
+ "mutated": True,
721
+ }
722
+
723
+
724
+ def plan_public_route_removal(
725
+ name: str,
726
+ *,
727
+ scope: str,
728
+ owner_repo: str,
729
+ environment: str,
730
+ app_slug: str,
731
+ api_token: str,
732
+ ) -> dict[str, Any]:
733
+ route_id = f"{_slug(app_slug, 'appSlug')}--{_slug(environment, 'environment')}"
734
+ owner_repo = _owner_repo(owner_repo)
735
+ host = cloudflare_ingress_status(name, scope)
736
+ if host["state"] != "ready":
737
+ raise DeploymentKitError("Cloudflare ingress must be ready before removing a managed public route.")
738
+ inventory = cloudflare_route_inventory(name, scope)
739
+ route = _find_route(inventory, route_id)
740
+ if route is None:
741
+ raise DeploymentKitError(f"Managed Cloudflare public route does not exist: {route_id}")
742
+ if route["ownerRepo"] != owner_repo or route["environment"] != environment or route["appSlug"] != app_slug:
743
+ raise DeploymentKitError("Cloudflare public-route removal ownership does not match.")
744
+ config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
745
+ rules, final_rule = _split_ingress(config)
746
+ expected_rule = _cloudflare_rule(route)
747
+ matches = [item for item in rules if item == expected_rule]
748
+ if len(matches) != 1:
749
+ raise DeploymentKitError("Cloudflare tunnel does not contain exactly one expected managed rule for this route.")
750
+ desired = {**config, "ingress": [item for item in rules if item != expected_rule] + [final_rule]}
751
+ dns = _exact_dns_record(
752
+ cloudflare_api.list_dns_records(api_token, host["zoneId"], name=route["publicHostname"]),
753
+ spec=route,
754
+ tunnel_id=host["tunnelId"],
755
+ )
756
+ if dns is None:
757
+ raise DeploymentKitError("Managed public route DNS record is missing; inspect drift before removal.")
758
+ return {
759
+ "targetName": name,
760
+ "scope": scope,
761
+ "action": "remove",
762
+ "route": route,
763
+ "routeInventorySnapshot": _route_inventory_snapshot(inventory),
764
+ "hostStatusSnapshot": _status_snapshot(host),
765
+ "tunnelConfig": config,
766
+ "tunnelConfigSha256": _config_snapshot(config),
767
+ "desiredTunnelConfig": desired,
768
+ "dnsRecord": deepcopy(dns),
769
+ "requiredAuthorization": PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
770
+ }
771
+
772
+
773
+ def apply_public_route_removal(plan: dict[str, Any], *, api_token: str) -> dict[str, Any]:
774
+ required = {
775
+ "targetName", "scope", "action", "route", "routeInventorySnapshot", "hostStatusSnapshot",
776
+ "tunnelConfig", "tunnelConfigSha256", "desiredTunnelConfig", "dnsRecord", "requiredAuthorization",
777
+ }
778
+ if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "remove":
779
+ raise DeploymentKitError("Malformed Cloudflare public-route removal plan.")
780
+ name = plan["targetName"]
781
+ scope = plan["scope"]
782
+ route = plan["route"]
783
+ host = cloudflare_ingress_status(name, scope)
784
+ if _status_snapshot(host) != plan["hostStatusSnapshot"]:
785
+ raise DeploymentKitError("Cloudflare ingress host state changed after removal planning.")
786
+ inventory = cloudflare_route_inventory(name, scope)
787
+ if _route_inventory_snapshot(inventory) != plan["routeInventorySnapshot"]:
788
+ raise DeploymentKitError("Cloudflare public-route inventory changed after removal planning.")
789
+ config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
790
+ if _config_snapshot(config) != plan["tunnelConfigSha256"]:
791
+ raise DeploymentKitError("Cloudflare tunnel configuration changed after removal planning.")
792
+ dns = _exact_dns_record(
793
+ cloudflare_api.list_dns_records(api_token, host["zoneId"], name=route["publicHostname"]),
794
+ spec=route,
795
+ tunnel_id=host["tunnelId"],
796
+ )
797
+ if dns is None or str(dns.get("id")) != str(plan["dnsRecord"].get("id")):
798
+ raise DeploymentKitError("Cloudflare DNS record changed after removal planning.")
799
+
800
+ cloudflare_api.update_tunnel_configuration(
801
+ api_token, host["accountId"], host["tunnelId"], plan["desiredTunnelConfig"]
802
+ )
803
+ try:
804
+ cloudflare_api.delete_dns_record(api_token, host["zoneId"], str(dns["id"]))
805
+ except Exception:
806
+ cloudflare_api.update_tunnel_configuration(
807
+ api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
808
+ )
809
+ raise
810
+
811
+ profile = load_profile(name)
812
+ access = verify_access(profile)
813
+ try:
814
+ _run_remote(profile, access["address"], "route-remove", scope=scope, spec=route)
815
+ except Exception:
816
+ try:
817
+ cloudflare_api.update_tunnel_configuration(
818
+ api_token, host["accountId"], host["tunnelId"], plan["tunnelConfig"]
819
+ )
820
+ cloudflare_api.create_dns_cname(
821
+ api_token,
822
+ host["zoneId"],
823
+ name=route["publicHostname"],
824
+ content=f"{host['tunnelId']}.cfargotunnel.com",
825
+ comment=_dns_marker(route),
826
+ )
827
+ except Exception:
828
+ pass
829
+ raise
830
+
831
+ final = cloudflare_route_inventory(name, scope)
832
+ if _find_route(final, route["routeId"]) is not None:
833
+ raise DeploymentKitError("Cloudflare public route remains in host ownership registry after removal.")
834
+ return {"targetName": name, "scope": scope, "routeId": route["routeId"], "mutated": True}