@mriekki/project-forge 4.21.0 → 4.22.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.
- package/assets/components.json +2 -2
- package/assets/generated/ASSEMBLY.json +6 -6
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +36 -2
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress.py +594 -1
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +347 -10
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +51 -1
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.22.0",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"canonicalDescriptor": "distributions/greenfield-workspace.json"
|
|
22
22
|
},
|
|
23
23
|
"deployment": {
|
|
24
|
-
"revision": "
|
|
24
|
+
"revision": "2258794181483747ed11a2c3c3226d6e66e1b9bb",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.22.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
|
|
@@ -361,7 +361,7 @@
|
|
|
361
361
|
]
|
|
362
362
|
},
|
|
363
363
|
"deployment": {
|
|
364
|
-
"revision": "
|
|
364
|
+
"revision": "2258794181483747ed11a2c3c3226d6e66e1b9bb",
|
|
365
365
|
"distribution": "greenfield-project",
|
|
366
366
|
"files": [
|
|
367
367
|
{
|
|
@@ -437,12 +437,12 @@
|
|
|
437
437
|
{
|
|
438
438
|
"source": "src/project_forge_deployment/cloudflare_ingress.py",
|
|
439
439
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py",
|
|
440
|
-
"sha256": "
|
|
440
|
+
"sha256": "31541e115f474fd3bd2a426eac1ca1beea429633c6ecc48afeace025a85054a9"
|
|
441
441
|
},
|
|
442
442
|
{
|
|
443
443
|
"source": "src/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
444
444
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
|
|
445
|
-
"sha256": "
|
|
445
|
+
"sha256": "19de0d8c04b7ad0006b8bc341eabd52a5eaa50c0e0bacdc9edb856de101dd259"
|
|
446
446
|
},
|
|
447
447
|
{
|
|
448
448
|
"source": "src/project_forge_deployment/cloudflare_route_apply.py",
|
|
@@ -502,7 +502,7 @@
|
|
|
502
502
|
{
|
|
503
503
|
"source": "src/project_forge_deployment/ingress_cli.py",
|
|
504
504
|
"destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py",
|
|
505
|
-
"sha256": "
|
|
505
|
+
"sha256": "069a7e5a4c7921448e6965ebff2df8b38a9aba77aaff1154a54969ab0e234324"
|
|
506
506
|
},
|
|
507
507
|
{
|
|
508
508
|
"source": "src/project_forge_deployment/keys.py",
|
|
@@ -647,7 +647,7 @@
|
|
|
647
647
|
{
|
|
648
648
|
"source": "docs/CLOUDFLARE_INGRESS.md",
|
|
649
649
|
"destination": "docs/project-forge/CLOUDFLARE_INGRESS.md",
|
|
650
|
-
"sha256": "
|
|
650
|
+
"sha256": "396327ae00bf4a54eeaea6eec8c07a17af6084eed5c4d0be97b55b29bd0160ff"
|
|
651
651
|
},
|
|
652
652
|
{
|
|
653
653
|
"source": "docs/DEVELOPER_ENVIRONMENTS.md",
|
|
@@ -200,11 +200,45 @@ without a zone or public hostname. Once a domain is registered and active in Clo
|
|
|
200
200
|
|
|
201
201
|
The shared Cloudflare zone is environment infrastructure and should not be copied into every Project as a credential-like default. The exact application hostname remains Project/environment state.
|
|
202
202
|
|
|
203
|
-
## Experimental authenticated public-origin mode
|
|
203
|
+
## Experimental authenticated public-origin mode
|
|
204
204
|
|
|
205
205
|
For an isolated test scope, `ingress cloudflare configure` supports `--authenticated-origin`. This explicit opt-in provisions a private Caddy Unix listener and mounts its socket directory only in the pinned cloudflared connector. Public hostname rules use `unix:/run/project-forge-origin/origin.sock` and the public hostname as Host; the LAN TCP listener and the existing default mode remain unchanged. The remote adapter derives each authenticated route's upstream from its still-owned LAN registry entry and strips/replaces forwarding headers before proxying.
|
|
206
206
|
|
|
207
|
-
**Not a supported Production setting yet:** origin mount possession is not end-user login. Real pinned cloudflared edge-to-app forwarding, canonical client-IP semantics, app proxy-peer trust, socket replacement, and rollback still need live proof. Do not grant the socket mount to unrelated containers or treat forwarded IP headers as an authorization credential.
|
|
207
|
+
**Not a supported Production setting yet:** origin mount possession is not end-user login. Real pinned cloudflared edge-to-app forwarding, canonical client-IP semantics, app proxy-peer trust, socket replacement, and rollback still need live proof. Do not grant the socket mount to unrelated containers or treat forwarded IP headers as an authorization credential.
|
|
208
|
+
|
|
209
|
+
An existing legacy scope can be migrated without replacing its tunnel, public routes, DNS/CNAME records, or unrelated tunnel rules. The operation has its own read-only plan, exact interactive authorization, and guarded apply:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
project-forge-deploy ingress cloudflare migrate-authenticated-origin <target> \
|
|
213
|
+
--scope production \
|
|
214
|
+
--zone example.com
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Before planning, the operator must stop all Cloudflare dashboard/API tunnel-configuration writers for this scope. The CLI asks for an exact scope-bound freeze-active acknowledgment before making plan reads, and the programmatic planning API requires the corresponding `change_freeze_active_acknowledgement`, for example:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
[CLOUDFLARE EXTERNAL CHANGE FREEZE] ACTIVE scope=production
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The freeze must remain in effect through apply, verification, and any rollback or recovery. It is an external operational prerequisite: the Deployment Kit cannot enforce it, identify all external writers, or prove that they are stopped. Cloudflare's full-configuration PUT has no compare-and-swap; pre-write snapshots and post-write checks do not prevent an external write from racing between a read and PUT. Do not start the operation unless this freeze can be maintained.
|
|
224
|
+
|
|
225
|
+
The plan requires every host-registered route to match one exact Project Forge legacy provider rule and one exact owned DNS record. Conflicting/unowned rules for managed hostnames, missing or duplicate routes, stale host/provider/DNS snapshots, or incomplete host migration evidence stop the operation. Other provider rules remain unchanged; no DNS write or tunnel creation occurs. Apply holds the existing per-scope host lock, prepares the connector-only socket and sanitized Caddy routes first, then changes only the exact managed Cloudflare rules. It rechecks ownership immediately before each phase and verifies the unchanged tunnel, route and DNS identities plus the socket/header contract afterward. These checks are defense in depth, not enforcement of the external freeze. There is no generic `--yes` bypass; the CLI first requires:
|
|
226
|
+
|
|
227
|
+
```text
|
|
228
|
+
[CLOUDFLARE AUTHENTICATED ORIGIN MIGRATION]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
After showing the plan, the CLI separately requires the migration authorization and an exact freeze acknowledgment bound to the reviewed scope and snapshot context, for example:
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
[CLOUDFLARE EXTERNAL CHANGE FREEZE] scope=production context=<plan-specific digest>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The programmatic apply API likewise requires `change_freeze_acknowledgement`; migration authorization alone is insufficient. The bound acknowledgment prevents accidental reuse for a different scope/plan, but is an operator assertion only—not a lock on Cloudflare. If the provider update is confirmed unchanged, a newly prepared host phase is rolled back from a protected host receipt. If provider state is uncertain or differs from both reviewed states, the host remains authenticated with its receipt retained; maintain the external freeze and do not hand-edit or rerun blindly. Re-plan/resume or roll back only after inspecting current state; keep the freeze until recovery is complete. This migration still does not establish an application identity/login flow or replace disposable application/database state.
|
|
238
|
+
|
|
239
|
+
Final migration output labels its evidence **structural-only**: it checks socket metadata, connector mount configuration, Caddy configuration, and the generated sanitized route files. It does not prove that the running pinned cloudflared connector can forward an HTTP request over that socket or that an application receives the intended headers. Live socket forwarding and end-to-end readiness remain unproven.
|
|
240
|
+
|
|
241
|
+
See `docs/EXPERIMENTAL_AUTHENTICATED_ORIGIN.md` and issue #78 for the detailed evidence boundary. For a new isolated scope, `configure --authenticated-origin` and the normal route gates remain unchanged.
|
|
208
242
|
|
|
209
243
|
## Current cloudflared runtime
|
|
210
244
|
|
|
@@ -37,6 +37,8 @@ CLOUDFLARED_IMAGE = f"cloudflare/cloudflared:{CLOUDFLARED_VERSION}"
|
|
|
37
37
|
HOST_PUBLIC_INGRESS_WRITE_PHRASE = "[HOST PUBLIC INGRESS WRITE]"
|
|
38
38
|
PUBLIC_INGRESS_ROUTE_WRITE_PHRASE = "[PUBLIC INGRESS ROUTE WRITE]"
|
|
39
39
|
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE = "[PUBLIC INGRESS ROUTE REMOVE]"
|
|
40
|
+
AUTHENTICATED_ORIGIN_MIGRATION_PHRASE = "[CLOUDFLARE AUTHENTICATED ORIGIN MIGRATION]"
|
|
41
|
+
AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE = "[CLOUDFLARE EXTERNAL CHANGE FREEZE]"
|
|
40
42
|
CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
|
|
41
43
|
|
|
42
44
|
|
|
@@ -197,13 +199,15 @@ def _run_remote(
|
|
|
197
199
|
scope: str,
|
|
198
200
|
metadata: dict[str, Any] | None = None,
|
|
199
201
|
spec: dict[str, Any] | None = None,
|
|
200
|
-
expected: dict[str, Any] | None = None,
|
|
202
|
+
expected: dict[str, Any] | str | None = None,
|
|
201
203
|
tunnel_token: str | None = None,
|
|
202
204
|
authenticated_origin: bool = False,
|
|
203
205
|
) -> str:
|
|
204
206
|
if action not in {
|
|
205
207
|
"status", "install", "upgrade", "route-inventory",
|
|
206
208
|
"route-register", "route-remove", "route-register-locked", "route-remove-locked",
|
|
209
|
+
"migration-inspect", "migrate-authenticated", "rollback-authenticated",
|
|
210
|
+
"finalize-authenticated", "verify-authenticated",
|
|
207
211
|
}:
|
|
208
212
|
raise DeploymentKitError(f"Unsupported Cloudflare ingress action: {action}")
|
|
209
213
|
args = [action, _slug(scope, "scope")]
|
|
@@ -213,6 +217,11 @@ def _run_remote(
|
|
|
213
217
|
args.append(_route_tsv(expected) if expected is not None else "NONE")
|
|
214
218
|
elif action in {"route-remove", "route-remove-locked"}:
|
|
215
219
|
args.append("")
|
|
220
|
+
elif action in {
|
|
221
|
+
"migration-inspect", "migrate-authenticated", "rollback-authenticated",
|
|
222
|
+
"finalize-authenticated", "verify-authenticated",
|
|
223
|
+
}:
|
|
224
|
+
args.append(str(expected) if expected is not None else "")
|
|
216
225
|
args.append("true" if authenticated_origin else "false")
|
|
217
226
|
remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
|
|
218
227
|
completed = subprocess.run(
|
|
@@ -649,6 +658,25 @@ def _route_inventory_snapshot(value: dict[str, Any]) -> str:
|
|
|
649
658
|
return hashlib.sha256(json.dumps(selected, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
650
659
|
|
|
651
660
|
|
|
661
|
+
def _route_inventory_wire_snapshot(value: dict[str, Any]) -> str:
|
|
662
|
+
routes = sorted(value["managedRoutes"], key=lambda item: item["routeId"])
|
|
663
|
+
lines = [
|
|
664
|
+
f"SUMMARY\tscope\t{value['scope']}",
|
|
665
|
+
f"SUMMARY\tmanagedRouteCount\t{len(routes)}",
|
|
666
|
+
]
|
|
667
|
+
for spec in routes:
|
|
668
|
+
lines.append(
|
|
669
|
+
"ROUTE\t" + "\t".join(
|
|
670
|
+
str(spec[key])
|
|
671
|
+
for key in (
|
|
672
|
+
"schemaVersion", "ownerRepo", "environment", "appSlug", "routeId",
|
|
673
|
+
"publicHostname", "originHostname",
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
return hashlib.sha256(("\n".join(lines) + "\n").encode("utf-8")).hexdigest()
|
|
678
|
+
|
|
679
|
+
|
|
652
680
|
def _find_route(inventory: dict[str, Any], route_id: str) -> dict[str, Any] | None:
|
|
653
681
|
return next((item for item in inventory["managedRoutes"] if item["routeId"] == route_id), None)
|
|
654
682
|
|
|
@@ -696,6 +724,571 @@ def _split_ingress(config: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[s
|
|
|
696
724
|
return [deepcopy(item) for item in rules], deepcopy(ingress[-1])
|
|
697
725
|
|
|
698
726
|
|
|
727
|
+
def _migration_ingress_config(
|
|
728
|
+
config: dict[str, Any],
|
|
729
|
+
routes: list[dict[str, Any]],
|
|
730
|
+
*,
|
|
731
|
+
host_authenticated: bool,
|
|
732
|
+
) -> tuple[dict[str, Any], int]:
|
|
733
|
+
rules, final_rule = _split_ingress(config)
|
|
734
|
+
if not routes:
|
|
735
|
+
raise DeploymentKitError("Authenticated-origin migration requires at least one managed public route.")
|
|
736
|
+
desired_rules = deepcopy(rules)
|
|
737
|
+
seen_route_ids: set[str] = set()
|
|
738
|
+
seen_hostnames: set[str] = set()
|
|
739
|
+
for spec in routes:
|
|
740
|
+
route_id = spec["routeId"]
|
|
741
|
+
hostname = spec["publicHostname"]
|
|
742
|
+
if route_id in seen_route_ids or hostname in seen_hostnames:
|
|
743
|
+
raise DeploymentKitError("Managed public-route identities are ambiguous; migration was not planned.")
|
|
744
|
+
seen_route_ids.add(route_id)
|
|
745
|
+
seen_hostnames.add(hostname)
|
|
746
|
+
legacy = _cloudflare_rule(spec)
|
|
747
|
+
authenticated = _cloudflare_rule(spec, authenticated_origin=True)
|
|
748
|
+
matches = [index for index, rule in enumerate(rules) if rule.get("hostname") == hostname]
|
|
749
|
+
if len(matches) != 1:
|
|
750
|
+
raise DeploymentKitError(
|
|
751
|
+
f"Managed public route {route_id} does not have exactly one provider ingress rule; refusing takeover."
|
|
752
|
+
)
|
|
753
|
+
index = matches[0]
|
|
754
|
+
if rules[index] == legacy:
|
|
755
|
+
desired_rules[index] = authenticated
|
|
756
|
+
elif rules[index] == authenticated and host_authenticated:
|
|
757
|
+
desired_rules[index] = authenticated
|
|
758
|
+
elif rules[index] == authenticated:
|
|
759
|
+
raise DeploymentKitError(
|
|
760
|
+
"Provider ingress is already authenticated while the managed connector is in legacy mode."
|
|
761
|
+
)
|
|
762
|
+
else:
|
|
763
|
+
raise DeploymentKitError(
|
|
764
|
+
f"Provider ingress for managed route {route_id} has conflicting, unowned, or changed fields."
|
|
765
|
+
)
|
|
766
|
+
desired = deepcopy(config)
|
|
767
|
+
desired["ingress"] = desired_rules + [final_rule]
|
|
768
|
+
return desired, sum(left != right for left, right in zip(rules, desired_rules))
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _unmanaged_ingress_summary(
|
|
772
|
+
config: dict[str, Any],
|
|
773
|
+
routes: list[dict[str, Any]],
|
|
774
|
+
) -> dict[str, Any]:
|
|
775
|
+
rules, _ = _split_ingress(config)
|
|
776
|
+
managed_hostnames = {spec["publicHostname"] for spec in routes}
|
|
777
|
+
unmanaged = [rule for rule in rules if rule.get("hostname") not in managed_hostnames]
|
|
778
|
+
return {
|
|
779
|
+
"unmanagedIngressRuleCount": len(unmanaged),
|
|
780
|
+
"unmanagedIngressRulesSha256": _config_snapshot({"rules": unmanaged}),
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _migration_dns_snapshot(
|
|
785
|
+
api_token: str,
|
|
786
|
+
host: dict[str, Any],
|
|
787
|
+
routes: list[dict[str, Any]],
|
|
788
|
+
) -> list[dict[str, str]]:
|
|
789
|
+
result: list[dict[str, str]] = []
|
|
790
|
+
for spec in routes:
|
|
791
|
+
records = cloudflare_api.list_dns_records(
|
|
792
|
+
api_token, host["zoneId"], name=spec["publicHostname"]
|
|
793
|
+
)
|
|
794
|
+
record = _exact_dns_record(records, spec=spec, tunnel_id=host["tunnelId"])
|
|
795
|
+
if record is None or not record.get("id"):
|
|
796
|
+
raise DeploymentKitError(
|
|
797
|
+
f"Managed route {spec['routeId']} has no exact owned DNS record; refusing migration."
|
|
798
|
+
)
|
|
799
|
+
result.append(
|
|
800
|
+
{
|
|
801
|
+
"routeId": spec["routeId"],
|
|
802
|
+
"recordId": str(record["id"]),
|
|
803
|
+
"recordSha256": _config_snapshot(record),
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
return result
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _migration_freeze_context(plan: dict[str, Any]) -> str:
|
|
810
|
+
context = {
|
|
811
|
+
"scope": plan["scope"],
|
|
812
|
+
"hostStatusSnapshot": plan["hostStatusSnapshot"],
|
|
813
|
+
"zone": plan["zone"],
|
|
814
|
+
"tunnel": plan["tunnel"],
|
|
815
|
+
"routeInventorySnapshot": plan["routeInventorySnapshot"],
|
|
816
|
+
"dnsSnapshot": plan["dnsSnapshot"],
|
|
817
|
+
"tunnelConfigSha256": plan["tunnelConfigSha256"],
|
|
818
|
+
"desiredTunnelConfigSha256": plan["desiredTunnelConfigSha256"],
|
|
819
|
+
}
|
|
820
|
+
return hashlib.sha256(
|
|
821
|
+
json.dumps(context, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
822
|
+
).hexdigest()[:16]
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _required_change_freeze_acknowledgement(plan: dict[str, Any]) -> str:
|
|
826
|
+
return (
|
|
827
|
+
f"{AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE} "
|
|
828
|
+
f"scope={plan['scope']} context={_migration_freeze_context(plan)}"
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _required_preplan_change_freeze_acknowledgement(scope: str) -> str:
|
|
833
|
+
return f"{AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE} ACTIVE scope={scope}"
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _remote_migration_state(name: str, scope: str) -> str:
|
|
837
|
+
profile = load_profile(name)
|
|
838
|
+
access = verify_access(profile)
|
|
839
|
+
output = _run_remote(profile, access["address"], "migration-inspect", scope=scope)
|
|
840
|
+
lines = output.splitlines()
|
|
841
|
+
if len(lines) != 1 or not lines[0].startswith("migrationState="):
|
|
842
|
+
raise DeploymentKitError("Remote authenticated-origin migration state is malformed.")
|
|
843
|
+
state = lines[0].split("=", 1)[1]
|
|
844
|
+
if state not in {"clean", "pending", "complete"}:
|
|
845
|
+
raise DeploymentKitError("Remote authenticated-origin migration state is unsupported.")
|
|
846
|
+
return state
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def plan_authenticated_origin_migration(
|
|
850
|
+
name: str,
|
|
851
|
+
*,
|
|
852
|
+
scope: str,
|
|
853
|
+
zone_name: str,
|
|
854
|
+
api_token: str,
|
|
855
|
+
change_freeze_active_acknowledgement: str,
|
|
856
|
+
) -> dict[str, Any]:
|
|
857
|
+
"""Plan a guarded migration of one existing legacy ingress scope."""
|
|
858
|
+
scope = _slug(scope, "scope")
|
|
859
|
+
if change_freeze_active_acknowledgement != _required_preplan_change_freeze_acknowledgement(scope):
|
|
860
|
+
raise DeploymentKitError(
|
|
861
|
+
"Planning requires an explicit scope-bound acknowledgement that the operator-controlled Cloudflare change freeze is active."
|
|
862
|
+
)
|
|
863
|
+
zone = cloudflare_api.resolve_zone(api_token, zone_name)
|
|
864
|
+
host = cloudflare_ingress_status(name, scope)
|
|
865
|
+
if host["state"] != "ready":
|
|
866
|
+
raise DeploymentKitError(f"Cloudflare ingress scope {scope} is not ready on target {name}.")
|
|
867
|
+
if (
|
|
868
|
+
host["accountId"] != zone["accountId"]
|
|
869
|
+
or host["zoneId"] != zone["zoneId"]
|
|
870
|
+
or host["zoneName"] != zone["zoneName"]
|
|
871
|
+
):
|
|
872
|
+
raise DeploymentKitError("Cloudflare zone does not match the existing ingress scope binding.")
|
|
873
|
+
tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], host["tunnelName"])
|
|
874
|
+
if (
|
|
875
|
+
tunnel is None
|
|
876
|
+
or str(tunnel.get("id")) != host["tunnelId"]
|
|
877
|
+
or tunnel.get("config_src") not in {None, "cloudflare"}
|
|
878
|
+
):
|
|
879
|
+
raise DeploymentKitError("Existing remotely managed tunnel identity changed; refusing migration.")
|
|
880
|
+
|
|
881
|
+
migration_state = _remote_migration_state(name, scope)
|
|
882
|
+
host_authenticated = bool(host["authenticatedOrigin"])
|
|
883
|
+
if host_authenticated and migration_state != "pending":
|
|
884
|
+
raise DeploymentKitError(
|
|
885
|
+
"This scope is already authenticated and has no pending migration receipt; no migration is required."
|
|
886
|
+
)
|
|
887
|
+
if not host_authenticated and migration_state != "clean":
|
|
888
|
+
raise DeploymentKitError(
|
|
889
|
+
"Legacy ingress scope has conflicting or incomplete authenticated-origin migration state."
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
inventory = cloudflare_route_inventory(name, scope)
|
|
893
|
+
if inventory["scope"] != scope:
|
|
894
|
+
raise DeploymentKitError("Remote managed-route inventory belongs to a different ingress scope.")
|
|
895
|
+
routes = sorted(inventory["managedRoutes"], key=lambda item: item["routeId"])
|
|
896
|
+
if not routes:
|
|
897
|
+
raise DeploymentKitError("Authenticated-origin migration requires at least one managed public route.")
|
|
898
|
+
for spec in routes:
|
|
899
|
+
_lan_route(name, spec)
|
|
900
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], host["tunnelId"])
|
|
901
|
+
desired_config, converted_count = _migration_ingress_config(
|
|
902
|
+
config, routes, host_authenticated=host_authenticated
|
|
903
|
+
)
|
|
904
|
+
if converted_count == 0 and not host_authenticated:
|
|
905
|
+
raise DeploymentKitError("No legacy managed route rules were found to migrate.")
|
|
906
|
+
dns_snapshot = _migration_dns_snapshot(api_token, host, routes)
|
|
907
|
+
unmanaged_summary = _unmanaged_ingress_summary(config, routes)
|
|
908
|
+
return {
|
|
909
|
+
"targetName": name,
|
|
910
|
+
"scope": scope,
|
|
911
|
+
"action": "resume" if migration_state == "pending" else "prepare",
|
|
912
|
+
"migrationState": migration_state,
|
|
913
|
+
"hostStatusSnapshot": _status_snapshot(host),
|
|
914
|
+
"zone": zone,
|
|
915
|
+
"tunnel": _tunnel_summary(tunnel),
|
|
916
|
+
"routeInventorySnapshot": _route_inventory_snapshot(inventory),
|
|
917
|
+
"routeInventoryWireSnapshot": _route_inventory_wire_snapshot(inventory),
|
|
918
|
+
"managedRoutes": routes,
|
|
919
|
+
"dnsSnapshot": dns_snapshot,
|
|
920
|
+
"tunnelConfigSha256": _config_snapshot(config),
|
|
921
|
+
"desiredTunnelConfigSha256": _config_snapshot(desired_config),
|
|
922
|
+
"convertedRouteCount": converted_count,
|
|
923
|
+
**unmanaged_summary,
|
|
924
|
+
"requiredAuthorization": AUTHENTICATED_ORIGIN_MIGRATION_PHRASE,
|
|
925
|
+
"requiredPrePlanChangeFreezeAcknowledgement":
|
|
926
|
+
_required_preplan_change_freeze_acknowledgement(scope),
|
|
927
|
+
"changeFreezePrerequisite": (
|
|
928
|
+
"Operator has stopped all Cloudflare dashboard/API tunnel-configuration writers for this scope "
|
|
929
|
+
"before planning and will keep them stopped through apply, verification, rollback, or recovery. "
|
|
930
|
+
"Deployment Kit cannot enforce or prove this external change freeze."
|
|
931
|
+
),
|
|
932
|
+
"requiredChangeFreezeAcknowledgement": _required_change_freeze_acknowledgement({
|
|
933
|
+
"scope": scope,
|
|
934
|
+
"hostStatusSnapshot": _status_snapshot(host),
|
|
935
|
+
"zone": zone,
|
|
936
|
+
"tunnel": _tunnel_summary(tunnel),
|
|
937
|
+
"routeInventorySnapshot": _route_inventory_snapshot(inventory),
|
|
938
|
+
"dnsSnapshot": dns_snapshot,
|
|
939
|
+
"tunnelConfigSha256": _config_snapshot(config),
|
|
940
|
+
"desiredTunnelConfigSha256": _config_snapshot(desired_config),
|
|
941
|
+
}),
|
|
942
|
+
"secrets": {
|
|
943
|
+
"apiToken": "workstation-only; not persisted by Deployment Kit",
|
|
944
|
+
"tunnelToken": "existing tunnel token is unchanged and never displayed",
|
|
945
|
+
"providerConfiguration": "not included in this migration plan",
|
|
946
|
+
},
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _migration_remote_action(
|
|
951
|
+
name: str,
|
|
952
|
+
scope: str,
|
|
953
|
+
action: str,
|
|
954
|
+
*,
|
|
955
|
+
zone: dict[str, Any],
|
|
956
|
+
tunnel: dict[str, Any],
|
|
957
|
+
route_snapshot: str,
|
|
958
|
+
authenticated_origin: bool = True,
|
|
959
|
+
) -> str:
|
|
960
|
+
profile = load_profile(name)
|
|
961
|
+
access = verify_access(profile)
|
|
962
|
+
return _run_remote(
|
|
963
|
+
profile,
|
|
964
|
+
access["address"],
|
|
965
|
+
action,
|
|
966
|
+
scope=scope,
|
|
967
|
+
metadata={
|
|
968
|
+
**zone,
|
|
969
|
+
"tunnelId": str(tunnel["id"]),
|
|
970
|
+
"tunnelName": str(tunnel["name"]),
|
|
971
|
+
},
|
|
972
|
+
expected=route_snapshot,
|
|
973
|
+
authenticated_origin=authenticated_origin,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _rollback_prepared_migration(
|
|
978
|
+
name: str,
|
|
979
|
+
scope: str,
|
|
980
|
+
*,
|
|
981
|
+
zone: dict[str, Any],
|
|
982
|
+
tunnel: dict[str, Any],
|
|
983
|
+
route_snapshot: str,
|
|
984
|
+
) -> None:
|
|
985
|
+
_migration_remote_action(
|
|
986
|
+
name,
|
|
987
|
+
scope,
|
|
988
|
+
"rollback-authenticated",
|
|
989
|
+
zone=zone,
|
|
990
|
+
tunnel=tunnel,
|
|
991
|
+
route_snapshot=route_snapshot,
|
|
992
|
+
)
|
|
993
|
+
restored_host = cloudflare_ingress_status(name, scope)
|
|
994
|
+
if restored_host["state"] != "ready" or restored_host["authenticatedOrigin"]:
|
|
995
|
+
raise DeploymentKitError("Legacy host rollback did not restore a ready unauthenticated scope.")
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _parse_migration_proof(output: str, expected_route_count: int) -> dict[str, Any]:
|
|
999
|
+
result: dict[str, str] = {}
|
|
1000
|
+
for line in output.splitlines():
|
|
1001
|
+
if "=" not in line:
|
|
1002
|
+
raise DeploymentKitError("Authenticated-origin final proof was malformed.")
|
|
1003
|
+
key, value = line.split("=", 1)
|
|
1004
|
+
if key in result:
|
|
1005
|
+
raise DeploymentKitError("Authenticated-origin final proof contained duplicate fields.")
|
|
1006
|
+
result[key] = value
|
|
1007
|
+
expected = {
|
|
1008
|
+
"evidenceLevel": "structural-only",
|
|
1009
|
+
"connectorOriginMount": "read-only",
|
|
1010
|
+
"socketMetadataVerified": "true",
|
|
1011
|
+
"caddyConfigValidated": "true",
|
|
1012
|
+
"sanitizedRouteConfigVerified": "true",
|
|
1013
|
+
"managedRouteCount": str(expected_route_count),
|
|
1014
|
+
}
|
|
1015
|
+
if result != expected:
|
|
1016
|
+
raise DeploymentKitError("Authenticated-origin structural checks did not match the reviewed contract.")
|
|
1017
|
+
return {
|
|
1018
|
+
"evidenceLevel": "structural-only",
|
|
1019
|
+
"connectorOriginMountReadOnly": True,
|
|
1020
|
+
"socketMetadataVerified": True,
|
|
1021
|
+
"caddyConfigValidated": True,
|
|
1022
|
+
"sanitizedRouteConfigVerified": True,
|
|
1023
|
+
"managedRouteCount": expected_route_count,
|
|
1024
|
+
"liveSocketForwardingVerified": False,
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def apply_authenticated_origin_migration(
|
|
1029
|
+
plan: dict[str, Any],
|
|
1030
|
+
*,
|
|
1031
|
+
api_token: str,
|
|
1032
|
+
change_freeze_acknowledgement: str,
|
|
1033
|
+
_lock_held: bool = False,
|
|
1034
|
+
) -> dict[str, Any]:
|
|
1035
|
+
"""Apply one reviewed existing-scope mode migration without replacing identities."""
|
|
1036
|
+
required = {
|
|
1037
|
+
"targetName", "scope", "action", "migrationState", "hostStatusSnapshot",
|
|
1038
|
+
"zone", "tunnel", "routeInventorySnapshot", "routeInventoryWireSnapshot",
|
|
1039
|
+
"managedRoutes", "dnsSnapshot", "tunnelConfigSha256",
|
|
1040
|
+
"desiredTunnelConfigSha256", "convertedRouteCount", "unmanagedIngressRuleCount",
|
|
1041
|
+
"unmanagedIngressRulesSha256", "requiredAuthorization",
|
|
1042
|
+
"changeFreezePrerequisite", "requiredChangeFreezeAcknowledgement", "secrets",
|
|
1043
|
+
"requiredPrePlanChangeFreezeAcknowledgement",
|
|
1044
|
+
}
|
|
1045
|
+
if not isinstance(plan, dict) or set(plan) != required:
|
|
1046
|
+
raise DeploymentKitError("Malformed authenticated-origin migration plan.")
|
|
1047
|
+
if (
|
|
1048
|
+
plan["requiredAuthorization"] != AUTHENTICATED_ORIGIN_MIGRATION_PHRASE
|
|
1049
|
+
or plan["action"] not in {"prepare", "resume"}
|
|
1050
|
+
or plan["migrationState"] not in {"clean", "pending"}
|
|
1051
|
+
or type(plan["convertedRouteCount"]) is not int
|
|
1052
|
+
or not isinstance(plan["managedRoutes"], list)
|
|
1053
|
+
or not plan["managedRoutes"]
|
|
1054
|
+
):
|
|
1055
|
+
raise DeploymentKitError("Malformed authenticated-origin migration authorization or route plan.")
|
|
1056
|
+
if (
|
|
1057
|
+
plan["requiredChangeFreezeAcknowledgement"]
|
|
1058
|
+
!= _required_change_freeze_acknowledgement(plan)
|
|
1059
|
+
or plan["requiredPrePlanChangeFreezeAcknowledgement"]
|
|
1060
|
+
!= _required_preplan_change_freeze_acknowledgement(plan["scope"])
|
|
1061
|
+
or change_freeze_acknowledgement != plan["requiredChangeFreezeAcknowledgement"]
|
|
1062
|
+
):
|
|
1063
|
+
raise DeploymentKitError(
|
|
1064
|
+
"The required scope- and context-bound Cloudflare change-freeze acknowledgement is missing or invalid."
|
|
1065
|
+
)
|
|
1066
|
+
if not _lock_held:
|
|
1067
|
+
with route_transaction_lock(plan["targetName"], plan["scope"]):
|
|
1068
|
+
return apply_authenticated_origin_migration(
|
|
1069
|
+
plan,
|
|
1070
|
+
api_token=api_token,
|
|
1071
|
+
change_freeze_acknowledgement=change_freeze_acknowledgement,
|
|
1072
|
+
_lock_held=True,
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
name, scope = plan["targetName"], _slug(plan["scope"], "scope")
|
|
1076
|
+
zone = cloudflare_api.resolve_zone(api_token, plan["zone"]["zoneName"])
|
|
1077
|
+
if zone != plan["zone"]:
|
|
1078
|
+
raise DeploymentKitError("Cloudflare zone identity changed after migration planning; create a fresh plan.")
|
|
1079
|
+
host = cloudflare_ingress_status(name, scope)
|
|
1080
|
+
if _status_snapshot(host) != plan["hostStatusSnapshot"]:
|
|
1081
|
+
raise DeploymentKitError("Host Cloudflare ingress state changed after migration planning.")
|
|
1082
|
+
if host["state"] != "ready" or host["authenticatedOrigin"] != (plan["migrationState"] == "pending"):
|
|
1083
|
+
raise DeploymentKitError("Host mode no longer matches the reviewed migration phase.")
|
|
1084
|
+
migration_state = _remote_migration_state(name, scope)
|
|
1085
|
+
if migration_state != plan["migrationState"]:
|
|
1086
|
+
raise DeploymentKitError("Host migration receipt changed after planning; create a fresh plan.")
|
|
1087
|
+
|
|
1088
|
+
tunnel_value = cloudflare_api.find_tunnel(api_token, zone["accountId"], host["tunnelName"])
|
|
1089
|
+
if (
|
|
1090
|
+
_tunnel_summary(tunnel_value) != plan["tunnel"]
|
|
1091
|
+
or tunnel_value is None
|
|
1092
|
+
or str(tunnel_value.get("id")) != host["tunnelId"]
|
|
1093
|
+
or tunnel_value.get("config_src") not in {None, "cloudflare"}
|
|
1094
|
+
):
|
|
1095
|
+
raise DeploymentKitError("Existing Cloudflare tunnel identity changed after migration planning.")
|
|
1096
|
+
tunnel = {"id": host["tunnelId"], "name": host["tunnelName"]}
|
|
1097
|
+
|
|
1098
|
+
inventory = cloudflare_route_inventory(name, scope)
|
|
1099
|
+
if (
|
|
1100
|
+
_route_inventory_snapshot(inventory) != plan["routeInventorySnapshot"]
|
|
1101
|
+
or _route_inventory_wire_snapshot(inventory) != plan["routeInventoryWireSnapshot"]
|
|
1102
|
+
or sorted(inventory["managedRoutes"], key=lambda item: item["routeId"]) != plan["managedRoutes"]
|
|
1103
|
+
):
|
|
1104
|
+
raise DeploymentKitError("Managed public-route ownership changed after migration planning.")
|
|
1105
|
+
for spec in plan["managedRoutes"]:
|
|
1106
|
+
_lan_route(name, spec)
|
|
1107
|
+
|
|
1108
|
+
config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], host["tunnelId"])
|
|
1109
|
+
if _config_snapshot(config) != plan["tunnelConfigSha256"]:
|
|
1110
|
+
raise DeploymentKitError("Cloudflare tunnel configuration changed after migration planning.")
|
|
1111
|
+
desired_config, converted_count = _migration_ingress_config(
|
|
1112
|
+
config, plan["managedRoutes"], host_authenticated=host["authenticatedOrigin"]
|
|
1113
|
+
)
|
|
1114
|
+
unmanaged_summary = _unmanaged_ingress_summary(config, plan["managedRoutes"])
|
|
1115
|
+
if (
|
|
1116
|
+
_config_snapshot(desired_config) != plan["desiredTunnelConfigSha256"]
|
|
1117
|
+
or converted_count != plan["convertedRouteCount"]
|
|
1118
|
+
or unmanaged_summary["unmanagedIngressRuleCount"] != plan["unmanagedIngressRuleCount"]
|
|
1119
|
+
or unmanaged_summary["unmanagedIngressRulesSha256"] != plan["unmanagedIngressRulesSha256"]
|
|
1120
|
+
):
|
|
1121
|
+
raise DeploymentKitError("Managed ingress rule ownership no longer matches the reviewed migration.")
|
|
1122
|
+
dns_snapshot = _migration_dns_snapshot(api_token, host, plan["managedRoutes"])
|
|
1123
|
+
if dns_snapshot != plan["dnsSnapshot"]:
|
|
1124
|
+
raise DeploymentKitError("Cloudflare DNS ownership or record identities changed after planning.")
|
|
1125
|
+
|
|
1126
|
+
host_prepared_by_this_apply = plan["migrationState"] == "clean"
|
|
1127
|
+
if host_prepared_by_this_apply:
|
|
1128
|
+
_migration_remote_action(
|
|
1129
|
+
name,
|
|
1130
|
+
scope,
|
|
1131
|
+
"migrate-authenticated",
|
|
1132
|
+
zone=zone,
|
|
1133
|
+
tunnel=tunnel,
|
|
1134
|
+
route_snapshot=plan["routeInventoryWireSnapshot"],
|
|
1135
|
+
)
|
|
1136
|
+
prepared_host = cloudflare_ingress_status(name, scope)
|
|
1137
|
+
prepared_inventory = cloudflare_route_inventory(name, scope)
|
|
1138
|
+
if (
|
|
1139
|
+
prepared_host["state"] != "ready"
|
|
1140
|
+
or not prepared_host["authenticatedOrigin"]
|
|
1141
|
+
or _route_inventory_snapshot(prepared_inventory) != plan["routeInventorySnapshot"]
|
|
1142
|
+
or _remote_migration_state(name, scope) != "pending"
|
|
1143
|
+
):
|
|
1144
|
+
raise DeploymentKitError(
|
|
1145
|
+
"Authenticated-origin host preparation failed verification; provider ingress was not changed."
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
prewrite_host = cloudflare_ingress_status(name, scope)
|
|
1149
|
+
prewrite_inventory = cloudflare_route_inventory(name, scope)
|
|
1150
|
+
prewrite_tunnel = cloudflare_api.find_tunnel(api_token, zone["accountId"], host["tunnelName"])
|
|
1151
|
+
if (
|
|
1152
|
+
prewrite_host["state"] != "ready"
|
|
1153
|
+
or not prewrite_host["authenticatedOrigin"]
|
|
1154
|
+
or prewrite_host["tunnelId"] != host["tunnelId"]
|
|
1155
|
+
or prewrite_host["tunnelName"] != host["tunnelName"]
|
|
1156
|
+
or _route_inventory_snapshot(prewrite_inventory) != plan["routeInventorySnapshot"]
|
|
1157
|
+
or _tunnel_summary(prewrite_tunnel) != plan["tunnel"]
|
|
1158
|
+
or _migration_dns_snapshot(api_token, prewrite_host, plan["managedRoutes"]) != plan["dnsSnapshot"]
|
|
1159
|
+
or _remote_migration_state(name, scope) != "pending"
|
|
1160
|
+
):
|
|
1161
|
+
raise DeploymentKitError(
|
|
1162
|
+
"Host, tunnel, managed-route, DNS, or recovery evidence changed before provider apply; "
|
|
1163
|
+
"the authenticated host phase was retained."
|
|
1164
|
+
)
|
|
1165
|
+
prewrite_config = cloudflare_api.get_tunnel_configuration(
|
|
1166
|
+
api_token, zone["accountId"], host["tunnelId"]
|
|
1167
|
+
)
|
|
1168
|
+
if _reviewed_config_matches(prewrite_config, desired_config):
|
|
1169
|
+
desired_already_applied = True
|
|
1170
|
+
elif _config_snapshot(prewrite_config) != plan["tunnelConfigSha256"]:
|
|
1171
|
+
raise DeploymentKitError(
|
|
1172
|
+
"Cloudflare tunnel configuration changed during host preparation; "
|
|
1173
|
+
"provider state was preserved and the authenticated host phase was retained."
|
|
1174
|
+
)
|
|
1175
|
+
else:
|
|
1176
|
+
desired_already_applied = False
|
|
1177
|
+
|
|
1178
|
+
# Cloudflare exposes a whole-configuration PUT without compare-and-swap.
|
|
1179
|
+
# The operator's external-writer freeze is the concurrency prerequisite;
|
|
1180
|
+
# these reads detect stale evidence but cannot enforce that freeze.
|
|
1181
|
+
if not desired_already_applied:
|
|
1182
|
+
try:
|
|
1183
|
+
cloudflare_api.update_tunnel_configuration(
|
|
1184
|
+
api_token, zone["accountId"], host["tunnelId"], desired_config
|
|
1185
|
+
)
|
|
1186
|
+
except Exception as write_error:
|
|
1187
|
+
try:
|
|
1188
|
+
after_error = cloudflare_api.get_tunnel_configuration(
|
|
1189
|
+
api_token, zone["accountId"], host["tunnelId"]
|
|
1190
|
+
)
|
|
1191
|
+
except Exception:
|
|
1192
|
+
raise DeploymentKitError(
|
|
1193
|
+
"Provider migration response is uncertain. The authenticated host phase and recovery receipt "
|
|
1194
|
+
"were retained; inspect Cloudflare state and re-plan before retrying."
|
|
1195
|
+
) from write_error
|
|
1196
|
+
if _reviewed_config_matches(after_error, prewrite_config):
|
|
1197
|
+
if host_prepared_by_this_apply:
|
|
1198
|
+
try:
|
|
1199
|
+
_rollback_prepared_migration(
|
|
1200
|
+
name, scope, zone=zone, tunnel=tunnel,
|
|
1201
|
+
route_snapshot=plan["routeInventoryWireSnapshot"],
|
|
1202
|
+
)
|
|
1203
|
+
except Exception as rollback_error:
|
|
1204
|
+
raise DeploymentKitError(
|
|
1205
|
+
"Provider state is unchanged, but authenticated host rollback did not complete. "
|
|
1206
|
+
"The migration receipt was retained for safe recovery."
|
|
1207
|
+
) from rollback_error
|
|
1208
|
+
raise DeploymentKitError(
|
|
1209
|
+
"Cloudflare provider update failed before changing the reviewed configuration; "
|
|
1210
|
+
"the operation was rolled back or remains safely resumable."
|
|
1211
|
+
) from write_error
|
|
1212
|
+
if not _reviewed_config_matches(after_error, desired_config):
|
|
1213
|
+
raise DeploymentKitError(
|
|
1214
|
+
"Provider migration response is uncertain and current configuration differs from both "
|
|
1215
|
+
"reviewed states. The authenticated host phase was retained; no rollback overwrote provider state."
|
|
1216
|
+
) from write_error
|
|
1217
|
+
|
|
1218
|
+
final_config = cloudflare_api.get_tunnel_configuration(
|
|
1219
|
+
api_token, zone["accountId"], host["tunnelId"]
|
|
1220
|
+
)
|
|
1221
|
+
if not _reviewed_config_matches(final_config, desired_config):
|
|
1222
|
+
raise DeploymentKitError(
|
|
1223
|
+
"Provider migration did not produce the exact reviewed authenticated rules; "
|
|
1224
|
+
"the host recovery receipt was retained."
|
|
1225
|
+
)
|
|
1226
|
+
final_host = cloudflare_ingress_status(name, scope)
|
|
1227
|
+
final_inventory = cloudflare_route_inventory(name, scope)
|
|
1228
|
+
final_dns_snapshot = _migration_dns_snapshot(api_token, final_host, plan["managedRoutes"])
|
|
1229
|
+
if (
|
|
1230
|
+
final_host["state"] != "ready"
|
|
1231
|
+
or not final_host["authenticatedOrigin"]
|
|
1232
|
+
or final_host["tunnelId"] != host["tunnelId"]
|
|
1233
|
+
or final_host["tunnelName"] != host["tunnelName"]
|
|
1234
|
+
or _route_inventory_snapshot(final_inventory) != plan["routeInventorySnapshot"]
|
|
1235
|
+
or final_dns_snapshot != plan["dnsSnapshot"]
|
|
1236
|
+
):
|
|
1237
|
+
raise DeploymentKitError(
|
|
1238
|
+
"Final migration state changed or did not preserve the reviewed tunnel, routes, or DNS identities; "
|
|
1239
|
+
"host recovery evidence was retained."
|
|
1240
|
+
)
|
|
1241
|
+
proof_output = _migration_remote_action(
|
|
1242
|
+
name,
|
|
1243
|
+
scope,
|
|
1244
|
+
"verify-authenticated",
|
|
1245
|
+
zone=zone,
|
|
1246
|
+
tunnel=tunnel,
|
|
1247
|
+
route_snapshot=plan["routeInventoryWireSnapshot"],
|
|
1248
|
+
)
|
|
1249
|
+
proof = _parse_migration_proof(proof_output, len(plan["managedRoutes"]))
|
|
1250
|
+
|
|
1251
|
+
_migration_remote_action(
|
|
1252
|
+
name,
|
|
1253
|
+
scope,
|
|
1254
|
+
"finalize-authenticated",
|
|
1255
|
+
zone=zone,
|
|
1256
|
+
tunnel=tunnel,
|
|
1257
|
+
route_snapshot=plan["routeInventoryWireSnapshot"],
|
|
1258
|
+
)
|
|
1259
|
+
if _remote_migration_state(name, scope) != "complete":
|
|
1260
|
+
raise DeploymentKitError(
|
|
1261
|
+
"Migration is verified but the host recovery receipt could not be finalized; inspect before retrying."
|
|
1262
|
+
)
|
|
1263
|
+
post_finalize_host = cloudflare_ingress_status(name, scope)
|
|
1264
|
+
if (
|
|
1265
|
+
post_finalize_host["state"] != "ready"
|
|
1266
|
+
or not post_finalize_host["authenticatedOrigin"]
|
|
1267
|
+
or post_finalize_host["tunnelId"] != host["tunnelId"]
|
|
1268
|
+
):
|
|
1269
|
+
raise DeploymentKitError("Final host status changed after migration finalization.")
|
|
1270
|
+
return {
|
|
1271
|
+
"targetName": name,
|
|
1272
|
+
"scope": scope,
|
|
1273
|
+
"tunnelId": host["tunnelId"],
|
|
1274
|
+
"tunnelName": host["tunnelName"],
|
|
1275
|
+
"authenticatedOrigin": True,
|
|
1276
|
+
"managedRoutes": [
|
|
1277
|
+
{"routeId": spec["routeId"], "publicHostname": spec["publicHostname"]}
|
|
1278
|
+
for spec in plan["managedRoutes"]
|
|
1279
|
+
],
|
|
1280
|
+
"preservedDnsRecordIds": [
|
|
1281
|
+
{"routeId": item["routeId"], "recordId": item["recordId"]}
|
|
1282
|
+
for item in plan["dnsSnapshot"]
|
|
1283
|
+
],
|
|
1284
|
+
"structuralEvidence": proof,
|
|
1285
|
+
"liveSocketForwardingVerified": False,
|
|
1286
|
+
"endToEndReadiness": "unproven",
|
|
1287
|
+
"requiredAuthorization": AUTHENTICATED_ORIGIN_MIGRATION_PHRASE,
|
|
1288
|
+
"mutated": True,
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
|
|
699
1292
|
def _dns_marker(spec: dict[str, Any]) -> str:
|
|
700
1293
|
identity = "|".join((spec["ownerRepo"], spec["environment"], spec["appSlug"])).encode("utf-8")
|
|
701
1294
|
return "Project Forge " + hashlib.sha256(identity).hexdigest()[:20]
|
package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
REMOTE_SCRIPT = r"""#!/usr/bin/env bash
|
|
4
4
|
set -Eeuo pipefail
|
|
5
|
+
export LC_ALL=C
|
|
5
6
|
|
|
6
7
|
ACTION="$1"
|
|
7
8
|
SCOPE="$2"
|
|
@@ -43,6 +44,9 @@ AUTH_ROOT="$PLATFORM_ROOT/proxy/data/authenticated/$SCOPE"
|
|
|
43
44
|
AUTH_ROUTES="$AUTH_ROOT/routes"
|
|
44
45
|
AUTH_CADDY="$PLATFORM_ROOT/proxy/conf.d/90-project-forge-auth-$SCOPE.caddy"
|
|
45
46
|
AUTH_SOCKET="$AUTH_ROOT/origin.sock"
|
|
47
|
+
MIGRATION_MARKER_BACKUP="$ROOT/.authenticated-origin-migration.marker"
|
|
48
|
+
MIGRATION_COMPOSE_BACKUP="$ROOT/.authenticated-origin-migration.compose"
|
|
49
|
+
MIGRATION_RECEIPT="$ROOT/.authenticated-origin-migration.receipt"
|
|
46
50
|
CONTAINER="project-forge-cloudflared-$SCOPE"
|
|
47
51
|
LOCK_FILE="/run/lock/project-forge-cloudflare-$SCOPE.lock"
|
|
48
52
|
|
|
@@ -204,6 +208,7 @@ http://:19078 {
|
|
|
204
208
|
EOF
|
|
205
209
|
chmod 0644 "$tmp"
|
|
206
210
|
mv -f "$tmp" "$AUTH_CADDY"
|
|
211
|
+
MIGRATION_CADDY_HASH="$(sha256sum "$AUTH_CADDY" | cut -d' ' -f1)"
|
|
207
212
|
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null
|
|
208
213
|
[[ -S "$AUTH_SOCKET" ]] || {
|
|
209
214
|
echo "Authenticated private origin socket did not appear." >&2; exit 27;
|
|
@@ -245,7 +250,19 @@ auth_route_write() {
|
|
|
245
250
|
tmp="$file.tmp.$BASHPID"
|
|
246
251
|
backup="$file.backup.$BASHPID"
|
|
247
252
|
[[ ! -f "$file" ]] || cp -a "$file" "$backup"
|
|
248
|
-
|
|
253
|
+
auth_route_content > "$tmp"
|
|
254
|
+
chmod 0644 "$tmp"
|
|
255
|
+
mv -f "$tmp" "$file"
|
|
256
|
+
if ! docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null; then
|
|
257
|
+
if [[ -f "$backup" ]]; then mv -f "$backup" "$file"; else rm -f "$file"; fi
|
|
258
|
+
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 || true
|
|
259
|
+
echo "Authenticated Caddy route failed validation; restored previous state." >&2
|
|
260
|
+
exit 35
|
|
261
|
+
fi
|
|
262
|
+
rm -f "$backup"
|
|
263
|
+
}
|
|
264
|
+
auth_route_content() {
|
|
265
|
+
cat <<EOF
|
|
249
266
|
@pf_$R_ROUTE host $R_PUBLIC
|
|
250
267
|
handle @pf_$R_ROUTE {
|
|
251
268
|
reverse_proxy $AUTH_UPSTREAM:$AUTH_PORT {
|
|
@@ -263,15 +280,6 @@ handle @pf_$R_ROUTE {
|
|
|
263
280
|
}
|
|
264
281
|
}
|
|
265
282
|
EOF
|
|
266
|
-
chmod 0644 "$tmp"
|
|
267
|
-
mv -f "$tmp" "$file"
|
|
268
|
-
if ! docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null; then
|
|
269
|
-
if [[ -f "$backup" ]]; then mv -f "$backup" "$file"; else rm -f "$file"; fi
|
|
270
|
-
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 || true
|
|
271
|
-
echo "Authenticated Caddy route failed validation; restored previous state." >&2
|
|
272
|
-
exit 35
|
|
273
|
-
fi
|
|
274
|
-
rm -f "$backup"
|
|
275
283
|
}
|
|
276
284
|
auth_route_remove() {
|
|
277
285
|
[[ "$(marker_value AUTHENTICATED_ORIGIN)" == true ]] || return 0
|
|
@@ -364,6 +372,320 @@ route_inventory() {
|
|
|
364
372
|
return 0
|
|
365
373
|
}
|
|
366
374
|
|
|
375
|
+
route_inventory_digest() {
|
|
376
|
+
route_inventory | sha256sum | cut -d' ' -f1
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
auth_files_digest() {
|
|
380
|
+
if [[ ! -d "$AUTH_ROOT" ]]; then
|
|
381
|
+
printf ''
|
|
382
|
+
return
|
|
383
|
+
fi
|
|
384
|
+
(
|
|
385
|
+
cd "$AUTH_ROOT"
|
|
386
|
+
find . -type f -print0 | sort -z | xargs -0r sha256sum
|
|
387
|
+
) | sha256sum | cut -d' ' -f1
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
receipt_value() {
|
|
391
|
+
local key="$1"
|
|
392
|
+
[[ -f "$MIGRATION_RECEIPT" && ! -L "$MIGRATION_RECEIPT" ]] || return 1
|
|
393
|
+
sed -n "s/^${key}=//p" "$MIGRATION_RECEIPT" | head -n1
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
migration_pending_valid() {
|
|
397
|
+
[[ -f "$MIGRATION_MARKER_BACKUP" && ! -L "$MIGRATION_MARKER_BACKUP" &&
|
|
398
|
+
-f "$MIGRATION_COMPOSE_BACKUP" && ! -L "$MIGRATION_COMPOSE_BACKUP" &&
|
|
399
|
+
-f "$MIGRATION_RECEIPT" && ! -L "$MIGRATION_RECEIPT" &&
|
|
400
|
+
-f "$MARKER" && ! -L "$MARKER" &&
|
|
401
|
+
-f "$COMPOSE_FILE" && ! -L "$COMPOSE_FILE" &&
|
|
402
|
+
-f "$AUTH_CADDY" && ! -L "$AUTH_CADDY" &&
|
|
403
|
+
-d "$AUTH_ROOT" && ! -L "$AUTH_ROOT" &&
|
|
404
|
+
-d "$AUTH_ROUTES" && ! -L "$AUTH_ROUTES" ]] || return 1
|
|
405
|
+
[[ "$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)" == true ]] || return 1
|
|
406
|
+
[[ "$(route_inventory_digest)" == "$(receipt_value ROUTE_SNAPSHOT_SHA256)" ]] || return 1
|
|
407
|
+
[[ "$(sha256sum "$MARKER" | cut -d' ' -f1)" == "$(receipt_value MARKER_SHA256)" ]] || return 1
|
|
408
|
+
[[ "$(sha256sum "$COMPOSE_FILE" | cut -d' ' -f1)" == "$(receipt_value COMPOSE_SHA256)" ]] || return 1
|
|
409
|
+
[[ "$(sha256sum "$AUTH_CADDY" | cut -d' ' -f1)" == "$(receipt_value CADDY_SHA256)" ]] || return 1
|
|
410
|
+
[[ "$(auth_files_digest)" == "$(receipt_value AUTH_FILES_SHA256)" ]] || return 1
|
|
411
|
+
return 0
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
migration_inspect() {
|
|
415
|
+
local mode state
|
|
416
|
+
mode="$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)"
|
|
417
|
+
if [[ "$mode" == true ]]; then
|
|
418
|
+
if migration_pending_valid; then
|
|
419
|
+
state=pending
|
|
420
|
+
elif [[ ! -e "$MIGRATION_MARKER_BACKUP" && ! -e "$MIGRATION_COMPOSE_BACKUP" &&
|
|
421
|
+
! -e "$MIGRATION_RECEIPT" ]]; then
|
|
422
|
+
state=complete
|
|
423
|
+
else
|
|
424
|
+
echo "Authenticated-origin migration recovery evidence is incomplete or changed." >&2
|
|
425
|
+
exit 37
|
|
426
|
+
fi
|
|
427
|
+
elif [[ "$mode" == false && ! -e "$MIGRATION_MARKER_BACKUP" &&
|
|
428
|
+
! -e "$MIGRATION_COMPOSE_BACKUP" && ! -e "$MIGRATION_RECEIPT" &&
|
|
429
|
+
! -e "$AUTH_ROOT" && ! -e "$AUTH_CADDY" ]]; then
|
|
430
|
+
state=clean
|
|
431
|
+
else
|
|
432
|
+
echo "Authenticated-origin migration paths conflict with unowned or incomplete state." >&2
|
|
433
|
+
exit 37
|
|
434
|
+
fi
|
|
435
|
+
printf 'migrationState=%s\n' "$state"
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
migration_rollback_on_error() {
|
|
439
|
+
local original_status="$1" rollback_failed=false path
|
|
440
|
+
trap - ERR EXIT
|
|
441
|
+
set +e
|
|
442
|
+
if [[ -f "$MIGRATION_MARKER_BACKUP" && ! -L "$MIGRATION_MARKER_BACKUP" ]]; then
|
|
443
|
+
cp -p "$MIGRATION_MARKER_BACKUP" "$MARKER.rollback.$$" &&
|
|
444
|
+
mv -f "$MARKER.rollback.$$" "$MARKER" || rollback_failed=true
|
|
445
|
+
fi
|
|
446
|
+
if [[ -f "$MIGRATION_COMPOSE_BACKUP" && ! -L "$MIGRATION_COMPOSE_BACKUP" ]]; then
|
|
447
|
+
cp -p "$MIGRATION_COMPOSE_BACKUP" "$COMPOSE_FILE.rollback.$$" &&
|
|
448
|
+
mv -f "$COMPOSE_FILE.rollback.$$" "$COMPOSE_FILE" || rollback_failed=true
|
|
449
|
+
docker compose -f "$COMPOSE_FILE" up -d --force-recreate >/dev/null 2>&1 || rollback_failed=true
|
|
450
|
+
fi
|
|
451
|
+
if [[ -e "$AUTH_CADDY" ]]; then
|
|
452
|
+
if [[ -f "$AUTH_CADDY" && ! -L "$AUTH_CADDY" &&
|
|
453
|
+
"$(sha256sum "$AUTH_CADDY" | cut -d' ' -f1)" == "${MIGRATION_CADDY_HASH:-}" ]]; then
|
|
454
|
+
rm -f "$AUTH_CADDY"
|
|
455
|
+
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 ||
|
|
456
|
+
rollback_failed=true
|
|
457
|
+
else
|
|
458
|
+
rollback_failed=true
|
|
459
|
+
fi
|
|
460
|
+
fi
|
|
461
|
+
local index
|
|
462
|
+
for index in "${!MIGRATION_CREATED_ROUTES[@]}"; do
|
|
463
|
+
path="${MIGRATION_CREATED_ROUTES[$index]}"
|
|
464
|
+
if [[ "$path" == "$AUTH_ROUTES/"*.caddy && -f "$path" && ! -L "$path" &&
|
|
465
|
+
"$(sha256sum "$path" | cut -d' ' -f1)" == "${MIGRATION_CREATED_ROUTE_HASHES[$index]}" ]]; then
|
|
466
|
+
rm -f "$path"
|
|
467
|
+
else
|
|
468
|
+
rollback_failed=true
|
|
469
|
+
fi
|
|
470
|
+
done
|
|
471
|
+
if [[ -e "$AUTH_SOCKET" ]]; then
|
|
472
|
+
if [[ -S "$AUTH_SOCKET" && "$(stat -c '%u:%g:%a' "$AUTH_SOCKET")" == "65532:65532:222" ]]; then
|
|
473
|
+
rm -f "$AUTH_SOCKET"
|
|
474
|
+
else
|
|
475
|
+
rollback_failed=true
|
|
476
|
+
fi
|
|
477
|
+
fi
|
|
478
|
+
if [[ -d "$AUTH_ROUTES" ]] && ! rmdir "$AUTH_ROUTES" 2>/dev/null; then rollback_failed=true; fi
|
|
479
|
+
if [[ -d "$AUTH_ROOT" ]] && ! rmdir "$AUTH_ROOT" 2>/dev/null; then rollback_failed=true; fi
|
|
480
|
+
if [[ "$rollback_failed" == false ]]; then
|
|
481
|
+
rm -f "$MIGRATION_MARKER_BACKUP" "$MIGRATION_COMPOSE_BACKUP" "$MIGRATION_RECEIPT"
|
|
482
|
+
echo "Authenticated-origin host preparation failed and was rolled back." >&2
|
|
483
|
+
else
|
|
484
|
+
echo "Authenticated-origin host preparation failed; rollback is incomplete and recovery evidence was retained." >&2
|
|
485
|
+
fi
|
|
486
|
+
exit "$original_status"
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
migrate_authenticated_origin() {
|
|
490
|
+
local expected_route_digest="$EXPECTED" state path route_spec
|
|
491
|
+
[[ "$AUTHENTICATED_ORIGIN" == true ]] || { echo "Authenticated-origin migration must opt in explicitly." >&2; exit 37; }
|
|
492
|
+
[[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready &&
|
|
493
|
+
"$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)" == false ]] || {
|
|
494
|
+
echo "Authenticated-origin migration requires a ready legacy scope." >&2; exit 37;
|
|
495
|
+
}
|
|
496
|
+
[[ "$(route_inventory_digest)" == "$expected_route_digest" ]] || {
|
|
497
|
+
echo "Managed public-route inventory changed after planning." >&2; exit 37;
|
|
498
|
+
}
|
|
499
|
+
state="$(migration_inspect | sed -n 's/^migrationState=//p')"
|
|
500
|
+
[[ "$state" == clean ]] || { echo "Authenticated-origin migration is not in a clean host state." >&2; exit 37; }
|
|
501
|
+
parse_meta
|
|
502
|
+
[[ "$(marker_value ACCOUNT_ID)" == "$M_ACCOUNT" &&
|
|
503
|
+
"$(marker_value ZONE_ID)" == "$M_ZONE_ID" &&
|
|
504
|
+
"$(marker_value ZONE_NAME)" == "$M_ZONE_NAME" &&
|
|
505
|
+
"$(marker_value TUNNEL_ID)" == "$M_TUNNEL_ID" &&
|
|
506
|
+
"$(marker_value TUNNEL_NAME)" == "$M_TUNNEL_NAME" ]] || {
|
|
507
|
+
echo "Existing host tunnel binding changed after planning." >&2; exit 37;
|
|
508
|
+
}
|
|
509
|
+
[[ -f "$MARKER" && ! -L "$MARKER" && -f "$COMPOSE_FILE" && ! -L "$COMPOSE_FILE" &&
|
|
510
|
+
-f "$TOKEN_FILE" && ! -L "$TOKEN_FILE" && -d "$ROUTE_ROOT" && ! -L "$ROUTE_ROOT" ]] || {
|
|
511
|
+
echo "Existing ingress host files are incomplete or unsafe." >&2; exit 37;
|
|
512
|
+
}
|
|
513
|
+
local route_count=0
|
|
514
|
+
MIGRATION_CREATED_ROUTES=()
|
|
515
|
+
MIGRATION_CREATED_ROUTE_HASHES=()
|
|
516
|
+
trap 'migration_rollback_on_error "$?"' EXIT
|
|
517
|
+
cp -p "$MARKER" "$MIGRATION_MARKER_BACKUP"
|
|
518
|
+
cp -p "$COMPOSE_FILE" "$MIGRATION_COMPOSE_BACKUP"
|
|
519
|
+
chmod 0600 "$MIGRATION_MARKER_BACKUP" "$MIGRATION_COMPOSE_BACKUP"
|
|
520
|
+
setup_authenticated_origin
|
|
521
|
+
AUTHENTICATED_ORIGIN=true
|
|
522
|
+
write_marker
|
|
523
|
+
write_compose
|
|
524
|
+
for path in "$ROUTE_ROOT"/*.route; do
|
|
525
|
+
[[ -f "$path" ]] || continue
|
|
526
|
+
route_spec="$(printf '%s\t%s\t%s\t%s\t%s\t%s\t%s' \
|
|
527
|
+
"$(route_value "$path" SCHEMA_VERSION)" "$(route_value "$path" OWNER_REPO)" \
|
|
528
|
+
"$(route_value "$path" ENVIRONMENT)" "$(route_value "$path" APP_SLUG)" \
|
|
529
|
+
"$(route_value "$path" ROUTE_ID)" "$(route_value "$path" PUBLIC_HOSTNAME)" \
|
|
530
|
+
"$(route_value "$path" ORIGIN_HOSTNAME)")"
|
|
531
|
+
parse_spec_value "$route_spec"
|
|
532
|
+
[[ "$(basename "$path")" == "$R_ROUTE.route" ]] || {
|
|
533
|
+
echo "Managed public-route identity is malformed." >&2; exit 37;
|
|
534
|
+
}
|
|
535
|
+
SPEC="$route_spec"
|
|
536
|
+
auth_route_write
|
|
537
|
+
MIGRATION_CREATED_ROUTES+=("$(auth_route_path "$R_ROUTE")")
|
|
538
|
+
MIGRATION_CREATED_ROUTE_HASHES+=("$(sha256sum "$(auth_route_path "$R_ROUTE")" | cut -d' ' -f1)")
|
|
539
|
+
route_count=$((route_count + 1))
|
|
540
|
+
done
|
|
541
|
+
(( route_count > 0 )) || { echo "No managed public routes are available to migrate." >&2; exit 37; }
|
|
542
|
+
docker compose -f "$COMPOSE_FILE" up -d --force-recreate >/dev/null
|
|
543
|
+
[[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready &&
|
|
544
|
+
"$(marker_value AUTHENTICATED_ORIGIN)" == true ]] || {
|
|
545
|
+
echo "Authenticated-origin connector did not become ready." >&2; exit 37;
|
|
546
|
+
}
|
|
547
|
+
cat > "$MIGRATION_RECEIPT.tmp.$$" <<EOF
|
|
548
|
+
ROUTE_SNAPSHOT_SHA256=$expected_route_digest
|
|
549
|
+
MARKER_SHA256=$(sha256sum "$MARKER" | cut -d' ' -f1)
|
|
550
|
+
COMPOSE_SHA256=$(sha256sum "$COMPOSE_FILE" | cut -d' ' -f1)
|
|
551
|
+
CADDY_SHA256=$(sha256sum "$AUTH_CADDY" | cut -d' ' -f1)
|
|
552
|
+
AUTH_FILES_SHA256=$(auth_files_digest)
|
|
553
|
+
EOF
|
|
554
|
+
chmod 0600 "$MIGRATION_RECEIPT.tmp.$$"
|
|
555
|
+
mv -f "$MIGRATION_RECEIPT.tmp.$$" "$MIGRATION_RECEIPT"
|
|
556
|
+
migration_pending_valid || { echo "Authenticated-origin recovery receipt validation failed." >&2; exit 37; }
|
|
557
|
+
trap - ERR EXIT
|
|
558
|
+
printf 'migrationState=pending\n'
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
rollback_authenticated_origin() {
|
|
562
|
+
migration_pending_valid || { echo "Authenticated-origin migration rollback evidence is stale or unsafe." >&2; exit 38; }
|
|
563
|
+
[[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready ]] || {
|
|
564
|
+
echo "Authenticated-origin host is not ready for rollback." >&2; exit 38;
|
|
565
|
+
}
|
|
566
|
+
cp -p "$MIGRATION_MARKER_BACKUP" "$MARKER.rollback.$$"
|
|
567
|
+
mv -f "$MARKER.rollback.$$" "$MARKER"
|
|
568
|
+
cp -p "$MIGRATION_COMPOSE_BACKUP" "$COMPOSE_FILE.rollback.$$"
|
|
569
|
+
mv -f "$COMPOSE_FILE.rollback.$$" "$COMPOSE_FILE"
|
|
570
|
+
docker compose -f "$COMPOSE_FILE" up -d --force-recreate >/dev/null
|
|
571
|
+
[[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready &&
|
|
572
|
+
"$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)" == false ]] || {
|
|
573
|
+
echo "Legacy connector did not become ready after authenticated-origin rollback." >&2; exit 38;
|
|
574
|
+
}
|
|
575
|
+
[[ -f "$AUTH_CADDY" && ! -L "$AUTH_CADDY" ]] || {
|
|
576
|
+
echo "Authenticated Caddy configuration changed before rollback." >&2; exit 38;
|
|
577
|
+
}
|
|
578
|
+
rm -f "$AUTH_CADDY"
|
|
579
|
+
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null
|
|
580
|
+
for path in "$AUTH_ROUTES"/*.caddy; do [[ -f "$path" ]] && rm -f "$path"; done
|
|
581
|
+
if [[ -e "$AUTH_SOCKET" ]]; then
|
|
582
|
+
[[ -S "$AUTH_SOCKET" && "$(stat -c '%u:%g:%a' "$AUTH_SOCKET")" == "65532:65532:222" ]] || {
|
|
583
|
+
echo "Authenticated origin socket changed before rollback." >&2; exit 38;
|
|
584
|
+
}
|
|
585
|
+
rm -f "$AUTH_SOCKET"
|
|
586
|
+
fi
|
|
587
|
+
rmdir "$AUTH_ROUTES" "$AUTH_ROOT"
|
|
588
|
+
rm -f "$MIGRATION_MARKER_BACKUP" "$MIGRATION_COMPOSE_BACKUP" "$MIGRATION_RECEIPT"
|
|
589
|
+
printf 'migrationState=rolled-back\n'
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
finalize_authenticated_origin() {
|
|
593
|
+
migration_pending_valid || { echo "Authenticated-origin migration receipt is stale or unsafe." >&2; exit 39; }
|
|
594
|
+
[[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready ]] || {
|
|
595
|
+
echo "Authenticated-origin host is not ready for finalization." >&2; exit 39;
|
|
596
|
+
}
|
|
597
|
+
rm -f "$MIGRATION_MARKER_BACKUP" "$MIGRATION_COMPOSE_BACKUP" "$MIGRATION_RECEIPT"
|
|
598
|
+
printf 'migrationState=complete\n'
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
verify_authenticated_origin() {
|
|
602
|
+
local expected_route_digest="$EXPECTED" path route_spec
|
|
603
|
+
[[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready &&
|
|
604
|
+
"$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)" == true &&
|
|
605
|
+
-S "$AUTH_SOCKET" && -d "$AUTH_ROOT" && ! -L "$AUTH_ROOT" &&
|
|
606
|
+
"$(stat -c '%u:%g:%a' "$AUTH_ROOT")" == "65532:65532:700" &&
|
|
607
|
+
"$(stat -c '%u:%g:%a' "$AUTH_SOCKET")" == "65532:65532:222" ]] || {
|
|
608
|
+
echo "Authenticated-origin socket or scope is not ready." >&2; exit 40;
|
|
609
|
+
}
|
|
610
|
+
[[ "$(route_inventory_digest)" == "$expected_route_digest" ]] || {
|
|
611
|
+
echo "Managed public-route inventory changed during migration." >&2; exit 40;
|
|
612
|
+
}
|
|
613
|
+
grep -Fqx " - $AUTH_ROOT:/run/project-forge-origin:ro" "$COMPOSE_FILE" || {
|
|
614
|
+
echo "Connector Compose file does not grant the exact read-only origin mount." >&2; exit 40;
|
|
615
|
+
}
|
|
616
|
+
path_overlap() {
|
|
617
|
+
local left="${1%/}" right="${2%/}"
|
|
618
|
+
[[ "$left" == "$right" || "$left" == "$right/"* || "$right" == "$left/"* ]]
|
|
619
|
+
}
|
|
620
|
+
local connector_id caddy_id mount_count other_id mounts source destination writable source_mount
|
|
621
|
+
connector_id="$(docker inspect --format '{{.Id}}' "$CONTAINER")"
|
|
622
|
+
caddy_id="$(docker inspect --format '{{.Id}}' "$CADDY_CONTAINER")"
|
|
623
|
+
mapfile -t mounts < <(docker inspect --format '{{range .Mounts}}{{printf "%s|%s|%t\n" .Source .Destination .RW}}{{end}}' "$CONTAINER")
|
|
624
|
+
mount_count=0
|
|
625
|
+
for source_mount in "${mounts[@]}"; do
|
|
626
|
+
IFS='|' read -r source destination writable <<< "$source_mount"
|
|
627
|
+
if path_overlap "$source" "$AUTH_ROOT" || path_overlap "$destination" "/run/project-forge-origin"; then
|
|
628
|
+
[[ "$source" == "$AUTH_ROOT" && "$destination" == "/run/project-forge-origin" &&
|
|
629
|
+
"$writable" == false ]] || {
|
|
630
|
+
echo "Connector has an unexpected mount overlapping the authenticated-origin socket." >&2; exit 40;
|
|
631
|
+
}
|
|
632
|
+
mount_count=$((mount_count + 1))
|
|
633
|
+
fi
|
|
634
|
+
done
|
|
635
|
+
[[ "$mount_count" == 1 ]] || {
|
|
636
|
+
echo "Running connector does not have exactly the expected read-only origin mount." >&2; exit 40;
|
|
637
|
+
}
|
|
638
|
+
while IFS= read -r other_id; do
|
|
639
|
+
[[ -n "$other_id" && "$other_id" != "$connector_id" && "$other_id" != "$caddy_id" ]] || continue
|
|
640
|
+
mapfile -t mounts < <(docker inspect --format '{{range .Mounts}}{{printf "%s|%s\n" .Source .Destination}}{{end}}' "$other_id")
|
|
641
|
+
for source_mount in "${mounts[@]}"; do
|
|
642
|
+
IFS='|' read -r source destination <<< "$source_mount"
|
|
643
|
+
if path_overlap "$source" "$AUTH_ROOT" ||
|
|
644
|
+
path_overlap "$destination" "/run/project-forge-origin"; then
|
|
645
|
+
echo "Authenticated-origin mount is present on a non-connector container." >&2
|
|
646
|
+
exit 40
|
|
647
|
+
fi
|
|
648
|
+
done
|
|
649
|
+
done < <(docker ps -aq)
|
|
650
|
+
[[ -f "$AUTH_CADDY" && ! -L "$AUTH_CADDY" ]] &&
|
|
651
|
+
grep -Fqx " bind unix//data/authenticated/$SCOPE/origin.sock|0222" "$AUTH_CADDY" &&
|
|
652
|
+
grep -Fqx " import /data/authenticated/$SCOPE/routes/*.caddy" "$AUTH_CADDY" || {
|
|
653
|
+
echo "Authenticated Caddy Unix listener configuration is missing or changed." >&2; exit 40;
|
|
654
|
+
}
|
|
655
|
+
docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile >/dev/null
|
|
656
|
+
local route_count=0
|
|
657
|
+
for path in "$ROUTE_ROOT"/*.route; do
|
|
658
|
+
[[ -f "$path" ]] || continue
|
|
659
|
+
route_spec="$(printf '%s\t%s\t%s\t%s\t%s\t%s\t%s' \
|
|
660
|
+
"$(route_value "$path" SCHEMA_VERSION)" "$(route_value "$path" OWNER_REPO)" \
|
|
661
|
+
"$(route_value "$path" ENVIRONMENT)" "$(route_value "$path" APP_SLUG)" \
|
|
662
|
+
"$(route_value "$path" ROUTE_ID)" "$(route_value "$path" PUBLIC_HOSTNAME)" \
|
|
663
|
+
"$(route_value "$path" ORIGIN_HOSTNAME)")"
|
|
664
|
+
parse_spec_value "$route_spec"
|
|
665
|
+
auth_route_verify_lan
|
|
666
|
+
local expected_file tmp
|
|
667
|
+
expected_file="$(auth_route_path "$R_ROUTE")"
|
|
668
|
+
[[ -f "$expected_file" && ! -L "$expected_file" ]] || {
|
|
669
|
+
echo "Authenticated Caddy route file is missing or unsafe." >&2; exit 40;
|
|
670
|
+
}
|
|
671
|
+
tmp="$AUTH_ROUTES/.verify-$R_ROUTE.$$"
|
|
672
|
+
auth_route_content > "$tmp"
|
|
673
|
+
if ! cmp -s "$tmp" "$expected_file"; then
|
|
674
|
+
rm -f "$tmp"
|
|
675
|
+
echo "Authenticated Caddy route differs from the sanitized reviewed contract." >&2
|
|
676
|
+
exit 40
|
|
677
|
+
fi
|
|
678
|
+
rm -f "$tmp"
|
|
679
|
+
route_count=$((route_count + 1))
|
|
680
|
+
done
|
|
681
|
+
printf 'evidenceLevel=structural-only\n'
|
|
682
|
+
printf 'connectorOriginMount=read-only\n'
|
|
683
|
+
printf 'socketMetadataVerified=true\n'
|
|
684
|
+
printf 'caddyConfigValidated=true\n'
|
|
685
|
+
printf 'sanitizedRouteConfigVerified=true\n'
|
|
686
|
+
printf 'managedRouteCount=%s\n' "$route_count"
|
|
687
|
+
}
|
|
688
|
+
|
|
367
689
|
metadata_matches() {
|
|
368
690
|
local path="$1" encoded="$2"
|
|
369
691
|
parse_spec_value "$encoded"
|
|
@@ -449,6 +771,21 @@ case "$ACTION" in
|
|
|
449
771
|
route-inventory)
|
|
450
772
|
route_inventory
|
|
451
773
|
;;
|
|
774
|
+
migration-inspect)
|
|
775
|
+
migration_inspect
|
|
776
|
+
;;
|
|
777
|
+
migrate-authenticated)
|
|
778
|
+
migrate_authenticated_origin
|
|
779
|
+
;;
|
|
780
|
+
rollback-authenticated)
|
|
781
|
+
rollback_authenticated_origin
|
|
782
|
+
;;
|
|
783
|
+
finalize-authenticated)
|
|
784
|
+
finalize_authenticated_origin
|
|
785
|
+
;;
|
|
786
|
+
verify-authenticated)
|
|
787
|
+
verify_authenticated_origin
|
|
788
|
+
;;
|
|
452
789
|
route-register|route-remove)
|
|
453
790
|
exec 9>>"$LOCK_FILE"
|
|
454
791
|
flock -n 9 || { echo "Another Cloudflare ingress operation holds the lock." >&2; exit 75; }
|
|
@@ -9,14 +9,18 @@ import sys
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from .cloudflare_ingress import (
|
|
12
|
+
AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE,
|
|
13
|
+
AUTHENTICATED_ORIGIN_MIGRATION_PHRASE,
|
|
12
14
|
CLOUDFLARE_API_TOKEN_ENV,
|
|
13
15
|
HOST_PUBLIC_INGRESS_WRITE_PHRASE,
|
|
14
16
|
PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
|
|
15
17
|
PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
|
|
18
|
+
apply_authenticated_origin_migration,
|
|
16
19
|
apply_cloudflare_ingress,
|
|
17
20
|
cloudflare_ingress_doctor,
|
|
18
21
|
cloudflare_ingress_status,
|
|
19
22
|
cloudflare_route_inventory,
|
|
23
|
+
plan_authenticated_origin_migration,
|
|
20
24
|
plan_cloudflare_ingress,
|
|
21
25
|
plan_public_route,
|
|
22
26
|
plan_public_route_removal,
|
|
@@ -50,7 +54,7 @@ def _token() -> str:
|
|
|
50
54
|
|
|
51
55
|
|
|
52
56
|
def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
|
|
53
|
-
if args
|
|
57
|
+
if getattr(args, "yes", False):
|
|
54
58
|
return
|
|
55
59
|
entered = input(f"Type {phrase} to {action}: ").strip()
|
|
56
60
|
if entered != phrase:
|
|
@@ -81,6 +85,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
81
85
|
configure.add_argument("--json", action="store_true")
|
|
82
86
|
configure.add_argument("--yes", action="store_true")
|
|
83
87
|
|
|
88
|
+
migrate = commands.add_parser(
|
|
89
|
+
"migrate-authenticated-origin",
|
|
90
|
+
help="Migrate an existing managed scope to connector-exclusive Unix-socket origins",
|
|
91
|
+
)
|
|
92
|
+
migrate.add_argument("name")
|
|
93
|
+
migrate.add_argument("--scope", required=True)
|
|
94
|
+
migrate.add_argument("--zone", required=True, help="Existing bound Cloudflare zone")
|
|
95
|
+
migrate.add_argument("--json", action="store_true")
|
|
96
|
+
|
|
84
97
|
route = commands.add_parser("route", help="Project-owned public hostname/DNS routes")
|
|
85
98
|
route_commands = route.add_subparsers(dest="route_command", required=True)
|
|
86
99
|
|
|
@@ -143,6 +156,43 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
143
156
|
)
|
|
144
157
|
_print(apply_cloudflare_ingress(plan, api_token=token), as_json=args.json)
|
|
145
158
|
return 0
|
|
159
|
+
if args.command == "migrate-authenticated-origin":
|
|
160
|
+
token = _token()
|
|
161
|
+
preplan_freeze_acknowledgement = (
|
|
162
|
+
f"{AUTHENTICATED_ORIGIN_CHANGE_FREEZE_PHRASE} ACTIVE scope={args.scope}"
|
|
163
|
+
)
|
|
164
|
+
_confirm(
|
|
165
|
+
args,
|
|
166
|
+
preplan_freeze_acknowledgement,
|
|
167
|
+
"confirm that all external Cloudflare tunnel-configuration writers are frozen for this scope before planning",
|
|
168
|
+
)
|
|
169
|
+
plan = plan_authenticated_origin_migration(
|
|
170
|
+
args.name,
|
|
171
|
+
scope=args.scope,
|
|
172
|
+
zone_name=args.zone,
|
|
173
|
+
api_token=token,
|
|
174
|
+
change_freeze_active_acknowledgement=preplan_freeze_acknowledgement,
|
|
175
|
+
)
|
|
176
|
+
_print(plan, as_json=False)
|
|
177
|
+
_confirm(
|
|
178
|
+
args,
|
|
179
|
+
AUTHENTICATED_ORIGIN_MIGRATION_PHRASE,
|
|
180
|
+
f"migrate existing Cloudflare ingress scope {args.scope} on target {args.name}",
|
|
181
|
+
)
|
|
182
|
+
_confirm(
|
|
183
|
+
args,
|
|
184
|
+
plan["requiredChangeFreezeAcknowledgement"],
|
|
185
|
+
"acknowledge the operator-controlled Cloudflare change freeze for this migration plan",
|
|
186
|
+
)
|
|
187
|
+
_print(
|
|
188
|
+
apply_authenticated_origin_migration(
|
|
189
|
+
plan,
|
|
190
|
+
api_token=token,
|
|
191
|
+
change_freeze_acknowledgement=plan["requiredChangeFreezeAcknowledgement"],
|
|
192
|
+
),
|
|
193
|
+
as_json=args.json,
|
|
194
|
+
)
|
|
195
|
+
return 0
|
|
146
196
|
if args.command == "route" and args.route_command == "inventory":
|
|
147
197
|
_print(cloudflare_route_inventory(args.name, args.scope), as_json=args.json)
|
|
148
198
|
return 0
|