#!/usr/bin/env python3 """Run a local read/write API using the existing production stores and both login providers. Requires the caller's existing ECS SSH authorization. Never logs or persists credentials. """ import argparse import importlib.util import ipaddress import json import os from pathlib import Path import re import shutil import signal import socket import subprocess import tempfile import time import urllib.request import urllib.parse ROOT = Path(__file__).resolve().parents[1] def loopback_origin(value): try: parsed = urllib.parse.urlsplit(value) if (parsed.scheme != "http" or not ipaddress.ip_address(parsed.hostname).is_loopback or parsed.username is not None or parsed.password is not None or parsed.path or parsed.query or parsed.fragment or parsed.port is None or not 1024 <= parsed.port <= 65535 or value != "http://" + parsed.netloc): raise ValueError() except (ValueError, TypeError): raise argparse.ArgumentTypeError("public origin must be an HTTP loopback origin with a port") from None return value def production_config(target): remote = '''import subprocess,pathlib,json pid=subprocess.check_output(["systemctl","show","lingniu-vehicle-platform.service","--property=MainPID"],universal_newlines=True).strip().split("=",1)[1] values=dict(x.split("=",1) for x in pathlib.Path("/proc/"+pid+"/environ").read_text().split("\\0") if "=" in x) keys=["MYSQL_DSN","TDENGINE_DSN","TDENGINE_DATABASE","TDENGINE_DRIVER","REDIS_ADDR","REDIS_USERNAME","REDIS_PASSWORD","REDIS_DB","AMAP_WEB_JS_KEY","AMAP_API_KEY","AMAP_SECURITY_JS_CODE"] print(json.dumps({k:values[k] for k in keys if k in values})) ''' result = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", target, "python3 -"], input=remote, capture_output=True, text=True, check=True) return json.loads(result.stdout) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--ssh-target", default="root@115.29.187.205") parser.add_argument("--oneos-origin", choices=["https://dev-oneos.lnh2e.com", "https://lnoneos.com"], default="https://dev-oneos.lnh2e.com", help="OneOS authentication and vehicle data origin") parser.add_argument("--port", type=int, default=20400) parser.add_argument("--public-origin", type=loopback_origin, help="Browser origin when using a separate local frontend dev server") parser.add_argument("--skip-web-build", action="store_true") parser.add_argument("--reuse-tunnels", action="store_true", help="Reuse the active local backend tunnels without owning them") parser.add_argument("--no-hydrogen-worker", action="store_true", help="Do not launch customer hydrogen projection worker") parser.add_argument("--tdengine-transport", choices=["websocket", "rest"], default="websocket", help="TDengine transport for the local API; REST requires --no-hydrogen-worker") args = parser.parse_args() if args.tdengine_transport == "rest" and not args.no_hydrogen_worker: parser.error("--tdengine-transport rest requires --no-hydrogen-worker") if not 1024 <= args.port <= 65535: raise RuntimeError("invalid local port") # Refuse an occupied API port before connecting the production store. with socket.socket() as listener: listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", args.port)) spec = importlib.util.spec_from_file_location("oneos_dev", Path(__file__).with_name("oneos-dev.py")) dev = importlib.util.module_from_spec(spec) spec.loader.exec_module(dev) dev.DEV_ORIGIN = args.oneos_origin opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), dev.NoRedirect()) public = dev.public_config(opener) run = Path(tempfile.mkdtemp(prefix="vehicle-oneos-backend-")) os.chmod(run, 0o700) clean = {key: os.environ[key] for key in ("PATH", "HOME", "TMPDIR") if key in os.environ} clean.update({"GOPROXY": "off", "GOSUMDB": "off", "GOTOOLCHAIN": "local"}) subprocess.run([shutil.which("go"), "build", "-o", str(run / "platform"), "./cmd/platform-api"], cwd=ROOT / "apps/api", env=clean, check=True) if not args.no_hydrogen_worker: subprocess.run([shutil.which("go"), "build", "-o", str(run / "customer-hydrogen-worker"), "./cmd/stats-backfill"], cwd=ROOT.parent / "go/vehicle-gateway", env=clean, check=True) if not args.skip_web_build: with (run / "web-build.log").open("w") as output: subprocess.run([shutil.which("pnpm"), "build"], cwd=ROOT / "apps/web", env=clean, stdout=output, stderr=subprocess.STDOUT, check=True) values = production_config(args.ssh_target) forwards = [] mysql = re.search(r"@tcp\(([^)]+)\)/([^?]+)", values.get("MYSQL_DSN", "")) if not mysql: raise RuntimeError("production MySQL TCP configuration unavailable") forwards.append((20430, mysql.group(1))) values["MYSQL_DSN"] = values["MYSQL_DSN"].replace("@tcp(" + mysql.group(1) + ")", "@tcp(127.0.0.1:20430)", 1) if values.get("TDENGINE_DSN"): td = re.search(r"@ws\(([^)]+)\)", values["TDENGINE_DSN"]) if not td: raise RuntimeError("production TDengine websocket configuration unavailable") forwards.append((20441, td.group(1))) values["TDENGINE_DSN"] = values["TDENGINE_DSN"].replace("@ws(" + td.group(1) + ")", "@ws(127.0.0.1:20441)", 1) if args.tdengine_transport == "rest": values["TDENGINE_DRIVER"] = "taosRestful" values["TDENGINE_DSN"] = values["TDENGINE_DSN"].replace("@ws(127.0.0.1:20441)", "@http(127.0.0.1:20441)", 1) if values.get("REDIS_ADDR"): forwards.append((20479, values["REDIS_ADDR"])) values["REDIS_ADDR"] = "127.0.0.1:20479" command = ["ssh", "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3"] for port, destination in forwards: command += ["-L", "127.0.0.1:%d:%s" % (port, destination)] command.append(args.ssh_target) tunnel = child = hydrogen_worker = None log = None origin = "http://127.0.0.1:%d" % args.port public_origin = args.public_origin or origin try: if not args.reuse_tunnels: tunnel = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) deadline = time.monotonic() + 15 for port, _ in forwards: while True: if tunnel is not None and tunnel.poll() is not None: raise RuntimeError("production tunnel could not start") try: with socket.create_connection(("127.0.0.1", port), timeout=0.5): break except OSError: if time.monotonic() > deadline: raise RuntimeError("production tunnel timed out") time.sleep(0.1) env = dict(clean) env.update(values) env.update({ "HTTP_ADDR": "127.0.0.1:%d" % args.port, "DATA_MODE": "production", "AUTH_MODE": "enforce", "STATIC_DIR": str(ROOT / "apps/web/dist"), "EXPORT_DIR": str(run / "exports"), "ONEOS_LOCAL_BACKEND": "true", "ONEOS_DEV_INTEGRATION": "true", "ONEOS_AUTH_ENABLED": "true", "ONEOS_PASSWORD_ENABLED": "true", "ONEOS_VEHICLE_QUERY_SCOPE": "true", "ONEOS_PROFILE_BASE_URL": dev.DEV_ORIGIN + "/prod-api", "ONEOS_PROFILE_ISSUER": dev.DEV_ORIGIN, "ONEOS_PROFILE_ALLOWED_TENANT": "000000", "ONEOS_PUBLIC_ORIGIN": public_origin, "ONEOS_PASSWORD_CLIENT_ID": public["VITE_GLOB_APP_CLIENT_ID"], "ONEOS_PASSWORD_RSA_PUBLIC_KEY": public["VITE_GLOB_RSA_PUBLIC_KEY"], "ONEOS_VEHICLE_SCOPE_PATH": "", "ONEOS_LOGIN_TICKET_EXCHANGE_PATH": "", "GODEBUG": "rsa1024min=0", "AMAP_SECURITY_SERVICE_HOST": public_origin + "/_AMapService", "ALERT_STREAM_MODE": "disabled", "HISTORY_EXPORT_CLEANUP_AUTOMATION_ENABLED": "false", "DAILY_GEOGRAPHY_ENABLED": "false", "REQUEST_TIMEOUT_MS": "60000", }) log = (run / "platform.log").open("w") os.chmod(run / "platform.log", 0o600) child = subprocess.Popen([str(run / "platform")], env=env, stdout=log, stderr=subprocess.STDOUT) deadline = time.monotonic() + 20 while True: try: with opener.open(origin + "/api/v2/auth/providers", timeout=2) as response: providers = json.load(response)["data"] if not providers["local"]["enabled"] or not providers["oneos"]["authorizationReady"]: raise RuntimeError("both login providers must be available") break except (OSError, ValueError, KeyError): if child.poll() is not None or time.monotonic() > deadline: raise RuntimeError("backend startup failed; see private runtime log") time.sleep(0.2) if not args.no_hydrogen_worker: worker_env = dict(env) worker_env.update({"BACKFILL_ENV_FILES": "/dev/null", "CUSTOMER_HYDROGEN_WORKER": "true", "BACKFILL_PROTOCOLS": "GB32960,YUTONG_MQTT", "BACKFILL_PROGRESS_EVERY": "10000"}) hydrogen_worker = subprocess.Popen([str(run / "customer-hydrogen-worker")], env=worker_env, stdout=log, stderr=subprocess.STDOUT) print(json.dumps({"platform": origin, "oneos": dev.DEV_ORIGIN, "dataSource": "production", "readWrite": True, "localLogin": True, "oneosVehicleScope": "caller-vehicle-query", "tdengineTransport": args.tdengine_transport, "runDirectory": str(run)}, ensure_ascii=False), flush=True) while child.poll() is None: time.sleep(0.5) raise RuntimeError("local backend exited") except KeyboardInterrupt: pass finally: for process in (hydrogen_worker, child, tunnel): if process is not None and process.poll() is None: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() if log: log.close() print("Local backend stopped. Evidence: " + str(run), flush=True) if __name__ == "__main__": signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) main()