@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.
- package/assets/components.json +2 -2
- package/assets/generated/ASSEMBLY.json +9 -9
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +6 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +18 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +120 -8
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +173 -3
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress.py +23 -6
- package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py +130 -1
- package/assets/generated/deployment/src/project_forge_deployment/ingress_cli.py +2 -0
- 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.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": "
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.")
|
package/assets/generated/deployment/src/project_forge_deployment/cloudflare_ingress_remote.py
CHANGED
|
@@ -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(
|