Files
claude-projects/projects/vpn/wuerth_vpn_tray.py
T

402 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()