@mriekki/project-forge 4.20.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.
@@ -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
- cat > "$tmp" <<EOF
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.yes:
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.20.0",
3
+ "version": "4.22.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {