@mriekki/project-forge 4.19.1 → 4.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/components.json +2 -2
- package/assets/generated/ASSEMBLY.json +6 -6
- package/assets/generated/deployment/docs/CLOUDFLARE_INGRESS.md +6 -0
- 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.20.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": "977ada7310f649adea34059381339012a0c0daaf",
|
|
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.20.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": "977ada7310f649adea34059381339012a0c0daaf",
|
|
365
365
|
"distribution": "greenfield-project",
|
|
366
366
|
"files": [
|
|
367
367
|
{
|
|
@@ -437,12 +437,12 @@
|
|
|
437
437
|
{
|
|
438
438
|
"source": "src/project_forge_deployment/cloudflare_ingress.py",
|
|
439
439
|
"destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py",
|
|
440
|
-
"sha256": "
|
|
440
|
+
"sha256": "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:
|
|
@@ -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(
|