projects/vpn: browsergebundene VPN-Tray-Apps (Wuerth/Heron) + Build-Scripts

This commit is contained in:
marcus.hinz
2026-06-25 14:34:54 +02:00
parent 4ae1b76049
commit d64b27ab88
10 changed files with 1138 additions and 0 deletions
+41
View File
@@ -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.
+112
View File
@@ -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"
+94
View File
@@ -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"
+362
View File
@@ -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.
+33
View File
@@ -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"],
)
+28
View File
@@ -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"],
)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+67
View File
@@ -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
+401
View File
@@ -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()