From d7d844ae351cdbc50588801b52eafc3d30beaf11 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:48:41 -0400 Subject: [PATCH] GLXY Client Changes --- frogpilot/system/galaxy/galaxy.py | 75 +++++++++++++++++++-------- frogpilot/system/the_pond/the_pond.py | 22 +++++--- selfdrive/ui/qt/offroad/settings.cc | 61 ++++++++++++++++------ selfdrive/ui/qt/offroad/settings.h | 2 +- 4 files changed, 114 insertions(+), 46 deletions(-) diff --git a/frogpilot/system/galaxy/galaxy.py b/frogpilot/system/galaxy/galaxy.py index 4a8527194..b03856b11 100644 --- a/frogpilot/system/galaxy/galaxy.py +++ b/frogpilot/system/galaxy/galaxy.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +import hmac +import json import platform import shutil import signal @@ -20,25 +22,47 @@ AUTH_PORT = 8083 process = None auth_server = None +VALID_PATHS = {"/glxylogin": "glxyauth", "/glxyverify": "glxysession"} + class AuthHandler(BaseHTTPRequestHandler): - """Serves only GET /glxyauth — returns the PIN hash file contents.""" - def do_GET(self): - if self.path == "/glxyauth": - auth_file = GALAXY_DIR / "glxyauth" - if auth_file.exists(): - data = auth_file.read_bytes() + """Validates login hashes and verifies session tokens.""" + dongle_id = "" + + def do_POST(self): + target = VALID_PATHS.get(self.path) + if not target: + self.send_response(404) + self.end_headers() + return + + file_path = GALAXY_DIR / target + if not file_path.exists(): + self.send_response(404) + self.end_headers() + return + + try: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode("utf-8").strip() + if hmac.compare_digest(body, file_path.read_text().strip()): self.send_response(200) - self.send_header("Content-Type", "text/plain") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + if self.path == "/glxylogin": + self.send_header("Content-Type", "application/json") + self.end_headers() + token = (GALAXY_DIR / "glxysession").read_text().strip() + self.wfile.write(json.dumps({"dongle_id": self.dongle_id, "token": token}).encode()) + else: + self.end_headers() return - self.send_response(404) + except Exception: + pass + + self.send_response(403) self.end_headers() def log_message(self, format, *args): - pass # suppress request logs + pass def start_auth_server(): @@ -111,7 +135,6 @@ def main(): signal.signal(signal.SIGTERM, cleanup_frpc) signal.signal(signal.SIGINT, cleanup_frpc) - # Wait for DongleId to be set (usually set on boot/pairing) dongle_id = params.get("DongleId", encoding='utf8') while not dongle_id: print("Galaxy: Waiting for DongleId...") @@ -119,16 +142,22 @@ def main(): dongle_id = params.get("DongleId", encoding='utf8') print(f"Galaxy: DongleId: {dongle_id}") + AuthHandler.dongle_id = dongle_id print("Galaxy: Starting manager loop...") + last_slug = None + while True: glxyauth_file = GALAXY_DIR / "glxyauth" galaxy_password_hash = glxyauth_file.read_text().strip() if glxyauth_file.exists() else None - is_paired = galaxy_password_hash and len(galaxy_password_hash) == 64 + slug_file = GALAXY_DIR / "glxyslug" + slug = slug_file.read_text().strip() if slug_file.exists() else None + is_paired = galaxy_password_hash and len(galaxy_password_hash) == 64 and slug if is_paired: - if process is None or process.poll() is not None: - if process is not None: + if process is None or process.poll() is not None or slug != last_slug: + cleanup_frpc() + if process is not None and slug == last_slug: print(f"Galaxy: frpc exited with code {process.returncode}. Restarting...") print("Galaxy: Password set. Preparing frpc tunnel...") @@ -137,7 +166,6 @@ def main(): time.sleep(10) continue - # Start the tiny auth HTTP server (serves /glxyauth on localhost) start_auth_server() frpc_toml = GALAXY_DIR / "frpc.toml" @@ -150,37 +178,38 @@ tls.enable = true poolCount = 2 [[proxies]] -name = "{dongle_id}_pond" +name = "{slug}_pond" type = "http" localIP = "127.0.0.1" localPort = 8082 -customDomains = ["{dongle_id}.devices.local"] +customDomains = ["{slug}.devices.local"] transport.useCompression = true [[proxies]] -name = "{dongle_id}_auth" +name = "{slug}_auth" type = "http" localIP = "127.0.0.1" localPort = {AUTH_PORT} -customDomains = ["auth-{dongle_id}.devices.local"] +customDomains = ["auth-{slug}.devices.local"] """ frpc_toml.write_text(config) - print("Galaxy: Starting frpc tunnel...") + print(f"Galaxy: Starting frpc tunnel (slug: {slug[:4]}...)...") log_file = open(FRPC_LOG, 'a') process = subprocess.Popen( [str(GALAXY_DIR / "frpc"), "-c", str(frpc_toml)], stdout=log_file, stderr=log_file ) + last_slug = slug else: if process is not None and process.poll() is None: print("Galaxy: Password cleared. Stopping frpc tunnel...") cleanup_frpc() + last_slug = None time.sleep(3) if __name__ == "__main__": main() - diff --git a/frogpilot/system/the_pond/the_pond.py b/frogpilot/system/the_pond/the_pond.py index fd4ce7d13..2b612c93f 100644 --- a/frogpilot/system/the_pond/the_pond.py +++ b/frogpilot/system/the_pond/the_pond.py @@ -3210,10 +3210,11 @@ def setup(app): paired = GALAXY_AUTH_FILE.is_file() and len(GALAXY_AUTH_FILE.read_text().strip()) == 64 except Exception: paired = False - dongle_id = params.get("DongleId", encoding="utf8") or "" + slug_file = GALAXY_DIR / "glxyslug" + slug = slug_file.read_text().strip() if slug_file.is_file() else "" return jsonify({ "paired": paired, - "url": f"https://galaxy.firestar.link/{dongle_id}" if dongle_id else "", + "url": f"https://galaxy.firestar.link/{slug}" if slug else "", }) @app.route("/api/galaxy/pair", methods=["POST"]) @@ -3226,17 +3227,26 @@ def setup(app): pw_hash = hashlib.sha256(password.encode()).hexdigest() GALAXY_DIR.mkdir(parents=True, exist_ok=True) GALAXY_AUTH_FILE.write_text(pw_hash) + + # Generate 256-bit secure session token + (GALAXY_DIR / "glxysession").write_text(secrets.token_hex(32)) + + # Generate 16-character alphanumeric routing slug + charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + slug = ''.join(secrets.choice(charset) for _ in range(16)) + (GALAXY_DIR / "glxyslug").write_text(slug) - dongle_id = params.get("DongleId", encoding="utf8") or "" return jsonify({ "message": "Pairing successful!", - "url": f"https://galaxy.firestar.link/{dongle_id}" if dongle_id else "", + "url": f"https://galaxy.firestar.link/{slug}", }) @app.route("/api/galaxy/unpair", methods=["POST"]) def galaxy_unpair(): - if GALAXY_AUTH_FILE.is_file(): - GALAXY_AUTH_FILE.unlink() + for f in ["glxyauth", "glxysession", "glxyslug"]: + file_path = GALAXY_DIR / f + if file_path.is_file(): + file_path.unlink() return jsonify({"message": "Galaxy unpaired successfully."}) @app.route("/api/tailscale/installed", methods=["GET"]) diff --git a/selfdrive/ui/qt/offroad/settings.cc b/selfdrive/ui/qt/offroad/settings.cc index 36b7881e3..7001a7d77 100644 --- a/selfdrive/ui/qt/offroad/settings.cc +++ b/selfdrive/ui/qt/offroad/settings.cc @@ -6,6 +6,7 @@ #include #include +#include #include #include "common/watchdog.h" @@ -259,6 +260,16 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) { }); addItem(pair_device); + // Shared helper to show QR popup + auto showGalaxyQR = [=]() { + std::string slug = util::read_file("/data/galaxy/glxyslug"); + if (slug.empty()) return; + QString url = "https://galaxy.firestar.link/" + QString::fromStdString(slug); + GalaxyQRPopup *popup = new GalaxyQRPopup(url, this); + popup->exec(); + popup->deleteLater(); + }; + pair_galaxy = new ButtonControl(tr("Galaxy"), tr("Pair"), tr("Pair your device with Galaxy for remote access to The Pond.")); connect(pair_galaxy, &ButtonControl::clicked, [=]() { std::string current_password = util::read_file("/data/galaxy/glxyauth"); @@ -268,32 +279,50 @@ DevicePanel::DevicePanel(SettingsWindow *parent) : ListWidget(parent) { std::string hash = QCryptographicHash::hash(new_password.toUtf8(), QCryptographicHash::Sha256).toHex().toStdString(); util::create_directories("/data/galaxy", 0775); util::write_file("/data/galaxy/glxyauth", hash.data(), hash.size(), O_WRONLY | O_CREAT | O_TRUNC); + + // Generate 256-bit session token (64 hex chars) + QByteArray session_bytes(32, 0); + QRandomGenerator::securelySeeded().fillRange(reinterpret_cast(session_bytes.data()), 8); + std::string session_token = session_bytes.toHex().toStdString(); + util::write_file("/data/galaxy/glxysession", session_token.data(), session_token.size(), O_WRONLY | O_CREAT | O_TRUNC); + + // Generate 16-char URL-safe slug (alphanumeric only) + const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + std::string slug(16, '\0'); + auto *rng = QRandomGenerator::global(); + for (int i = 0; i < 16; i++) slug[i] = charset[rng->bounded(62)]; + util::write_file("/data/galaxy/glxyslug", slug.data(), slug.size(), O_WRONLY | O_CREAT | O_TRUNC); + pair_galaxy->setText(tr("Unpair")); - ConfirmationDialog::alert(tr("Pairing successful! Visit galaxy.firestar.link/") + getDongleId().value_or("") + tr(" to connect."), this); - galaxy_qr->setVisible(true); + galaxy_qr_btn->setVisible(true); + showGalaxyQR(); } } else { if (ConfirmationDialog::confirm(tr("Are you sure you want to unpair from Galaxy?"), tr("Unpair"), this)) { std::remove("/data/galaxy/glxyauth"); + std::remove("/data/galaxy/glxysession"); + std::remove("/data/galaxy/glxyslug"); pair_galaxy->setText(tr("Pair")); - galaxy_qr->setVisible(false); + galaxy_qr_btn->setVisible(false); } } }); + + // QR button — sits inline next to the Pair/Unpair button + galaxy_qr_btn = new QPushButton(tr("QR")); + galaxy_qr_btn->setFixedSize(250, 100); + galaxy_qr_btn->setStyleSheet(R"( + QPushButton { background-color: #393939; color: #E4E4E4; border-radius: 50px; font-size: 35px; font-weight: 500; } + QPushButton:pressed { background-color: #4a4a4a; } + )"); + galaxy_qr_btn->setVisible(false); + connect(galaxy_qr_btn, &QPushButton::clicked, showGalaxyQR); + if (QHBoxLayout *hlayout = pair_galaxy->findChild()) { + hlayout->insertWidget(3, galaxy_qr_btn); + } + addItem(pair_galaxy); - galaxy_qr = new ButtonControl(tr("Galaxy QR"), tr("SHOW"), tr("Show a QR code to quickly open Galaxy on your phone.")); - connect(galaxy_qr, &ButtonControl::clicked, [=]() { - auto dongleId = getDongleId(); - if (!dongleId) return; - - QString url = "https://galaxy.firestar.link/" + *dongleId; - GalaxyQRPopup *popup = new GalaxyQRPopup(url, this); - popup->exec(); - popup->deleteLater(); - }); - addItem(galaxy_qr); - // offroad-only buttons auto dcamBtn = new ButtonControl(tr("Driver Camera"), tr("PREVIEW"), @@ -437,7 +466,7 @@ void DevicePanel::showEvent(QShowEvent *event) { std::string galaxy_pin = util::read_file("/data/galaxy/glxyauth"); bool galaxy_paired = !galaxy_pin.empty(); pair_galaxy->setText(galaxy_paired ? tr("Unpair") : tr("Pair")); - galaxy_qr->setVisible(galaxy_paired); + galaxy_qr_btn->setVisible(galaxy_paired); ListWidget::showEvent(event); } diff --git a/selfdrive/ui/qt/offroad/settings.h b/selfdrive/ui/qt/offroad/settings.h index a5942adf4..2a46014df 100644 --- a/selfdrive/ui/qt/offroad/settings.h +++ b/selfdrive/ui/qt/offroad/settings.h @@ -86,7 +86,7 @@ private: Params params; ButtonControl *pair_device; ButtonControl *pair_galaxy; - ButtonControl *galaxy_qr; + QPushButton *galaxy_qr_btn; }; class TogglesPanel : public ListWidget {