Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks. fix(ci): harden release smoke SS path for slow arm64 runners Wait for Shadowsocks TCP bind after async ApplyConfig; lengthen proxy checks.
222 lines
10 KiB
Python
222 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise a real panel/core pair on loopback, with isolated disposable data.
|
|
|
|
Usage: python3 scripts/smoke-runtime.py PANEL_BINARY MIHOMO_BINARY
|
|
The core must be in an allowed installation directory (/opt/ or the managed
|
|
/usr/local/lib/3m-ui/). No existing service, database or public endpoint is used.
|
|
"""
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import http.client
|
|
import http.server
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def stop_process(process):
|
|
if process is not None and process.poll() is None:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.wait(timeout=5)
|
|
|
|
|
|
def main():
|
|
panel, core = map(lambda p: str(Path(p).resolve()), sys.argv[1:])
|
|
processes = []
|
|
with tempfile.TemporaryDirectory(prefix="3m-runtime-") as tmp:
|
|
root = Path(tmp)
|
|
data = root / "data"
|
|
config = root / "config.yaml"
|
|
port, listener_port, proxy_port, controller_port = [free_port() for _ in range(4)]
|
|
env = os.environ.copy()
|
|
env.update(THREE_M_UI_CONFIG=str(config), THREE_M_UI_DATA_DIR=str(data),
|
|
THREE_M_UI_MIHOMO_BINARY=core, THREE_M_UI_PORT=str(port),
|
|
THREE_M_UI_LISTEN="127.0.0.1", THREE_M_UI_ADMIN_USERNAME="admin",
|
|
THREE_M_UI_ADMIN_PASSWORD="",
|
|
THREE_M_UI_MIHOMO_CONTROLLER=f"127.0.0.1:{controller_port}")
|
|
initialized = subprocess.run([panel, "init"], env=env, capture_output=True, text=True, check=True)
|
|
password = re.search(r"^Initial password: (.+)$", initialized.stdout, re.M).group(1)
|
|
fingerprint = hashlib.sha256(config.read_bytes()).hexdigest()
|
|
again = subprocess.run([panel, "init"], env=env, capture_output=True, text=True, check=True)
|
|
assert "Initial password:" not in again.stdout, "Repeated init reset credentials"
|
|
assert hashlib.sha256(config.read_bytes()).hexdigest() == fingerprint, "Repeated init changed keys"
|
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
token = None
|
|
|
|
def api(route, body=None):
|
|
request = urllib.request.Request(f"http://127.0.0.1:{port}/api/v1/{route}",
|
|
data=None if body is None else json.dumps(body).encode())
|
|
request.add_header("Content-Type", "application/json")
|
|
if token:
|
|
request.add_header("Authorization", f"Bearer {token}")
|
|
try:
|
|
with opener.open(request, timeout=10) as response:
|
|
return json.load(response)
|
|
except urllib.error.HTTPError as error:
|
|
raise AssertionError(f"{route}: HTTP {error.code}: {error.read().decode()}") from None
|
|
|
|
def start_panel(log):
|
|
process = subprocess.Popen([panel], env=env, cwd=root, stdout=log, stderr=log, start_new_session=True)
|
|
processes.append(process)
|
|
for _ in range(60):
|
|
if process.poll() is not None:
|
|
raise AssertionError("Panel exited before readiness")
|
|
result = subprocess.run([panel, "healthcheck"], env=env, capture_output=True)
|
|
if result.returncode == 0:
|
|
return process
|
|
time.sleep(0.25)
|
|
raise AssertionError("Panel readiness timed out")
|
|
|
|
with contextlib.ExitStack() as stack:
|
|
stack.callback(lambda: [stop_process(process) for process in reversed(processes)])
|
|
def stop_core():
|
|
with contextlib.suppress(Exception):
|
|
api("mihomo/stop", {})
|
|
stack.callback(stop_core)
|
|
log = stack.enter_context((root / "panel.log").open("w+"))
|
|
running = start_panel(log)
|
|
token = api("auth/login", {"username": "admin", "password": password})["token"]
|
|
password = "smoke-" + secrets.token_urlsafe(20)
|
|
original = re.search(r"^Initial password: (.+)$", initialized.stdout, re.M).group(1)
|
|
api("auth/password", {"current_password": original, "new_password": password})
|
|
token = api("auth/login", {"username": "admin", "password": password})["token"]
|
|
ss_password = secrets.token_urlsafe(24)
|
|
listener = api("listeners", {
|
|
"name": "runtime-smoke", "protocol": "shadowsocks", "port": str(listener_port),
|
|
"bind_address": "127.0.0.1", "enabled": True,
|
|
"config": json.dumps({"cipher": "aes-128-gcm", "password": ss_password}),
|
|
})
|
|
# Core may finish start slightly after the listener response; poll briefly.
|
|
for _ in range(80):
|
|
if api("mihomo/status").get("running"):
|
|
break
|
|
time.sleep(0.25)
|
|
else:
|
|
raise AssertionError("Bundled core did not start")
|
|
# ApplyConfig is debounced asynchronously. On slow arm64 runners the
|
|
# SS socket may lag the "running" status — wait until TCP accepts.
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", listener_port), timeout=1):
|
|
break
|
|
except OSError:
|
|
time.sleep(0.25)
|
|
else:
|
|
raise AssertionError(f"Shadowsocks listener did not bind on 127.0.0.1:{listener_port}")
|
|
# Persist real listener certificates as well as the Shadowsocks node.
|
|
# Create returns after the panel DB write; Mihomo generate/Apply (and
|
|
# certstore.Save) is debounced asynchronously — poll briefly.
|
|
api("listeners", {"name": "certificate-smoke", "protocol": "trojan", "port": str(free_port()),
|
|
"bind_address": "127.0.0.1", "enabled": True, "config": "{}"})
|
|
certificates = {}
|
|
for _ in range(40):
|
|
cert_dir = data / "listener-certs"
|
|
if cert_dir.is_dir():
|
|
certificates = {
|
|
str(p.relative_to(data)): hashlib.sha256(p.read_bytes()).hexdigest()
|
|
for p in cert_dir.rglob("*") if p.is_file()
|
|
}
|
|
if certificates:
|
|
break
|
|
time.sleep(0.25)
|
|
assert certificates, "Listener certificates were not persisted"
|
|
|
|
class Target(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
body = b"3m-ui-real-proxy-ok"
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
target = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Target)
|
|
threading.Thread(target=target.serve_forever, daemon=True).start()
|
|
stack.callback(target.server_close)
|
|
stack.callback(target.shutdown)
|
|
client_config = root / "client.json"
|
|
client_config.write_text(json.dumps({
|
|
"mixed-port": proxy_port, "bind-address": "127.0.0.1", "allow-lan": False,
|
|
"dns": {"enable": False}, "log-level": "silent",
|
|
"proxies": [{"name": "server", "type": "ss", "server": "127.0.0.1", "port": listener_port,
|
|
"cipher": "aes-128-gcm", "password": ss_password}],
|
|
"rules": ["MATCH,server"],
|
|
}))
|
|
client_log = stack.enter_context((root / "client.log").open("w+"))
|
|
client = subprocess.Popen([core, "-d", str(root / "client"), "-f", str(client_config)],
|
|
stdout=client_log, stderr=client_log, start_new_session=True)
|
|
processes.append(client)
|
|
|
|
def check_connection():
|
|
# Allow extra time on ARM runners (client core + first QUIC/TCP path).
|
|
for _ in range(80):
|
|
try:
|
|
connection = http.client.HTTPConnection("127.0.0.1", proxy_port, timeout=3)
|
|
connection.request("GET", f"http://127.0.0.1:{target.server_port}/")
|
|
response = connection.getresponse()
|
|
content = response.read()
|
|
connection.close()
|
|
if response.status == 200 and content == b"3m-ui-real-proxy-ok":
|
|
return
|
|
except (OSError, http.client.HTTPException):
|
|
pass
|
|
time.sleep(0.35)
|
|
raise AssertionError("Actual HTTP -> Shadowsocks -> target request failed")
|
|
|
|
check_connection()
|
|
# Use the existing authenticated stop API to stop the independently
|
|
# supervised core before ending the panel process.
|
|
api("mihomo/stop", {})
|
|
stop_process(running)
|
|
snapshot = root / "snapshot"
|
|
shutil.copytree(data, snapshot)
|
|
running = start_panel(log)
|
|
token = api("auth/login", {"username": "admin", "password": password})["token"]
|
|
assert any(item["id"] == listener["id"] for item in api("listeners")), "Restart lost node"
|
|
check_connection()
|
|
api("mihomo/stop", {})
|
|
stop_process(running)
|
|
shutil.rmtree(data)
|
|
shutil.copytree(snapshot, data)
|
|
running = start_panel(log)
|
|
token = api("auth/login", {"username": "admin", "password": password})["token"]
|
|
check_connection()
|
|
assert hashlib.sha256(config.read_bytes()).hexdigest() == fingerprint, "Restart changed keys"
|
|
for name, expected in certificates.items():
|
|
assert hashlib.sha256((data / name).read_bytes()).hexdigest() == expected, "Restore changed TLS identity"
|
|
api("mihomo/stop", {})
|
|
stop_process(running)
|
|
print("PASS: init, login/change-password, real Shadowsocks connection, restart, data restore and TLS identity")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 3:
|
|
raise SystemExit(__doc__)
|
|
main()
|