From f0441904740dc80187aed6547ee569e9c98fe78a Mon Sep 17 00:00:00 2001 From: "marcus.hinz" Date: Thu, 25 Jun 2026 16:56:43 +0200 Subject: [PATCH] projects/vpn: Heron rootlos auf macOS (GlobalProtect/Duo-SAML via WKWebView + HIP), Reiher-Icon --- projects/vpn/README.md | 11 +- projects/vpn/build_heron_app.sh | 33 ++--- projects/vpn/heron_gp_saml.py | 196 +++++++++++++++++++++++++++++ projects/vpn/heron_mask.png | Bin 0 -> 11582 bytes projects/vpn/heron_vpn_tray.py | 214 +++++++++++++++++++++----------- projects/vpn/setup_heron.py | 2 + 6 files changed, 363 insertions(+), 93 deletions(-) create mode 100644 projects/vpn/heron_gp_saml.py create mode 100644 projects/vpn/heron_mask.png diff --git a/projects/vpn/README.md b/projects/vpn/README.md index 3c1c2fb..1a8369d 100644 --- a/projects/vpn/README.md +++ b/projects/vpn/README.md @@ -9,10 +9,13 @@ und eigenes Browser-Profil — mehrere VPNs laufen parallel ohne Konflikt. - **wuerth_vpn_tray.py** — Würth, Cisco AnyConnect (`--protocol=anyconnect`), User+Passwort. Fertig und in Betrieb. -- **heron_vpn_tray.py** — Heron, GlobalProtect (`--protocol=gp`). Gerüst fertig; - der SAML-POST-/Duo-Login ist auf macOS nicht sauber rootlos lösbar - (openconnect `--external-browser` bedient nur SAML-REDIRECT; gp-saml-gui - braucht WebKit2GTK). Unter Linux via gp-saml-gui umsetzbar. +- **heron_vpn_tray.py** — Heron, GlobalProtect (`--protocol=gp`), Duo-SAML. + Läuft rootlos auf macOS: Der SAML-Login (`heron_gp_saml.py`) erfolgt in einem + nativen WKWebView (pyobjc) mit GlobalProtect-User-Agent, greift den + `prelogin-cookie` aus den HTTP-Antwort-Headern ab (was QtWebEngine nicht kann) + und startet damit openconnect inkl. HIP-Report (`--csd-wrapper`) über ocproxy. + Der SAML-Teil ist ein eigener CLT-Python-Prozess (braucht pyobjc), die Tray-App + ruft ihn und reicht den Cookie an openconnect. ## Voraussetzungen diff --git a/projects/vpn/build_heron_app.sh b/projects/vpn/build_heron_app.sh index 927381d..a320893 100644 --- a/projects/vpn/build_heron_app.sh +++ b/projects/vpn/build_heron_app.sh @@ -14,25 +14,28 @@ cd "$APPS" # --- 0. Icon erzeugen (H-Badge -> icon_heron.icns) --- TMP="$(mktemp -d)" -QT_QPA_PLATFORM=offscreen "$PY" - "$TMP/h.png" <<'PYEOF' +QT_QPA_PLATFORM=offscreen "$PY" - "$APPS/heron_mask.png" "$TMP/h.png" <<'PYEOF' import sys -from PyQt6.QtGui import QImage, QPainter, QColor, QFont, QGuiApplication +from PyQt6.QtGui import QImage, QPainter, QColor, qAlpha, qRgba from PyQt6.QtCore import Qt -_app = QGuiApplication([]) # noetig fuer Font-Zugriff +# Reiher-Maske gruen einfaerben und mittig auf quadratische Flaeche setzen. +mask = QImage(sys.argv[1]).convertToFormat(QImage.Format.Format_ARGB32) +tgt = QColor("#2e7d32") +for y in range(mask.height()): + for x in range(mask.width()): + a = qAlpha(mask.pixel(x, y)) + if a: + mask.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a)) S = 1024 -img = QImage(S, S, QImage.Format.Format_ARGB32) -img.fill(Qt.GlobalColor.transparent) -p = QPainter(img) -p.setRenderHint(QPainter.RenderHint.Antialiasing) -p.setBrush(QColor("#2e7d32")) -p.setPen(Qt.PenStyle.NoPen) -m = int(S * 0.09) -p.drawRoundedRect(m, m, S - 2 * m, S - 2 * m, S * 0.18, S * 0.18) -p.setPen(QColor("#ffffff")) -f = QFont(); f.setBold(True); f.setPixelSize(int(S * 0.6)); p.setFont(f) -p.drawText(img.rect(), Qt.AlignmentFlag.AlignCenter, "H") +inner = int(S * 0.80) +scaled = mask.scaled(inner, inner, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) +canvas = QImage(S, S, QImage.Format.Format_ARGB32) +canvas.fill(Qt.GlobalColor.transparent) +p = QPainter(canvas) +p.drawImage((S - scaled.width()) // 2, (S - scaled.height()) // 2, scaled) p.end() -img.save(sys.argv[1]) +canvas.save(sys.argv[2]) PYEOF ISET="$TMP/icon.iconset"; mkdir -p "$ISET" for s in 16 32 128 256 512; do diff --git a/projects/vpn/heron_gp_saml.py b/projects/vpn/heron_gp_saml.py new file mode 100644 index 0000000..5663b84 --- /dev/null +++ b/projects/vpn/heron_gp_saml.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +PoC: GlobalProtect SAML-POST (Duo) auf macOS rootlos. + +Holt den GP-Prelogin, fuehrt das Duo-SAML in einem eingebetteten WKWebView durch, +greift den `prelogin-cookie`/`saml-username` aus den HTTP-Antwort-Headern ab +(was QtWebEngine nicht kann, das native WKWebView ueber den Navigation-Response- +Delegate aber schon) und startet damit openconnect rootlos ueber ocproxy. + +Start: /Library/Developer/CommandLineTools/usr/bin/python3 ~/apps/heron_gp_saml.py +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import shutil +import subprocess +import sys +import urllib.request + +import objc +from Cocoa import ( + NSApplication, NSObject, NSWindow, NSURL, NSMakeRect, + NSBackingStoreBuffered, +) +from Foundation import NSHTTPURLResponse, NSHTTPCookie +from WebKit import WKWebView, WKWebViewConfiguration + +SERVER = "ssl.heron.at" +PORT = 11090 +GP_UA = "PAN GlobalProtect" # GP liefert den prelogin-cookie nur dem Client-UA +NSWindowStyleMaskTitled = 1 +NSWindowStyleMaskClosable = 2 +NSWindowStyleMaskResizable = 8 +WKNavigationResponsePolicyAllow = 1 + +captured: dict[str, str] = {} + + +def _tool(n: str) -> str: + return shutil.which(n) or ( + f"/opt/homebrew/bin/{n}" if os.path.exists(f"/opt/homebrew/bin/{n}") else n + ) + + +def _hipreport() -> str | None: + import glob + for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh", + *glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"), + "/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]: + if os.path.exists(p): + return p + return None + + +def prelogin() -> tuple[str, str]: + """Liefert (saml_html, sessid).""" + # Gateway-Prelogin direkt (nicht das Portal) -> liefert den Gateway-Cookie. + url = (f"https://{SERVER}/ssl-vpn/prelogin.esp" + "?tmp=tmp&clientVer=4100&clientos=Mac") + req = urllib.request.Request(url, method="POST", data=b"", + headers={"User-Agent": GP_UA}) + with urllib.request.urlopen(req, timeout=20) as r: + xml = r.read().decode("utf-8", "replace") + sessid = "" + for c in r.headers.get_all("Set-Cookie") or []: + m = re.search(r"SESSID=([^;]+)", c) + if m: + sessid = m.group(1) + m = re.search(r"(.*?)", xml, re.S) + if not m: + raise SystemExit("Kein im Prelogin.") + return base64.b64decode(m.group(1)).decode("utf-8", "replace"), sessid + + +class Delegate(NSObject): + def webView_decidePolicyForNavigationAction_decisionHandler_( + self, webView, navAction, handler): + u = str(navAction.request().URL().absoluteString()) + print("NAV ->", u[:120], file=sys.stderr) + if u.startswith("globalprotectcallback:"): + payload = u[len("globalprotectcallback:"):] + try: + dec = base64.b64decode(payload).decode("utf-8", "replace") + except Exception: + dec = payload + print("CALLBACK-Payload:", dec[:500], file=sys.stderr) + for key in ("prelogin-cookie", "saml-username", + "portal-userauthcookie"): + m = re.search(rf"{key}[=:>]+\s*([^<&\"']+)", dec) + if m: + captured[key] = m.group(1).strip() + handler(0) # Cancel + if "prelogin-cookie" in captured: + NSApplication.sharedApplication().stop_(None) + return + handler(1) # Allow + + def webView_decidePolicyForNavigationResponse_decisionHandler_( + self, webView, navResponse, handler): + resp = navResponse.response() + if resp.isKindOfClass_(NSHTTPURLResponse): + for k, v in resp.allHeaderFields().items(): + lk = str(k).lower() + if lk in ("prelogin-cookie", "saml-username", + "portal-userauthcookie", "saml-auth-status"): + captured[lk] = str(v) + handler(WKNavigationResponsePolicyAllow) + if "prelogin-cookie" in captured and "saml-username" in captured: + NSApplication.sharedApplication().stop_(None) + + def webView_didFinishNavigation_(self, webView, nav): + # Nur die SAML-Weiterleitungsform (id=myform) anstossen, sonst wuerde + # auch das Duo-Login-Formular dauernd neu abgesendet (Flackern). + webView.evaluateJavaScript_completionHandler_( + "var f=document.getElementById('myform'); if(f){f.submit();}", None) + + +def run_saml(html: str, sessid: str): + app = NSApplication.sharedApplication() + app.setActivationPolicy_(0) # Regular -> Fenster bekommt sauber den Fokus + rect = NSMakeRect(0, 0, 540, 720) + style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable + | NSWindowStyleMaskResizable) + win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + rect, style, NSBackingStoreBuffered, False) + win.setTitle_("Heron SSO-Login") + + cfg = WKWebViewConfiguration.alloc().init() + wv = WKWebView.alloc().initWithFrame_configuration_(rect, cfg) + wv.setCustomUserAgent_(GP_UA) + delegate = Delegate.alloc().init() + wv.setNavigationDelegate_(delegate) + win.setContentView_(wv) + win.makeKeyAndOrderFront_(None) + + base = NSURL.URLWithString_(f"https://{SERVER}/") + + def load(_=None): + wv.loadHTMLString_baseURL_(html, base) + + if sessid: + store = cfg.websiteDataStore().httpCookieStore() + cookie = NSHTTPCookie.cookieWithProperties_({ + "Name": "SESSID", "Value": sessid, "Domain": SERVER, + "Path": "/", "Secure": "TRUE", + }) + store.setCookie_completionHandler_(cookie, load) + else: + load() + + app.activateIgnoringOtherApps_(True) + app.run() + + +def main(): + auth_only = "--auth-only" in sys.argv + html, sessid = prelogin() + run_saml(html, sessid) + + if "prelogin-cookie" not in captured: + print(f"Kein prelogin-cookie erhalten. Erfasst: {list(captured)}", + file=sys.stderr) + raise SystemExit(2) + + if auth_only: + # Nur den Cookie als JSON auf stdout -> die Tray-App liest ihn. + print(json.dumps({ + "prelogin-cookie": captured["prelogin-cookie"], + "saml-username": captured.get("saml-username", ""), + })) + return + + print("Erfasst:", {k: v[:20] + "…" for k, v in captured.items()}, + file=sys.stderr) + cmd = [ + _tool("openconnect"), "--protocol=gp", + "--user", captured.get("saml-username", ""), + "--usergroup=gateway:prelogin-cookie", "--passwd-on-stdin", + "--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun", + ] + hip = _hipreport() + if hip: + cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway + cmd.append(SERVER) + print("Starte:", " ".join(cmd), file=sys.stderr) + p = subprocess.Popen(cmd, stdin=subprocess.PIPE) + p.stdin.write((captured["prelogin-cookie"] + "\n").encode()) + p.stdin.flush() + p.wait() + + +if __name__ == "__main__": + main() diff --git a/projects/vpn/heron_mask.png b/projects/vpn/heron_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..4e11070f77753baff4fa04ce96394918e4af73ce GIT binary patch literal 11582 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 literal 0 HcmV?d00001 diff --git a/projects/vpn/heron_vpn_tray.py b/projects/vpn/heron_vpn_tray.py index ef352b8..611fc9c 100644 --- a/projects/vpn/heron_vpn_tray.py +++ b/projects/vpn/heron_vpn_tray.py @@ -8,14 +8,18 @@ VPN schickt und alles andere direkt. Start/Stop ueber das Tray-Icon. Eigene Ports/Profil/Bundle -> laeuft parallel zur Wuerth-App. -Abhaengigkeiten: - openconnect + ocproxy auf dem PATH (macOS: brew install openconnect ocproxy) +Auth: GlobalProtect-SAML (Duo). Der SAML-Login laeuft als eigener Cocoa/pyobjc- +Prozess (heron_gp_saml.py --auth-only, via CLT-Python) und liefert nur den +prelogin-cookie; diese App startet damit openconnect (+HIP-Report) ueber ocproxy. -ZU FUELLEN: VPN_USER, INTERNAL_GLOB, START_URL, BOOKMARKS. +Abhaengigkeiten: + openconnect + ocproxy (macOS: brew install openconnect ocproxy), + CLT-Python mit pyobjc (pyobjc-framework-WebKit) fuer heron_gp_saml.py. """ from __future__ import annotations +import glob as _glob import json import os import platform @@ -26,30 +30,32 @@ import subprocess import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from PyQt6.QtCore import QTimer, Qt -from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap +from PyQt6.QtCore import QProcess, QProcessEnvironment, QTimer, Qt +from PyQt6.QtGui import ( + QAction, QColor, QIcon, QImage, QPainter, QPixmap, qAlpha, qRgba, +) from PyQt6.QtWidgets import ( - QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, QMessageBox, + QApplication, QSystemTrayIcon, QMenu, QMessageBox, ) # ===== CONFIG ================================================================= APP_NAME = "Heron VPN" PROTOCOL = "gp" # GlobalProtect (Palo Alto) SERVER = "ssl.heron.at" -AUTH = "saml" # "saml" = SSO via externem Browser, "password" -VPN_USER = "marcus.hinz@robotunits.com" # nur fuer AUTH="password" noetig -PORT = 11090 # SOCKS5 (ocproxy) – andere Ports als Wuerth +PORT = 11090 # SOCKS5 (ocproxy) – andere Ports als Wuerth PACPORT = 11091 # lokaler PAC-HTTP-Server -INTERNAL_GLOB = "*.heron.at" # <-- interne Hosts anpassen (ueber VPN) +# Hosts, die ueber das VPN laufen (Domain-Globs und/oder IP-Globs). +INTERNAL_GLOBS = ["*.heron.at", "172.16.*"] PROFILE = os.path.expanduser("~/.heron-edge") -START_URL = "https://robotunits.com" # <-- gewuenschte Startseite +START_URL = "https://robotunits.com" LOGFILE = os.path.expanduser("~/.heron-vpn.log") PAC_NAME = "heron.pac" +CLT_PYTHON = "/Library/Developer/CommandLineTools/usr/bin/python3" # hat pyobjc -# Lesezeichen, die ins frische Profil gesaet werden (Name, URL). <-- anpassen +# Lesezeichen, die ins frische Profil gesaet werden (Name, URL). BOOKMARKS: list[tuple[str, str]] = [ - ("Heron", "https://ssl.heron.at"), - + ("Storybook", "http://172.16.80.30:30060/"), + ("Heron", "https://heron.at"), ] # ============================================================================= @@ -123,20 +129,31 @@ def _tool(name: str) -> str: return name -def _external_browser() -> str: - """Programm, das openconnect fuer das SAML-Login mit der SSO-URL aufruft.""" - s = _system() - if s == "Darwin": - return "/usr/bin/open" # oeffnet Standardbrowser mit der URL - if s == "Windows": - return "cmd" # TODO: 'cmd /c start' verifizieren - return shutil.which("xdg-open") or "xdg-open" +def _saml_script() -> str: + """Pfad zu heron_gp_saml.py (im Bundle unter Resources, sonst neben uns).""" + base = os.environ.get( + "RESOURCEPATH", os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base, "heron_gp_saml.py") + + +def _hipreport() -> str | None: + for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh", + *_glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"), + "/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]: + if os.path.exists(p): + return p + return None + + +def _resolver_rules() -> str: + return " , ".join(f"MAP {g} ~NOTFOUND" for g in INTERNAL_GLOBS) # ---------- PAC-Server (in-process) ------------------------------------------ +_PAC_COND = " || ".join(f'shExpMatch(host, "{g}")' for g in INTERNAL_GLOBS) PAC_BODY = ( "function FindProxyForURL(url, host) {\n" - f' if (shExpMatch(host, "{INTERNAL_GLOB}")) {{\n' + f" if ({_PAC_COND}) {{\n" f' return "SOCKS5 127.0.0.1:{PORT}";\n' " }\n" ' return "DIRECT";\n' @@ -163,28 +180,46 @@ def start_pac_server() -> ThreadingHTTPServer: return srv -# ---------- Icon (generiertes "H"-Badge in Zustandsfarbe) -------------------- +# ---------- Icon (Reiher-Silhouette in Zustandsfarbe) ------------------------ _ICON_CACHE: dict[str, QIcon] = {} +_MASK: QImage | None = None -def badge_icon(color: str) -> QIcon: +def _heron_mask() -> QImage: + global _MASK + if _MASK is None: + base = os.environ.get( + "RESOURCEPATH", os.path.dirname(os.path.abspath(__file__))) + img = QImage(os.path.join(base, "heron_mask.png")) + _MASK = (img.scaled(44, 44, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + .convertToFormat(QImage.Format.Format_ARGB32) + if not img.isNull() else QImage()) + return _MASK + + +def heron_icon(color: str) -> QIcon: if color in _ICON_CACHE: return _ICON_CACHE[color] - pm = QPixmap(44, 44) - pm.fill(Qt.GlobalColor.transparent) - p = QPainter(pm) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - p.setBrush(QColor(color)) - p.setPen(Qt.PenStyle.NoPen) - p.drawRoundedRect(4, 4, 36, 36, 9, 9) - p.setPen(QColor("#ffffff")) - f = p.font() - f.setBold(True) - f.setPixelSize(26) - p.setFont(f) - p.drawText(pm.rect(), Qt.AlignmentFlag.AlignCenter, "H") - p.end() - icon = QIcon(pm) + m = _heron_mask() + if m.isNull(): # Fallback: einfacher Punkt + pm = QPixmap(22, 22) + pm.fill(Qt.GlobalColor.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setBrush(QColor(color)) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(4, 4, 14, 14) + p.end() + return QIcon(pm) + tgt = QColor(color) + o = QImage(m) + for y in range(o.height()): + for x in range(o.width()): + a = qAlpha(o.pixel(x, y)) + if a: + o.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a)) + icon = QIcon(QPixmap.fromImage(o)) _ICON_CACHE[color] = icon return icon @@ -195,13 +230,13 @@ class VpnTray: self.app = app self.oc: subprocess.Popen | None = None self.browser: subprocess.Popen | None = None - self.vpn_pw: str | None = None + self.auth_proc: QProcess | None = None self.state = "disconnected" self.pac = start_pac_server() self.tray = QSystemTrayIcon() - self.tray.setIcon(badge_icon("#9a9a9a")) + self.tray.setIcon(heron_icon("#9a9a9a")) self.tray.setToolTip(f"{APP_NAME} – getrennt") self.menu = QMenu() @@ -240,7 +275,7 @@ class VpnTray: "connected": ("#2e7d32", "Verbunden"), "error": ("#cc3333", "Fehler"), }[state] - self.tray.setIcon(badge_icon(color)) + self.tray.setIcon(heron_icon(color)) self.tray.setToolTip(f"{APP_NAME} – {label}") self.act_status.setText(label) self.act_connect.setEnabled(state in ("disconnected", "error")) @@ -248,48 +283,79 @@ class VpnTray: self.act_open.setEnabled(state == "connected") def connect(self): - base = [ - _tool("openconnect"), - f"--protocol={PROTOCOL}", - "--script", f"{_tool('ocproxy')} -D {PORT}", - "--script-tun", + if self.auth_proc is not None: + return + self._set_state("connecting") + # SAML-Login als eigener pyobjc/WKWebView-Prozess -> liefert Cookie als JSON. + self.auth_proc = QProcess() + # Bereinigte Umgebung: sonst erbt das CLT-Python die PYTHONHOME/PATH des + # py2app-Bundles und findet sein pyobjc nicht (Subprozess stirbt sofort). + env = QProcessEnvironment.systemEnvironment() + for v in ("PYTHONHOME", "PYTHONPATH", "PYTHONEXECUTABLE", + "PYTHONNOUSERSITE", "PYTHONDONTWRITEBYTECODE"): + env.remove(v) + self.auth_proc.setProcessEnvironment(env) + self.auth_proc.finished.connect(self._on_auth_done) + self.auth_proc.start(CLT_PYTHON, [_saml_script(), "--auth-only"]) + if not self.auth_proc.waitForStarted(3000): + self.auth_proc = None + self._set_state("error") + QMessageBox.critical(None, APP_NAME, + "SAML-Login (CLT-Python/pyobjc) nicht startbar.") + + def _on_auth_done(self, code, status): + out = bytes(self.auth_proc.readAllStandardOutput()).decode("utf-8", "replace") + self.auth_proc = None + auth = None + for line in out.strip().splitlines(): + line = line.strip() + if line.startswith("{"): + try: + auth = json.loads(line) + except Exception: + pass + if not auth or not auth.get("prelogin-cookie"): + self._set_state("error") + return + self._start_openconnect(auth.get("saml-username", ""), + auth["prelogin-cookie"]) + + def _start_openconnect(self, user: str, cookie: str): + # Reste eines frueheren Laufs wegraeumen, sonst Portkonflikt auf SOCKS. + if _system() in ("Darwin", "Linux"): + subprocess.run(["pkill", "-f", f"ocproxy -D {PORT}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + cmd = [ + _tool("openconnect"), f"--protocol={PROTOCOL}", + "--user", user, "--usergroup=gateway:prelogin-cookie", + "--passwd-on-stdin", + "--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun", ] - send_pw = None - - if AUTH == "saml": - # SSO: openconnect oeffnet den Standardbrowser fuer das Login. - cmd = base + ["--external-browser", _external_browser(), - f"https://{SERVER}"] - else: - if not self.vpn_pw: - pw, ok = QInputDialog.getText( - None, APP_NAME, f"VPN-Passwort fuer {VPN_USER}:", - QLineEdit.EchoMode.Password, - ) - if not (ok and pw): - return - self.vpn_pw = pw - send_pw = self.vpn_pw - cmd = base + ["--user", VPN_USER, "--passwd-on-stdin", - f"https://{SERVER}"] - + hip = _hipreport() + if hip: + cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway + cmd.append(SERVER) try: self._log = open(LOGFILE, "wb") self.oc = subprocess.Popen( - cmd, stdin=subprocess.PIPE if send_pw else None, + cmd, stdin=subprocess.PIPE, stdout=self._log, stderr=subprocess.STDOUT, ) - if send_pw: - self.oc.stdin.write((send_pw + "\n").encode()) - self.oc.stdin.flush() + self.oc.stdin.write((cookie + "\n").encode()) + self.oc.stdin.flush() except FileNotFoundError: self._set_state("error") QMessageBox.critical(None, APP_NAME, "openconnect nicht gefunden.") return - - self._set_state("connecting") + # bleibt "connecting"; _tick erkennt den offenen SOCKS-Port def disconnect(self): + if self.auth_proc is not None: + try: + self.auth_proc.kill() + except Exception: + pass + self.auth_proc = None self._kill_browser() if self.oc and self.oc.poll() is None: try: @@ -318,7 +384,7 @@ class VpnTray: argv = browser_argv() + [ f"--user-data-dir={PROFILE}", f"--proxy-pac-url=http://127.0.0.1:{PACPORT}/{PAC_NAME}", - f"--host-resolver-rules=MAP {INTERNAL_GLOB} ~NOTFOUND", + f"--host-resolver-rules={_resolver_rules()}", "--no-first-run", "--no-default-browser-check", "--disable-features=msSmartScreenProtection", "--test-type", diff --git a/projects/vpn/setup_heron.py b/projects/vpn/setup_heron.py index 2c954f3..6b8d82b 100644 --- a/projects/vpn/setup_heron.py +++ b/projects/vpn/setup_heron.py @@ -6,6 +6,7 @@ Bauen: cd ~/apps && bash build_heron_app.sh from setuptools import setup APP = ["heron_vpn_tray.py"] +DATA_FILES = ["heron_gp_saml.py", "heron_mask.png"] # Auth-Prozess + Icon-Maske OPTIONS = { "iconfile": "icon_heron.icns", "packages": ["PyQt6"], @@ -23,6 +24,7 @@ OPTIONS = { setup( app=APP, name="HeronVPN", + data_files=DATA_FILES, options={"py2app": OPTIONS}, setup_requires=["py2app"], )