diff --git a/projects/vpn/README.md b/projects/vpn/README.md new file mode 100644 index 0000000..3c1c2fb --- /dev/null +++ b/projects/vpn/README.md @@ -0,0 +1,41 @@ +# VPN Tray-Apps (browsergebunden, rootlos) + +Menübar-Apps, die ein VPN rootlos aufbauen (openconnect + ocproxy als SOCKS5) +und einen dedizierten Browser starten, der per PAC nur die internen Hosts über +das VPN schickt und alles andere direkt. Jede App hat eigenen SOCKS-/PAC-Port +und eigenes Browser-Profil — mehrere VPNs laufen parallel ohne Konflikt. + +## Apps + +- **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. + +## Voraussetzungen + +- `openconnect` + `ocproxy` (macOS: `brew install openconnect ocproxy`) +- PyQt6 (für die Tray-App), Microsoft Edge (dedizierter Browser) +- Bauen als App-Bundle: py2app (`pip install --user py2app`) + +## Bauen (macOS-App-Bundle, Menübar-Only, kein Dock-Icon) + + bash build_wuerth_app.sh # -> ~/Applications/WuerthVPN.app + bash build_heron_app.sh # -> ~/Applications/HeronVPN.app + +Die Build-Scripts bauen via py2app, dünnen die Qt-Frameworks aus (~79 MB), +installieren nach `~/Applications` und legen einen LaunchAgent für optionalen +Autostart an (`launchctl load ~/Library/LaunchAgents/com..vpntray.plist`). + +## Ohne Bundle (nur Shell) + +- **wuerth_vpn_proxy.sh** — schlanke Shell-Variante: fragt Passwort, baut das + VPN auf und startet Edge mit PAC. Ohne Tray/Bundle. + +## Konfiguration + +Im Kopf der jeweiligen `*_tray.py`: SERVER, VPN_USER, PORT/PACPORT, +INTERNAL_GLOB (welche Hosts über das VPN), START_URL, BOOKMARKS. Nach Änderung +neu bauen, da das Script ins Bundle kopiert wird. diff --git a/projects/vpn/build_heron_app.sh b/projects/vpn/build_heron_app.sh new file mode 100644 index 0000000..927381d --- /dev/null +++ b/projects/vpn/build_heron_app.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Baut HeronVPN.app via py2app (Menuebar-Only, kein Dock via LSUIElement), +# generiert ein "H"-Icon, duennt aus, installiert nach ~/Applications und legt +# einen LaunchAgent fuer optionalen Autostart bereit. +set -euo pipefail + +PY="/Library/Developer/CommandLineTools/usr/bin/python3" +APPS="$HOME/apps" +APP="$HOME/Applications/HeronVPN.app" +BID="com.heron.vpntray" +LSREG=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister + +cd "$APPS" + +# --- 0. Icon erzeugen (H-Badge -> icon_heron.icns) --- +TMP="$(mktemp -d)" +QT_QPA_PLATFORM=offscreen "$PY" - "$TMP/h.png" <<'PYEOF' +import sys +from PyQt6.QtGui import QImage, QPainter, QColor, QFont, QGuiApplication +from PyQt6.QtCore import Qt +_app = QGuiApplication([]) # noetig fuer Font-Zugriff +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") +p.end() +img.save(sys.argv[1]) +PYEOF +ISET="$TMP/icon.iconset"; mkdir -p "$ISET" +for s in 16 32 128 256 512; do + sips -z $s $s "$TMP/h.png" --out "$ISET/icon_${s}x${s}.png" >/dev/null + sips -z $((s*2)) $((s*2)) "$TMP/h.png" --out "$ISET/icon_${s}x${s}@2x.png" >/dev/null +done +iconutil -c icns "$ISET" -o "$APPS/icon_heron.icns" +rm -rf "$TMP" + +# --- 1. Bauen --- +rm -rf build dist +"$PY" setup_heron.py py2app >/tmp/heron_py2app.log 2>&1 || { tail -20 /tmp/heron_py2app.log; exit 1; } + +# --- 2. Nach ~/Applications uebernehmen --- +pkill -f "HeronVPN.app/Contents/MacOS/HeronVPN" 2>/dev/null || true +rm -rf "$APP" +cp -R "$APPS/dist/HeronVPN.app" "$APP" + +# --- 2b. Ausduennen (wie Wuerth) --- +QT="$APP/Contents/Resources/lib/python3.9/PyQt6/Qt6" +KEEP="QtCore QtGui QtWidgets QtDBus QtNetwork QtPrintSupport QtSvg QtSvgWidgets" +for fw in "$QT/lib"/*.framework; do + name="$(basename "$fw" .framework)" + echo " $KEEP " | grep -q " $name " || rm -rf "$fw" +done +rm -rf "$QT/qml" "$QT/translations" "$QT/qsci" +if [ -d "$QT/plugins" ]; then + for d in "$QT/plugins"/*/; do + case "$(basename "$d")" in + platforms|styles|imageformats|iconengines) ;; + *) rm -rf "$d";; + esac + done +fi +PYQT="$APP/Contents/Resources/lib/python3.9/PyQt6" +for m in QtQml QtQuick QtQuick3D QtQuickWidgets QtPdf QtPdfWidgets QtDesigner \ + QtMultimedia QtMultimediaWidgets Qt3DCore QtCharts QtDataVisualization \ + QtSql QtTest QtBluetooth QtPositioning QtSensors QtWebChannel \ + QtWebSockets QtNfc QtSpatialAudio QtShaderTools QtOpenGL QtOpenGLWidgets; do + find "$PYQT" -maxdepth 1 -name "${m}.*so" -delete 2>/dev/null || true +done +while IFS= read -r f; do + archs="$(lipo -archs "$f" 2>/dev/null || true)" + if [[ "$archs" == *x86_64* && "$archs" == *arm64* ]]; then + lipo "$f" -thin arm64 -output "$f" 2>/dev/null || true + fi +done < <(find "$APP" -type f \( -name "*.so" -o -name "*.dylib" -o -perm -u+x \)) +find "$APP" -type f \( -name "*.so" -o -name "*.dylib" \) -exec strip -x {} \; 2>/dev/null || true +codesign --force --deep -s - "$APP" >/dev/null 2>&1 + +# --- 3. Build-Ausgabe entfernen --- +"$LSREG" -u "$APPS/dist/HeronVPN.app" 2>/dev/null || true +rm -rf build dist + +# --- 4. registrieren --- +"$LSREG" -f "$APP" + +# --- 5. LaunchAgent --- +LA="$HOME/Library/LaunchAgents/$BID.plist" +mkdir -p "$HOME/Library/LaunchAgents" +cat >"$LA" < + + + + Label$BID + ProgramArguments + /usr/bin/open-a$APP + RunAtLoad + KeepAlive + + +AGENT + +echo "Fertig: $APP" +echo "Autostart aktivieren: launchctl load $LA" +echo "Autostart deaktivieren: launchctl unload $LA" diff --git a/projects/vpn/build_wuerth_app.sh b/projects/vpn/build_wuerth_app.sh new file mode 100755 index 0000000..4e229a8 --- /dev/null +++ b/projects/vpn/build_wuerth_app.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Baut WuerthVPN.app via py2app (Menuebar-Only, kein Dock-Icon via LSUIElement), +# installiert nach ~/Applications, raeumt die Build-Ausgabe weg (sonst doppelt +# im Launchpad) und legt einen LaunchAgent fuer optionalen Autostart bereit. +set -euo pipefail + +PY="/Library/Developer/CommandLineTools/usr/bin/python3" +APPS="$HOME/apps" +APP="$HOME/Applications/WuerthVPN.app" +BID="com.wuerth.vpntray" +LSREG=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister + +cd "$APPS" + +# --- 1. Bauen --- +rm -rf build dist +"$PY" setup.py py2app >/tmp/wuerth_py2app.log 2>&1 || { tail -20 /tmp/wuerth_py2app.log; exit 1; } + +# --- 2. Nach ~/Applications uebernehmen --- +pkill -f "WuerthVPN.app/Contents/MacOS/WuerthVPN" 2>/dev/null || true +rm -rf "$APP" +cp -R "$APPS/dist/WuerthVPN.app" "$APP" + +# --- 2b. Ausduennen: nur QtCore/Gui/Widgets + Abhaengigkeiten behalten --- +QT="$APP/Contents/Resources/lib/python3.9/PyQt6/Qt6" +KEEP="QtCore QtGui QtWidgets QtDBus QtNetwork QtPrintSupport QtSvg QtSvgWidgets" +for fw in "$QT/lib"/*.framework; do + name="$(basename "$fw" .framework)" + echo " $KEEP " | grep -q " $name " || rm -rf "$fw" +done +rm -rf "$QT/qml" "$QT/translations" "$QT/qsci" +if [ -d "$QT/plugins" ]; then + for d in "$QT/plugins"/*/; do + case "$(basename "$d")" in + platforms|styles|imageformats|iconengines) ;; + *) rm -rf "$d";; + esac + done +fi + +# uebrige PyQt6-Bindings der entfernten Qt-Module loeschen +PYQT="$APP/Contents/Resources/lib/python3.9/PyQt6" +for m in QtQml QtQuick QtQuick3D QtQuickWidgets QtPdf QtPdfWidgets QtDesigner \ + QtMultimedia QtMultimediaWidgets Qt3DCore QtCharts QtDataVisualization \ + QtSql QtTest QtBluetooth QtPositioning QtSensors QtWebChannel \ + QtWebSockets QtNfc QtSpatialAudio QtShaderTools QtOpenGL QtOpenGLWidgets; do + find "$PYQT" -maxdepth 1 -name "${m}.*so" -delete 2>/dev/null || true +done + +# universale Mach-O auf arm64 thinnen +while IFS= read -r f; do + archs="$(lipo -archs "$f" 2>/dev/null || true)" + if [[ "$archs" == *x86_64* && "$archs" == *arm64* ]]; then + lipo "$f" -thin arm64 -output "$f" 2>/dev/null || true + fi +done < <(find "$APP" -type f \( -name "*.so" -o -name "*.dylib" -o -perm -u+x \)) + +# Symbole strippen +find "$APP" -type f \( -name "*.so" -o -name "*.dylib" \) -exec strip -x {} \; 2>/dev/null || true + +# Bundle-Siegel nach allen Aenderungen erneuern (sonst -10669/Gatekeeper) +codesign --force --deep -s - "$APP" >/dev/null 2>&1 + +# --- 3. Build-Ausgabe entfernen (sonst zweite App im Launchpad) --- +"$LSREG" -u "$APPS/dist/WuerthVPN.app" 2>/dev/null || true +rm -rf build dist + +# --- 4. App registrieren --- +"$LSREG" -f "$APP" + +# --- 5. LaunchAgent fuer Autostart bereitlegen (nicht automatisch geladen) --- +LA="$HOME/Library/LaunchAgents/$BID.plist" +mkdir -p "$HOME/Library/LaunchAgents" +cat >"$LA" < + + + + Label$BID + ProgramArguments + + /usr/bin/open + -a + $APP + + RunAtLoad + KeepAlive + + +AGENT + +echo "Fertig: $APP" +echo "Autostart aktivieren: launchctl load $LA" +echo "Autostart deaktivieren: launchctl unload $LA" diff --git a/projects/vpn/heron_vpn_tray.py b/projects/vpn/heron_vpn_tray.py new file mode 100644 index 0000000..ef352b8 --- /dev/null +++ b/projects/vpn/heron_vpn_tray.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Heron VPN – plattformuebergreifende Tray-App (PyQt6), GlobalProtect. + +Baut das VPN rootlos auf (openconnect --protocol=gp + 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. + +Eigene Ports/Profil/Bundle -> laeuft parallel zur Wuerth-App. + +Abhaengigkeiten: + openconnect + ocproxy auf dem PATH (macOS: brew install openconnect ocproxy) + +ZU FUELLEN: VPN_USER, INTERNAL_GLOB, START_URL, BOOKMARKS. +""" + +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, QPainter, QPixmap +from PyQt6.QtWidgets import ( + QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, 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 +PACPORT = 11091 # lokaler PAC-HTTP-Server +INTERNAL_GLOB = "*.heron.at" # <-- interne Hosts anpassen (ueber VPN) +PROFILE = os.path.expanduser("~/.heron-edge") +START_URL = "https://robotunits.com" # <-- gewuenschte Startseite +LOGFILE = os.path.expanduser("~/.heron-vpn.log") +PAC_NAME = "heron.pac" + +# Lesezeichen, die ins frische Profil gesaet werden (Name, URL). <-- anpassen +BOOKMARKS: list[tuple[str, str]] = [ + ("Heron", "https://ssl.heron.at"), + +] +# ============================================================================= + + +# ---------- 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"] + 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) + + 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 + + +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" + + +# ---------- 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 (generiertes "H"-Badge in Zustandsfarbe) -------------------- +_ICON_CACHE: dict[str, QIcon] = {} + + +def badge_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) + _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(badge_icon("#9a9a9a")) + self.tray.setToolTip(f"{APP_NAME} – 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") + + self.timer = QTimer() + self.timer.timeout.connect(self._tick) + self.timer.start(700) + + def _set_state(self, state: str): + self.state = state + color, label = { + "disconnected": ("#9a9a9a", "Getrennt"), + "connecting": ("#e6a817", "Verbinde…"), + "connected": ("#2e7d32", "Verbunden"), + "error": ("#cc3333", "Fehler"), + }[state] + self.tray.setIcon(badge_icon(color)) + self.tray.setToolTip(f"{APP_NAME} – {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") + + def connect(self): + base = [ + _tool("openconnect"), + f"--protocol={PROTOCOL}", + "--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}"] + + try: + self._log = open(LOGFILE, "wb") + self.oc = subprocess.Popen( + cmd, stdin=subprocess.PIPE if send_pw else None, + stdout=self._log, stderr=subprocess.STDOUT, + ) + if send_pw: + self.oc.stdin.write((send_pw + "\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") + + def disconnect(self): + self._kill_browser() + if self.oc and self.oc.poll() is None: + 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): + if _system() in ("Darwin", "Linux"): + 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 + + def launch_browser(self): + seed_profile() + 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", + "--no-first-run", "--no-default-browser-check", + "--disable-features=msSmartScreenProtection", + "--test-type", + START_URL, + ] + self.browser = subprocess.Popen(argv) + + 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(): + app = QApplication([]) + app.setApplicationName("HeronVPN") + app.setQuitOnLastWindowClosed(False) + _tray = VpnTray(app) # noqa: F841 + app.exec() + + +if __name__ == "__main__": + main() diff --git a/projects/vpn/icon.icns b/projects/vpn/icon.icns new file mode 100644 index 0000000..907a2f5 Binary files /dev/null and b/projects/vpn/icon.icns differ diff --git a/projects/vpn/setup.py b/projects/vpn/setup.py new file mode 100644 index 0000000..4aebb48 --- /dev/null +++ b/projects/vpn/setup.py @@ -0,0 +1,33 @@ +""" +py2app-Build fuer WuerthVPN.app (Menuebar-Only, kein Dock-Icon via LSUIElement). + +Bauen: + cd ~/apps + /Library/Developer/CommandLineTools/usr/bin/python3 setup.py py2app +Ergebnis: ~/apps/dist/WuerthVPN.app +""" +from setuptools import setup + +APP = ["wuerth_vpn_tray.py"] +DATA_FILES = ["wuerth.png"] +OPTIONS = { + "iconfile": "icon.icns", + "packages": ["PyQt6"], + "plist": { + "CFBundleName": "WuerthVPN", + "CFBundleDisplayName": "WuerthVPN", + "CFBundleIdentifier": "com.wuerth.vpntray", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "LSUIElement": True, + "NSHighResolutionCapable": True, + }, +} + +setup( + app=APP, + name="WuerthVPN", + data_files=DATA_FILES, + options={"py2app": OPTIONS}, + setup_requires=["py2app"], +) diff --git a/projects/vpn/setup_heron.py b/projects/vpn/setup_heron.py new file mode 100644 index 0000000..2c954f3 --- /dev/null +++ b/projects/vpn/setup_heron.py @@ -0,0 +1,28 @@ +""" +py2app-Build fuer HeronVPN.app (Menuebar-Only, kein Dock-Icon via LSUIElement). + +Bauen: cd ~/apps && bash build_heron_app.sh +""" +from setuptools import setup + +APP = ["heron_vpn_tray.py"] +OPTIONS = { + "iconfile": "icon_heron.icns", + "packages": ["PyQt6"], + "plist": { + "CFBundleName": "HeronVPN", + "CFBundleDisplayName": "HeronVPN", + "CFBundleIdentifier": "com.heron.vpntray", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "LSUIElement": True, + "NSHighResolutionCapable": True, + }, +} + +setup( + app=APP, + name="HeronVPN", + options={"py2app": OPTIONS}, + setup_requires=["py2app"], +) diff --git a/projects/vpn/wuerth.png b/projects/vpn/wuerth.png new file mode 100755 index 0000000..95ae414 Binary files /dev/null and b/projects/vpn/wuerth.png differ diff --git a/projects/vpn/wuerth_vpn_proxy.sh b/projects/vpn/wuerth_vpn_proxy.sh new file mode 100755 index 0000000..03dc66f --- /dev/null +++ b/projects/vpn/wuerth_vpn_proxy.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Baut das Wuerth-VPN rootlos auf (openconnect + ocproxy, SOCKS5 statt tun) +# und startet Edge mit eigenem, isoliertem Profil ueber diesen Proxy. +# Beim Schliessen von Edge wird openconnect beendet. +set -euo pipefail + +export PATH="/opt/homebrew/bin:$PATH" + +SERVER="loki.witglobal.net" +VPN_USER="ex08802409" +PORT=11080 +PACPORT=11081 +PROFILE="$HOME/.wuerth-edge" +EDGE="/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" +START_URL="https://gitops.apps.ocp-dev01.wgn.wuerth.com" +# Hosts, die ueber das VPN (SOCKS) laufen sollen; alles andere geht direkt. +INTERNAL_GLOB="*.wgn.wuerth.com" + +read -rsp "VPN-Passwort fuer ${VPN_USER}: " VPN_PW; echo + +printf '%s\n' "$VPN_PW" | openconnect \ + --protocol=anyconnect \ + --user "$VPN_USER" \ + --passwd-on-stdin \ + --script "ocproxy -D ${PORT}" \ + --script-tun \ + "https://${SERVER}" & +OC_PID=$! + +# PAC-Datei: nur INTERNAL_GLOB ueber den VPN-SOCKS, Rest direkt ins Internet. +PACDIR="$(mktemp -d)" +cat >"$PACDIR/wuerth.pac" < ueber lokalen HTTP-Server ausliefern. +python3 -m http.server "$PACPORT" --bind 127.0.0.1 --directory "$PACDIR" >/dev/null 2>&1 & +HTTP_PID=$! + +cleanup() { kill "$OC_PID" "$HTTP_PID" 2>/dev/null || true; rm -rf "$PACDIR"; } +trap cleanup EXIT INT TERM + +# Warten, bis ocproxy den SOCKS-Port geoeffnet hat (max. 30 s) +for _ in $(seq 1 60); do + if nc -z 127.0.0.1 "$PORT" 2>/dev/null; then break; fi + if ! kill -0 "$OC_PID" 2>/dev/null; then + echo "openconnect beendet sich (Auth fehlgeschlagen?)." >&2 + exit 1 + fi + sleep 0.5 +done + +echo "SOCKS5 auf 127.0.0.1:${PORT} aktiv. Starte Edge..." + +"$EDGE" \ + --user-data-dir="$PROFILE" \ + --proxy-pac-url="http://127.0.0.1:${PACPORT}/wuerth.pac" \ + --host-resolver-rules="MAP ${INTERNAL_GLOB} ~NOTFOUND" \ + --no-first-run --no-default-browser-check \ + "$START_URL" + +# Edge beendet -> trap raeumt openconnect ab diff --git a/projects/vpn/wuerth_vpn_tray.py b/projects/vpn/wuerth_vpn_tray.py new file mode 100755 index 0000000..1d71561 --- /dev/null +++ b/projects/vpn/wuerth_vpn_tray.py @@ -0,0 +1,401 @@ +#!/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()