402 lines
14 KiB
Python
Executable File
402 lines
14 KiB
Python
Executable File
#!/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()
|