From 7d46313213787e1f982112979bc85bce7110633e Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:50:57 -0400 Subject: [PATCH] Sluglas, the Stripper Slug --- .../assets/components/sentry_notifications.js | 9 +- .../assets/components/tools/sentry.js | 11 +- .../system/the_galaxy/assets/js/utils.js | 1 + .../tests/test_sentry_push_and_routing.py | 140 ++++++++++++++++++ starpilot/system/the_galaxy/the_galaxy.py | 85 +++++++++-- starpilot/third_party/py_vapid/__init__.py | 15 +- 6 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 starpilot/system/the_galaxy/tests/test_sentry_push_and_routing.py diff --git a/starpilot/system/the_galaxy/assets/components/sentry_notifications.js b/starpilot/system/the_galaxy/assets/components/sentry_notifications.js index cb378cecb..3def6c4c9 100644 --- a/starpilot/system/the_galaxy/assets/components/sentry_notifications.js +++ b/starpilot/system/the_galaxy/assets/components/sentry_notifications.js @@ -24,7 +24,7 @@ function rememberEvent(eventId) { async function pollSentryEvent() { try { - const response = await fetch("/api/sentry/status", { cache: "no-store" }) + const response = await fetch(galaxyPath("/api/sentry/status"), { cache: "no-store" }) if (!response.ok) return const payload = await response.json() const event = payload?.lastEvent @@ -81,6 +81,13 @@ async function readJsonResponse(response) { try { return JSON.parse(body) } catch { + const contentType = response.headers?.get("content-type") || "" + if (contentType.includes("text/html") || body.trim().startsWith("<")) { + if (response.status === 200) { + throw new Error("Galaxy returned an HTML page instead of JSON data. Check your session or connection.") + } + throw new Error(`Galaxy returned an HTML error page (${response.status}). Check the device connection or Galaxy tunnel.`) + } throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) } } diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.js b/starpilot/system/the_galaxy/assets/components/tools/sentry.js index 3700c1b71..e8b3e9e23 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.js +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.js @@ -108,14 +108,14 @@ async function sendTestEvent() { state.testBusy = true try { const response = await fetch(galaxyPath("/api/sentry/test"), { method: "POST" }) - const payload = await response.json() + const payload = await readJsonResponse(response) if (!response.ok) { showSnackbar(payload.error || "Sentry test failed.") return } showSnackbar("Test capture started. The images will appear here shortly.") } catch (error) { - showSnackbar("Network error — is the device reachable?") + showSnackbar(error.message || "Network error — is the device reachable?") } finally { state.testBusy = false } @@ -128,6 +128,13 @@ async function readJsonResponse(response) { try { return JSON.parse(body) } catch { + const contentType = response.headers?.get("content-type") || "" + if (contentType.includes("text/html") || body.trim().startsWith("<")) { + if (response.status === 200) { + throw new Error("Galaxy returned an HTML page instead of JSON data. Check your session or connection.") + } + throw new Error(`Galaxy returned an HTML error page (${response.status}). Check the device connection or Galaxy tunnel.`) + } throw new Error(`Galaxy returned an unexpected ${response.status} response. Check the device connection or Galaxy tunnel.`) } } diff --git a/starpilot/system/the_galaxy/assets/js/utils.js b/starpilot/system/the_galaxy/assets/js/utils.js index b00d24aac..d66bd6f45 100644 --- a/starpilot/system/the_galaxy/assets/js/utils.js +++ b/starpilot/system/the_galaxy/assets/js/utils.js @@ -91,6 +91,7 @@ export function isGalaxyTunnel() { export function galaxyPath(path) { const suffix = path.startsWith("/") ? path : `/${path}` if (!isGalaxyTunnel()) return suffix + if (suffix === "/api" || suffix.startsWith("/api/")) return suffix const firstPathSegment = window.location.pathname.split("/").filter(Boolean)[0] || "" const slug = /^[A-Za-z0-9]{16}$/.test(firstPathSegment) ? `/${firstPathSegment}` : "" diff --git a/starpilot/system/the_galaxy/tests/test_sentry_push_and_routing.py b/starpilot/system/the_galaxy/tests/test_sentry_push_and_routing.py new file mode 100644 index 000000000..63f73d089 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_sentry_push_and_routing.py @@ -0,0 +1,140 @@ +import json +import time +from pathlib import Path +import pytest + +from test_dashboard_stats import FakeParams, MODULE_DIR, _install_server_import_stubs + + +def _load_server_module(): + import importlib.util + import sys + + _install_server_import_stubs() + spec = importlib.util.spec_from_file_location("sentry_routing_server", MODULE_DIR / "the_galaxy.py") + module = importlib.util.module_from_spec(spec) + sys.modules["sentry_routing_server"] = module + spec.loader.exec_module(module) + return module + + +the_galaxy = _load_server_module() + + +@pytest.fixture +def client(monkeypatch, tmp_path): + assert the_galaxy._import_galaxy_web_symbols() + monkeypatch.setattr(the_galaxy, "params", FakeParams()) + monkeypatch.setattr(the_galaxy, "_get_galaxy_dir", lambda: tmp_path) + + app = the_galaxy.Flask( + f"test_galaxy_{time.monotonic_ns()}", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + the_galaxy.setup(app) + return app.test_client() + + +def test_slug_middleware_strips_16_char_slug(client): + # Slug-prefixed API call to sentry push config + response = client.get("/df70390ca648d7c3/api/sentry/push/config") + assert response.status_code == 200 + assert "application/json" in response.headers.get("Content-Type", "") + data = response.get_json() + assert data["enabled"] is True + assert len(data["publicKey"]) > 20 + + # Direct unslugged API call + response_direct = client.get("/api/sentry/push/config") + assert response_direct.status_code == 200 + assert response_direct.get_json()["publicKey"] == data["publicKey"] + + +def test_slug_middleware_service_worker_and_headers(client): + with client.get("/df70390ca648d7c3/service-worker.js") as response: + assert response.status_code == 200 + assert response.headers.get("Service-Worker-Allowed") == "/" + assert "no-store" in response.headers.get("Cache-Control", "") + + with client.get("/service-worker.js") as response_direct: + assert response_direct.status_code == 200 + assert response_direct.headers.get("Service-Worker-Allowed") == "/" + + +def test_404_api_returns_json_not_html(client): + # Non-existent API route without slug + res1 = client.get("/api/nonexistent") + assert res1.status_code == 404 + assert "application/json" in res1.headers.get("Content-Type", "") + assert res1.get_json() == {"error": "Not found"} + + # Non-existent API route with slug + res2 = client.get("/df70390ca648d7c3/api/nonexistent") + assert res2.status_code == 404 + assert "application/json" in res2.headers.get("Content-Type", "") + assert res2.get_json() == {"error": "Not found"} + + # POST to non-existent route returns 404 JSON + res3 = client.post("/random_post_route") + assert res3.status_code == 404 + assert "application/json" in res3.headers.get("Content-Type", "") + + +def test_404_assets_returns_not_found_text(client): + res = client.get("/assets/nonexistent_image.png") + assert res.status_code == 404 + assert res.get_data(as_text=True) == "Not found" + + +def test_404_spa_client_routes_return_html(client): + # SPA route without slug returns index.html + res1 = client.get("/sentry") + assert res1.status_code == 200 + assert "text/html" in res1.headers.get("Content-Type", "") + + # SPA route with slug returns index.html + res2 = client.get("/df70390ca648d7c3/sentry") + assert res2.status_code == 200 + assert "text/html" in res2.headers.get("Content-Type", "") + + +def test_sentry_push_subscribe_lifecycle(client): + subscription_payload = { + "endpoint": "https://fcm.googleapis.com/fcm/send/test-endpoint-id", + "expirationTime": None, + "keys": { + "p256dh": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-Skv60QVu3vW5PFGhmqazETUFAmeLbvDWP00n-5wViBRio5B-dQ31-10", + "auth": "5KkU95j6j8gBsmVdYqC8pA", + }, + } + + res = client.post( + "/api/sentry/push/subscribe", + data=json.dumps(subscription_payload), + content_type="application/json", + ) + assert res.status_code == 200 + assert res.get_json()["subscribed"] is True + assert res.get_json()["subscriptionCount"] == 1 + + # Check config shows count 1 + res_cfg = client.get("/api/sentry/push/config") + assert res_cfg.get_json()["subscriptionCount"] == 1 + + +def test_sentry_vapid_corrupt_file_self_healing(tmp_path, monkeypatch): + monkeypatch.setattr(the_galaxy, "_get_galaxy_dir", lambda: tmp_path) + key_path, _ = the_galaxy._sentry_push_paths() + key_path.parent.mkdir(parents=True, exist_ok=True) + + # Write 0-byte corrupted file + key_path.write_bytes(b"") + assert key_path.stat().st_size == 0 + + # Should self-heal and generate valid key + vapid = the_galaxy._get_sentry_vapid() + assert vapid is not None + assert key_path.stat().st_size > 0 + pub_key = the_galaxy._sentry_vapid_public_key(vapid) + assert len(pub_key) > 20 diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 3b12cad4c..87b94f54f 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -6,6 +6,7 @@ import importlib import math import numbers import os +import platform import sys import sysconfig import tarfile @@ -186,7 +187,9 @@ def _galaxy_runtime_dependency_paths() -> tuple[str, ...]: "/usr/local/venv/lib/python3.12/site-packages", ] - for venv_name in (".venv", ".venv-linux-arm64"): + is_arm = platform.machine().lower() in ("aarch64", "arm64") + venv_names = (".venv-linux-arm64", ".venv") if is_arm else (".venv",) + for venv_name in venv_names: venv_path = repo_root / venv_name / "lib" if venv_path.is_dir(): candidates.extend(str(path) for path in venv_path.glob("python*/site-packages")) @@ -196,10 +199,14 @@ def _galaxy_runtime_dependency_paths() -> tuple[str, ...]: REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party" GALAXY_RUNTIME_DEPENDENCY_PATHS = _galaxy_runtime_dependency_paths() -for deps_path in GALAXY_DEPS_PATHS + GALAXY_RUNTIME_DEPENDENCY_PATHS: +for deps_path in GALAXY_DEPS_PATHS: if os.path.isdir(deps_path) and deps_path not in sys.path: sys.path.insert(0, deps_path) +for deps_path in GALAXY_RUNTIME_DEPENDENCY_PATHS: + if os.path.isdir(deps_path) and deps_path not in sys.path: + sys.path.append(deps_path) + if REPO_THIRD_PARTY_PATH.is_dir() and str(REPO_THIRD_PARTY_PATH) not in sys.path: sys.path.insert(0, str(REPO_THIRD_PARTY_PATH)) @@ -949,16 +956,23 @@ def _get_sentry_vapid(): except ModuleNotFoundError as error: raise RuntimeError("pywebpush is not installed") from error - private_key_path, _ = _sentry_push_paths() - private_key_path.parent.mkdir(parents=True, exist_ok=True) - if private_key_path.is_file(): - return Vapid.from_file(str(private_key_path)) + with _SENTRY_PUSH_LOCK: + private_key_path, _ = _sentry_push_paths() + private_key_path.parent.mkdir(parents=True, exist_ok=True) + if private_key_path.is_file(): + try: + if private_key_path.stat().st_size > 0: + return Vapid.from_file(str(private_key_path)) + except Exception as error: + cloudlog.warning("Galaxy: Existing Sentry VAPID private key was invalid, regenerating: %s", error) - vapid = Vapid() - vapid.generate_keys() - vapid.save_key(str(private_key_path)) - private_key_path.chmod(0o600) - return vapid + vapid = Vapid() + vapid.generate_keys() + temporary_path = private_key_path.with_suffix(".tmp") + vapid.save_key(str(temporary_path)) + temporary_path.chmod(0o600) + temporary_path.replace(private_key_path) + return vapid def _sentry_vapid_public_key(vapid) -> str: @@ -4959,7 +4973,30 @@ def _set_lateral_maneuver_mode(enabled): return _save_lateral_maneuver_status(status) + +_SLUG_PREFIX_RE = re.compile(r"^/([A-Za-z0-9]{16})(/.*)?$") + + +class GalaxySlugMiddleware: + """WSGI middleware to normalize reverse-proxy requests prefixed with a 16-character tunnel slug.""" + + def __init__(self, wsgi_app): + self.wsgi_app = wsgi_app + + def __call__(self, environ, start_response): + path_info = environ.get("PATH_INFO", "") + match = _SLUG_PREFIX_RE.match(path_info) + if match: + environ["HTTP_X_GALAXY_SLUG"] = match.group(1) + remainder = match.group(2) + environ["PATH_INFO"] = remainder if remainder else "/" + return self.wsgi_app(environ, start_response) + + def setup(app): + if not isinstance(app.wsgi_app, GalaxySlugMiddleware): + app.wsgi_app = GalaxySlugMiddleware(app.wsgi_app) + model_status_debug = { "last_signature": None, "last_log_time": 0.0, @@ -5005,6 +5042,19 @@ def setup(app): @app.errorhandler(404) def not_found(_): + is_api = ( + request.path == "/api" + or request.path.startswith("/api/") + or "/api/" in request.path + or request.is_json + or (request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html) + ) + if is_api or request.method not in ("GET", "HEAD"): + return jsonify({"error": "Not found"}), 404 + + if request.path.startswith(("/assets/", "/screen_recordings/", "/thumbnails/", "/video/")): + return "Not found", 404 + response = make_response(render_template("index.html")) response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" @@ -8186,14 +8236,19 @@ def setup(app): def sentry_service_worker(): response = send_from_directory(app.static_folder, "service-worker.js", mimetype="application/javascript") response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Service-Worker-Allowed"] = "/" return response @app.route("/api/sentry/push/config", methods=["GET"]) def sentry_push_config(): try: public_key = _sentry_vapid_public_key(_get_sentry_vapid()) - except Exception: + except (RuntimeError, ModuleNotFoundError) as error: + cloudlog.warning("Galaxy: Sentry Web Push dependencies unavailable: %s", error) return jsonify({"enabled": False, "error": "Web Push dependencies are unavailable."}), 503 + except Exception as error: + cloudlog.exception("Galaxy: Failed to initialize Sentry Web Push: %s", error) + return jsonify({"enabled": False, "error": f"Push notification service error: {error}"}), 500 return jsonify({ "enabled": True, @@ -8209,8 +8264,12 @@ def setup(app): try: _get_sentry_vapid() - except Exception: + except (RuntimeError, ModuleNotFoundError) as error: + cloudlog.warning("Galaxy: Sentry Web Push dependencies unavailable: %s", error) return jsonify({"error": "Web Push dependencies are unavailable."}), 503 + except Exception as error: + cloudlog.exception("Galaxy: Failed to initialize Sentry Web Push for subscription: %s", error) + return jsonify({"error": f"Push notification service error: {error}"}), 500 with _SENTRY_PUSH_LOCK: subscriptions = _load_sentry_push_subscriptions() diff --git a/starpilot/third_party/py_vapid/__init__.py b/starpilot/third_party/py_vapid/__init__.py index 4a6eff248..fc5e1119c 100644 --- a/starpilot/third_party/py_vapid/__init__.py +++ b/starpilot/third_party/py_vapid/__init__.py @@ -86,9 +86,16 @@ class Vapid01(object): :type private_key: bytes """ - # not sure why, but load_pem_private_key fails to deserialize - return cls.from_der( - b''.join(private_key.splitlines()[1:-1])) + try: + key = serialization.load_pem_private_key( + private_key, + password=None, + backend=default_backend() + ) + return cls(key) + except Exception: + lines = [line.strip() for line in private_key.splitlines() if line.strip() and not line.strip().startswith(b"-----")] + return cls.from_der(b''.join(lines)) @classmethod def from_der(cls, private_key): @@ -197,7 +204,7 @@ class Vapid01(object): def generate_keys(self): """Generate a valid ECDSA Key Pair.""" - self.private_key = ec.generate_private_key(ec.SECP256R1, + self.private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) def private_pem(self):