feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild customer hydrogen evidence through existing local backend tunnels.
|
||||
Only the dedicated projection table is written; existing mileage is untouched.
|
||||
Credentials remain in memory. --write publishes complete daily scans.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--date-from', required=True)
|
||||
parser.add_argument('--date-to', required=True)
|
||||
parser.add_argument('--vins', default='')
|
||||
parser.add_argument('--write', action='store_true')
|
||||
parser.add_argument('--limit', type=int, default=0)
|
||||
parser.add_argument('--ssh-target', default='root@115.29.187.205')
|
||||
args = parser.parse_args()
|
||||
start, end = (datetime.date.fromisoformat(x) for x in (args.date_from, args.date_to))
|
||||
if start > end or (end-start).days > 365: parser.error('date range must contain 1–366 days')
|
||||
if args.write and args.limit: parser.error('a limited scan cannot publish daily results')
|
||||
spec = importlib.util.spec_from_file_location('oneos_backend', ROOT/'scripts/oneos-backend.py')
|
||||
backend = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(backend)
|
||||
values = backend.production_config(args.ssh_target)
|
||||
env = {k: os.environ[k] for k in ('PATH', 'HOME', 'TMPDIR') if k in os.environ}
|
||||
env.update({'MYSQL_DSN': re.sub(r'@tcp\([^)]+\)', '@tcp(127.0.0.1:20430)', values['MYSQL_DSN'], count=1),
|
||||
'TDENGINE_DSN': re.sub(r'@ws\([^)]+\)', '@ws(127.0.0.1:20441)', values['TDENGINE_DSN'], count=1),
|
||||
'TDENGINE_DATABASE': values['TDENGINE_DATABASE'], 'TDENGINE_DRIVER': values.get('TDENGINE_DRIVER', 'taosWS'),
|
||||
'BACKFILL_ENV_FILES': '/dev/null', 'BACKFILL_HYDROGEN_ONLY': 'true',
|
||||
'BACKFILL_DRY_RUN': 'false' if args.write else 'true', 'BACKFILL_LIMIT': str(args.limit),
|
||||
'BACKFILL_PROTOCOLS': 'GB32960,YUTONG_MQTT', 'BACKFILL_VINS': args.vins, 'BACKFILL_PROGRESS_EVERY': '10000'})
|
||||
with tempfile.TemporaryDirectory(prefix='customer-hydrogen-') as run:
|
||||
binary = str(Path(run)/'backfill')
|
||||
subprocess.run(['go', 'build', '-o', binary, './cmd/stats-backfill'], cwd=ROOT.parent/'go/vehicle-gateway', check=True)
|
||||
day = start
|
||||
while day <= end:
|
||||
env.update({'BACKFILL_DATE_FROM': day.isoformat(), 'BACKFILL_DATE_TO': day.isoformat()})
|
||||
subprocess.run([binary], env=env, check=True)
|
||||
day += datetime.timedelta(days=1)
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the local platform against the real, explicitly allowed dev OneOS.
|
||||
|
||||
Reads dev OneOS's public browser config. Does not store login passwords, deploy,
|
||||
run migrations or modify upstream permissions. The real OneOS asset page is available
|
||||
with explicit menu grants. --real-data enables existing platform queries for verified
|
||||
OneOS administrators through a read-only bridge. Ctrl-C stops the local API and tunnel.
|
||||
"""
|
||||
import argparse
|
||||
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
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEV_ORIGIN = "https://dev-oneos.lnh2e.com"
|
||||
LOCAL_ORIGIN = "http://127.0.0.1:20400"
|
||||
DATA_ORIGIN = "http://127.0.0.1:20420"
|
||||
|
||||
|
||||
def read_ecs_query_config(ssh_target):
|
||||
# Read only the existing query credential and public map client key. Never
|
||||
# print them, copy database credentials, or write them into the repository.
|
||||
remote = '''import json, shlex
|
||||
from pathlib import Path
|
||||
values = {}
|
||||
for line in Path("/opt/lingniu-vehicle-platform/env/platform.env").read_text().splitlines():
|
||||
if "=" in line and not line.lstrip().startswith("#"):
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
raw = values["AUTH_TOKENS_JSON"]
|
||||
if raw.startswith("'"):
|
||||
raw = shlex.split(raw)[0]
|
||||
principals = json.loads(raw)
|
||||
token = next(p["token"] for p in principals if p.get("role") == "operator")
|
||||
key = values.get("AMAP_WEB_JS_KEY", "").strip("\\\"'")
|
||||
print(json.dumps({"token": token, "mapKey": key}))
|
||||
'''
|
||||
result = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", ssh_target, "python3 -"], input=remote, text=True, capture_output=True, check=True)
|
||||
value = json.loads(result.stdout)
|
||||
if len(value.get("token", "")) < 16:
|
||||
raise RuntimeError("ECS query credential is unavailable")
|
||||
return value
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def public_config(opener):
|
||||
with opener.open(DEV_ORIGIN + "/_app.config.js", timeout=10) as response:
|
||||
raw = response.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise RuntimeError("dev OneOS public config is too large")
|
||||
match = re.search(r"window\._VBEN_ADMIN_PRO_APP_CONF_=(\{.*?\});", raw.decode())
|
||||
if not match:
|
||||
raise RuntimeError("dev OneOS public config format is unsupported")
|
||||
config = json.loads(match.group(1))
|
||||
if config.get("VITE_GLOB_API_URL") != "/prod-api" or config.get("VITE_GLOB_ENABLE_ENCRYPT") != "true":
|
||||
raise RuntimeError("dev OneOS API or encryption config changed; review required")
|
||||
for key in ("VITE_GLOB_APP_CLIENT_ID", "VITE_GLOB_RSA_PUBLIC_KEY"):
|
||||
if not isinstance(config.get(key), str) or not config[key]:
|
||||
raise RuntimeError("dev OneOS public authentication config is incomplete")
|
||||
return config
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--skip-web-build", action="store_true")
|
||||
parser.add_argument("--real-data", action="store_true", help="Read existing ECS platform data through a local SSH tunnel; writes remain blocked")
|
||||
parser.add_argument("--ssh-target", default="root@115.29.187.205")
|
||||
args = parser.parse_args()
|
||||
run = Path(tempfile.mkdtemp(prefix="vehicle-oneos-dev-"))
|
||||
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", "NO_UPDATE_NOTIFIER": "1"})
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
child = None
|
||||
tunnel = None
|
||||
log = None
|
||||
try:
|
||||
config = public_config(opener)
|
||||
subprocess.run([shutil.which("go"), "build", "-o", str(run / "platform"), "./cmd/platform-api"], cwd=ROOT / "apps/api", env=clean, check=True)
|
||||
if not args.skip_web_build:
|
||||
with (run / "web-build.log").open("w") as build_log:
|
||||
subprocess.run([shutil.which("pnpm"), "build"], cwd=ROOT / "apps/web", env=clean, stdout=build_log, stderr=subprocess.STDOUT, check=True)
|
||||
if not (ROOT / "apps/web/dist/index.html").is_file():
|
||||
raise RuntimeError("local frontend build is missing")
|
||||
api_env = dict(clean)
|
||||
api_env.update({
|
||||
"HTTP_ADDR": "127.0.0.1:20400", "DATA_MODE": "mock", "AUTH_MODE": "enforce",
|
||||
"STATIC_DIR": str(ROOT / "apps/web/dist"), "EXPORT_DIR": str(run / "exports"),
|
||||
"ONEOS_DEV_INTEGRATION": "true", "ONEOS_AUTH_ENABLED": "true", "ONEOS_PASSWORD_ENABLED": "true",
|
||||
# Go 1.24+ otherwise rejects dev OneOS's published legacy 512-bit key.
|
||||
# This isolated dev process still validates the fixed HTTPS origin.
|
||||
"GODEBUG": "rsa1024min=0",
|
||||
"ONEOS_PROFILE_BASE_URL": DEV_ORIGIN + "/prod-api",
|
||||
"ONEOS_PROFILE_ISSUER": DEV_ORIGIN, "ONEOS_PROFILE_ALLOWED_TENANT": "000000",
|
||||
"ONEOS_PUBLIC_ORIGIN": LOCAL_ORIGIN,
|
||||
"ONEOS_PASSWORD_CLIENT_ID": config["VITE_GLOB_APP_CLIENT_ID"],
|
||||
"ONEOS_PASSWORD_RSA_PUBLIC_KEY": config["VITE_GLOB_RSA_PUBLIC_KEY"],
|
||||
# No invented upstream scope endpoint or SSO credentials.
|
||||
"ONEOS_VEHICLE_SCOPE_PATH": "", "ONEOS_LOGIN_TICKET_EXCHANGE_PATH": "",
|
||||
})
|
||||
if args.real_data:
|
||||
upstream = read_ecs_query_config(args.ssh_target)
|
||||
tunnel = subprocess.Popen(["ssh", "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-L", "127.0.0.1:20420:127.0.0.1:20300", args.ssh_target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if tunnel.poll() is not None:
|
||||
raise RuntimeError("ECS read-only tunnel could not start")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", 20420), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("ECS read-only tunnel timed out")
|
||||
time.sleep(0.1)
|
||||
req = urllib.request.Request(DATA_ORIGIN + "/api/v2/monitor/summary", headers={"Authorization": "Bearer " + upstream["token"]})
|
||||
with opener.open(req, timeout=20) as response:
|
||||
if "data" not in json.load(response):
|
||||
raise RuntimeError("ECS platform query preflight failed")
|
||||
api_env.update({"ONEOS_DEV_DATA_PROXY_URL": DATA_ORIGIN, "ONEOS_DEV_DATA_PROXY_TOKEN": upstream["token"], "AMAP_WEB_JS_KEY": upstream["mapKey"], "AMAP_SECURITY_SERVICE_HOST": "/_AMapService", "REQUEST_TIMEOUT_MS": "30000"})
|
||||
log = (run / "platform.log").open("w")
|
||||
child = subprocess.Popen([str(run / "platform")], env=api_env, stdout=log, stderr=subprocess.STDOUT)
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
with opener.open(LOCAL_ORIGIN + "/api/v2/auth/providers", timeout=1) as response:
|
||||
provider = json.load(response)["data"]["oneos"]
|
||||
if not provider.get("devIntegration") or not provider.get("passwordEnabled") or bool(provider.get("realDataEnabled")) != args.real_data:
|
||||
raise RuntimeError("dev authentication startup is not ready")
|
||||
break
|
||||
except (OSError, ValueError, KeyError):
|
||||
if child.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError("local dev integration failed to start; see platform.log")
|
||||
time.sleep(0.1)
|
||||
print(json.dumps({"platform": LOCAL_ORIGIN, "oneos": DEV_ORIGIN, "authentication": "real-dev-oneos", "businessAuthorization": "ecs-admin-readonly" if args.real_data else "oneos-asset-catalog-with-menu-grants", "simulator": False, "runDirectory": str(run)}, ensure_ascii=False), flush=True)
|
||||
while child.poll() is None:
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("local platform exited")
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
if log is not None:
|
||||
log.close()
|
||||
if tunnel is not None and tunnel.poll() is None:
|
||||
tunnel.terminate()
|
||||
try:
|
||||
tunnel.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
tunnel.kill()
|
||||
tunnel.wait()
|
||||
print(f"Local platform stopped. Evidence: {run}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and run an isolated local platform + fake OneOS, without external services.
|
||||
|
||||
Requires installed Go/Node dependencies. No install, SSH, migration, cloud CLI,
|
||||
external fetch or production configuration is used. Ctrl-C stops both children.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--skip-web-build", action="store_true")
|
||||
args = parser.parse_args()
|
||||
run = Path(tempfile.mkdtemp(prefix="vehicle-oneos-local-"))
|
||||
os.chmod(run, 0o700)
|
||||
# Build tools need installed paths/cache, but no inherited service credentials.
|
||||
clean = {key: os.environ[key] for key in ("PATH", "HOME", "TMPDIR") if key in os.environ}
|
||||
clean.update({"GOPROXY": "off", "GOSUMDB": "off", "GOTOOLCHAIN": "local", "NO_UPDATE_NOTIFIER": "1"})
|
||||
children, logs = [], []
|
||||
try:
|
||||
for name, package in (("fixture", "./cmd/oneos-local-fixture"), ("platform", "./cmd/platform-api")):
|
||||
subprocess.run([shutil.which("go"), "build", "-o", str(run / name), package], cwd=ROOT / "apps/api", env=clean, check=True)
|
||||
if not args.skip_web_build:
|
||||
with (run / "web-build.log").open("w") as build_log:
|
||||
subprocess.run([shutil.which("pnpm"), "build"], cwd=ROOT / "apps/web", env=clean, stdout=build_log, stderr=subprocess.STDOUT, check=True)
|
||||
if not (ROOT / "apps/web/dist/index.html").is_file():
|
||||
raise RuntimeError("local frontend build is missing")
|
||||
config_file = run / "fixture.json"
|
||||
fixture_log = (run / "fixture.log").open("w"); logs.append(fixture_log)
|
||||
children.append(subprocess.Popen([str(run / "fixture"), "-config-file", str(config_file)], env=clean, stdout=fixture_log, stderr=subprocess.STDOUT))
|
||||
deadline = time.monotonic() + 10
|
||||
while not config_file.exists():
|
||||
if children[-1].poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError("local fixture failed to start; see fixture.log")
|
||||
time.sleep(0.1)
|
||||
fixture = json.loads(config_file.read_text())
|
||||
api_env = dict(clean)
|
||||
api_env.update({
|
||||
"HTTP_ADDR": "127.0.0.1:20400", "DATA_MODE": "mock", "AUTH_MODE": "enforce",
|
||||
"STATIC_DIR": str(ROOT / "apps/web/dist"), "EXPORT_DIR": str(run / "exports"),
|
||||
"ONEOS_LOCAL_ONLY": "true", "ONEOS_AUTH_ENABLED": "true", "ONEOS_PASSWORD_ENABLED": "true",
|
||||
"ONEOS_PROFILE_BASE_URL": fixture["baseURL"],
|
||||
"ONEOS_PROFILE_ISSUER": fixture["issuer"], "ONEOS_PROFILE_ALLOWED_TENANT": fixture["tenantId"],
|
||||
"ONEOS_PUBLIC_ORIGIN": fixture["publicOrigin"], "ONEOS_PASSWORD_CLIENT_ID": fixture["clientId"],
|
||||
"ONEOS_PASSWORD_RSA_PUBLIC_KEY": fixture["publicKey"],
|
||||
"ONEOS_LOGIN_TICKET_EXCHANGE_PATH": "/auth/inner/v1/sso/vehicle-platform/token-exchange",
|
||||
"ONEOS_LOGIN_TICKET_SERVICE_TOKEN": fixture["serviceToken"],
|
||||
"ONEOS_VEHICLE_SCOPE_PATH": "/asset/vehicle-platform/scope",
|
||||
})
|
||||
api_log = (run / "platform.log").open("w"); logs.append(api_log)
|
||||
children.append(subprocess.Popen([str(run / "platform")], env=api_env, stdout=api_log, stderr=subprocess.STDOUT))
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
try:
|
||||
with opener.open("http://127.0.0.1:20400/api/v2/auth/providers", timeout=1) as resp:
|
||||
providers = json.load(resp)["data"]
|
||||
if not providers["oneos"]["passwordEnabled"] or not providers["oneos"]["ssoEnabled"]:
|
||||
raise RuntimeError("local shared-token authentication not ready")
|
||||
break
|
||||
except (OSError, ValueError, KeyError):
|
||||
if children[-1].poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError("local platform failed to start; see platform.log")
|
||||
time.sleep(0.1)
|
||||
print(json.dumps({"platform": fixture["publicOrigin"], "oneosFixture": fixture["baseURL"], "runDirectory": str(run), "fixtureConfig": str(config_file), "localOnly": True}), flush=True)
|
||||
while all(child.poll() is None for child in children):
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("one of the local child processes exited")
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for child in reversed(children):
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
for child in children:
|
||||
try: child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired: child.kill(); child.wait()
|
||||
for handle in logs: handle.close()
|
||||
# Keep evidence and fixtures for local inspection; directory is mode 0700.
|
||||
print(f"Local processes stopped. Local evidence: {run}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start the local platform against real dev OneOS; --fixture opts into simulation."""
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
backend = "--backend" in sys.argv
|
||||
if backend:
|
||||
sys.argv.remove("--backend")
|
||||
fixture = "--fixture" in sys.argv
|
||||
if fixture:
|
||||
sys.argv.remove("--fixture")
|
||||
target = Path(__file__).with_name("oneos-backend.py" if backend else "oneos-fixture.py" if fixture else "oneos-dev.py")
|
||||
runpy.run_path(str(target), run_name="__main__")
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Black-box shared-token acceptance against the loopback-only fixture launcher."""
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("fixture_config", type=Path)
|
||||
args = parser.parse_args()
|
||||
cfg = json.loads(args.fixture_config.read_text())
|
||||
origins = (cfg["publicOrigin"], cfg["baseURL"])
|
||||
for value in origins:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
assert parsed.scheme == "http" and ipaddress.ip_address(parsed.hostname).is_loopback
|
||||
assert not parsed.username and not parsed.query and not parsed.fragment and not parsed.path
|
||||
platform, upstream = origins
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(jar), NoRedirect())
|
||||
|
||||
def request(origin, path, body=None, credential=None, headers=None, form=False, method=None):
|
||||
assert origin in origins and path.startswith("/") and not path.startswith("//")
|
||||
h = {"Origin": platform}
|
||||
if credential:
|
||||
h.update({"Authorization": "Bearer " + credential["accessToken"], "X-OneOS-Client-ID": credential["clientId"]})
|
||||
if headers: h.update(headers)
|
||||
data = None
|
||||
if body is not None:
|
||||
h["Content-Type"] = "application/x-www-form-urlencoded" if form else "application/json"
|
||||
data = (urllib.parse.urlencode(body) if form else json.dumps(body)).encode()
|
||||
req = urllib.request.Request(origin + path, data=data, headers=h, method=method)
|
||||
try: resp = opener.open(req, timeout=5)
|
||||
except urllib.error.HTTPError as error: resp = error
|
||||
with resp:
|
||||
raw = resp.read()
|
||||
result = json.loads(raw) if "json" in resp.headers.get("Content-Type", "") else None
|
||||
return resp.code, result, resp.headers
|
||||
|
||||
passed = []
|
||||
def check(name, condition):
|
||||
assert condition, name
|
||||
passed.append(name)
|
||||
print("PASS " + name, flush=True)
|
||||
|
||||
def login(name):
|
||||
status, body, _ = request(platform, "/api/v2/auth/oneos/login", {"username": name, "password": "LocalOnly123!"})
|
||||
assert status == 200, "local fixture login failed"
|
||||
return body["data"]
|
||||
|
||||
def control(name, action):
|
||||
status, _, _ = request(upstream, "/__local/control", {"username": name, "action": action}, headers={"Authorization": "Bearer " + cfg["serviceToken"]})
|
||||
assert status == 200
|
||||
|
||||
alpha, beta = login("alpha"), login("beta")
|
||||
status, identity, _ = request(upstream, "/system/user/getInfo", headers={"Authorization": "Bearer " + alpha["accessToken"], "ClientID": alpha["clientId"]})
|
||||
check("platform-returned token works unchanged on OneOS", status == 200 and identity["data"]["user"]["userName"] == "alpha")
|
||||
status, me, _ = request(platform, "/api/v2/auth/oneos/me", credential=alpha)
|
||||
check("platform invokes live OneOS as current user", status == 200 and me["data"]["username"] == "alpha")
|
||||
_, a, _ = request(platform, "/api/vehicles", credential=alpha)
|
||||
_, b, _ = request(platform, "/api/vehicles", credential=beta)
|
||||
check("two user vehicle lists remain disjoint", {v["vin"] for v in a["data"]["items"]} == {"LB9A32A24R0LS1426"} and {v["vin"] for v in b["data"]["items"]} == {"LNXNEGRR7SR318212"})
|
||||
status, _, _ = request(platform, "/api/v2/tracks?vin=LNXNEGRR7SR318212", credential=alpha)
|
||||
check("explicit cross-user VIN rejected", status == 403)
|
||||
status, _, _ = request(platform, "/api/v2/admin/users", credential=alpha)
|
||||
check("ungranted management API rejected without relogin", status == 403)
|
||||
status, _, _ = request(platform, "/api/v2/session", credential=alpha)
|
||||
check("forbidden route did not revoke valid token", status == 200)
|
||||
status, _, _ = request(platform, "/api/v2/session", credential={**alpha, "clientId": "wrong-client"})
|
||||
check("wrong OneOS client ID rejected", status == 401)
|
||||
status, _, _ = request(platform, "/api/v2/auth/oneos/login", {"username": "noaccess", "password": "LocalOnly123!"})
|
||||
check("valid OneOS identity without entry permission denied", status == 403)
|
||||
status, _, _ = request(platform, "/api/v2/auth/oneos/login", {"username": "alpha", "password": "wrong-password"})
|
||||
check("wrong password denied without local fallback", status == 401)
|
||||
status, _, _ = request(platform, "/api/v2/auth/oneos/login", {"username": "alpha", "password": "LocalOnly123!"}, headers={"Origin": "http://127.0.0.1:29999"})
|
||||
check("cross-origin login rejected", status == 403)
|
||||
|
||||
status, _, _ = request(upstream, "/portal/login", {"username": "alpha", "password": "LocalOnly123!"}, form=True)
|
||||
assert status == 303
|
||||
portal_token = next(cookie.value for cookie in jar if cookie.name == "fixture_oneos")
|
||||
status, _, hdr = request(upstream, "/portal/ticket", {})
|
||||
assert status == 303
|
||||
callback = urllib.parse.urlsplit(hdr["Location"])
|
||||
assert urllib.parse.urlunsplit((callback.scheme, callback.netloc, "", "", "")) == platform
|
||||
code = urllib.parse.parse_qs(callback.query)["code"][0]
|
||||
status, _, hdr = request(platform, callback.path + "?" + callback.query)
|
||||
check("5-second ticket redeemed before SPA with clean redirect", status == 303 and hdr["Location"] == "/auth/oneos/complete" and "HttpOnly" in hdr.get("Set-Cookie", ""))
|
||||
status, pending, _ = request(platform, "/api/v2/auth/oneos/pending")
|
||||
check("SSO staged identity has no exposed access token", status == 200 and "accessToken" not in pending["data"])
|
||||
status, _, _ = request(platform, "/api/v2/auth/oneos/confirm", {"csrfToken": "wrong"})
|
||||
check("SSO wrong CSRF rejected", status == 403)
|
||||
status, confirmed, _ = request(platform, "/api/v2/auth/oneos/confirm", {"csrfToken": pending["data"]["csrfToken"]})
|
||||
check("SSO returns original OneOS portal token byte-for-byte", status == 200 and confirmed["data"]["accessToken"] == portal_token)
|
||||
check("password and SSO map to same platform subject", confirmed["data"]["session"]["subjectId"] == alpha["session"]["subjectId"])
|
||||
status, _, _ = request(platform, "/api/v2/auth/oneos/confirm", {"csrfToken": pending["data"]["csrfToken"]})
|
||||
check("SSO confirmation replay rejected", status in (401, 403))
|
||||
status, _, _ = request(upstream, "/auth/inner/v1/sso/vehicle-platform/token-exchange", {"code": code}, headers={"Authorization": "Bearer " + cfg["serviceToken"]})
|
||||
check("consumed upstream ticket cannot be exchanged twice", status == 401)
|
||||
_, _, hdr = request(upstream, "/portal/ticket", {})
|
||||
expired = urllib.parse.urlsplit(hdr["Location"])
|
||||
time.sleep(5.1)
|
||||
status, _, hdr = request(platform, expired.path + "?" + expired.query)
|
||||
check("expired 5-second ticket denied with clean URL", status == 303 and "error=" in hdr["Location"] and "code=" not in hdr["Location"])
|
||||
|
||||
control("alpha", "revoke-vehicles")
|
||||
status, empty, _ = request(platform, "/api/vehicles", credential=alpha)
|
||||
check("vehicle revocation effective next request", status == 200 and empty["data"]["items"] == [])
|
||||
control("alpha", "restore-vehicles")
|
||||
control("alpha", "disable")
|
||||
status, _, _ = request(platform, "/api/v2/session", credential=alpha)
|
||||
check("OneOS account disable effective next request", status == 401)
|
||||
control("alpha", "enable")
|
||||
status, _, _ = request(platform, "/api/v2/auth/logout", {}, credential=beta)
|
||||
check("unified logout accepted", status == 200)
|
||||
status, _, _ = request(upstream, "/system/user/getInfo", headers={"Authorization": "Bearer " + beta["accessToken"], "ClientID": beta["clientId"]})
|
||||
check("logout revoked same token at OneOS", status == 401)
|
||||
status, _, _ = request(platform, "/api/v2/session", credential=beta)
|
||||
check("logout revoked same token at platform", status == 401)
|
||||
print(json.dumps({"passed": len(passed), "environment": "literal-loopback-only", "remoteCalls": 0}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user