@mriekki/project-forge 4.19.1 → 4.21.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.19.1",
4
+ "version": "4.21.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": "4ce2c2f76556c85a5bc4836a10e197cad425688d",
24
+ "revision": "f2d20ec7aac61494ef9fdb7f3702060b8d8d8865",
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.19.1",
4
+ "version": "4.21.0",
5
5
  "components": {
6
6
  "core": {
7
7
  "revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
@@ -361,7 +361,7 @@
361
361
  ]
362
362
  },
363
363
  "deployment": {
364
- "revision": "4ce2c2f76556c85a5bc4836a10e197cad425688d",
364
+ "revision": "f2d20ec7aac61494ef9fdb7f3702060b8d8d8865",
365
365
  "distribution": "greenfield-project",
366
366
  "files": [
367
367
  {
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "source": "src/project_forge_deployment/app_cli.py",
394
394
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py",
395
- "sha256": "f27704c80b1eff45888bf56745bcf8755f2fad88851147600af7056953757825"
395
+ "sha256": "b44ec913fd1080ff66bff6643d2d9fad17262725c0f35557c56f52c86090c060"
396
396
  },
397
397
  {
398
398
  "source": "src/project_forge_deployment/app_deployment.py",
@@ -402,12 +402,12 @@
402
402
  {
403
403
  "source": "src/project_forge_deployment/app_runtime.py",
404
404
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime.py",
405
- "sha256": "73736aada7c3db0e2a7c74a3e88990316b89e6c34a71b4997cdc4c79c7e31708"
405
+ "sha256": "6f4ae7f663dc7a4e51225c1420f7aa6be5d2b77445b792f9e4495ba3eeb059ad"
406
406
  },
407
407
  {
408
408
  "source": "src/project_forge_deployment/app_runtime_remote.py",
409
409
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime_remote.py",
410
- "sha256": "791cc7592cff93555f7b6facdd31fbe58de5e711732d9dfcb9321500a0d79cdf"
410
+ "sha256": "5b6ddffcc1105d3a401ae17456cdfdb279c3005bfcb6c81bbd1eabc27479de05"
411
411
  },
412
412
  {
413
413
  "source": "src/project_forge_deployment/app_runtime_workstation.py",
@@ -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": "f462bcc1cb9d1fd20a93cf7fa6ea00402df0be69507308ffbd7e2af0d0f2900f"
440
+ "sha256": "d07c79b7f9f6c9a0f9b417fd0a1d4502101075b128a12d23b2791c31e03d4f01"
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": "6f92ec8b50baeb6894f096935450f3cf1f86abf5313a681ee3c5bca2283ddc99"
445
+ "sha256": "73d9f68cbece33155ccac8ce2e24da8f0ec6698a7b66fba18cb9bf38734ea686"
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": "59ff40843573da74dc430b517130f300b3eb2f2e4f00de0eb6dab5f9efb27692"
505
+ "sha256": "973b89daa7e57d925f0be56fbfc0dc4c6f79ab082036c7fb26462691f2f16d02"
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": "603cf5e732439efdb4f628a16555eba1226db9ef0a665872fd0341c85f9050ee"
650
+ "sha256": "b47bfb1ef1dcd7be3c501b88add0dea675907ce3f9e65cf7852c5ffddbe0ce46"
651
651
  },
652
652
  {
653
653
  "source": "docs/DEVELOPER_ENVIRONMENTS.md",
@@ -200,6 +200,12 @@ 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 (new scopes only)
204
+
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
+
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. An installed ingress scope cannot switch modes in place; use a new dedicated scope and the normal host/route write gates. See the canonical Kit's `docs/EXPERIMENTAL_AUTHENTICATED_ORIGIN.md` and issue #78 for the detailed evidence boundary.
208
+
203
209
  ## Current cloudflared runtime
204
210
 
205
211
  The first partial implementation selects Cloudflare release `2026.10.4` and pins the actual Docker pull to the verified multi-platform image index digest:
@@ -49,6 +49,16 @@ def _parse_env(items: list[str]) -> list[tuple[str, str]]:
49
49
  return result
50
50
 
51
51
 
52
+ def _parse_materials(items: list[str]) -> list[tuple[str, str]]:
53
+ values: list[tuple[str, str]] = []
54
+ for item in items:
55
+ if "=" not in item:
56
+ raise DeploymentKitError("--read-only-material requires NAME=/absolute/container/directory.")
57
+ name, target = item.split("=", 1)
58
+ values.append((name, target))
59
+ return values
60
+
61
+
52
62
  def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
53
63
  if args.yes:
54
64
  return
@@ -91,6 +101,13 @@ def _add_runtime_contract(parser: argparse.ArgumentParser) -> None:
91
101
  parser.add_argument("--health-path", required=True)
92
102
  parser.add_argument("--run-as", required=True)
93
103
  parser.add_argument("--data-container-path")
104
+ parser.add_argument(
105
+ "--read-only-material",
106
+ action="append",
107
+ default=[],
108
+ metavar="NAME=/container/directory",
109
+ help="Opt-in root-owned remote material directory; source derived from Project/environment/name",
110
+ )
94
111
  parser.add_argument(
95
112
  "--env",
96
113
  action="append",
@@ -185,6 +202,7 @@ def main(argv: list[str] | None = None) -> int:
185
202
  persistence_mode=args.persistence_mode,
186
203
  data_container_path=args.data_container_path,
187
204
  environment_variables=_parse_env(args.env),
205
+ read_only_materials=_parse_materials(args.read_only_material),
188
206
  )
189
207
  assert_app_spec_matches_persistence(args.name, spec)
190
208
 
@@ -19,6 +19,8 @@ from .app_runtime_remote import REMOTE_SCRIPT
19
19
 
20
20
  APP_ROOT = "/srv/project-forge-apps"
21
21
  APP_SCHEMA_VERSION = 1
22
+ # Operator-provisioned remote sources: never workstation paths or auto-created directories.
23
+ REMOTE_MATERIAL_ROOT = "/srv/project-forge-material"
22
24
  APP_DEPLOY_WRITE_PHRASE = "[APP DEPLOY WRITE]"
23
25
  PRODUCTION_APP_DEPLOY_WRITE_PHRASE = "[PRODUCTION APP DEPLOY]"
24
26
 
@@ -84,6 +86,67 @@ def _container_path(value: str | None) -> str | None:
84
86
  return value.rstrip("/") or "/"
85
87
 
86
88
 
89
+
90
+ def _normalize_read_only_materials(
91
+ materials: Iterable[tuple[str, str]] | None,
92
+ *,
93
+ data_path: str | None,
94
+ ) -> list[dict[str, str]]:
95
+ if materials is None:
96
+ return []
97
+ result: list[dict[str, str]] = []
98
+ names: set[str] = set()
99
+ paths: list[str] = []
100
+ for name, target in materials:
101
+ if len(result) >= 8:
102
+ raise DeploymentKitError("Remote read-only material mount count must not exceed eight.")
103
+ name = _slug(name, "read-only material name")
104
+ if name in names:
105
+ raise DeploymentKitError(f"Duplicate read-only material name: {name}")
106
+ if not isinstance(target, str) or _CONTAINER_PATH_RE.fullmatch(target) is None:
107
+ raise DeploymentKitError("Read-only material target must be a safe absolute container directory.")
108
+ parts = target.split("/")[1:]
109
+ if not parts or any(part in {"", ".", ".."} for part in parts):
110
+ raise DeploymentKitError("Read-only material target must be normalized and cannot be root.")
111
+ if parts[0] in {"proc", "sys", "dev", "run"} or parts[:2] == ["var", "run"]:
112
+ raise DeploymentKitError("Read-only material cannot mask a container system runtime directory.")
113
+ for other in paths + ([data_path] if data_path else []):
114
+ if target == other or target.startswith(other.rstrip("/") + "/") or other.startswith(target + "/"):
115
+ raise DeploymentKitError("Read-only material target overlaps another mount.")
116
+ names.add(name)
117
+ paths.append(target)
118
+ result.append({"name": name, "containerPath": target})
119
+ return sorted(result, key=lambda item: item["name"])
120
+
121
+
122
+ def _material_evidence_for_spec(name: str, spec: dict[str, Any]) -> dict[str, str] | None:
123
+ materials = spec.get("readOnlyMaterials")
124
+ if not materials:
125
+ return None
126
+ profile = load_profile(name)
127
+ evidence = verify_access(profile)
128
+ output = _run_remote(profile, evidence["address"], "material-inspect", spec=spec)
129
+ observed: dict[str, str] = {}
130
+ for line in output.splitlines():
131
+ parts = line.split("\t")
132
+ if len(parts) != 3 or parts[0] != "MATERIAL":
133
+ raise DeploymentKitError("Malformed remote material source evidence.")
134
+ _, material_name, identity = parts
135
+ if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", material_name) or not re.fullmatch(r"[0-9]+:[0-9]+", identity):
136
+ raise DeploymentKitError("Malformed remote material source identity.")
137
+ if material_name in observed:
138
+ raise DeploymentKitError("Duplicate remote material source evidence.")
139
+ observed[material_name] = identity
140
+ if set(observed) != {item["name"] for item in materials}:
141
+ raise DeploymentKitError("Remote material inspection omitted or added a source.")
142
+ return dict(sorted(observed.items()))
143
+
144
+
145
+ def _verify_material_evidence(name: str, plan: dict[str, Any]) -> None:
146
+ if plan["spec"].get("readOnlyMaterials"):
147
+ if _material_evidence_for_spec(name, plan["spec"]) != plan["readOnlyMaterialEvidence"]:
148
+ raise DeploymentKitError("Remote material directory identity changed after planning; review a new plan.")
149
+
87
150
  def _normalize_environment_variables(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
88
151
  pairs = items.items() if isinstance(items, dict) else items
89
152
  result: dict[str, str] = {}
@@ -111,6 +174,7 @@ def normalize_app_spec(
111
174
  persistence_mode: str,
112
175
  data_container_path: str | None,
113
176
  environment_variables: dict[str, str] | Iterable[tuple[str, str]],
177
+ read_only_materials: Iterable[tuple[str, str]] | None = None,
114
178
  ) -> dict[str, Any]:
115
179
  if persistence_mode not in {"none", "directory"}:
116
180
  raise DeploymentKitError("persistenceMode must be 'none' or 'directory'.")
@@ -135,6 +199,9 @@ def normalize_app_spec(
135
199
  },
136
200
  "environmentVariables": _normalize_environment_variables(environment_variables),
137
201
  }
202
+ materials = _normalize_read_only_materials(read_only_materials, data_path=data_path)
203
+ if materials:
204
+ spec["readOnlyMaterials"] = materials
138
205
  spec["specSha256"] = _spec_sha(spec)
139
206
  return spec
140
207
 
@@ -150,8 +217,13 @@ def _b64(value: str) -> str:
150
217
  return base64.b64encode(value.encode("utf-8")).decode("ascii")
151
218
 
152
219
 
153
- def build_remote_script(spec: dict[str, Any] | None = None) -> str:
220
+ def build_remote_script(
221
+ spec: dict[str, Any] | None = None, *, expected_materials: dict[str, str] | None = None,
222
+ ) -> str:
154
223
  env_keys: list[str] = []
224
+ material_names: list[str] = []
225
+ material_targets: list[str] = []
226
+ material_expected: list[str] = []
155
227
  env_values: list[str] = []
156
228
  desired = {
157
229
  "ownerRepo": "",
@@ -183,9 +255,14 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
183
255
  for key, value in spec["environmentVariables"].items():
184
256
  env_keys.append(_b64(key))
185
257
  env_values.append(_b64(value))
258
+ for item in spec.get("readOnlyMaterials", []):
259
+ material_names.append(_b64(item["name"]))
260
+ material_targets.append(_b64(item["containerPath"]))
261
+ material_expected.append((expected_materials or {}).get(item["name"], ""))
186
262
 
187
263
  replacements = {
188
264
  "__APP_ROOT__": APP_ROOT,
265
+ "__MATERIAL_ROOT__": REMOTE_MATERIAL_ROOT,
189
266
  "__APP_SCHEMA_VERSION__": str(APP_SCHEMA_VERSION),
190
267
  "__NETWORK_NAME__": NETWORK_NAME,
191
268
  "__CADDY_CONTAINER__": CADDY_CONTAINER,
@@ -203,6 +280,9 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
203
280
  "__D_SPEC_SHA__": desired["specSha256"],
204
281
  "__D_ENV_KEYS__": " ".join(f"'{item}'" for item in env_keys),
205
282
  "__D_ENV_VALUES__": " ".join(f"'{item}'" for item in env_values),
283
+ "__D_MATERIAL_NAMES__": " ".join(shlex.quote(x) for x in material_names),
284
+ "__D_MATERIAL_TARGETS__": " ".join(shlex.quote(x) for x in material_targets),
285
+ "__D_MATERIAL_EXPECTED__": " ".join(shlex.quote(x) for x in material_expected),
206
286
  }
207
287
  script = REMOTE_SCRIPT
208
288
  for placeholder, value in replacements.items():
@@ -224,7 +304,7 @@ def _parse_inventory(output: str) -> dict[str, Any]:
224
304
  raise DeploymentKitError("Malformed or duplicate application inventory summary.")
225
305
  summary[parts[1]] = parts[2]
226
306
  continue
227
- if parts[0] != "APP" or len(parts) != 15:
307
+ if parts[0] != "APP" or len(parts) not in (15, 17):
228
308
  raise DeploymentKitError("Malformed application inventory record.")
229
309
  try:
230
310
  port = int(parts[7])
@@ -258,6 +338,29 @@ def _parse_inventory(output: str) -> dict[str, Any]:
258
338
  "connectedToSharedNetwork": parts[13] == "true",
259
339
  "httpHealthy": parts[14] == "true",
260
340
  }
341
+ if len(parts) == 17:
342
+ if parts[15] not in {"true", "false"} or not parts[16]:
343
+ raise DeploymentKitError("Malformed remote material mount readiness.")
344
+ materials: list[dict[str, str]] = []
345
+ for encoded in parts[16].split(";"):
346
+ pieces = encoded.split("@")
347
+ if len(pieces) != 3 or re.fullmatch(r"[0-9]+:[0-9]+", pieces[2]) is None:
348
+ raise DeploymentKitError("Malformed remote material inventory identity.")
349
+ materials.append({
350
+ "name": pieces[0],
351
+ "containerPath": pieces[1],
352
+ "sourceIdentity": pieces[2],
353
+ })
354
+ normalized = _normalize_read_only_materials(
355
+ [(item["name"], item["containerPath"]) for item in materials],
356
+ data_path=None,
357
+ )
358
+ if [(item["name"], item["containerPath"]) for item in normalized] != [
359
+ (item["name"], item["containerPath"]) for item in materials
360
+ ]:
361
+ raise DeploymentKitError("Remote material inventory is unordered or conflicting.")
362
+ app["readOnlyMaterials"] = materials
363
+ app["materialMountsVerified"] = parts[15] == "true"
261
364
  apps.append(app)
262
365
  if set(summary) != {"appCount"}:
263
366
  raise DeploymentKitError("Application inventory omitted appCount.")
@@ -280,8 +383,9 @@ def _run_remote(
280
383
  *,
281
384
  spec: dict[str, Any] | None = None,
282
385
  expected_spec_sha: str = "",
386
+ expected_materials: dict[str, str] | None = None,
283
387
  ) -> str:
284
- if action not in {"inventory", "deploy"}:
388
+ if action not in {"inventory", "deploy", "material-inspect"}:
285
389
  raise DeploymentKitError(f"Unsupported application-runtime action: {action}")
286
390
  args = [action]
287
391
  if action == "deploy":
@@ -293,7 +397,7 @@ def _run_remote(
293
397
  )
294
398
  completed = subprocess.run(
295
399
  command,
296
- input=build_remote_script(spec).encode("utf-8"),
400
+ input=build_remote_script(spec, expected_materials=expected_materials).encode("utf-8"),
297
401
  check=False,
298
402
  capture_output=True,
299
403
  )
@@ -360,9 +464,10 @@ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
360
464
  and existing["containerRunning"]
361
465
  and existing["connectedToSharedNetwork"]
362
466
  and existing["httpHealthy"]
467
+ and existing.get("materialMountsVerified", True)
363
468
  ):
364
469
  raise DeploymentKitError("The exact application runtime spec is already deployed and healthy.")
365
- return {
470
+ plan = {
366
471
  "targetName": name,
367
472
  "action": "deploy",
368
473
  "environment": spec["environment"],
@@ -385,6 +490,9 @@ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
385
490
  "expectedSpecSha256": existing["specSha256"] if existing is not None else None,
386
491
  "spec": spec,
387
492
  }
493
+ if spec.get("readOnlyMaterials"):
494
+ plan["readOnlyMaterialEvidence"] = _material_evidence_for_spec(name, spec)
495
+ return plan
388
496
 
389
497
 
390
498
  def deploy_write_phrase(plan: dict[str, Any]) -> str:
@@ -428,7 +536,7 @@ def render_locked_app_deploy_stage(
428
536
  "expectedSpecSha256",
429
537
  "spec",
430
538
  }
431
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
539
+ if not isinstance(plan, dict) or set(plan) != required | ({"readOnlyMaterialEvidence"} if plan.get("spec", {}).get("readOnlyMaterials") else set()) or plan["action"] != "deploy":
432
540
  raise DeploymentKitError("Malformed application deployment plan.")
433
541
  if (plan["changeType"] != "update" or plan["retainsPreviousRelease"] is not True
434
542
  or not isinstance(plan["expectedSpecSha256"], str)
@@ -456,7 +564,8 @@ def render_locked_app_deploy_stage(
456
564
  )
457
565
  profile = load_profile(plan["targetName"])
458
566
  evidence = verify_access(profile)
459
- script = build_remote_script(plan["spec"])
567
+ _verify_material_evidence(plan["targetName"], plan)
568
+ script = build_remote_script(plan["spec"], expected_materials=plan.get("readOnlyMaterialEvidence"))
460
569
  delimiter = "PROJECT_FORGE_APP_DEPLOY_STAGE_" + hashlib.sha256(script.encode()).hexdigest()[:24]
461
570
  if delimiter in script:
462
571
  raise DeploymentKitError("Unsafe application deployment script delimiter collision.")
@@ -495,7 +604,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
495
604
  "expectedSpecSha256",
496
605
  "spec",
497
606
  }
498
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
607
+ if not isinstance(plan, dict) or set(plan) != required | ({"readOnlyMaterialEvidence"} if plan.get("spec", {}).get("readOnlyMaterials") else set()) or plan["action"] != "deploy":
499
608
  raise DeploymentKitError("Malformed application deployment plan.")
500
609
 
501
610
  current = app_inventory(plan["targetName"])
@@ -507,6 +616,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
507
616
  if platform["state"] != "ready":
508
617
  raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
509
618
 
619
+ _verify_material_evidence(plan["targetName"], plan)
510
620
  profile = load_profile(plan["targetName"])
511
621
  evidence = verify_access(profile)
512
622
  _run_remote(
@@ -515,6 +625,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
515
625
  "deploy",
516
626
  spec=plan["spec"],
517
627
  expected_spec_sha=plan["expectedSpecSha256"] or "",
628
+ expected_materials=plan.get("readOnlyMaterialEvidence"),
518
629
  )
519
630
  final = app_inventory(plan["targetName"])
520
631
  deployed = _find_app(final, plan["appSlug"], plan["environment"])
@@ -526,6 +637,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
526
637
  deployed["containerRunning"]
527
638
  and deployed["connectedToSharedNetwork"]
528
639
  and deployed["httpHealthy"]
640
+ and deployed.get("materialMountsVerified", True)
529
641
  ):
530
642
  raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
531
643
  final["mutated"] = True
@@ -8,6 +8,7 @@ EXPECTED_SPEC_SHA="${2:-}"
8
8
  DEPLOY_USER="${3:-}"
9
9
  HELD_LOCK_FD="${4:-}"
10
10
  APP_ROOT="__APP_ROOT__"
11
+ MATERIAL_ROOT="__MATERIAL_ROOT__"
11
12
  APP_SCHEMA_VERSION="__APP_SCHEMA_VERSION__"
12
13
  NETWORK_NAME="__NETWORK_NAME__"
13
14
  CADDY_CONTAINER="__CADDY_CONTAINER__"
@@ -26,6 +27,9 @@ D_DATA_TARGET_B64="__D_DATA_TARGET_B64__"
26
27
  D_SPEC_SHA="__D_SPEC_SHA__"
27
28
  D_ENV_KEYS=(__D_ENV_KEYS__)
28
29
  D_ENV_VALUES=(__D_ENV_VALUES__)
30
+ D_MATERIAL_NAMES=(__D_MATERIAL_NAMES__)
31
+ D_MATERIAL_TARGETS=(__D_MATERIAL_TARGETS__)
32
+ D_MATERIAL_EXPECTED=(__D_MATERIAL_EXPECTED__)
29
33
 
30
34
  b64d() { printf '%s' "$1" | base64 --decode; }
31
35
  meta_value() {
@@ -53,6 +57,92 @@ network_has_container() {
53
57
  grep -Fxq "$container"
54
58
  }
55
59
 
60
+
61
+ material_source() {
62
+ printf '%s/%s/%s/%s' "$MATERIAL_ROOT" "$D_APP" "$D_ENV" "$1"
63
+ }
64
+ material_identity() {
65
+ local source="$1" run_as="$2" uid gid dir owner mode hazards
66
+ uid="${run_as%%:*}"
67
+ gid="$uid"
68
+ [[ "$run_as" != *:* ]] || gid="${run_as#*:}"
69
+ command -v setpriv >/dev/null 2>&1 || {
70
+ echo "setpriv is required to verify read-only material access for runAs." >&2; return 1;
71
+ }
72
+ for dir in /srv "$MATERIAL_ROOT" "$MATERIAL_ROOT/$D_APP" "$MATERIAL_ROOT/$D_APP/$D_ENV" "$source"; do
73
+ [[ -d "$dir" && ! -L "$dir" ]] || {
74
+ echo "Read-only material directory missing or linked: $dir" >&2; return 1;
75
+ }
76
+ owner="$(stat -c '%u' "$dir")"
77
+ mode="$(stat -c '%a' "$dir")"
78
+ [[ "$owner" == 0 ]] && (( (8#$mode & 022) == 0 )) || {
79
+ echo "Read-only material directory ownership or mode is unsafe: $dir" >&2; return 1;
80
+ }
81
+ done
82
+ hazards="$(find -P "$source" -mindepth 1 \( -type l -o ! -uid 0 -o -perm /022 -o \( ! -type f -a ! -type d \) \) -print -quit)" || return 1
83
+ [[ -z "$hazards" ]] || {
84
+ echo "Read-only material tree has linked, writable, unowned or special entries." >&2; return 1;
85
+ }
86
+ setpriv --reuid "$uid" --regid "$gid" --clear-groups -- bash -c '
87
+ path="$1"
88
+ [[ -r "$path" && -x "$path" ]] || exit 1
89
+ invalid="$(find -P "$path" \( -type d \( ! -readable -o ! -executable \) -o -type f ! -readable \) -print -quit)" || exit 1
90
+ [[ -z "$invalid" ]]
91
+ ' -- "$source" || {
92
+ echo "Read-only material tree is not readable/traversable by runAs." >&2; return 1;
93
+ }
94
+ stat -c '%d:%i' "$source"
95
+ }
96
+ validate_material_entries() {
97
+ [[ "${#D_MATERIAL_NAMES[@]}" -eq "${#D_MATERIAL_TARGETS[@]}" &&
98
+ "${#D_MATERIAL_NAMES[@]}" -eq "${#D_MATERIAL_EXPECTED[@]}" ]] || {
99
+ echo "Read-only material argument count mismatch." >&2; exit 33;
100
+ }
101
+ local index name target part prior data
102
+ local -a segments targets=()
103
+ data="$D_DATA_TARGET"
104
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
105
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
106
+ target="$(b64d "${D_MATERIAL_TARGETS[$index]}")"
107
+ valid_slug "$name" || { echo "Invalid read-only material name." >&2; exit 33; }
108
+ [[ "$target" == /* && "$target" != / && "$target" =~ ^/[A-Za-z0-9._~+:/-]+$ ]] || {
109
+ echo "Invalid read-only material target." >&2; exit 33;
110
+ }
111
+ IFS='/' read -r -a segments <<< "${target#/}"
112
+ for part in "${segments[@]}"; do
113
+ [[ -n "$part" && "$part" != . && "$part" != .. ]] || {
114
+ echo "Read-only material target is not normalized." >&2; exit 33;
115
+ }
116
+ done
117
+ case "$target/" in
118
+ /proc/*|/sys/*|/dev/*|/run/*|/var/run/*)
119
+ echo "Read-only material target overlaps a system path." >&2; exit 33;;
120
+ esac
121
+ for prior in "${targets[@]}" "$data"; do
122
+ [[ -z "$prior" ]] && continue
123
+ if [[ "$target" == "$prior" || "$target" == "$prior/"* || "$prior" == "$target/"* ]]; then
124
+ echo "Read-only material target overlaps another mount." >&2; exit 33
125
+ fi
126
+ done
127
+ targets+=("$target")
128
+ done
129
+ }
130
+ material_probe() {
131
+ local check_expected="$1" index name source identity expected
132
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
133
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
134
+ source="$(material_source "$name")"
135
+ identity="$(material_identity "$source" "$D_RUN_AS")" || return 1
136
+ expected="${D_MATERIAL_EXPECTED[$index]}"
137
+ if [[ "$check_expected" == true && "$identity" != "$expected" ]]; then
138
+ echo "Read-only material directory changed after planning: $name" >&2; return 1
139
+ fi
140
+ if [[ "$check_expected" != true ]]; then
141
+ printf 'MATERIAL\t%s\t%s\n' "$name" "$identity"
142
+ fi
143
+ done
144
+ }
145
+
56
146
  probe_http() {
57
147
  local container="$1" port="$2" path="$3"
58
148
  docker exec "$CADDY_CONTAINER" \
@@ -75,6 +165,47 @@ wait_ready() {
75
165
  return 1
76
166
  }
77
167
 
168
+
169
+ material_inventory_state() {
170
+ local file="$1" app="$2" env="$3" container="$4" persistence="$5"
171
+ local number index name target source recorded current raw mounts run_as data_target line
172
+ number="$(meta_value "$file" MATERIAL_COUNT || true)"
173
+ MATERIAL_OK=true
174
+ MATERIAL_ENTRIES=""
175
+ [[ -n "$number" ]] || return 0
176
+ [[ "$number" =~ ^[1-8]$ ]] || { echo "Invalid saved material count." >&2; return 1; }
177
+ D_APP="$app"
178
+ D_ENV="$env"
179
+ run_as="$(meta_value "$file" RUN_AS || true)"
180
+ valid_run_as "$run_as" || { echo "Invalid saved material runAs." >&2; return 1; }
181
+ mounts="$(docker inspect --format '{{range .Mounts}}{{printf "%s@%s@%t@%s\n" .Source .Destination .RW .Type}}{{end}}' "$container" 2>/dev/null)" || {
182
+ MATERIAL_OK=false
183
+ mounts=""
184
+ }
185
+ local expected_count="$number"
186
+ if [[ "$persistence" == directory ]]; then
187
+ data_target="$(b64d "$(meta_value "$file" DATA_TARGET_B64 || true)")"
188
+ line="$APP_ROOT/$app/$env/data@$data_target@true@bind"
189
+ grep -Fxq -- "$line" <<< "$mounts" || MATERIAL_OK=false
190
+ expected_count="$((number + 1))"
191
+ fi
192
+ [[ "$(grep -c '.' <<< "$mounts" || true)" == "$expected_count" ]] || MATERIAL_OK=false
193
+ for ((index=0; index < number; index++)); do
194
+ name="$(b64d "$(meta_value "$file" MATERIAL_NAME_B64_"$index" || true)")"
195
+ target="$(b64d "$(meta_value "$file" MATERIAL_TARGET_B64_"$index" || true)")"
196
+ recorded="$(meta_value "$file" MATERIAL_ID_"$index" || true)"
197
+ valid_slug "$name" && [[ "$target" =~ ^/[A-Za-z0-9._~+:/-]+$ && "$recorded" =~ ^[0-9]+:[0-9]+$ ]] || {
198
+ echo "Invalid saved material mount metadata." >&2; return 1;
199
+ }
200
+ source="$(material_source "$name")"
201
+ current="$(material_identity "$source" "$run_as" 2>/dev/null)" || { MATERIAL_OK=false; current=""; }
202
+ [[ "$current" == "$recorded" ]] || MATERIAL_OK=false
203
+ grep -Fxq -- "$source@$target@false@bind" <<< "$mounts" || MATERIAL_OK=false
204
+ [[ -z "$MATERIAL_ENTRIES" ]] || MATERIAL_ENTRIES+=";"
205
+ MATERIAL_ENTRIES+="$name@$target@$recorded"
206
+ done
207
+ }
208
+
78
209
  inventory() {
79
210
  local count=0 state current previous release_file
80
211
  local owner env app container image spec_sha port health persistence
@@ -114,9 +245,16 @@ inventory() {
114
245
  if [[ "$running" == true && "$connected" == true ]]; then
115
246
  probe_http "$container" "$port" "$health" && healthy=true || true
116
247
  fi
117
- printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
118
- "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
119
- "$current" "$previous" "$running" "$connected" "$healthy"
248
+ material_inventory_state "$release_file" "$app" "$env" "$container" "$persistence" || exit 31
249
+ if [[ -n "$MATERIAL_ENTRIES" ]]; then
250
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
251
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
252
+ "$current" "$previous" "$running" "$connected" "$healthy" "$MATERIAL_OK" "$MATERIAL_ENTRIES"
253
+ else
254
+ printf 'APP\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
255
+ "$owner" "$env" "$app" "$container" "$image" "$spec_sha" "$port" "$health" "$persistence" \
256
+ "$current" "$previous" "$running" "$connected" "$healthy"
257
+ fi
120
258
  count=$((count + 1))
121
259
  done < <(find "$APP_ROOT" -mindepth 3 -maxdepth 3 -type f -name deployment.env -print 2>/dev/null | sort)
122
260
  fi
@@ -173,6 +311,7 @@ decode_desired() {
173
311
  exit 33
174
312
  }
175
313
  valid_run_as "$D_RUN_AS" || { echo "Invalid numeric runAs value." >&2; exit 33; }
314
+ validate_material_entries
176
315
  [[ "$D_PERSISTENCE" == "none" || "$D_PERSISTENCE" == "directory" ]] || {
177
316
  echo "Invalid persistence mode." >&2
178
317
  exit 33
@@ -216,6 +355,14 @@ write_release_metadata() {
216
355
  printf 'PERSISTENCE_MODE=%s\n' "$D_PERSISTENCE"
217
356
  printf 'DATA_TARGET_B64=%s\n' "$D_DATA_TARGET_B64"
218
357
  printf 'SPEC_SHA256=%s\n' "$D_SPEC_SHA"
358
+ if (( ${#D_MATERIAL_NAMES[@]} > 0 )); then
359
+ printf 'MATERIAL_COUNT=%s\n' "${#D_MATERIAL_NAMES[@]}"
360
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
361
+ printf 'MATERIAL_NAME_B64_%s=%s\n' "$index" "${D_MATERIAL_NAMES[$index]}"
362
+ printf 'MATERIAL_TARGET_B64_%s=%s\n' "$index" "${D_MATERIAL_TARGETS[$index]}"
363
+ printf 'MATERIAL_ID_%s=%s\n' "$index" "${D_MATERIAL_EXPECTED[$index]}"
364
+ done
365
+ fi
219
366
  printf 'ENV_COUNT=%s\n' "${#D_ENV_KEYS[@]}"
220
367
  for index in "${!D_ENV_KEYS[@]}"; do
221
368
  key_b64="${D_ENV_KEYS[$index]}"
@@ -271,6 +418,13 @@ start_desired_container() {
271
418
  value="$(b64d "${D_ENV_VALUES[$index]}")"
272
419
  docker_args+=(--env "$key=$value")
273
420
  done
421
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
422
+ local source target name
423
+ name="$(b64d "${D_MATERIAL_NAMES[$index]}")"
424
+ target="$(b64d "${D_MATERIAL_TARGETS[$index]}")"
425
+ source="$(material_source "$name")"
426
+ docker_args+=(--mount "type=bind,src=$source,dst=$target,readonly")
427
+ done
274
428
  docker_args+=("$D_IMAGE")
275
429
  "${docker_args[@]}" >/dev/null
276
430
  }
@@ -339,6 +493,7 @@ deploy() {
339
493
  release_file="$release_dir/release.env"
340
494
 
341
495
  acquire_deployment_lock "$lock_file"
496
+ material_probe true || { echo "Read-only material preflight failed." >&2; exit 40; }
342
497
 
343
498
  for path in "$root" "$releases" "$state" "$root/data" "$root/deployment-evidence.jsonl"; do
344
499
  [[ ! -L "$path" ]] || { echo "Managed application path must not be a symbolic link: $path" >&2; exit 35; }
@@ -397,11 +552,22 @@ deploy() {
397
552
  echo "Release directory collision with different runtime metadata." >&2
398
553
  exit 35
399
554
  }
555
+ if (( ${#D_MATERIAL_NAMES[@]} > 0 )); then
556
+ [[ "$(release_value "$release_file" MATERIAL_COUNT || true)" == "${#D_MATERIAL_NAMES[@]}" ]] || {
557
+ echo "Existing release material metadata mismatch." >&2; exit 35;
558
+ }
559
+ for index in "${!D_MATERIAL_NAMES[@]}"; do
560
+ [[ "$(release_value "$release_file" MATERIAL_ID_"$index" || true)" == "${D_MATERIAL_EXPECTED[$index]}" ]] || {
561
+ echo "Existing release material directory identity changed." >&2; exit 35;
562
+ }
563
+ done
564
+ fi
400
565
  else
401
566
  write_release_metadata "$release_file"
402
567
  fi
403
568
 
404
569
  docker pull "$D_IMAGE" >/dev/null
570
+ material_probe true || { echo "Read-only material source changed before runtime replacement." >&2; exit 40; }
405
571
 
406
572
  if [[ -n "$current_container" ]]; then
407
573
  docker rm -f "$current_container" >/dev/null
@@ -442,6 +608,10 @@ case "$ACTION" in
442
608
  deploy)
443
609
  deploy
444
610
  ;;
611
+ material-inspect)
612
+ decode_desired
613
+ material_probe false || exit 40
614
+ ;;
445
615
  *)
446
616
  echo "Unsupported application-runtime action: $ACTION" >&2
447
617
  exit 2
@@ -43,12 +43,12 @@ CLOUDFLARE_API_TOKEN_ENV = "PROJECT_FORGE_CLOUDFLARE_API_TOKEN"
43
43
  _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
44
44
  _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
45
45
  _LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
46
- _BOOL_STATUS_KEYS = {"markerPresent", "platformReady", "tokenPresent", "containerRunning", "networkAttached"}
46
+ _BOOL_STATUS_KEYS = {"markerPresent", "platformReady", "tokenPresent", "containerRunning", "networkAttached", "authenticatedOrigin"}
47
47
  _STATUS_KEYS = {
48
48
  "state", "scope", "markerPresent", "markerSchemaVersion", "markerIngressVersion",
49
49
  "markerDistribution", "platformReady", "tokenPresent", "containerRunning",
50
50
  "networkAttached", "containerName", "cloudflaredImage", "accountId", "zoneId",
51
- "zoneName", "tunnelId", "tunnelName",
51
+ "zoneName", "tunnelId", "tunnelName", "authenticatedOrigin",
52
52
  }
53
53
 
54
54
 
@@ -199,6 +199,7 @@ def _run_remote(
199
199
  spec: dict[str, Any] | None = None,
200
200
  expected: dict[str, Any] | None = None,
201
201
  tunnel_token: str | None = None,
202
+ authenticated_origin: bool = False,
202
203
  ) -> str:
203
204
  if action not in {
204
205
  "status", "install", "upgrade", "route-inventory",
@@ -212,6 +213,7 @@ def _run_remote(
212
213
  args.append(_route_tsv(expected) if expected is not None else "NONE")
213
214
  elif action in {"route-remove", "route-remove-locked"}:
214
215
  args.append("")
216
+ args.append("true" if authenticated_origin else "false")
215
217
  remote_command = "sudo -n bash -s -- " + " ".join(shlex.quote(item) for item in args)
216
218
  completed = subprocess.run(
217
219
  ssh_args(profile, address, command=remote_command),
@@ -424,9 +426,12 @@ def plan_cloudflare_ingress(
424
426
  zone_name: str,
425
427
  api_token: str,
426
428
  tunnel_name: str | None = None,
429
+ authenticated_origin: bool = False,
427
430
  ) -> dict[str, Any]:
428
431
  """Plan creation/adoption of one shared remotely managed Cloudflare tunnel."""
429
432
  scope = _slug(scope, "scope")
433
+ if type(authenticated_origin) is not bool:
434
+ raise DeploymentKitError("Authenticated origin must be an explicit boolean opt-in.")
430
435
  zone = cloudflare_api.resolve_zone(api_token, zone_name)
431
436
  expected_tunnel_name = tunnel_name or tunnel_name_for_scope(scope)
432
437
  if not isinstance(expected_tunnel_name, str) or not expected_tunnel_name.strip():
@@ -452,6 +457,8 @@ def plan_cloudflare_ingress(
452
457
  tunnel_config = cloudflare_api.get_tunnel_configuration(api_token, zone["accountId"], str(tunnel["id"]))
453
458
 
454
459
  if host["state"] in {"ready", "upgrade-required"}:
460
+ if bool(host["authenticatedOrigin"]) != authenticated_origin:
461
+ raise DeploymentKitError("Origin authentication mode cannot change on an existing scope; use a new isolated scope.")
455
462
  expected = {
456
463
  "accountId": zone["accountId"],
457
464
  "zoneId": zone["zoneId"],
@@ -485,6 +492,7 @@ def plan_cloudflare_ingress(
485
492
  "hostState": host["state"],
486
493
  "hostStatusSnapshot": _status_snapshot(host),
487
494
  "cloudflaredImage": CLOUDFLARED_IMAGE,
495
+ "authenticatedOrigin": authenticated_origin,
488
496
  "requiredAuthorization": HOST_PUBLIC_INGRESS_WRITE_PHRASE,
489
497
  "secrets": {
490
498
  "apiToken": "workstation-only; not persisted by Deployment Kit",
@@ -506,10 +514,12 @@ def apply_cloudflare_ingress(plan: dict[str, Any], *, api_token: str) -> dict[st
506
514
  required = {
507
515
  "targetName", "scope", "action", "zone", "tunnelName", "existingTunnel",
508
516
  "existingTunnelConfigSha256", "hostState", "hostStatusSnapshot", "cloudflaredImage",
509
- "requiredAuthorization", "secrets",
517
+ "requiredAuthorization", "secrets", "authenticatedOrigin",
510
518
  }
511
519
  if not isinstance(plan, dict) or set(plan) != required:
512
520
  raise DeploymentKitError("Malformed Cloudflare ingress plan.")
521
+ if type(plan["authenticatedOrigin"]) is not bool:
522
+ raise DeploymentKitError("Malformed authenticated-origin plan mode.")
513
523
  if plan["cloudflaredImage"] != CLOUDFLARED_IMAGE:
514
524
  raise DeploymentKitError("Cloudflare ingress plan uses a different cloudflared image; re-plan with the current package.")
515
525
 
@@ -561,6 +571,7 @@ def apply_cloudflare_ingress(plan: dict[str, Any], *, api_token: str) -> dict[st
561
571
  scope=scope,
562
572
  metadata=metadata,
563
573
  tunnel_token=token,
574
+ authenticated_origin=plan["authenticatedOrigin"],
564
575
  )
565
576
  final = cloudflare_ingress_status(name, scope)
566
577
  if final["state"] != "ready":
@@ -659,7 +670,13 @@ def _lan_route(name: str, spec: dict[str, Any]) -> dict[str, Any]:
659
670
  return route
660
671
 
661
672
 
662
- def _cloudflare_rule(spec: dict[str, Any]) -> dict[str, Any]:
673
+ def _cloudflare_rule(spec: dict[str, Any], *, authenticated_origin: bool = False) -> dict[str, Any]:
674
+ if authenticated_origin:
675
+ return {
676
+ "hostname": spec["publicHostname"],
677
+ "service": "unix:/run/project-forge-origin/origin.sock",
678
+ "originRequest": {"httpHostHeader": spec["publicHostname"]},
679
+ }
663
680
  return {
664
681
  "hostname": spec["publicHostname"],
665
682
  "service": f"http://{CADDY_CONTAINER}:80",
@@ -800,7 +817,7 @@ def plan_public_route(
800
817
 
801
818
  config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
802
819
  rules, final_rule = _split_ingress(config)
803
- desired_rule = _cloudflare_rule(spec)
820
+ desired_rule = _cloudflare_rule(spec, authenticated_origin=host["authenticatedOrigin"])
804
821
  conflicting = [item for item in rules if item.get("hostname") == spec["publicHostname"] and item != desired_rule]
805
822
  if conflicting:
806
823
  raise DeploymentKitError("Cloudflare tunnel already has a conflicting ingress rule for this hostname.")
@@ -984,7 +1001,7 @@ def plan_public_route_removal(
984
1001
  raise DeploymentKitError("Cloudflare public-route removal ownership does not match.")
985
1002
  config = cloudflare_api.get_tunnel_configuration(api_token, host["accountId"], host["tunnelId"])
986
1003
  rules, final_rule = _split_ingress(config)
987
- expected_rule = _cloudflare_rule(route)
1004
+ expected_rule = _cloudflare_rule(route, authenticated_origin=host["authenticatedOrigin"])
988
1005
  matches = [item for item in rules if item == expected_rule]
989
1006
  if len(matches) != 1:
990
1007
  raise DeploymentKitError("Cloudflare tunnel does not contain exactly one expected managed rule for this route.")
@@ -8,6 +8,7 @@ SCOPE="$2"
8
8
  META="${3:-}"
9
9
  SPEC="${4:-}"
10
10
  EXPECTED="${5:-}"
11
+ AUTHENTICATED_ORIGIN="${6:-false}"
11
12
 
12
13
  PLATFORM_ROOT="__PLATFORM_ROOT__"
13
14
  NETWORK_NAME="__NETWORK_NAME__"
@@ -38,6 +39,10 @@ MARKER="$ROOT/ingress.env"
38
39
  TOKEN_FILE="$ROOT/tunnel-token"
39
40
  COMPOSE_FILE="$ROOT/compose.yaml"
40
41
  ROUTE_ROOT="$ROOT/routes"
42
+ AUTH_ROOT="$PLATFORM_ROOT/proxy/data/authenticated/$SCOPE"
43
+ AUTH_ROUTES="$AUTH_ROOT/routes"
44
+ AUTH_CADDY="$PLATFORM_ROOT/proxy/conf.d/90-project-forge-auth-$SCOPE.caddy"
45
+ AUTH_SOCKET="$AUTH_ROOT/origin.sock"
41
46
  CONTAINER="project-forge-cloudflared-$SCOPE"
42
47
  LOCK_FILE="/run/lock/project-forge-cloudflare-$SCOPE.lock"
43
48
 
@@ -73,7 +78,9 @@ status() {
73
78
  network_attached && attached=true || true
74
79
  platform_ready && platform=true || true
75
80
 
76
- local state marker_schema marker_version marker_distribution marker_image runtime_image
81
+ local state marker_schema marker_version marker_distribution marker_image runtime_image mode
82
+ mode="$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)"
83
+ [[ "$mode" == true ]] || mode=false
77
84
  marker_schema="$(marker_value SCHEMA_VERSION 2>/dev/null || true)"
78
85
  marker_version="$(marker_value INGRESS_VERSION 2>/dev/null || true)"
79
86
  marker_distribution="$(marker_value DISTRIBUTION 2>/dev/null || true)"
@@ -90,6 +97,8 @@ status() {
90
97
  state="upgrade-required"
91
98
  elif [[ "$running" == true && "$runtime_image" != "$CLOUDFLARED_IMAGE" ]]; then
92
99
  state="drifted"
100
+ elif [[ "$mode" == true && ( ! -S "$AUTH_SOCKET" || ! -f "$AUTH_CADDY" ) ]]; then
101
+ state="drifted"
93
102
  elif [[ "$token" == true && "$running" == true && "$attached" == true && -f "$COMPOSE_FILE" && -d "$ROUTE_ROOT" ]]; then
94
103
  state="ready"
95
104
  else
@@ -113,6 +122,7 @@ status() {
113
122
  printf 'zoneName=%s\n' "$(marker_value ZONE_NAME 2>/dev/null || true)"
114
123
  printf 'tunnelId=%s\n' "$(marker_value TUNNEL_ID 2>/dev/null || true)"
115
124
  printf 'tunnelName=%s\n' "$(marker_value TUNNEL_NAME 2>/dev/null || true)"
125
+ printf 'authenticatedOrigin=%s\n' "$mode"
116
126
  }
117
127
 
118
128
  parse_meta() {
@@ -136,6 +146,7 @@ ZONE_NAME=$M_ZONE_NAME
136
146
  TUNNEL_ID=$M_TUNNEL_ID
137
147
  TUNNEL_NAME=$M_TUNNEL_NAME
138
148
  CLOUDFLARED_IMAGE=$CLOUDFLARED_IMAGE
149
+ AUTHENTICATED_ORIGIN=$AUTHENTICATED_ORIGIN
139
150
  EOF
140
151
  chmod 0600 "$tmp"
141
152
  mv -f "$tmp" "$MARKER"
@@ -152,6 +163,11 @@ services:
152
163
  command: tunnel --no-autoupdate run --token-file /run/secrets/tunnel-token
153
164
  volumes:
154
165
  - $TOKEN_FILE:/run/secrets/tunnel-token:ro
166
+ EOF
167
+ if [[ "$AUTHENTICATED_ORIGIN" == true ]]; then
168
+ printf ' - %s:/run/project-forge-origin:ro\n' "$AUTH_ROOT" >> "$tmp"
169
+ fi
170
+ cat >> "$tmp" <<EOF
155
171
  networks:
156
172
  - $NETWORK_NAME
157
173
  logging:
@@ -169,6 +185,112 @@ EOF
169
185
  mv -f "$tmp" "$COMPOSE_FILE"
170
186
  }
171
187
 
188
+
189
+ setup_authenticated_origin() {
190
+ [[ "$AUTHENTICATED_ORIGIN" == true ]] || return 0
191
+ [[ ! -L "$AUTH_ROOT" && ! -L "$AUTH_CADDY" ]] || {
192
+ echo "Authenticated origin path is a symbolic link." >&2; exit 27;
193
+ }
194
+ install -d -m 0700 "$AUTH_ROOT" "$AUTH_ROUTES"
195
+ chown 65532:65532 "$AUTH_ROOT"
196
+ chmod 0700 "$AUTH_ROOT"
197
+ local tmp="$AUTH_CADDY.tmp.$BASHPID"
198
+ cat > "$tmp" <<EOF
199
+ http://:19078 {
200
+ bind unix//data/authenticated/$SCOPE/origin.sock|0222
201
+ import /data/authenticated/$SCOPE/routes/*.caddy
202
+ respond "Not Found" 404
203
+ }
204
+ EOF
205
+ chmod 0644 "$tmp"
206
+ mv -f "$tmp" "$AUTH_CADDY"
207
+ docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null
208
+ [[ -S "$AUTH_SOCKET" ]] || {
209
+ echo "Authenticated private origin socket did not appear." >&2; exit 27;
210
+ }
211
+ }
212
+
213
+ # Public-only Caddy handler: the LAN listener never receives this virtual host.
214
+ # All routing metadata comes from the existing, owned LAN route on this host.
215
+ auth_route_path() { printf '%s/%s.caddy' "$AUTH_ROUTES" "$1"; }
216
+ auth_route_verify_lan() {
217
+ local lan="$PLATFORM_ROOT/routes/$R_ROUTE.route" hosts
218
+ [[ -f "$lan" && ! -L "$lan" ]] || { echo "Owned LAN route missing." >&2; exit 35; }
219
+ [[ "$(route_value "$lan" OWNER_REPO)" == "$R_OWNER" &&
220
+ "$(route_value "$lan" ENVIRONMENT)" == "$R_ENV" &&
221
+ "$(route_value "$lan" APP_SLUG)" == "$R_APP" &&
222
+ "$(route_value "$lan" ROUTE_ID)" == "$R_ROUTE" ]] || {
223
+ echo "LAN route ownership changed." >&2; exit 35;
224
+ }
225
+ hosts="$(route_value "$lan" HOSTNAMES)"
226
+ [[ ",$hosts," == *",$R_ORIGIN,"* ]] || {
227
+ echo "LAN origin hostname is no longer owned." >&2; exit 35;
228
+ }
229
+ AUTH_UPSTREAM="$(route_value "$lan" UPSTREAM_CONTAINER)"
230
+ AUTH_PORT="$(route_value "$lan" UPSTREAM_PORT)"
231
+ [[ "$AUTH_UPSTREAM" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ &&
232
+ "$AUTH_PORT" =~ ^[0-9]+$ ]] || {
233
+ echo "LAN upstream is malformed." >&2; exit 35;
234
+ }
235
+ (( 10#$AUTH_PORT >= 1 && 10#$AUTH_PORT <= 65535 )) || {
236
+ echo "LAN port out of range." >&2; exit 35;
237
+ }
238
+ }
239
+ auth_route_write() {
240
+ [[ "$(marker_value AUTHENTICATED_ORIGIN)" == true ]] || return 0
241
+ auth_route_verify_lan
242
+ local file tmp backup
243
+ file="$(auth_route_path "$R_ROUTE")"
244
+ [[ ! -L "$file" ]] || { echo "Unsafe authenticated route symlink." >&2; exit 35; }
245
+ tmp="$file.tmp.$BASHPID"
246
+ backup="$file.backup.$BASHPID"
247
+ [[ ! -f "$file" ]] || cp -a "$file" "$backup"
248
+ cat > "$tmp" <<EOF
249
+ @pf_$R_ROUTE host $R_PUBLIC
250
+ handle @pf_$R_ROUTE {
251
+ reverse_proxy $AUTH_UPSTREAM:$AUTH_PORT {
252
+ header_up Host $R_PUBLIC
253
+ header_up -Forwarded
254
+ header_up -X-Forwarded-*
255
+ header_up -X-Real-IP
256
+ header_up -True-Client-IP
257
+ header_up -CF-Visitor
258
+ header_up -CF-Connecting-IP
259
+ header_up X-Forwarded-Proto https
260
+ header_up X-Forwarded-Host $R_PUBLIC
261
+ header_up X-Forwarded-For {http.request.header.Cf-Connecting-Ip}
262
+ header_up X-Real-IP {http.request.header.Cf-Connecting-Ip}
263
+ }
264
+ }
265
+ 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
+ }
276
+ auth_route_remove() {
277
+ [[ "$(marker_value AUTHENTICATED_ORIGIN)" == true ]] || return 0
278
+ local file backup
279
+ file="$(auth_route_path "$R_ROUTE")"
280
+ [[ -f "$file" && ! -L "$file" ]] || {
281
+ echo "Expected authenticated Caddy route is missing." >&2; exit 35;
282
+ }
283
+ backup="$file.backup.$BASHPID"
284
+ mv -f "$file" "$backup"
285
+ if ! docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null; then
286
+ mv -f "$backup" "$file"
287
+ docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile >/dev/null 2>&1 || true
288
+ echo "Authenticated Caddy removal failed; restored previous route." >&2
289
+ exit 35
290
+ fi
291
+ rm -f "$backup"
292
+ }
293
+
172
294
  write_token() {
173
295
  [[ -n "$TOKEN_B64" ]] || { echo "Tunnel token was not supplied." >&2; exit 20; }
174
296
  local tmp="$TOKEN_FILE.tmp.$$"
@@ -190,6 +312,9 @@ install_or_upgrade() {
190
312
  exit 22
191
313
  fi
192
314
  install -d -m 0700 "$ROOT" "$ROUTE_ROOT"
315
+ if [[ "$AUTHENTICATED_ORIGIN" == true ]]; then
316
+ setup_authenticated_origin
317
+ fi
193
318
  write_token
194
319
  write_compose
195
320
  write_marker
@@ -289,6 +414,9 @@ PUBLIC_HOSTNAME=$R_PUBLIC
289
414
  ORIGIN_HOSTNAME=$R_ORIGIN
290
415
  EOF
291
416
  chmod 0600 "$tmp"
417
+ if [[ "$(marker_value AUTHENTICATED_ORIGIN 2>/dev/null || true)" == true ]]; then
418
+ auth_route_write
419
+ fi
292
420
  mv -f "$tmp" "$path"
293
421
  }
294
422
 
@@ -298,6 +426,7 @@ route_remove() {
298
426
  path="$(route_path "$R_ROUTE")"
299
427
  [[ -f "$path" && ! -L "$path" ]] || { echo "Managed public-ingress route does not exist." >&2; exit 34; }
300
428
  metadata_matches "$path" "$SPEC" || { echo "Public-ingress route ownership/state mismatch." >&2; exit 34; }
429
+ auth_route_remove
301
430
  rm -f "$path"
302
431
  }
303
432
 
@@ -77,6 +77,7 @@ def build_parser() -> argparse.ArgumentParser:
77
77
  configure.add_argument("--scope", required=True)
78
78
  configure.add_argument("--zone", required=True, help="Existing active Cloudflare zone, for example example.com")
79
79
  configure.add_argument("--tunnel-name", help="Optional explicit tunnel name; default is project-forge-<scope>")
80
+ configure.add_argument("--authenticated-origin", action="store_true", help="Experimental: isolate the public connector origin on a private Unix socket; only new scopes")
80
81
  configure.add_argument("--json", action="store_true")
81
82
  configure.add_argument("--yes", action="store_true")
82
83
 
@@ -132,6 +133,7 @@ def main(argv: list[str] | None = None) -> int:
132
133
  zone_name=args.zone,
133
134
  api_token=token,
134
135
  tunnel_name=args.tunnel_name,
136
+ authenticated_origin=args.authenticated_origin,
135
137
  )
136
138
  _print(plan, as_json=False)
137
139
  _confirm(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.19.1",
3
+ "version": "4.21.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {