gowalk-cicd 1.0.147 → 1.0.148
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/CLAUDE.md +8 -0
- package/README.md +2 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/mobile_inputs.cjs +1 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/action.yml +39 -0
- package/backend-action/edge_health.py +72 -0
- package/backend-action/edge_inputs.py +115 -0
- package/backend-action/edge_vhost.py +140 -0
- package/backend-action/remote_deploy.sh +26 -4
- package/backend-action/test_edge_health.py +75 -0
- package/backend-action/test_edge_inputs.py +79 -0
- package/backend-action/test_edge_nginx.py +103 -0
- package/backend-action/test_edge_vhost.py +72 -0
- package/docs/BACKEND_EDGE.md +39 -0
- package/docs/CONFIGURATION.md +11 -0
- package/package.json +2 -1
- package/src/config/settings.mjs +2 -1
- package/templates/deploy-backend.yml +5 -0
- package/templates/deploy.yml +5 -0
- package/templates/history.json.gz +0 -0
package/CLAUDE.md
CHANGED
|
@@ -242,6 +242,14 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
242
242
|
Docker port changes update only the managed nginx upstream, preserving existing TLS directives.
|
|
243
243
|
Existing certificate inventory is followed by domain-scoped `certbot install`, so a legacy
|
|
244
244
|
HTTP-only vhost also recovers without reissuance. Installation shares the bounded lock-only retry.
|
|
245
|
+
- Optional backend edge delivery keeps `api-domain` as infrastructure and verifies the separate
|
|
246
|
+
`public-health-url` against exact health JSON `build_sha`. `public-base-url` supplies
|
|
247
|
+
`BACKEND_PUBLIC_BASE_URL` to Compose interpolation; app services consume it for generated URLs.
|
|
248
|
+
`ingress-required` requires a private `ingress-token` before deployment. Protect only the distinct
|
|
249
|
+
`backend-<app>-edge.conf`; preserve a legacy vhost's TLS/public access and update both managed
|
|
250
|
+
ports. A missing token/settings cannot downgrade an already protected app. Keep tokens in private
|
|
251
|
+
stdin/file transport, strip the ingress header before app forwarding, and validate real nginx in
|
|
252
|
+
focused tests. Preserve legacy deployments that have not opted in and existing failure phases.
|
|
245
253
|
- The Android action serves two build systems. `android_config.project_kind()`
|
|
246
254
|
is the only place that decides which; every downstream step branches on the
|
|
247
255
|
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
package/README.md
CHANGED
|
@@ -201,6 +201,8 @@ branch yet, manually dispatching **Mobile Deploy** on the feature branch runs
|
|
|
201
201
|
the same backend action as a `backend-preview` job. The job is feature-branch
|
|
202
202
|
only; normal default-branch backend deploys remain in `deploy-backend.yml`.
|
|
203
203
|
|
|
204
|
+
For optional app-owned Cloudflare ingress, follow [backend edge delivery](docs/BACKEND_EDGE.md).
|
|
205
|
+
|
|
204
206
|
## Android credentials
|
|
205
207
|
|
|
206
208
|
Place these files under `creds/`:
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.148
|
|
@@ -24,6 +24,7 @@ function input(name, platform) {
|
|
|
24
24
|
// checked against the template, and share the projection with the freeze CLI.
|
|
25
25
|
const BUILD_VARIABLES = [
|
|
26
26
|
'BACKEND_API_DOMAIN', 'BACKEND_APP_NAME', 'BACKEND_DEPLOY_HOST', 'BACKEND_HEALTH_PATH',
|
|
27
|
+
'BACKEND_INGRESS_REQUIRED', 'BACKEND_PUBLIC_BASE_URL', 'BACKEND_PUBLIC_HEALTH_URL',
|
|
27
28
|
'DEPLOY_PLATFORMS', 'FIREBASE_APP_ID', 'GOOGLE_PLAY_STATUS', 'GOOGLE_PLAY_TRACK',
|
|
28
29
|
'GOOGLE_PLAY_USER_FRACTION', 'JAVA_VERSION', 'ONBOARDING_PUBLIC_ORIGIN',
|
|
29
30
|
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.148
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.148
|
|
@@ -53,6 +53,26 @@ inputs:
|
|
|
53
53
|
description: HTTP path the service answers 2xx on once healthy.
|
|
54
54
|
required: false
|
|
55
55
|
default: "/health"
|
|
56
|
+
public-health-url:
|
|
57
|
+
description: App-owned HTTPS edge health URL; empty preserves the infrastructure health probe.
|
|
58
|
+
required: false
|
|
59
|
+
default: ""
|
|
60
|
+
public-base-url:
|
|
61
|
+
description: Canonical client API base supplied as BACKEND_PUBLIC_BASE_URL to Compose interpolation.
|
|
62
|
+
required: false
|
|
63
|
+
default: ""
|
|
64
|
+
expected-build-sha:
|
|
65
|
+
description: Exact 40-character lowercase commit SHA required in health JSON build_sha.
|
|
66
|
+
required: false
|
|
67
|
+
default: ""
|
|
68
|
+
ingress-required:
|
|
69
|
+
description: Protect a distinct edge-only infrastructure vhost, retaining an existing legacy vhost.
|
|
70
|
+
required: false
|
|
71
|
+
default: "false"
|
|
72
|
+
ingress-token:
|
|
73
|
+
description: Backend-only per-app token sent by the edge in X-App-Robot-Backend-Token.
|
|
74
|
+
required: false
|
|
75
|
+
default: ""
|
|
56
76
|
cert-email:
|
|
57
77
|
description: Email for the Let's Encrypt registration.
|
|
58
78
|
required: false
|
|
@@ -82,10 +102,16 @@ runs:
|
|
|
82
102
|
env:
|
|
83
103
|
BACKEND_DIR: ${{ inputs.backend-dir }}
|
|
84
104
|
COMPOSE_FILE_INPUT: ${{ inputs.compose-file }}
|
|
105
|
+
BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
|
|
106
|
+
BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
|
|
107
|
+
BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
|
|
108
|
+
BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
|
|
109
|
+
BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
|
|
85
110
|
run: |
|
|
86
111
|
set -euo pipefail
|
|
87
112
|
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
88
113
|
BACKEND_DEPLOY_PHASE=validate_inputs
|
|
114
|
+
python3 "${{ github.action_path }}/edge_inputs.py" check
|
|
89
115
|
COMPOSE_FILE="${COMPOSE_FILE_INPUT:-$BACKEND_DIR/docker-compose.yml}"
|
|
90
116
|
test -d "$BACKEND_DIR" \
|
|
91
117
|
|| { echo "::error::no backend directory $BACKEND_DIR"; exit 1; }
|
|
@@ -138,6 +164,7 @@ runs:
|
|
|
138
164
|
# (generated DB password, etc.) survive across deploys.
|
|
139
165
|
if [ "$COMPOSE_FILE" = "$BACKEND_DIR/docker-compose.yml" ]; then
|
|
140
166
|
rsync -az --delete --exclude '.env' --exclude '.git' \
|
|
167
|
+
--exclude '.backend-edge.json' --exclude '.backend-ingress.conf' --exclude '.backend-public.env' \
|
|
141
168
|
-e "$SSH" "$BACKEND_DIR/" \
|
|
142
169
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
143
170
|
else
|
|
@@ -156,6 +183,9 @@ runs:
|
|
|
156
183
|
"${{ github.action_path }}/select_certbot_account.sh" \
|
|
157
184
|
"${{ github.action_path }}/ensure_certificate.sh" \
|
|
158
185
|
"${{ github.action_path }}/configure_vhost.py" \
|
|
186
|
+
"${{ github.action_path }}/edge_inputs.py" \
|
|
187
|
+
"${{ github.action_path }}/edge_vhost.py" \
|
|
188
|
+
"${{ github.action_path }}/edge_health.py" \
|
|
159
189
|
"${{ github.action_path }}/deploy_evidence.sh" \
|
|
160
190
|
"${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
|
|
161
191
|
|
|
@@ -164,12 +194,21 @@ runs:
|
|
|
164
194
|
shell: bash
|
|
165
195
|
env:
|
|
166
196
|
RUNTIME_ENV: ${{ inputs.runtime-env }}
|
|
197
|
+
BACKEND_PUBLIC_HEALTH_URL: ${{ inputs.public-health-url }}
|
|
198
|
+
BACKEND_PUBLIC_BASE_URL: ${{ inputs.public-base-url }}
|
|
199
|
+
BACKEND_EXPECTED_BUILD_SHA: ${{ inputs.expected-build-sha }}
|
|
200
|
+
BACKEND_INGRESS_REQUIRED: ${{ inputs.ingress-required }}
|
|
201
|
+
BACKEND_INGRESS_TOKEN: ${{ inputs.ingress-token }}
|
|
167
202
|
run: |
|
|
168
203
|
set -euo pipefail
|
|
169
204
|
source "${{ github.action_path }}/deploy_evidence.sh"
|
|
170
205
|
BACKEND_DEPLOY_PHASE=write_runtime_env
|
|
171
206
|
SSH="ssh -i ~/.ssh/backend_deploy -o IdentitiesOnly=yes"
|
|
172
207
|
DEST="/opt/gowalk-backends/${{ inputs.app-name }}"
|
|
208
|
+
EDGE_DIR="/opt/gowalk-backend-ingress/${{ inputs.app-name }}"
|
|
209
|
+
python3 "${{ github.action_path }}/edge_inputs.py" export | $SSH \
|
|
210
|
+
"${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
211
|
+
"umask 077; mkdir -p '$EDGE_DIR'; python3 '$DEST/edge_inputs.py' receive '$EDGE_DIR/.backend-edge.json'"
|
|
173
212
|
if [ -n "${RUNTIME_ENV:-}" ]; then
|
|
174
213
|
printf '%s\n' "$RUNTIME_ENV" | $SSH \
|
|
175
214
|
"${{ inputs.ssh-user }}@${{ inputs.host }}" \
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Verify private-origin refusal, retained aliases and the public edge's exact build."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from edge_inputs import load, TOKEN_HEADER
|
|
10
|
+
from edge_vhost import NGINX_DIR, paths, recorded_domain
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def request(url, *, headers=None, run=subprocess.run):
|
|
14
|
+
# curl retains its strict TLS/time bounds. Private headers and bounded response
|
|
15
|
+
# bytes live only in an owned mode-0700 directory, never argv or diagnostics.
|
|
16
|
+
with tempfile.TemporaryDirectory(prefix="backend-edge-health-") as directory:
|
|
17
|
+
root = Path(directory)
|
|
18
|
+
config, body = root / "curl.conf", root / "body"
|
|
19
|
+
config.write_text("".join(f'header = "{key}: {value}"\n' for key, value in (headers or {}).items()))
|
|
20
|
+
config.chmod(0o600)
|
|
21
|
+
result = run(["curl", "--disable", "--silent", "--config", str(config), "--output", str(body),
|
|
22
|
+
"--write-out", "%{http_code}", "--proto", "=https", "--connect-timeout", "5",
|
|
23
|
+
"--max-time", "10", "--max-filesize", "65536", url],
|
|
24
|
+
capture_output=True, timeout=15)
|
|
25
|
+
status = result.stdout.decode("ascii", errors="replace")
|
|
26
|
+
data = body.read_bytes() if body.exists() and body.stat().st_size <= 65536 else b""
|
|
27
|
+
return result.returncode, int(status) if status.isdigit() else 0, data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def healthy(url, sha="", *, headers=None, fetch=request, sleep=time.sleep):
|
|
31
|
+
for attempt in range(15):
|
|
32
|
+
try:
|
|
33
|
+
code, status, body = fetch(url, headers=headers)
|
|
34
|
+
valid = code == 0 and 200 <= status < 300
|
|
35
|
+
if valid and sha:
|
|
36
|
+
value = json.loads(body)
|
|
37
|
+
valid = isinstance(value, dict) and value.get("build_sha") == sha
|
|
38
|
+
if valid:
|
|
39
|
+
return
|
|
40
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
41
|
+
pass
|
|
42
|
+
if attempt != 14:
|
|
43
|
+
sleep(2)
|
|
44
|
+
raise ValueError("backend_public_health_failed")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify(config, app, domain, health, *, nginx_dir=NGINX_DIR, fetch=request, sleep=time.sleep):
|
|
48
|
+
value = load(config)
|
|
49
|
+
if not health.startswith("/") or any(character in health for character in "\r\n?#"):
|
|
50
|
+
raise ValueError("backend_health_path_invalid")
|
|
51
|
+
origin_url = f"https://{domain}{health}"
|
|
52
|
+
if value["ingress_required"] == "true":
|
|
53
|
+
for headers in (None, {TOKEN_HEADER: "invalid-backend-ingress-token"}):
|
|
54
|
+
code, status, _body = fetch(origin_url, headers=headers)
|
|
55
|
+
if code != 0 or status != 403:
|
|
56
|
+
raise ValueError("backend_origin_ingress_unverified")
|
|
57
|
+
healthy(origin_url, value["expected_build_sha"], headers={TOKEN_HEADER: value["ingress_token"]},
|
|
58
|
+
fetch=fetch, sleep=sleep)
|
|
59
|
+
legacy = recorded_domain(paths(config, app, nginx_dir)[0])
|
|
60
|
+
if legacy:
|
|
61
|
+
healthy(f"https://{legacy}{health}", fetch=fetch, sleep=sleep)
|
|
62
|
+
public = value["public_health_url"] or origin_url
|
|
63
|
+
healthy(public, value["expected_build_sha"], fetch=fetch, sleep=sleep)
|
|
64
|
+
return {"ok": True, "public_health_verified": True, "build_sha": value["expected_build_sha"],
|
|
65
|
+
"ingress_verified": value["ingress_required"] == "true"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
try:
|
|
70
|
+
print(json.dumps(verify(*sys.argv[1:5])))
|
|
71
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
72
|
+
sys.exit("backend_edge_health_failed")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Validate optional edge delivery inputs without exposing credentials or response bodies."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
CONFIG_NAME = ".backend-edge.json"
|
|
11
|
+
TOKEN_HEADER = "X-App-Robot-Backend-Token"
|
|
12
|
+
FIELDS = ("public_health_url", "public_base_url", "expected_build_sha", "ingress_required", "ingress_token")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(value):
|
|
16
|
+
if not isinstance(value, dict) or set(value) != set(FIELDS):
|
|
17
|
+
raise ValueError("backend_edge_inputs_invalid")
|
|
18
|
+
if any(not isinstance(value[key], str) for key in FIELDS):
|
|
19
|
+
raise ValueError("backend_edge_inputs_invalid")
|
|
20
|
+
public, base, sha, required, token = (value[key] for key in FIELDS)
|
|
21
|
+
if required not in {"true", "false"}:
|
|
22
|
+
raise ValueError("backend_ingress_required_invalid")
|
|
23
|
+
if sha and not re.fullmatch(r"[a-f0-9]{40}", sha):
|
|
24
|
+
raise ValueError("backend_build_sha_invalid")
|
|
25
|
+
if public:
|
|
26
|
+
parsed = urlsplit(public)
|
|
27
|
+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
|
|
28
|
+
or parsed.query or parsed.fragment or re.search(r"[\s\\]", public) or not sha):
|
|
29
|
+
raise ValueError("backend_public_health_invalid")
|
|
30
|
+
if parsed.port not in {None, 443}:
|
|
31
|
+
raise ValueError("backend_public_health_invalid")
|
|
32
|
+
if base:
|
|
33
|
+
parsed = urlsplit(base)
|
|
34
|
+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
|
|
35
|
+
or parsed.query or parsed.fragment or parsed.port not in {None, 443}
|
|
36
|
+
or not re.fullmatch(r"[a-zA-Z0-9._~:/%-]+", base) or base.endswith("/")):
|
|
37
|
+
raise ValueError("backend_public_base_invalid")
|
|
38
|
+
if public and not public.startswith(base + "/"):
|
|
39
|
+
raise ValueError("backend_public_health_base_mismatch")
|
|
40
|
+
if required == "true" and (not public or not base or not re.fullmatch(r"[a-zA-Z0-9_-]{43,128}", token)):
|
|
41
|
+
raise ValueError("backend_ingress_configuration_required")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def environment():
|
|
46
|
+
return validate({key: os.environ.get("BACKEND_" + key.upper(), "false" if key == "ingress_required" else "")
|
|
47
|
+
for key in FIELDS})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(path):
|
|
51
|
+
path = Path(path)
|
|
52
|
+
if path.is_symlink():
|
|
53
|
+
raise ValueError("backend_edge_config_symlink")
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return validate(dict(zip(FIELDS, ("", "", "", "false", ""))))
|
|
56
|
+
if path.stat().st_size > 8192:
|
|
57
|
+
raise ValueError("backend_edge_config_invalid")
|
|
58
|
+
return validate(json.loads(path.read_text()))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def mode(value):
|
|
62
|
+
if value["ingress_required"] == "true":
|
|
63
|
+
return "edge"
|
|
64
|
+
return "verify" if value["public_health_url"] or value["expected_build_sha"] else "legacy"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def runtime(path):
|
|
68
|
+
value = load(path)["public_base_url"]
|
|
69
|
+
if value:
|
|
70
|
+
target = Path(path).parent / ".backend-public.env"
|
|
71
|
+
if target.is_symlink():
|
|
72
|
+
raise ValueError("backend_public_env_symlink")
|
|
73
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
|
74
|
+
with os.fdopen(descriptor, "w") as output:
|
|
75
|
+
os.fchmod(output.fileno(), 0o600)
|
|
76
|
+
output.write("BACKEND_PUBLIC_BASE_URL=" + json.dumps(value) + "\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def receive(path):
|
|
80
|
+
target = Path(path)
|
|
81
|
+
value = validate(json.loads(sys.stdin.buffer.read(8193)))
|
|
82
|
+
if target.is_symlink() or target.parent.is_symlink():
|
|
83
|
+
raise ValueError("backend_edge_config_symlink")
|
|
84
|
+
descriptor, temporary = tempfile.mkstemp(prefix=target.name + ".", dir=target.parent)
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(descriptor, "w") as output:
|
|
87
|
+
os.fchmod(output.fileno(), 0o600)
|
|
88
|
+
json.dump(value, output)
|
|
89
|
+
output.flush()
|
|
90
|
+
os.fsync(output.fileno())
|
|
91
|
+
os.replace(temporary, target)
|
|
92
|
+
finally:
|
|
93
|
+
Path(temporary).unlink(missing_ok=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main():
|
|
97
|
+
if sys.argv[1] == "check":
|
|
98
|
+
environment()
|
|
99
|
+
elif sys.argv[1] == "export":
|
|
100
|
+
print(json.dumps(environment()))
|
|
101
|
+
elif sys.argv[1] == "mode":
|
|
102
|
+
print(mode(load(sys.argv[2])))
|
|
103
|
+
elif sys.argv[1] == "runtime":
|
|
104
|
+
runtime(sys.argv[2])
|
|
105
|
+
elif sys.argv[1] == "receive":
|
|
106
|
+
receive(sys.argv[2])
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError("backend_edge_operation_invalid")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
try:
|
|
113
|
+
main()
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
sys.exit("backend_edge_configuration_invalid")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Protect a distinct edge vhost while retaining an app's released direct-host address."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from configure_vhost import content
|
|
11
|
+
from edge_inputs import load
|
|
12
|
+
|
|
13
|
+
NGINX_DIR = Path("/etc/nginx/conf.d")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def paths(config, app, nginx_dir=NGINX_DIR):
|
|
17
|
+
if not re.fullmatch(r"[a-z0-9-]+", app):
|
|
18
|
+
raise ValueError("backend_app_invalid")
|
|
19
|
+
return (nginx_dir / f"backend-{app}.conf", nginx_dir / f"backend-{app}-edge.conf",
|
|
20
|
+
Path(config).parent / ".backend-ingress.conf")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def previous(path):
|
|
24
|
+
if path.is_symlink():
|
|
25
|
+
raise ValueError("backend_vhost_symlink")
|
|
26
|
+
return path.read_text() if path.exists() else ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def recorded_domain(path):
|
|
30
|
+
text = previous(path)
|
|
31
|
+
if not text:
|
|
32
|
+
return ""
|
|
33
|
+
names = set(re.findall(r"(?m)^\s*server_name\s+([^;]+);", text))
|
|
34
|
+
if len(names) != 1:
|
|
35
|
+
raise ValueError("backend_vhost_identity_invalid")
|
|
36
|
+
domain = names.pop().strip()
|
|
37
|
+
content(text, domain, 1)
|
|
38
|
+
return domain
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate(config, app, domain, nginx_dir=NGINX_DIR):
|
|
42
|
+
value = load(config)
|
|
43
|
+
legacy, edge, include = paths(config, app, nginx_dir)
|
|
44
|
+
protected = bool(previous(edge) or previous(include))
|
|
45
|
+
if value["ingress_required"] != "true":
|
|
46
|
+
if protected:
|
|
47
|
+
raise ValueError("backend_ingress_downgrade_refused")
|
|
48
|
+
return value
|
|
49
|
+
content("", domain, 1)
|
|
50
|
+
old_domain = recorded_domain(legacy)
|
|
51
|
+
if old_domain == domain:
|
|
52
|
+
raise ValueError("backend_edge_domain_must_be_distinct")
|
|
53
|
+
if previous(edge) and recorded_domain(edge) != domain:
|
|
54
|
+
raise ValueError("backend_edge_domain_changed")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def edge_content(text, domain, port, include):
|
|
59
|
+
updated = content(text, domain, port)
|
|
60
|
+
directive = f" include {include};\n"
|
|
61
|
+
if re.search(r"[\s;{}]", str(include)):
|
|
62
|
+
raise ValueError("backend_ingress_path_invalid")
|
|
63
|
+
if updated.count(directive) == 1:
|
|
64
|
+
return updated
|
|
65
|
+
if ".backend-ingress.conf" in updated or len(re.findall(r"\bproxy_pass\b", updated)) != 1:
|
|
66
|
+
raise ValueError("backend_edge_vhost_invalid")
|
|
67
|
+
pattern = r"(?m)^(\s*location / \{\n)"
|
|
68
|
+
updated, count = re.subn(pattern, lambda match: match[1] + directive, updated)
|
|
69
|
+
if count != 1:
|
|
70
|
+
raise ValueError("backend_edge_location_invalid")
|
|
71
|
+
return updated
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def replace(path, data, mode):
|
|
75
|
+
fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
|
|
76
|
+
try:
|
|
77
|
+
with os.fdopen(fd, "wb") as output:
|
|
78
|
+
os.fchmod(output.fileno(), mode)
|
|
79
|
+
output.write(data)
|
|
80
|
+
output.flush()
|
|
81
|
+
os.fsync(output.fileno())
|
|
82
|
+
os.replace(temporary, path)
|
|
83
|
+
finally:
|
|
84
|
+
Path(temporary).unlink(missing_ok=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def planned(config, app, domain, port, nginx_dir=NGINX_DIR):
|
|
88
|
+
value = validate(config, app, domain, nginx_dir)
|
|
89
|
+
if value["ingress_required"] != "true":
|
|
90
|
+
raise ValueError("backend_ingress_configuration_required")
|
|
91
|
+
legacy, edge, include = paths(config, app, nginx_dir)
|
|
92
|
+
token = value["ingress_token"]
|
|
93
|
+
guard = (f'if ($http_x_app_robot_backend_token != "{token}") {{ return 403; }}\n'
|
|
94
|
+
'proxy_set_header X-App-Robot-Backend-Token "";\n')
|
|
95
|
+
files = {include: (guard.encode(), 0o600),
|
|
96
|
+
edge: (edge_content(previous(edge), domain, port, include).encode(), 0o644)}
|
|
97
|
+
old_domain = recorded_domain(legacy)
|
|
98
|
+
if old_domain:
|
|
99
|
+
files[legacy] = (content(previous(legacy), old_domain, port).encode(),
|
|
100
|
+
stat.S_IMODE(legacy.stat().st_mode))
|
|
101
|
+
return files
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def configure(config, app, domain, port, nginx_dir=NGINX_DIR, run=subprocess.run):
|
|
105
|
+
files = planned(config, app, domain, port, nginx_dir)
|
|
106
|
+
saved = {path: ((path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) if path.exists() else None)
|
|
107
|
+
for path in files}
|
|
108
|
+
changes = {path: value for path, value in files.items() if saved[path] != value}
|
|
109
|
+
if not changes:
|
|
110
|
+
return "unchanged"
|
|
111
|
+
try:
|
|
112
|
+
for path, (data, mode) in changes.items():
|
|
113
|
+
replace(path, data, mode)
|
|
114
|
+
run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
|
|
115
|
+
run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
|
|
116
|
+
except (OSError, subprocess.SubprocessError):
|
|
117
|
+
for path in changes:
|
|
118
|
+
if saved[path] is None:
|
|
119
|
+
path.unlink(missing_ok=True)
|
|
120
|
+
else:
|
|
121
|
+
replace(path, *saved[path])
|
|
122
|
+
raise ValueError("backend_edge_nginx_failed") from None
|
|
123
|
+
return "changed"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
operation, config, app, domain = sys.argv[1:5]
|
|
128
|
+
if operation == "validate":
|
|
129
|
+
validate(config, app, domain)
|
|
130
|
+
elif operation == "configure":
|
|
131
|
+
print(configure(config, app, domain, int(sys.argv[5])))
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError("backend_edge_operation_invalid")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
try:
|
|
138
|
+
main()
|
|
139
|
+
except (OSError, ValueError):
|
|
140
|
+
sys.exit("backend_edge_vhost_refused")
|
|
@@ -17,6 +17,15 @@ source "$DIR/ensure_certificate.sh"
|
|
|
17
17
|
|
|
18
18
|
log() { echo "[deploy $APP] $*"; }
|
|
19
19
|
|
|
20
|
+
# Refuse a missing ingress token or an older unprotected workflow before Compose changes.
|
|
21
|
+
EDGE_CONFIG="/opt/gowalk-backend-ingress/$APP/.backend-edge.json"
|
|
22
|
+
EDGE_MODE=legacy
|
|
23
|
+
if [ -f "$EDGE_CONFIG" ] || [ -e "/etc/nginx/conf.d/backend-$APP-edge.conf" ]; then
|
|
24
|
+
python3 "$DIR/edge_vhost.py" validate "$EDGE_CONFIG" "$APP" "$DOMAIN"
|
|
25
|
+
EDGE_MODE="$(python3 "$DIR/edge_inputs.py" mode "$EDGE_CONFIG")"
|
|
26
|
+
python3 "$DIR/edge_inputs.py" runtime "$EDGE_CONFIG"
|
|
27
|
+
fi
|
|
28
|
+
|
|
20
29
|
# ── secrets: generate a per-app .env once, then preserve it (rsync excludes it)
|
|
21
30
|
BACKEND_DEPLOY_PHASE=initialize_environment
|
|
22
31
|
if [ ! -f .env ]; then
|
|
@@ -33,6 +42,10 @@ if [ -s .runtime.env ]; then
|
|
|
33
42
|
chmod 600 .runtime.env
|
|
34
43
|
COMPOSE_ENV+=(--env-file .runtime.env)
|
|
35
44
|
fi
|
|
45
|
+
EDGE_PUBLIC="$(dirname "$EDGE_CONFIG")/.backend-public.env"
|
|
46
|
+
if [ -s "$EDGE_PUBLIC" ]; then
|
|
47
|
+
COMPOSE_ENV+=(--env-file "$EDGE_PUBLIC")
|
|
48
|
+
fi
|
|
36
49
|
|
|
37
50
|
# ── bring the stack up
|
|
38
51
|
BACKEND_DEPLOY_PHASE=compose_up
|
|
@@ -80,10 +93,14 @@ fi
|
|
|
80
93
|
# ── nginx vhost → proxy the domain to the published port
|
|
81
94
|
BACKEND_DEPLOY_PHASE=configure_vhost
|
|
82
95
|
VHOST="/etc/nginx/conf.d/backend-$APP.conf"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
if [ "${EDGE_MODE:-legacy}" = edge ]; then
|
|
97
|
+
python3 "$DIR/edge_vhost.py" configure "$EDGE_CONFIG" "$APP" "$DOMAIN" "$PORT"
|
|
98
|
+
else
|
|
99
|
+
VHOST_STATE="$(python3 "$DIR/configure_vhost.py" "$VHOST" "$DOMAIN" "$PORT")"
|
|
100
|
+
if [ "$VHOST_STATE" = changed ]; then
|
|
101
|
+
log "updated nginx upstream for $DOMAIN → 127.0.0.1:$PORT"
|
|
102
|
+
nginx -t && systemctl reload nginx
|
|
103
|
+
fi
|
|
87
104
|
fi
|
|
88
105
|
|
|
89
106
|
# ── Let's Encrypt cert: failed inventory is never absence; only lock contention retries.
|
|
@@ -99,6 +116,11 @@ fi
|
|
|
99
116
|
# ── public health-check. This validates DNS, the certificate, nginx routing,
|
|
100
117
|
# and the application together; a green deploy must mean the public API works.
|
|
101
118
|
BACKEND_DEPLOY_PHASE=public_health
|
|
119
|
+
if [ "$EDGE_MODE" != legacy ]; then
|
|
120
|
+
python3 "$DIR/edge_health.py" "$EDGE_CONFIG" "$APP" "$DOMAIN" "$HEALTH"
|
|
121
|
+
log "done"
|
|
122
|
+
exit 0
|
|
123
|
+
fi
|
|
102
124
|
PUBLIC_HEALTHY=""
|
|
103
125
|
for i in $(seq 1 15); do
|
|
104
126
|
CURL_EXIT=0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import configure_vhost
|
|
8
|
+
import edge_health
|
|
9
|
+
from test_edge_inputs import configured
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Health(unittest.TestCase):
|
|
13
|
+
def test_exact_edge_build_and_legacy_access_with_no_client_ingress_token(self):
|
|
14
|
+
calls = []
|
|
15
|
+
|
|
16
|
+
def fetch(url, *, headers=None):
|
|
17
|
+
calls.append((url, headers))
|
|
18
|
+
if url.startswith("https://edge.example") and (headers or {}).get(
|
|
19
|
+
edge_health.TOKEN_HEADER) != configured()["ingress_token"]:
|
|
20
|
+
return 0, 403, b""
|
|
21
|
+
return 0, 200, json.dumps({"build_sha": "a" * 40}).encode()
|
|
22
|
+
|
|
23
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
24
|
+
root = Path(directory)
|
|
25
|
+
config = root / ".backend-edge.json"
|
|
26
|
+
config.write_text(json.dumps(configured()))
|
|
27
|
+
(root / "backend-fixture.conf").write_text(configure_vhost.content("", "legacy.example", 32000))
|
|
28
|
+
result = edge_health.verify(config, "fixture", "edge.example", "/healthz",
|
|
29
|
+
nginx_dir=root, fetch=fetch)
|
|
30
|
+
self.assertTrue(result["ok"])
|
|
31
|
+
self.assertEqual(calls[-2:], [("https://legacy.example/healthz", None),
|
|
32
|
+
("https://app.pages.dev/api/healthz", None)])
|
|
33
|
+
self.assertEqual(len(calls), 5)
|
|
34
|
+
|
|
35
|
+
def test_redirect_partial_response_missing_build_and_mismatch_fail(self):
|
|
36
|
+
cases = [(0, 302, b""), (28, 200, b'{"build_sha":"' + b"a" * 40 + b'"}'),
|
|
37
|
+
(0, 200, b"{}"), (0, 200, b'{"build_sha":"wrong"}'), (0, 200, b"invalid")]
|
|
38
|
+
for response in cases:
|
|
39
|
+
calls = []
|
|
40
|
+
|
|
41
|
+
def fetch(url, **kwargs):
|
|
42
|
+
calls.append(url)
|
|
43
|
+
return response
|
|
44
|
+
|
|
45
|
+
with self.subTest(response=response), self.assertRaises(ValueError):
|
|
46
|
+
edge_health.healthy("https://app.pages.dev/api/healthz", "a" * 40,
|
|
47
|
+
fetch=fetch, sleep=lambda _seconds: None)
|
|
48
|
+
self.assertEqual(len(calls), 15)
|
|
49
|
+
|
|
50
|
+
def test_origin_that_accepts_a_missing_token_fails_before_public_probe(self):
|
|
51
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
52
|
+
config = Path(directory) / ".backend-edge.json"
|
|
53
|
+
config.write_text(json.dumps(configured()))
|
|
54
|
+
with self.assertRaisesRegex(ValueError, "ingress_unverified"):
|
|
55
|
+
edge_health.verify(config, "fixture", "edge.example", "/healthz",
|
|
56
|
+
fetch=lambda *args, **kwargs: (0, 200, b"{}"))
|
|
57
|
+
|
|
58
|
+
def test_curl_keeps_token_out_of_argv_and_deletes_private_temporary_files(self):
|
|
59
|
+
retained = []
|
|
60
|
+
|
|
61
|
+
def run(command, **kwargs):
|
|
62
|
+
self.assertNotIn(configured()["ingress_token"], str(command))
|
|
63
|
+
config = Path(command[command.index("--config") + 1])
|
|
64
|
+
retained.append(config.parent)
|
|
65
|
+
self.assertEqual(config.stat().st_mode & 0o777, 0o600)
|
|
66
|
+
self.assertIn(configured()["ingress_token"], config.read_text())
|
|
67
|
+
for unsafe in ("--insecure", "-k", "--location", "-L"):
|
|
68
|
+
self.assertNotIn(unsafe, command)
|
|
69
|
+
Path(command[command.index("--output") + 1]).write_text('{"build_sha":"' + "a" * 40 + '"}')
|
|
70
|
+
return subprocess.CompletedProcess(command, 0, stdout=b"200", stderr=b"")
|
|
71
|
+
|
|
72
|
+
result = edge_health.request("https://edge.example/healthz",
|
|
73
|
+
headers={edge_health.TOKEN_HEADER: configured()["ingress_token"]}, run=run)
|
|
74
|
+
self.assertEqual(result[:2], (0, 200))
|
|
75
|
+
self.assertFalse(retained[0].exists())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
import edge_inputs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configured(**changes):
|
|
13
|
+
return {"public_health_url": "https://app.pages.dev/api/healthz",
|
|
14
|
+
"public_base_url": "https://app.pages.dev/api", "expected_build_sha": "a" * 40,
|
|
15
|
+
"ingress_required": "true", "ingress_token": "synthetic-token-" + "a" * 43, **changes}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Inputs(unittest.TestCase):
|
|
19
|
+
def test_edge_and_legacy_configurations(self):
|
|
20
|
+
self.assertEqual(edge_inputs.mode(edge_inputs.validate(configured())), "edge")
|
|
21
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
22
|
+
value = edge_inputs.load(Path(directory) / "absent")
|
|
23
|
+
self.assertEqual(edge_inputs.mode(value), "legacy")
|
|
24
|
+
self.assertEqual(edge_inputs.mode(configured(ingress_required="false")), "verify")
|
|
25
|
+
|
|
26
|
+
def test_invalid_inputs_refuse_without_credential_output(self):
|
|
27
|
+
cases = [{"ingress_token": ""}, {"ingress_token": 'private\"; return 200;'},
|
|
28
|
+
{"ingress_required": "yes"}, {"expected_build_sha": "A" * 40},
|
|
29
|
+
{"public_health_url": "http://app.pages.dev/api/healthz"},
|
|
30
|
+
{"public_health_url": "https://other.pages.dev/api/healthz"},
|
|
31
|
+
{"public_base_url": "https://app.pages.dev/api/$TOKEN"},
|
|
32
|
+
{"public_base_url": ""}, {"expected_build_sha": ""},
|
|
33
|
+
{"public_health_url": "https://user:private@app.pages.dev/api/healthz"}]
|
|
34
|
+
for changes in cases:
|
|
35
|
+
with self.subTest(changes=changes), self.assertRaises(ValueError):
|
|
36
|
+
edge_inputs.validate(configured(**changes))
|
|
37
|
+
env = {"BACKEND_" + key.upper(): value for key, value in configured(ingress_token="private").items()}
|
|
38
|
+
result = subprocess.run([sys.executable, str(Path(edge_inputs.__file__)), "check"],
|
|
39
|
+
env={**os.environ, **env}, capture_output=True, text=True)
|
|
40
|
+
self.assertNotEqual(result.returncode, 0)
|
|
41
|
+
self.assertNotIn("private", result.stdout + result.stderr)
|
|
42
|
+
|
|
43
|
+
def test_runtime_input_is_private_and_separate_from_secret_dotenv(self):
|
|
44
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
45
|
+
root = Path(directory)
|
|
46
|
+
config = root / edge_inputs.CONFIG_NAME
|
|
47
|
+
config.write_text(json.dumps(configured()))
|
|
48
|
+
secrets = root / ".runtime.env"
|
|
49
|
+
secrets.write_text("PASSWORD=synthetic-private\n")
|
|
50
|
+
edge_inputs.runtime(config)
|
|
51
|
+
output = root / ".backend-public.env"
|
|
52
|
+
self.assertEqual(output.read_text(), 'BACKEND_PUBLIC_BASE_URL="https://app.pages.dev/api"\n')
|
|
53
|
+
self.assertEqual(output.stat().st_mode & 0o777, 0o600)
|
|
54
|
+
self.assertEqual(secrets.read_text(), "PASSWORD=synthetic-private\n")
|
|
55
|
+
edge_inputs.runtime(config)
|
|
56
|
+
output.unlink()
|
|
57
|
+
output.symlink_to(secrets)
|
|
58
|
+
with self.assertRaises(ValueError):
|
|
59
|
+
edge_inputs.runtime(config)
|
|
60
|
+
self.assertEqual(secrets.read_text(), "PASSWORD=synthetic-private\n")
|
|
61
|
+
|
|
62
|
+
def test_private_input_receive_is_atomic_and_refuses_symlinks(self):
|
|
63
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
64
|
+
path = Path(directory) / ".backend-edge.json"
|
|
65
|
+
command = [sys.executable, str(Path(edge_inputs.__file__)), "receive", str(path)]
|
|
66
|
+
value = json.dumps(configured())
|
|
67
|
+
result = subprocess.run(command, input=value, capture_output=True, text=True)
|
|
68
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
69
|
+
self.assertEqual(result.stdout, "")
|
|
70
|
+
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
|
71
|
+
invalid = subprocess.run(command, input="broken", capture_output=True, text=True)
|
|
72
|
+
self.assertNotEqual(invalid.returncode, 0)
|
|
73
|
+
self.assertEqual(json.loads(path.read_text()), configured())
|
|
74
|
+
other = Path(directory) / "preserved"
|
|
75
|
+
path.rename(other)
|
|
76
|
+
path.symlink_to(other)
|
|
77
|
+
refused = subprocess.run(command, input=value, capture_output=True, text=True)
|
|
78
|
+
self.assertNotEqual(refused.returncode, 0)
|
|
79
|
+
self.assertEqual(json.loads(other.read_text()), configured())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Exercise the generated ingress config with an owned nginx process and real HTTP."""
|
|
2
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
import configure_vhost
|
|
16
|
+
import edge_vhost
|
|
17
|
+
from test_edge_inputs import configured
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Backend(BaseHTTPRequestHandler):
|
|
21
|
+
def do_GET(self):
|
|
22
|
+
payload = json.dumps({"build_sha": "a" * 40, "path": self.path,
|
|
23
|
+
"ingress_received": "X-App-Robot-Backend-Token" in self.headers}).encode()
|
|
24
|
+
self.send_response(200)
|
|
25
|
+
self.send_header("Content-Type", "application/json")
|
|
26
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
27
|
+
self.end_headers()
|
|
28
|
+
self.wfile.write(payload)
|
|
29
|
+
|
|
30
|
+
def log_message(self, *_args):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stop(process):
|
|
35
|
+
process.terminate()
|
|
36
|
+
try:
|
|
37
|
+
process.communicate(timeout=5)
|
|
38
|
+
except subprocess.TimeoutExpired:
|
|
39
|
+
process.kill()
|
|
40
|
+
process.communicate(timeout=5)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Ingress(unittest.TestCase):
|
|
44
|
+
def setUp(self):
|
|
45
|
+
self.assertIsNotNone(shutil.which("nginx"), "nginx is required for the backend ingress contract")
|
|
46
|
+
directory = tempfile.TemporaryDirectory()
|
|
47
|
+
self.addCleanup(directory.cleanup)
|
|
48
|
+
self.root = Path(directory.name)
|
|
49
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Backend)
|
|
50
|
+
self.addCleanup(server.server_close)
|
|
51
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
52
|
+
thread.start()
|
|
53
|
+
self.addCleanup(server.shutdown)
|
|
54
|
+
with socket.socket() as listener:
|
|
55
|
+
listener.bind(("127.0.0.1", 0))
|
|
56
|
+
self.port = listener.getsockname()[1]
|
|
57
|
+
self.config = self.root / ".backend-edge.json"
|
|
58
|
+
self.config.write_text(json.dumps(configured()))
|
|
59
|
+
legacy = self.root / "backend-fixture.conf"
|
|
60
|
+
legacy.write_text(configure_vhost.content("", "legacy.example", server.server_port))
|
|
61
|
+
files = edge_vhost.planned(self.config, "fixture", "edge.example", server.server_port, self.root)
|
|
62
|
+
for path, (data, mode) in files.items():
|
|
63
|
+
path.write_bytes(data.replace(b"listen 80;", f"listen 127.0.0.1:{self.port};".encode()))
|
|
64
|
+
path.chmod(mode)
|
|
65
|
+
nginx = (f"pid {self.root}/nginx.pid;\nerror_log {self.root}/error.log;\n"
|
|
66
|
+
"events { worker_connections 64; }\nhttp { access_log off;\n"
|
|
67
|
+
f"include {self.root}/backend-fixture.conf;\n"
|
|
68
|
+
f"include {self.root}/backend-fixture-edge.conf;\n}}\n")
|
|
69
|
+
(self.root / "nginx.conf").write_text(nginx)
|
|
70
|
+
process = subprocess.Popen(["nginx", "-p", str(self.root) + "/", "-c", "nginx.conf",
|
|
71
|
+
"-g", "daemon off;"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
72
|
+
self.addCleanup(stop, process)
|
|
73
|
+
for _ in range(50):
|
|
74
|
+
try:
|
|
75
|
+
self.fetch("legacy.example")
|
|
76
|
+
break
|
|
77
|
+
except URLError:
|
|
78
|
+
self.assertIsNone(process.poll(), "owned nginx did not start")
|
|
79
|
+
time.sleep(0.05)
|
|
80
|
+
else:
|
|
81
|
+
self.fail("owned nginx did not become ready")
|
|
82
|
+
|
|
83
|
+
def fetch(self, host, token=None):
|
|
84
|
+
headers = {"Host": host}
|
|
85
|
+
if token is not None:
|
|
86
|
+
headers["X-App-Robot-Backend-Token"] = token
|
|
87
|
+
request = Request(f"http://127.0.0.1:{self.port}/healthz?probe=owned", headers=headers)
|
|
88
|
+
try:
|
|
89
|
+
with urlopen(request, timeout=2) as response:
|
|
90
|
+
return response.status, json.load(response)
|
|
91
|
+
except HTTPError as error:
|
|
92
|
+
with error:
|
|
93
|
+
return error.code, None
|
|
94
|
+
|
|
95
|
+
def test_real_nginx_rejects_missing_and_forged_tokens_and_retains_legacy_access(self):
|
|
96
|
+
self.assertEqual(self.fetch("edge.example"), (403, None))
|
|
97
|
+
self.assertEqual(self.fetch("edge.example", "wrong-token"), (403, None))
|
|
98
|
+
for host, token in (("edge.example", configured()["ingress_token"]), ("legacy.example", None)):
|
|
99
|
+
status, body = self.fetch(host, token)
|
|
100
|
+
self.assertEqual(status, 200)
|
|
101
|
+
self.assertEqual(body["build_sha"], "a" * 40)
|
|
102
|
+
self.assertEqual(body["path"], "/healthz?probe=owned")
|
|
103
|
+
self.assertFalse(body["ingress_received"])
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import configure_vhost
|
|
8
|
+
import edge_vhost
|
|
9
|
+
from test_edge_inputs import configured
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Vhosts(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.directory = tempfile.TemporaryDirectory()
|
|
15
|
+
self.addCleanup(self.directory.cleanup)
|
|
16
|
+
self.root = Path(self.directory.name)
|
|
17
|
+
self.config = self.root / ".backend-edge.json"
|
|
18
|
+
self.config.write_text(json.dumps(configured()))
|
|
19
|
+
self.legacy, self.edge, self.include = edge_vhost.paths(self.config, "fixture", self.root)
|
|
20
|
+
self.calls = []
|
|
21
|
+
|
|
22
|
+
def run_nginx(self, command, **kwargs):
|
|
23
|
+
self.calls.append(command)
|
|
24
|
+
|
|
25
|
+
def test_legacy_tls_port_changes_and_replay(self):
|
|
26
|
+
original = configure_vhost.content("", "legacy.example", 31000).replace(
|
|
27
|
+
"listen 80;", "listen 443 ssl;\n ssl_certificate /existing/cert;")
|
|
28
|
+
self.legacy.write_text(original)
|
|
29
|
+
self.legacy.chmod(0o640)
|
|
30
|
+
for port in (32000, 33000, 33000):
|
|
31
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", port, self.root, self.run_nginx)
|
|
32
|
+
self.assertEqual(self.legacy.read_text(), original.replace(":31000;", f":{port};"))
|
|
33
|
+
self.assertEqual(self.legacy.stat().st_mode & 0o777, 0o640)
|
|
34
|
+
self.assertIn(f":{port};", self.edge.read_text())
|
|
35
|
+
self.assertNotIn("backend-ingress", self.legacy.read_text())
|
|
36
|
+
self.assertEqual(self.calls, [["nginx", "-t"], ["systemctl", "reload", "nginx"]] * 2)
|
|
37
|
+
self.assertEqual(self.include.stat().st_mode & 0o777, 0o600)
|
|
38
|
+
self.assertNotIn(configured()["ingress_token"], self.edge.read_text())
|
|
39
|
+
|
|
40
|
+
def test_new_app_has_no_public_legacy_vhost_and_cannot_downgrade(self):
|
|
41
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, self.run_nginx)
|
|
42
|
+
self.assertFalse(self.legacy.exists())
|
|
43
|
+
self.config.write_text(json.dumps(configured(ingress_required="false", ingress_token="")))
|
|
44
|
+
with self.assertRaisesRegex(ValueError, "downgrade"):
|
|
45
|
+
edge_vhost.validate(self.config, "fixture", "edge.example", self.root)
|
|
46
|
+
self.config.unlink()
|
|
47
|
+
with self.assertRaisesRegex(ValueError, "downgrade"):
|
|
48
|
+
edge_vhost.validate(self.config, "fixture", "edge.example", self.root)
|
|
49
|
+
|
|
50
|
+
def test_domain_collision_and_ambiguous_legacy_refuse_without_changes(self):
|
|
51
|
+
for original in (configure_vhost.content("", "edge.example", 31000),
|
|
52
|
+
"server {\n server_name ambiguous.example another.example;\n}"):
|
|
53
|
+
self.legacy.write_text(original)
|
|
54
|
+
with self.assertRaises(ValueError):
|
|
55
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, self.run_nginx)
|
|
56
|
+
self.assertEqual(self.legacy.read_text(), original)
|
|
57
|
+
self.assertFalse(self.edge.exists())
|
|
58
|
+
self.assertFalse(self.include.exists())
|
|
59
|
+
self.assertEqual(self.calls, [])
|
|
60
|
+
|
|
61
|
+
def test_failed_nginx_check_restores_only_the_owned_files(self):
|
|
62
|
+
original = configure_vhost.content("", "legacy.example", 31000)
|
|
63
|
+
self.legacy.write_text(original)
|
|
64
|
+
|
|
65
|
+
def failure(command, **kwargs):
|
|
66
|
+
raise subprocess.CalledProcessError(1, command, stderr=b"private-output")
|
|
67
|
+
|
|
68
|
+
with self.assertRaisesRegex(ValueError, "backend_edge_nginx_failed"):
|
|
69
|
+
edge_vhost.configure(self.config, "fixture", "edge.example", 32000, self.root, failure)
|
|
70
|
+
self.assertEqual(self.legacy.read_text(), original)
|
|
71
|
+
self.assertFalse(self.edge.exists())
|
|
72
|
+
self.assertFalse(self.include.exists())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# App-owned Cloudflare API ingress
|
|
2
|
+
|
|
3
|
+
Provision the app's Pages/Workers route and its per-app ingress token before deploying the backend.
|
|
4
|
+
`BACKEND_API_DOMAIN` remains a distinct infrastructure hostname pointing to the deploy host; never
|
|
5
|
+
set it to a Pages/Workers hostname. Set these repository values for edge delivery:
|
|
6
|
+
|
|
7
|
+
- `BACKEND_PUBLIC_BASE_URL`: the client API base, for example `https://app.pages.dev/api`.
|
|
8
|
+
- `BACKEND_PUBLIC_HEALTH_URL`: that base plus the backend health path, for example `/healthz`.
|
|
9
|
+
- `BACKEND_INGRESS_REQUIRED`: `true`.
|
|
10
|
+
- Secret `BACKEND_INGRESS_TOKEN`: the same 43–128 character URL-safe token the edge sends in
|
|
11
|
+
`X-App-Robot-Backend-Token`; never ship it in clients or app source.
|
|
12
|
+
|
|
13
|
+
The workflow supplies the exact commit SHA, and the public health endpoint must return JSON
|
|
14
|
+
with `build_sha` equal to that SHA. A completed HTTPS 2xx is required; redirects, partial transfers,
|
|
15
|
+
missing identity and another build fail deployment. The same check rejects missing/incorrect tokens
|
|
16
|
+
at the infrastructure hostname, verifies authenticated origin health and probes the public URL
|
|
17
|
+
without passing the token. The edge must add it itself.
|
|
18
|
+
|
|
19
|
+
The action adds `BACKEND_PUBLIC_BASE_URL` to Compose interpolation through a separate host-side
|
|
20
|
+
dotenv file, after `.env` and `.runtime.env`. The app's Compose service must explicitly consume it:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
environment:
|
|
24
|
+
BACKEND_PUBLIC_BASE_URL: ${BACKEND_PUBLIC_BASE_URL:?required}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use that value for redirects and generated API links. Preserve the action's private runtime files;
|
|
28
|
+
the ingress token is transported on SSH stdin and used in a mode-0600 nginx include, not CLI arguments.
|
|
29
|
+
Ingress configuration and public dotenv live in `/opt/gowalk-backend-ingress/<app>/`, outside the
|
|
30
|
+
application's Docker build context; broad application COPY instructions cannot include the token.
|
|
31
|
+
The new protected vhost is `backend-<app>-edge.conf`. An existing `backend-<app>.conf` keeps its
|
|
32
|
+
legacy domain and TLS configuration, and both upstream ports follow Compose on every deployment.
|
|
33
|
+
The old public hostname is health-checked without authentication so released clients remain usable.
|
|
34
|
+
An edge alias matching the existing legacy hostname is refused; provision a new alias instead.
|
|
35
|
+
Missing ingress settings on an already protected app cannot reopen its origin.
|
|
36
|
+
|
|
37
|
+
These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
|
|
38
|
+
`public-health-url`, `expected-build-sha`, `ingress-required` and `ingress-token`. Reference secrets
|
|
39
|
+
by name. Deployments without these options retain their existing nginx/certificate/health behavior.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -19,6 +19,11 @@ mobile:
|
|
|
19
19
|
backend:
|
|
20
20
|
host: ${{ vars.BACKEND_DEPLOY_HOST }}
|
|
21
21
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
|
22
|
+
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
23
|
+
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
24
|
+
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
25
|
+
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
26
|
+
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
22
27
|
workflows:
|
|
23
28
|
deploy.yml:
|
|
24
29
|
overrides: []
|
|
@@ -30,6 +35,12 @@ until the app explicitly replaces their references in this file. Keep passwords,
|
|
|
30
35
|
signing material, tokens and private keys in GitHub Secrets. Reference their names;
|
|
31
36
|
never put their values in this configuration.
|
|
32
37
|
|
|
38
|
+
Backend edge settings are optional and preserve legacy deployments when absent. When enabled,
|
|
39
|
+
the infrastructure `api-domain` must be a fresh alias distinct from the retained legacy hostname.
|
|
40
|
+
Provision its Cloudflare route/token first. Public health proves the exact `build_sha`; the app's
|
|
41
|
+
Compose service must pass `BACKEND_PUBLIC_BASE_URL` into its container and use it in generated URLs.
|
|
42
|
+
The same named repository inputs reach the feature-branch backend preview in `deploy.yml`.
|
|
43
|
+
|
|
33
44
|
The supported managed files are `deploy.yml`, `mobile-candidate.yml`,
|
|
34
45
|
`deploy-web.yml` and `deploy-backend.yml`. App-specific values outside the named
|
|
35
46
|
mobile/backend inputs, and custom jobs or steps embedded in those workflows,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gowalk-cicd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.148",
|
|
4
4
|
"description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"CLAUDE.md",
|
|
24
24
|
"docs/GRADLE_CACHE.md",
|
|
25
25
|
"docs/CANDIDATE_ARTIFACTS.md",
|
|
26
|
+
"docs/BACKEND_EDGE.md",
|
|
26
27
|
"scripts/upgrade-org.mjs",
|
|
27
28
|
"scripts/lib/",
|
|
28
29
|
"docs/CONFIGURATION.md"
|
package/src/config/settings.mjs
CHANGED
|
@@ -8,7 +8,8 @@ const backend = ['jobs', 'deploy', 'steps', 'items', 'name:Deploy backend', 'wit
|
|
|
8
8
|
const inputs = {
|
|
9
9
|
ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id'],
|
|
10
10
|
android: ['project-dir', 'module', 'variant', 'firebase-app-id', 'dart-defines'],
|
|
11
|
-
backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env'
|
|
11
|
+
backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env',
|
|
12
|
+
'public-health-url', 'public-base-url', 'expected-build-sha', 'ingress-required', 'ingress-token'],
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export function settingsPaths(file) {
|
|
@@ -72,4 +72,9 @@ jobs:
|
|
|
72
72
|
backend-dir: ${{ steps.dir.outputs.path }}
|
|
73
73
|
compose-file: ${{ steps.dir.outputs.compose }}
|
|
74
74
|
health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
|
|
75
|
+
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
76
|
+
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
77
|
+
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
78
|
+
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
79
|
+
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
75
80
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
package/templates/deploy.yml
CHANGED
|
@@ -714,4 +714,9 @@ jobs:
|
|
|
714
714
|
backend-dir: ${{ steps.dir.outputs.path }}
|
|
715
715
|
compose-file: ${{ steps.dir.outputs.compose }}
|
|
716
716
|
health-path: ${{ vars.BACKEND_HEALTH_PATH || '/health' }}
|
|
717
|
+
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
718
|
+
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
719
|
+
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
720
|
+
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
721
|
+
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
717
722
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
|
Binary file
|