gowalk-cicd 1.0.148 → 1.0.149
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 +3 -1
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/configure_vhost.py +28 -5
- package/backend-action/nginx_test_fixture.py +98 -0
- package/backend-action/test_edge_nginx.py +50 -28
- package/docs/BACKEND_EDGE.md +7 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -249,7 +249,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
249
249
|
`backend-<app>-edge.conf`; preserve a legacy vhost's TLS/public access and update both managed
|
|
250
250
|
ports. A missing token/settings cannot downgrade an already protected app. Keep tokens in private
|
|
251
251
|
stdin/file transport, strip the ingress header before app forwarding, and validate real nginx in
|
|
252
|
-
focused tests.
|
|
252
|
+
focused tests. Managed locations forward WebSocket upgrades with a domain-scoped Connection map
|
|
253
|
+
and disable response buffering; verify a real frame exchange and a first SSE event before upstream
|
|
254
|
+
completion. Preserve legacy deployments that have not opted in and existing failure phases.
|
|
253
255
|
- The Android action serves two build systems. `android_config.project_kind()`
|
|
254
256
|
is the only place that decides which; every downstream step branches on the
|
|
255
257
|
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.149
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.149
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.149
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Update
|
|
1
|
+
"""Update managed nginx transport without discarding Certbot's HTTPS configuration."""
|
|
2
|
+
import hashlib
|
|
2
3
|
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
import re
|
|
@@ -7,6 +8,29 @@ import sys
|
|
|
7
8
|
import tempfile
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
def transport(text, domain):
|
|
12
|
+
variable = "$gowalk_backend_connection_" + hashlib.sha256(domain.encode()).hexdigest()[:16]
|
|
13
|
+
mapping = f"map $http_upgrade {variable} {{\n default upgrade;\n '' close;\n}}\n\n"
|
|
14
|
+
result = text.removeprefix(mapping)
|
|
15
|
+
directives = (r"proxy_http_version", r"proxy_set_header[ \t]+Upgrade",
|
|
16
|
+
r"proxy_set_header[ \t]+Connection", r"proxy_buffering")
|
|
17
|
+
for directive in directives:
|
|
18
|
+
pattern = rf"(?mi)^[ \t]*{directive}[ \t]+[^;\n]+;[^\n]*\n"
|
|
19
|
+
result, count = re.subn(pattern, "", result)
|
|
20
|
+
if count > 1:
|
|
21
|
+
raise ValueError("managed transport directive is not unique")
|
|
22
|
+
if variable in result:
|
|
23
|
+
raise ValueError("managed connection map is not recognized")
|
|
24
|
+
settings = ("proxy_http_version 1.1;", "proxy_set_header Upgrade $http_upgrade;",
|
|
25
|
+
f"proxy_set_header Connection {variable};", "proxy_buffering off;")
|
|
26
|
+
pattern = r"(?m)^([ \t]*)proxy_pass[ \t]+http://127\.0\.0\.1:\d+;[^\n]*$"
|
|
27
|
+
result, count = re.subn(pattern, lambda match: match[0] + "\n"
|
|
28
|
+
+ "\n".join(match[1] + setting for setting in settings), result)
|
|
29
|
+
if count != 1:
|
|
30
|
+
raise ValueError("managed upstream is not unique")
|
|
31
|
+
return mapping + result
|
|
32
|
+
|
|
33
|
+
|
|
10
34
|
def content(previous, domain, port):
|
|
11
35
|
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", domain) or not 0 < port < 65536:
|
|
12
36
|
raise ValueError("invalid domain or port")
|
|
@@ -18,14 +42,13 @@ def content(previous, domain, port):
|
|
|
18
42
|
result, count = re.subn(pattern, lambda match: f"{match[1]}{port}{match[2]}", previous)
|
|
19
43
|
if count != 1:
|
|
20
44
|
raise ValueError("managed upstream is not unique")
|
|
21
|
-
return result
|
|
22
|
-
return f"""server {{
|
|
45
|
+
return transport(result, domain)
|
|
46
|
+
return transport(f"""server {{
|
|
23
47
|
listen 80;
|
|
24
48
|
server_name {domain};
|
|
25
49
|
client_max_body_size 100m;
|
|
26
50
|
location / {{
|
|
27
51
|
proxy_pass http://127.0.0.1:{port};
|
|
28
|
-
proxy_http_version 1.1;
|
|
29
52
|
proxy_set_header Host $host;
|
|
30
53
|
proxy_set_header X-Real-IP $remote_addr;
|
|
31
54
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
@@ -33,7 +56,7 @@ def content(previous, domain, port):
|
|
|
33
56
|
proxy_read_timeout 120s;
|
|
34
57
|
}}
|
|
35
58
|
}}
|
|
36
|
-
"""
|
|
59
|
+
""", domain)
|
|
37
60
|
|
|
38
61
|
|
|
39
62
|
def configure(path, domain, port):
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""A task-owned HTTP server for real nginx protocol and streaming checks."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
LEGACY_VHOST = """server {
|
|
10
|
+
listen 80;
|
|
11
|
+
server_name legacy.example;
|
|
12
|
+
location / {
|
|
13
|
+
proxy_pass http://127.0.0.1:31000;
|
|
14
|
+
proxy_http_version 1.1;
|
|
15
|
+
proxy_set_header Host $host;
|
|
16
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
17
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
18
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
19
|
+
proxy_read_timeout 120s;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BackendServer(ThreadingHTTPServer):
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__(("127.0.0.1", 0), Backend)
|
|
28
|
+
self.stream_release = threading.Event()
|
|
29
|
+
self.stream_finished = threading.Event()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Backend(BaseHTTPRequestHandler):
|
|
33
|
+
protocol_version = "HTTP/1.1"
|
|
34
|
+
|
|
35
|
+
def do_GET(self):
|
|
36
|
+
if self.path == "/ws":
|
|
37
|
+
return self.websocket()
|
|
38
|
+
if self.path == "/stream":
|
|
39
|
+
return self.stream()
|
|
40
|
+
payload = json.dumps({"build_sha": "a" * 40, "path": self.path,
|
|
41
|
+
"upgrade": self.headers.get("Upgrade"),
|
|
42
|
+
"connection": self.headers.get("Connection"),
|
|
43
|
+
"ingress_received": "X-App-Robot-Backend-Token" in self.headers}).encode()
|
|
44
|
+
self.send_response(200)
|
|
45
|
+
self.send_header("Content-Type", "application/json")
|
|
46
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
47
|
+
self.end_headers()
|
|
48
|
+
self.wfile.write(payload)
|
|
49
|
+
|
|
50
|
+
def websocket(self):
|
|
51
|
+
self.close_connection = True
|
|
52
|
+
if (self.headers.get("Upgrade", "").lower() != "websocket"
|
|
53
|
+
or self.headers.get("Connection", "").lower() != "upgrade"
|
|
54
|
+
or self.request_version != "HTTP/1.1"
|
|
55
|
+
or "X-App-Robot-Backend-Token" in self.headers):
|
|
56
|
+
self.send_error(400)
|
|
57
|
+
return
|
|
58
|
+
key = self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
59
|
+
self.send_response(101)
|
|
60
|
+
self.send_header("Upgrade", "websocket")
|
|
61
|
+
self.send_header("Connection", "Upgrade")
|
|
62
|
+
self.send_header("Sec-WebSocket-Accept", base64.b64encode(hashlib.sha1(key.encode()).digest()).decode())
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.connection.settimeout(2)
|
|
65
|
+
frame = self.rfile.read(2)
|
|
66
|
+
if len(frame) != 2 or frame[0] != 0x81 or frame[1] & 0x80 == 0 or frame[1] & 0x7F > 125:
|
|
67
|
+
return
|
|
68
|
+
mask = self.rfile.read(4)
|
|
69
|
+
payload = self.rfile.read(frame[1] & 0x7F)
|
|
70
|
+
decoded = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
71
|
+
self.wfile.write(bytes((0x81, len(decoded))) + decoded)
|
|
72
|
+
self.wfile.flush()
|
|
73
|
+
|
|
74
|
+
def stream(self):
|
|
75
|
+
self.close_connection = True
|
|
76
|
+
self.send_response(200)
|
|
77
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
78
|
+
self.end_headers()
|
|
79
|
+
self.wfile.write(b"data: first\n\n")
|
|
80
|
+
self.wfile.flush()
|
|
81
|
+
try:
|
|
82
|
+
if self.server.stream_release.wait(5):
|
|
83
|
+
self.wfile.write(b"data: second\n\n")
|
|
84
|
+
self.wfile.flush()
|
|
85
|
+
finally:
|
|
86
|
+
self.server.stream_finished.set()
|
|
87
|
+
|
|
88
|
+
def log_message(self, *_args):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def stop(process):
|
|
93
|
+
process.terminate()
|
|
94
|
+
try:
|
|
95
|
+
process.communicate(timeout=5)
|
|
96
|
+
except subprocess.TimeoutExpired:
|
|
97
|
+
process.kill()
|
|
98
|
+
process.communicate(timeout=5)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"""Exercise
|
|
2
|
-
from http.
|
|
1
|
+
"""Exercise generated ingress with an owned nginx process and real HTTP, WebSocket and SSE."""
|
|
2
|
+
from http.client import HTTPResponse
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
import shutil
|
|
@@ -12,41 +12,18 @@ import unittest
|
|
|
12
12
|
from urllib.error import HTTPError, URLError
|
|
13
13
|
from urllib.request import Request, urlopen
|
|
14
14
|
|
|
15
|
-
import configure_vhost
|
|
16
15
|
import edge_vhost
|
|
16
|
+
from nginx_test_fixture import BackendServer, LEGACY_VHOST, stop
|
|
17
17
|
from test_edge_inputs import configured
|
|
18
18
|
|
|
19
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
20
|
class Ingress(unittest.TestCase):
|
|
44
21
|
def setUp(self):
|
|
45
22
|
self.assertIsNotNone(shutil.which("nginx"), "nginx is required for the backend ingress contract")
|
|
46
23
|
directory = tempfile.TemporaryDirectory()
|
|
47
24
|
self.addCleanup(directory.cleanup)
|
|
48
25
|
self.root = Path(directory.name)
|
|
49
|
-
server =
|
|
26
|
+
server = self.server = BackendServer()
|
|
50
27
|
self.addCleanup(server.server_close)
|
|
51
28
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
52
29
|
thread.start()
|
|
@@ -57,7 +34,7 @@ class Ingress(unittest.TestCase):
|
|
|
57
34
|
self.config = self.root / ".backend-edge.json"
|
|
58
35
|
self.config.write_text(json.dumps(configured()))
|
|
59
36
|
legacy = self.root / "backend-fixture.conf"
|
|
60
|
-
legacy.write_text(
|
|
37
|
+
legacy.write_text(LEGACY_VHOST.replace(":31000;", f":{server.server_port};"))
|
|
61
38
|
files = edge_vhost.planned(self.config, "fixture", "edge.example", server.server_port, self.root)
|
|
62
39
|
for path, (data, mode) in files.items():
|
|
63
40
|
path.write_bytes(data.replace(b"listen 80;", f"listen 127.0.0.1:{self.port};".encode()))
|
|
@@ -101,3 +78,48 @@ class Ingress(unittest.TestCase):
|
|
|
101
78
|
self.assertEqual(body["build_sha"], "a" * 40)
|
|
102
79
|
self.assertEqual(body["path"], "/healthz?probe=owned")
|
|
103
80
|
self.assertFalse(body["ingress_received"])
|
|
81
|
+
self.assertIsNone(body["upgrade"])
|
|
82
|
+
self.assertEqual(body["connection"], "close")
|
|
83
|
+
|
|
84
|
+
def test_real_nginx_exchanges_websocket_frames_and_enforces_ingress(self):
|
|
85
|
+
cases = (("edge.example", None, 403), ("edge.example", "wrong-token", 403),
|
|
86
|
+
("edge.example", configured()["ingress_token"], 101), ("legacy.example", None, 101))
|
|
87
|
+
for host, token, status in cases:
|
|
88
|
+
with self.subTest(host=host, status=status), socket.create_connection(("127.0.0.1", self.port), 2) as peer:
|
|
89
|
+
request = (f"GET /ws HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
|
|
90
|
+
"Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n"
|
|
91
|
+
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n")
|
|
92
|
+
if token is not None:
|
|
93
|
+
request += f"X-App-Robot-Backend-Token: {token}\r\n"
|
|
94
|
+
peer.sendall((request + "\r\n").encode())
|
|
95
|
+
with HTTPResponse(peer) as response:
|
|
96
|
+
response.begin()
|
|
97
|
+
self.assertEqual(response.status, status)
|
|
98
|
+
if status != 101:
|
|
99
|
+
continue
|
|
100
|
+
self.assertEqual(response.getheader("Upgrade").lower(), "websocket")
|
|
101
|
+
self.assertEqual(response.getheader("Sec-WebSocket-Accept"), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")
|
|
102
|
+
payload, mask = b"ping-through-nginx", b"\x01\x02\x03\x04"
|
|
103
|
+
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
104
|
+
peer.sendall(bytes((0x81, 0x80 | len(payload))) + mask + masked)
|
|
105
|
+
self.assertEqual(response.fp.read(2), bytes((0x81, len(payload))))
|
|
106
|
+
self.assertEqual(response.fp.read(len(payload)), payload)
|
|
107
|
+
|
|
108
|
+
def test_real_nginx_delivers_sse_before_upstream_completion(self):
|
|
109
|
+
for host, token in (("edge.example", configured()["ingress_token"]), ("legacy.example", None)):
|
|
110
|
+
with self.subTest(host=host):
|
|
111
|
+
self.server.stream_release.clear()
|
|
112
|
+
self.server.stream_finished.clear()
|
|
113
|
+
headers = {"Host": host}
|
|
114
|
+
if token is not None:
|
|
115
|
+
headers["X-App-Robot-Backend-Token"] = token
|
|
116
|
+
request = Request(f"http://127.0.0.1:{self.port}/stream", headers=headers)
|
|
117
|
+
try:
|
|
118
|
+
with urlopen(request, timeout=2) as response:
|
|
119
|
+
self.assertEqual(response.readline(), b"data: first\n")
|
|
120
|
+
self.assertFalse(self.server.stream_finished.is_set())
|
|
121
|
+
self.server.stream_release.set()
|
|
122
|
+
self.assertEqual(response.read(), b"\ndata: second\n\n")
|
|
123
|
+
finally:
|
|
124
|
+
self.server.stream_release.set()
|
|
125
|
+
self.assertTrue(self.server.stream_finished.wait(2))
|
package/docs/BACKEND_EDGE.md
CHANGED
|
@@ -34,6 +34,12 @@ The old public hostname is health-checked without authentication so released cli
|
|
|
34
34
|
An edge alias matching the existing legacy hostname is refused; provision a new alias instead.
|
|
35
35
|
Missing ingress settings on an already protected app cannot reopen its origin.
|
|
36
36
|
|
|
37
|
+
Managed backend locations forward WebSocket upgrades and disable response buffering so SSE and
|
|
38
|
+
streaming responses arrive incrementally. The Connection header becomes `upgrade` only when the
|
|
39
|
+
request carries an Upgrade header; ordinary HTTP requests use `close`. Each vhost owns a distinct
|
|
40
|
+
connection map, so protected and legacy domains coexist. Deployment updates existing locations
|
|
41
|
+
while preserving their domains, TLS settings and ingress checks.
|
|
42
|
+
|
|
37
43
|
These options are also named backend settings in `.gowalk-cicd.yml`: `public-base-url`,
|
|
38
44
|
`public-health-url`, `expected-build-sha`, `ingress-required` and `ingress-token`. Reference secrets
|
|
39
|
-
by name. Deployments without these options retain
|
|
45
|
+
by name. Deployments without these options retain public origin access, certificate handling and health checks.
|