projects/vpn: browsergebundene VPN-Tray-Apps (Wuerth/Heron) + Build-Scripts
This commit is contained in:
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