projects/vpn: Heron rootlos auf macOS (GlobalProtect/Duo-SAML via WKWebView + HIP), Reiher-Icon
This commit is contained in:
+140
-74
@@ -8,14 +8,18 @@ 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)
|
||||
Auth: GlobalProtect-SAML (Duo). Der SAML-Login laeuft als eigener Cocoa/pyobjc-
|
||||
Prozess (heron_gp_saml.py --auth-only, via CLT-Python) und liefert nur den
|
||||
prelogin-cookie; diese App startet damit openconnect (+HIP-Report) ueber ocproxy.
|
||||
|
||||
ZU FUELLEN: VPN_USER, INTERNAL_GLOB, START_URL, BOOKMARKS.
|
||||
Abhaengigkeiten:
|
||||
openconnect + ocproxy (macOS: brew install openconnect ocproxy),
|
||||
CLT-Python mit pyobjc (pyobjc-framework-WebKit) fuer heron_gp_saml.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob as _glob
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
@@ -26,30 +30,32 @@ 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.QtCore import QProcess, QProcessEnvironment, QTimer, Qt
|
||||
from PyQt6.QtGui import (
|
||||
QAction, QColor, QIcon, QImage, QPainter, QPixmap, qAlpha, qRgba,
|
||||
)
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, QMessageBox,
|
||||
QApplication, QSystemTrayIcon, QMenu, 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
|
||||
PORT = 11090 # SOCKS5 (ocproxy) – andere Ports als Wuerth
|
||||
PACPORT = 11091 # lokaler PAC-HTTP-Server
|
||||
INTERNAL_GLOB = "*.heron.at" # <-- interne Hosts anpassen (ueber VPN)
|
||||
# Hosts, die ueber das VPN laufen (Domain-Globs und/oder IP-Globs).
|
||||
INTERNAL_GLOBS = ["*.heron.at", "172.16.*"]
|
||||
PROFILE = os.path.expanduser("~/.heron-edge")
|
||||
START_URL = "https://robotunits.com" # <-- gewuenschte Startseite
|
||||
START_URL = "https://robotunits.com"
|
||||
LOGFILE = os.path.expanduser("~/.heron-vpn.log")
|
||||
PAC_NAME = "heron.pac"
|
||||
CLT_PYTHON = "/Library/Developer/CommandLineTools/usr/bin/python3" # hat pyobjc
|
||||
|
||||
# Lesezeichen, die ins frische Profil gesaet werden (Name, URL). <-- anpassen
|
||||
# Lesezeichen, die ins frische Profil gesaet werden (Name, URL).
|
||||
BOOKMARKS: list[tuple[str, str]] = [
|
||||
("Heron", "https://ssl.heron.at"),
|
||||
|
||||
("Storybook", "http://172.16.80.30:30060/"),
|
||||
("Heron", "https://heron.at"),
|
||||
]
|
||||
# =============================================================================
|
||||
|
||||
@@ -123,20 +129,31 @@ def _tool(name: str) -> str:
|
||||
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"
|
||||
def _saml_script() -> str:
|
||||
"""Pfad zu heron_gp_saml.py (im Bundle unter Resources, sonst neben uns)."""
|
||||
base = os.environ.get(
|
||||
"RESOURCEPATH", os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(base, "heron_gp_saml.py")
|
||||
|
||||
|
||||
def _hipreport() -> str | None:
|
||||
for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh",
|
||||
*_glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"),
|
||||
"/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolver_rules() -> str:
|
||||
return " , ".join(f"MAP {g} ~NOTFOUND" for g in INTERNAL_GLOBS)
|
||||
|
||||
|
||||
# ---------- PAC-Server (in-process) ------------------------------------------
|
||||
_PAC_COND = " || ".join(f'shExpMatch(host, "{g}")' for g in INTERNAL_GLOBS)
|
||||
PAC_BODY = (
|
||||
"function FindProxyForURL(url, host) {\n"
|
||||
f' if (shExpMatch(host, "{INTERNAL_GLOB}")) {{\n'
|
||||
f" if ({_PAC_COND}) {{\n"
|
||||
f' return "SOCKS5 127.0.0.1:{PORT}";\n'
|
||||
" }\n"
|
||||
' return "DIRECT";\n'
|
||||
@@ -163,28 +180,46 @@ def start_pac_server() -> ThreadingHTTPServer:
|
||||
return srv
|
||||
|
||||
|
||||
# ---------- Icon (generiertes "H"-Badge in Zustandsfarbe) --------------------
|
||||
# ---------- Icon (Reiher-Silhouette in Zustandsfarbe) ------------------------
|
||||
_ICON_CACHE: dict[str, QIcon] = {}
|
||||
_MASK: QImage | None = None
|
||||
|
||||
|
||||
def badge_icon(color: str) -> QIcon:
|
||||
def _heron_mask() -> QImage:
|
||||
global _MASK
|
||||
if _MASK is None:
|
||||
base = os.environ.get(
|
||||
"RESOURCEPATH", os.path.dirname(os.path.abspath(__file__)))
|
||||
img = QImage(os.path.join(base, "heron_mask.png"))
|
||||
_MASK = (img.scaled(44, 44, Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
.convertToFormat(QImage.Format.Format_ARGB32)
|
||||
if not img.isNull() else QImage())
|
||||
return _MASK
|
||||
|
||||
|
||||
def heron_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)
|
||||
m = _heron_mask()
|
||||
if m.isNull(): # Fallback: einfacher Punkt
|
||||
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)
|
||||
tgt = QColor(color)
|
||||
o = QImage(m)
|
||||
for y in range(o.height()):
|
||||
for x in range(o.width()):
|
||||
a = qAlpha(o.pixel(x, y))
|
||||
if a:
|
||||
o.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a))
|
||||
icon = QIcon(QPixmap.fromImage(o))
|
||||
_ICON_CACHE[color] = icon
|
||||
return icon
|
||||
|
||||
@@ -195,13 +230,13 @@ class VpnTray:
|
||||
self.app = app
|
||||
self.oc: subprocess.Popen | None = None
|
||||
self.browser: subprocess.Popen | None = None
|
||||
self.vpn_pw: str | None = None
|
||||
self.auth_proc: QProcess | None = None
|
||||
self.state = "disconnected"
|
||||
|
||||
self.pac = start_pac_server()
|
||||
|
||||
self.tray = QSystemTrayIcon()
|
||||
self.tray.setIcon(badge_icon("#9a9a9a"))
|
||||
self.tray.setIcon(heron_icon("#9a9a9a"))
|
||||
self.tray.setToolTip(f"{APP_NAME} – getrennt")
|
||||
|
||||
self.menu = QMenu()
|
||||
@@ -240,7 +275,7 @@ class VpnTray:
|
||||
"connected": ("#2e7d32", "Verbunden"),
|
||||
"error": ("#cc3333", "Fehler"),
|
||||
}[state]
|
||||
self.tray.setIcon(badge_icon(color))
|
||||
self.tray.setIcon(heron_icon(color))
|
||||
self.tray.setToolTip(f"{APP_NAME} – {label}")
|
||||
self.act_status.setText(label)
|
||||
self.act_connect.setEnabled(state in ("disconnected", "error"))
|
||||
@@ -248,48 +283,79 @@ class VpnTray:
|
||||
self.act_open.setEnabled(state == "connected")
|
||||
|
||||
def connect(self):
|
||||
base = [
|
||||
_tool("openconnect"),
|
||||
f"--protocol={PROTOCOL}",
|
||||
"--script", f"{_tool('ocproxy')} -D {PORT}",
|
||||
"--script-tun",
|
||||
if self.auth_proc is not None:
|
||||
return
|
||||
self._set_state("connecting")
|
||||
# SAML-Login als eigener pyobjc/WKWebView-Prozess -> liefert Cookie als JSON.
|
||||
self.auth_proc = QProcess()
|
||||
# Bereinigte Umgebung: sonst erbt das CLT-Python die PYTHONHOME/PATH des
|
||||
# py2app-Bundles und findet sein pyobjc nicht (Subprozess stirbt sofort).
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
for v in ("PYTHONHOME", "PYTHONPATH", "PYTHONEXECUTABLE",
|
||||
"PYTHONNOUSERSITE", "PYTHONDONTWRITEBYTECODE"):
|
||||
env.remove(v)
|
||||
self.auth_proc.setProcessEnvironment(env)
|
||||
self.auth_proc.finished.connect(self._on_auth_done)
|
||||
self.auth_proc.start(CLT_PYTHON, [_saml_script(), "--auth-only"])
|
||||
if not self.auth_proc.waitForStarted(3000):
|
||||
self.auth_proc = None
|
||||
self._set_state("error")
|
||||
QMessageBox.critical(None, APP_NAME,
|
||||
"SAML-Login (CLT-Python/pyobjc) nicht startbar.")
|
||||
|
||||
def _on_auth_done(self, code, status):
|
||||
out = bytes(self.auth_proc.readAllStandardOutput()).decode("utf-8", "replace")
|
||||
self.auth_proc = None
|
||||
auth = None
|
||||
for line in out.strip().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
try:
|
||||
auth = json.loads(line)
|
||||
except Exception:
|
||||
pass
|
||||
if not auth or not auth.get("prelogin-cookie"):
|
||||
self._set_state("error")
|
||||
return
|
||||
self._start_openconnect(auth.get("saml-username", ""),
|
||||
auth["prelogin-cookie"])
|
||||
|
||||
def _start_openconnect(self, user: str, cookie: str):
|
||||
# Reste eines frueheren Laufs wegraeumen, sonst Portkonflikt auf SOCKS.
|
||||
if _system() in ("Darwin", "Linux"):
|
||||
subprocess.run(["pkill", "-f", f"ocproxy -D {PORT}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
cmd = [
|
||||
_tool("openconnect"), f"--protocol={PROTOCOL}",
|
||||
"--user", user, "--usergroup=gateway:prelogin-cookie",
|
||||
"--passwd-on-stdin",
|
||||
"--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}"]
|
||||
|
||||
hip = _hipreport()
|
||||
if hip:
|
||||
cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway
|
||||
cmd.append(SERVER)
|
||||
try:
|
||||
self._log = open(LOGFILE, "wb")
|
||||
self.oc = subprocess.Popen(
|
||||
cmd, stdin=subprocess.PIPE if send_pw else None,
|
||||
cmd, stdin=subprocess.PIPE,
|
||||
stdout=self._log, stderr=subprocess.STDOUT,
|
||||
)
|
||||
if send_pw:
|
||||
self.oc.stdin.write((send_pw + "\n").encode())
|
||||
self.oc.stdin.flush()
|
||||
self.oc.stdin.write((cookie + "\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")
|
||||
# bleibt "connecting"; _tick erkennt den offenen SOCKS-Port
|
||||
|
||||
def disconnect(self):
|
||||
if self.auth_proc is not None:
|
||||
try:
|
||||
self.auth_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self.auth_proc = None
|
||||
self._kill_browser()
|
||||
if self.oc and self.oc.poll() is None:
|
||||
try:
|
||||
@@ -318,7 +384,7 @@ class VpnTray:
|
||||
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",
|
||||
f"--host-resolver-rules={_resolver_rules()}",
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-features=msSmartScreenProtection",
|
||||
"--test-type",
|
||||
|
||||
Reference in New Issue
Block a user