#!/usr/bin/env python3 """ Wuerth VPN – plattformuebergreifende Tray-App (PyQt6). Baut das VPN rootlos auf (openconnect + ocproxy als SOCKS5) und startet einen dedizierten Browser, der per PAC nur die internen Hosts ueber das VPN schickt und alles andere direkt. Start/Stop ueber das Tray-Icon. macOS/Linux: lauffaehig. Windows: rootloser ocproxy-Pfad noch zu verifizieren. Abhaengigkeiten: pip3 install PyQt6 --break-system-packages openconnect + ocproxy auf dem PATH (macOS: brew install openconnect ocproxy) """ from __future__ import annotations import json import os import platform import shutil import signal import socket import subprocess import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from PyQt6.QtCore import QTimer, Qt from PyQt6.QtGui import ( QAction, QColor, QIcon, QImage, QPainter, QPixmap, qAlpha, qBlue, qGreen, qRed, qRgba, ) from PyQt6.QtWidgets import ( QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, QMessageBox, ) # ===== CONFIG ================================================================= SERVER = "loki.witglobal.net" VPN_USER = "ex08802409" PORT = 11080 # SOCKS5 (ocproxy) PACPORT = 11081 # lokaler PAC-HTTP-Server INTERNAL_GLOB = "*.wgn.wuerth.com" # nur diese Hosts ueber das VPN PROFILE = os.path.expanduser("~/.wuerth-edge") START_URL = "https://www.wuerth.com" ICON_PATH = os.path.join( os.environ.get("RESOURCEPATH", os.path.dirname(os.path.abspath(__file__))), "wuerth.png", ) # Lesezeichen, die ins frische Profil gesaet werden (Name, URL). BOOKMARKS = [ ("ArgoCD Dev", "https://gitops.apps.ocp-dev01.wgn.wuerth.com"), ("ArgoCD Prod", "https://gitops.apps.ocp-01.wgn.wuerth.com/"), ("OpenShift DEV", "https://console-openshift-console.apps.ocp-dev01.wgn.wuerth.com"), ("OpenShift Prod","https://console-openshift-console.apps.ocp-01.wgn.wuerth.com/dashboards"), ("Plato", "https://plato.witglobal.net"), ("Wuerth GitHub", "https://github.com/login"), ("Wuerth", "https://www.wuerth.com"), ] # ============================================================================= # ---------- Plattform-Abstraktion -------------------------------------------- def _system() -> str: return platform.system() # 'Darwin' | 'Linux' | 'Windows' def browser_argv() -> list[str]: s = _system() if s == "Darwin": return ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"] if s == "Windows": for p in ( r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", ): if os.path.exists(p): return [p] return ["msedge.exe"] # Linux for name in ("microsoft-edge", "microsoft-edge-stable", "msedge"): path = shutil.which(name) if path: return [path] return ["microsoft-edge"] def seed_profile(): """Saet beim ersten Start Lesezeichen + sichtbare Leiste ins Profil.""" default = os.path.join(PROFILE, "Default") bm_path = os.path.join(default, "Bookmarks") if not os.path.exists(bm_path): os.makedirs(default, exist_ok=True) children = [ {"date_added": "0", "id": str(100 + i), "name": name, "type": "url", "url": url} for i, (name, url) in enumerate(BOOKMARKS) ] data = { "checksum": "", "roots": { "bookmark_bar": {"children": children, "date_added": "0", "id": "1", "name": "Lesezeichenleiste", "type": "folder"}, "other": {"children": [], "date_added": "0", "id": "2", "name": "Weitere Lesezeichen", "type": "folder"}, "synced": {"children": [], "date_added": "0", "id": "3", "name": "Mobile Lesezeichen", "type": "folder"}, }, "version": 1, } with open(bm_path, "w") as f: json.dump(data, f, indent=2) # Lesezeichenleiste dauerhaft einblenden pref_path = os.path.join(default, "Preferences") if not os.path.exists(pref_path): os.makedirs(default, exist_ok=True) with open(pref_path, "w") as f: json.dump({"bookmark_bar": {"show_on_all_tabs": True}}, f) def _tool(name: str) -> str: path = shutil.which(name) if path: return path if _system() == "Darwin": cand = f"/opt/homebrew/bin/{name}" if os.path.exists(cand): return cand return name # ---------- PAC-Server (in-process) ------------------------------------------ PAC_BODY = ( "function FindProxyForURL(url, host) {\n" f' if (shExpMatch(host, "{INTERNAL_GLOB}")) {{\n' f' return "SOCKS5 127.0.0.1:{PORT}";\n' " }\n" ' return "DIRECT";\n' "}\n" ) class _PacHandler(BaseHTTPRequestHandler): def do_GET(self): body = PAC_BODY.encode() self.send_response(200) self.send_header("Content-Type", "application/x-ns-proxy-autoconfig") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *_): pass def start_pac_server() -> ThreadingHTTPServer: srv = ThreadingHTTPServer(("127.0.0.1", PACPORT), _PacHandler) threading.Thread(target=srv.serve_forever, daemon=True).start() return srv # ---------- Icon ------------------------------------------------------------- def dot_icon(color: str) -> QIcon: 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) _BASE_IMG: QImage | None = None _ICON_CACHE: dict[str, QIcon] = {} def _base_image() -> QImage: global _BASE_IMG if _BASE_IMG is None: img = QImage(ICON_PATH) if img.isNull(): _BASE_IMG = QImage() else: _BASE_IMG = img.scaled( 44, 44, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ).convertToFormat(QImage.Format.Format_ARGB32) return _BASE_IMG def logo_icon(color: str) -> QIcon: """Wuerth-Wappen in der Zustandsfarbe; weisser Hintergrund -> transparent.""" if color in _ICON_CACHE: return _ICON_CACHE[color] base = _base_image() if base.isNull(): return dot_icon(color) tgt = QColor(color) img = QImage(base) for y in range(img.height()): for x in range(img.width()): px = img.pixel(x, y) a = qAlpha(px) if a < 20: continue r, g, b = qRed(px), qGreen(px), qBlue(px) hi, lo = max(r, g, b), min(r, g, b) if hi > 225 and (hi - lo) < 30: # weiss -> transparent img.setPixel(x, y, qRgba(0, 0, 0, 0)) elif hi < 90: # schwarzer Text -> belassen continue else: # rote Flaeche -> Zustandsfarbe img.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a)) icon = QIcon(QPixmap.fromImage(img)) _ICON_CACHE[color] = icon return icon # ---------- App -------------------------------------------------------------- class VpnTray: def __init__(self, app: QApplication): self.app = app self.oc: subprocess.Popen | None = None self.browser: subprocess.Popen | None = None self.vpn_pw: str | None = None self.state = "disconnected" self.pac = start_pac_server() self.tray = QSystemTrayIcon() self.tray.setIcon(logo_icon("#9a9a9a")) self.tray.setToolTip("Wuerth VPN – getrennt") self.menu = QMenu() self.act_status = QAction("Getrennt") self.act_status.setEnabled(False) self.act_connect = QAction("Verbinden") self.act_disconnect = QAction("Trennen") self.act_open = QAction("Edge oeffnen") self.act_quit = QAction("Beenden") self.act_connect.triggered.connect(self.connect) self.act_disconnect.triggered.connect(self.disconnect) self.act_open.triggered.connect(self.launch_browser) self.act_quit.triggered.connect(self.quit) for a in (self.act_status, None, self.act_connect, self.act_disconnect, self.act_open, None, self.act_quit): if a is None: self.menu.addSeparator() else: self.menu.addAction(a) self.tray.setContextMenu(self.menu) self.tray.show() self._set_state("disconnected") # Pollt openconnect/SOCKS-Status self.timer = QTimer() self.timer.timeout.connect(self._tick) self.timer.start(700) # --- UI-State --- def _set_state(self, state: str): self.state = state color, label = { "disconnected": ("#9a9a9a", "Getrennt"), "connecting": ("#e6a817", "Verbinde…"), "connected": ("#cc0000", "Verbunden"), "error": ("#666666", "Fehler"), }[state] self.tray.setIcon(logo_icon(color)) self.tray.setToolTip(f"Wuerth VPN – {label}") self.act_status.setText(label) self.act_connect.setEnabled(state in ("disconnected", "error")) self.act_disconnect.setEnabled(state in ("connecting", "connected")) self.act_open.setEnabled(state == "connected") # --- Verbinden --- def connect(self): if not self.vpn_pw: pw, ok = QInputDialog.getText( None, "Wuerth VPN", f"VPN-Passwort fuer {VPN_USER}:", QLineEdit.EchoMode.Password, ) if not (ok and pw): return self.vpn_pw = pw cmd = [ _tool("openconnect"), "--protocol=anyconnect", "--user", VPN_USER, "--passwd-on-stdin", "--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun", f"https://{SERVER}", ] try: self._log = open(os.path.expanduser("~/.wuerth-vpn.log"), "wb") self.oc = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=self._log, stderr=subprocess.STDOUT, ) self.oc.stdin.write((self.vpn_pw + "\n").encode()) self.oc.stdin.flush() except FileNotFoundError: self._set_state("error") QMessageBox.critical(None, "Wuerth VPN", "openconnect nicht gefunden.") return self._set_state("connecting") # --- Trennen --- def disconnect(self): self._kill_browser() if self.oc and self.oc.poll() is None: # SIGINT -> openconnect sendet ein sauberes BYE und meldet die # Session am Gateway ab (statt sie per SIGTERM hart zu kappen). try: self.oc.send_signal(signal.SIGINT) self.oc.wait(timeout=4) except Exception: try: self.oc.terminate() except Exception: pass self.oc = None self._set_state("disconnected") def _kill_browser(self): # Ueber das dedizierte Profil beenden – trifft nur diese Instanz, # nicht ein evtl. normal laufendes Edge. if _system() in ("Darwin", "Linux"): # Muster ohne fuehrenden Bindestrich, sonst parst pkill es als Option. subprocess.run( ["pkill", "-f", f"user-data-dir={PROFILE}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) elif self.browser and self.browser.poll() is None: self.browser.terminate() self.browser = None # --- Browser --- def launch_browser(self): # Immer starten: laeuft schon eine Instanz mit diesem Profil, oeffnet # sie nur ein neues Fenster mit START_URL; sonst startet sie frisch. seed_profile() argv = browser_argv() + [ f"--user-data-dir={PROFILE}", f"--proxy-pac-url=http://127.0.0.1:{PACPORT}/wuerth.pac", f"--host-resolver-rules=MAP {INTERNAL_GLOB} ~NOTFOUND", "--no-first-run", "--no-default-browser-check", "--disable-features=msSmartScreenProtection", "--test-type", # unterdrueckt die "unsupported flag"-Warnleiste START_URL, ] self.browser = subprocess.Popen(argv) # --- Poll --- def _tick(self): if self.state == "connecting": if self.oc and self.oc.poll() is not None: self._set_state("error") return if _port_open(PORT): self._set_state("connected") self.launch_browser() elif self.state == "connected": if self.oc and self.oc.poll() is not None: self.disconnect() def quit(self): self.disconnect() self.pac.shutdown() self.app.quit() def _port_open(port: int) -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(0.3) return s.connect_ex(("127.0.0.1", port)) == 0 def main(): # Kein Dock-Icon: ueber LSUIElement im py2app-Bundle (greift beim Start). app = QApplication([]) app.setApplicationName("WuerthVPN") app.setQuitOnLastWindowClosed(False) _tray = VpnTray(app) # noqa: F841 (Referenz halten) app.exec() if __name__ == "__main__": main()