projects/vpn: browsergebundene VPN-Tray-Apps (Wuerth/Heron) + Build-Scripts
This commit is contained in:
@@ -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.<name>.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.
|
||||
@@ -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" <<AGENT
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>$BID</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>/usr/bin/open</string><string>-a</string><string>$APP</string></array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><false/>
|
||||
</dict>
|
||||
</plist>
|
||||
AGENT
|
||||
|
||||
echo "Fertig: $APP"
|
||||
echo "Autostart aktivieren: launchctl load $LA"
|
||||
echo "Autostart deaktivieren: launchctl unload $LA"
|
||||
Executable
+94
@@ -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" <<AGENT
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>$BID</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/open</string>
|
||||
<string>-a</string>
|
||||
<string>$APP</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><false/>
|
||||
</dict>
|
||||
</plist>
|
||||
AGENT
|
||||
|
||||
echo "Fertig: $APP"
|
||||
echo "Autostart aktivieren: launchctl load $LA"
|
||||
echo "Autostart deaktivieren: launchctl unload $LA"
|
||||
@@ -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()
|
||||
Binary file not shown.
@@ -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"],
|
||||
)
|
||||
@@ -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"],
|
||||
)
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Executable
+67
@@ -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" <<PAC
|
||||
function FindProxyForURL(url, host) {
|
||||
if (shExpMatch(host, "${INTERNAL_GLOB}")) {
|
||||
return "SOCKS5 127.0.0.1:${PORT}";
|
||||
}
|
||||
return "DIRECT";
|
||||
}
|
||||
PAC
|
||||
|
||||
# Edge blockt file://-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
|
||||
Executable
+401
@@ -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()
|
||||
Reference in New Issue
Block a user