projects/vpn: Heron rootlos auf macOS (GlobalProtect/Duo-SAML via WKWebView + HIP), Reiher-Icon
This commit is contained in:
@@ -9,10 +9,13 @@ und eigenes Browser-Profil — mehrere VPNs laufen parallel ohne Konflikt.
|
|||||||
|
|
||||||
- **wuerth_vpn_tray.py** — Würth, Cisco AnyConnect (`--protocol=anyconnect`),
|
- **wuerth_vpn_tray.py** — Würth, Cisco AnyConnect (`--protocol=anyconnect`),
|
||||||
User+Passwort. Fertig und in Betrieb.
|
User+Passwort. Fertig und in Betrieb.
|
||||||
- **heron_vpn_tray.py** — Heron, GlobalProtect (`--protocol=gp`). Gerüst fertig;
|
- **heron_vpn_tray.py** — Heron, GlobalProtect (`--protocol=gp`), Duo-SAML.
|
||||||
der SAML-POST-/Duo-Login ist auf macOS nicht sauber rootlos lösbar
|
Läuft rootlos auf macOS: Der SAML-Login (`heron_gp_saml.py`) erfolgt in einem
|
||||||
(openconnect `--external-browser` bedient nur SAML-REDIRECT; gp-saml-gui
|
nativen WKWebView (pyobjc) mit GlobalProtect-User-Agent, greift den
|
||||||
braucht WebKit2GTK). Unter Linux via gp-saml-gui umsetzbar.
|
`prelogin-cookie` aus den HTTP-Antwort-Headern ab (was QtWebEngine nicht kann)
|
||||||
|
und startet damit openconnect inkl. HIP-Report (`--csd-wrapper`) über ocproxy.
|
||||||
|
Der SAML-Teil ist ein eigener CLT-Python-Prozess (braucht pyobjc), die Tray-App
|
||||||
|
ruft ihn und reicht den Cookie an openconnect.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
|
|||||||
@@ -14,25 +14,28 @@ cd "$APPS"
|
|||||||
|
|
||||||
# --- 0. Icon erzeugen (H-Badge -> icon_heron.icns) ---
|
# --- 0. Icon erzeugen (H-Badge -> icon_heron.icns) ---
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
QT_QPA_PLATFORM=offscreen "$PY" - "$TMP/h.png" <<'PYEOF'
|
QT_QPA_PLATFORM=offscreen "$PY" - "$APPS/heron_mask.png" "$TMP/h.png" <<'PYEOF'
|
||||||
import sys
|
import sys
|
||||||
from PyQt6.QtGui import QImage, QPainter, QColor, QFont, QGuiApplication
|
from PyQt6.QtGui import QImage, QPainter, QColor, qAlpha, qRgba
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
_app = QGuiApplication([]) # noetig fuer Font-Zugriff
|
# Reiher-Maske gruen einfaerben und mittig auf quadratische Flaeche setzen.
|
||||||
|
mask = QImage(sys.argv[1]).convertToFormat(QImage.Format.Format_ARGB32)
|
||||||
|
tgt = QColor("#2e7d32")
|
||||||
|
for y in range(mask.height()):
|
||||||
|
for x in range(mask.width()):
|
||||||
|
a = qAlpha(mask.pixel(x, y))
|
||||||
|
if a:
|
||||||
|
mask.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a))
|
||||||
S = 1024
|
S = 1024
|
||||||
img = QImage(S, S, QImage.Format.Format_ARGB32)
|
inner = int(S * 0.80)
|
||||||
img.fill(Qt.GlobalColor.transparent)
|
scaled = mask.scaled(inner, inner, Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
p = QPainter(img)
|
Qt.TransformationMode.SmoothTransformation)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
canvas = QImage(S, S, QImage.Format.Format_ARGB32)
|
||||||
p.setBrush(QColor("#2e7d32"))
|
canvas.fill(Qt.GlobalColor.transparent)
|
||||||
p.setPen(Qt.PenStyle.NoPen)
|
p = QPainter(canvas)
|
||||||
m = int(S * 0.09)
|
p.drawImage((S - scaled.width()) // 2, (S - scaled.height()) // 2, scaled)
|
||||||
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()
|
p.end()
|
||||||
img.save(sys.argv[1])
|
canvas.save(sys.argv[2])
|
||||||
PYEOF
|
PYEOF
|
||||||
ISET="$TMP/icon.iconset"; mkdir -p "$ISET"
|
ISET="$TMP/icon.iconset"; mkdir -p "$ISET"
|
||||||
for s in 16 32 128 256 512; do
|
for s in 16 32 128 256 512; do
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PoC: GlobalProtect SAML-POST (Duo) auf macOS rootlos.
|
||||||
|
|
||||||
|
Holt den GP-Prelogin, fuehrt das Duo-SAML in einem eingebetteten WKWebView durch,
|
||||||
|
greift den `prelogin-cookie`/`saml-username` aus den HTTP-Antwort-Headern ab
|
||||||
|
(was QtWebEngine nicht kann, das native WKWebView ueber den Navigation-Response-
|
||||||
|
Delegate aber schon) und startet damit openconnect rootlos ueber ocproxy.
|
||||||
|
|
||||||
|
Start: /Library/Developer/CommandLineTools/usr/bin/python3 ~/apps/heron_gp_saml.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import objc
|
||||||
|
from Cocoa import (
|
||||||
|
NSApplication, NSObject, NSWindow, NSURL, NSMakeRect,
|
||||||
|
NSBackingStoreBuffered,
|
||||||
|
)
|
||||||
|
from Foundation import NSHTTPURLResponse, NSHTTPCookie
|
||||||
|
from WebKit import WKWebView, WKWebViewConfiguration
|
||||||
|
|
||||||
|
SERVER = "ssl.heron.at"
|
||||||
|
PORT = 11090
|
||||||
|
GP_UA = "PAN GlobalProtect" # GP liefert den prelogin-cookie nur dem Client-UA
|
||||||
|
NSWindowStyleMaskTitled = 1
|
||||||
|
NSWindowStyleMaskClosable = 2
|
||||||
|
NSWindowStyleMaskResizable = 8
|
||||||
|
WKNavigationResponsePolicyAllow = 1
|
||||||
|
|
||||||
|
captured: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(n: str) -> str:
|
||||||
|
return shutil.which(n) or (
|
||||||
|
f"/opt/homebrew/bin/{n}" if os.path.exists(f"/opt/homebrew/bin/{n}") else n
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hipreport() -> str | None:
|
||||||
|
import glob
|
||||||
|
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 prelogin() -> tuple[str, str]:
|
||||||
|
"""Liefert (saml_html, sessid)."""
|
||||||
|
# Gateway-Prelogin direkt (nicht das Portal) -> liefert den Gateway-Cookie.
|
||||||
|
url = (f"https://{SERVER}/ssl-vpn/prelogin.esp"
|
||||||
|
"?tmp=tmp&clientVer=4100&clientos=Mac")
|
||||||
|
req = urllib.request.Request(url, method="POST", data=b"",
|
||||||
|
headers={"User-Agent": GP_UA})
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
xml = r.read().decode("utf-8", "replace")
|
||||||
|
sessid = ""
|
||||||
|
for c in r.headers.get_all("Set-Cookie") or []:
|
||||||
|
m = re.search(r"SESSID=([^;]+)", c)
|
||||||
|
if m:
|
||||||
|
sessid = m.group(1)
|
||||||
|
m = re.search(r"<saml-request>(.*?)</saml-request>", xml, re.S)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit("Kein <saml-request> im Prelogin.")
|
||||||
|
return base64.b64decode(m.group(1)).decode("utf-8", "replace"), sessid
|
||||||
|
|
||||||
|
|
||||||
|
class Delegate(NSObject):
|
||||||
|
def webView_decidePolicyForNavigationAction_decisionHandler_(
|
||||||
|
self, webView, navAction, handler):
|
||||||
|
u = str(navAction.request().URL().absoluteString())
|
||||||
|
print("NAV ->", u[:120], file=sys.stderr)
|
||||||
|
if u.startswith("globalprotectcallback:"):
|
||||||
|
payload = u[len("globalprotectcallback:"):]
|
||||||
|
try:
|
||||||
|
dec = base64.b64decode(payload).decode("utf-8", "replace")
|
||||||
|
except Exception:
|
||||||
|
dec = payload
|
||||||
|
print("CALLBACK-Payload:", dec[:500], file=sys.stderr)
|
||||||
|
for key in ("prelogin-cookie", "saml-username",
|
||||||
|
"portal-userauthcookie"):
|
||||||
|
m = re.search(rf"{key}[=:>]+\s*([^<&\"']+)", dec)
|
||||||
|
if m:
|
||||||
|
captured[key] = m.group(1).strip()
|
||||||
|
handler(0) # Cancel
|
||||||
|
if "prelogin-cookie" in captured:
|
||||||
|
NSApplication.sharedApplication().stop_(None)
|
||||||
|
return
|
||||||
|
handler(1) # Allow
|
||||||
|
|
||||||
|
def webView_decidePolicyForNavigationResponse_decisionHandler_(
|
||||||
|
self, webView, navResponse, handler):
|
||||||
|
resp = navResponse.response()
|
||||||
|
if resp.isKindOfClass_(NSHTTPURLResponse):
|
||||||
|
for k, v in resp.allHeaderFields().items():
|
||||||
|
lk = str(k).lower()
|
||||||
|
if lk in ("prelogin-cookie", "saml-username",
|
||||||
|
"portal-userauthcookie", "saml-auth-status"):
|
||||||
|
captured[lk] = str(v)
|
||||||
|
handler(WKNavigationResponsePolicyAllow)
|
||||||
|
if "prelogin-cookie" in captured and "saml-username" in captured:
|
||||||
|
NSApplication.sharedApplication().stop_(None)
|
||||||
|
|
||||||
|
def webView_didFinishNavigation_(self, webView, nav):
|
||||||
|
# Nur die SAML-Weiterleitungsform (id=myform) anstossen, sonst wuerde
|
||||||
|
# auch das Duo-Login-Formular dauernd neu abgesendet (Flackern).
|
||||||
|
webView.evaluateJavaScript_completionHandler_(
|
||||||
|
"var f=document.getElementById('myform'); if(f){f.submit();}", None)
|
||||||
|
|
||||||
|
|
||||||
|
def run_saml(html: str, sessid: str):
|
||||||
|
app = NSApplication.sharedApplication()
|
||||||
|
app.setActivationPolicy_(0) # Regular -> Fenster bekommt sauber den Fokus
|
||||||
|
rect = NSMakeRect(0, 0, 540, 720)
|
||||||
|
style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
|
||||||
|
| NSWindowStyleMaskResizable)
|
||||||
|
win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||||
|
rect, style, NSBackingStoreBuffered, False)
|
||||||
|
win.setTitle_("Heron SSO-Login")
|
||||||
|
|
||||||
|
cfg = WKWebViewConfiguration.alloc().init()
|
||||||
|
wv = WKWebView.alloc().initWithFrame_configuration_(rect, cfg)
|
||||||
|
wv.setCustomUserAgent_(GP_UA)
|
||||||
|
delegate = Delegate.alloc().init()
|
||||||
|
wv.setNavigationDelegate_(delegate)
|
||||||
|
win.setContentView_(wv)
|
||||||
|
win.makeKeyAndOrderFront_(None)
|
||||||
|
|
||||||
|
base = NSURL.URLWithString_(f"https://{SERVER}/")
|
||||||
|
|
||||||
|
def load(_=None):
|
||||||
|
wv.loadHTMLString_baseURL_(html, base)
|
||||||
|
|
||||||
|
if sessid:
|
||||||
|
store = cfg.websiteDataStore().httpCookieStore()
|
||||||
|
cookie = NSHTTPCookie.cookieWithProperties_({
|
||||||
|
"Name": "SESSID", "Value": sessid, "Domain": SERVER,
|
||||||
|
"Path": "/", "Secure": "TRUE",
|
||||||
|
})
|
||||||
|
store.setCookie_completionHandler_(cookie, load)
|
||||||
|
else:
|
||||||
|
load()
|
||||||
|
|
||||||
|
app.activateIgnoringOtherApps_(True)
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
auth_only = "--auth-only" in sys.argv
|
||||||
|
html, sessid = prelogin()
|
||||||
|
run_saml(html, sessid)
|
||||||
|
|
||||||
|
if "prelogin-cookie" not in captured:
|
||||||
|
print(f"Kein prelogin-cookie erhalten. Erfasst: {list(captured)}",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
if auth_only:
|
||||||
|
# Nur den Cookie als JSON auf stdout -> die Tray-App liest ihn.
|
||||||
|
print(json.dumps({
|
||||||
|
"prelogin-cookie": captured["prelogin-cookie"],
|
||||||
|
"saml-username": captured.get("saml-username", ""),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Erfasst:", {k: v[:20] + "…" for k, v in captured.items()},
|
||||||
|
file=sys.stderr)
|
||||||
|
cmd = [
|
||||||
|
_tool("openconnect"), "--protocol=gp",
|
||||||
|
"--user", captured.get("saml-username", ""),
|
||||||
|
"--usergroup=gateway:prelogin-cookie", "--passwd-on-stdin",
|
||||||
|
"--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun",
|
||||||
|
]
|
||||||
|
hip = _hipreport()
|
||||||
|
if hip:
|
||||||
|
cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway
|
||||||
|
cmd.append(SERVER)
|
||||||
|
print("Starte:", " ".join(cmd), file=sys.stderr)
|
||||||
|
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||||
|
p.stdin.write((captured["prelogin-cookie"] + "\n").encode())
|
||||||
|
p.stdin.flush()
|
||||||
|
p.wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
+131
-65
@@ -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.
|
Eigene Ports/Profil/Bundle -> laeuft parallel zur Wuerth-App.
|
||||||
|
|
||||||
Abhaengigkeiten:
|
Auth: GlobalProtect-SAML (Duo). Der SAML-Login laeuft als eigener Cocoa/pyobjc-
|
||||||
openconnect + ocproxy auf dem PATH (macOS: brew install openconnect ocproxy)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob as _glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -26,30 +30,32 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer, Qt
|
from PyQt6.QtCore import QProcess, QProcessEnvironment, QTimer, Qt
|
||||||
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap
|
from PyQt6.QtGui import (
|
||||||
|
QAction, QColor, QIcon, QImage, QPainter, QPixmap, qAlpha, qRgba,
|
||||||
|
)
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, QMessageBox,
|
QApplication, QSystemTrayIcon, QMenu, QMessageBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== CONFIG =================================================================
|
# ===== CONFIG =================================================================
|
||||||
APP_NAME = "Heron VPN"
|
APP_NAME = "Heron VPN"
|
||||||
PROTOCOL = "gp" # GlobalProtect (Palo Alto)
|
PROTOCOL = "gp" # GlobalProtect (Palo Alto)
|
||||||
SERVER = "ssl.heron.at"
|
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
|
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")
|
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")
|
LOGFILE = os.path.expanduser("~/.heron-vpn.log")
|
||||||
PAC_NAME = "heron.pac"
|
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]] = [
|
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
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _external_browser() -> str:
|
def _saml_script() -> str:
|
||||||
"""Programm, das openconnect fuer das SAML-Login mit der SSO-URL aufruft."""
|
"""Pfad zu heron_gp_saml.py (im Bundle unter Resources, sonst neben uns)."""
|
||||||
s = _system()
|
base = os.environ.get(
|
||||||
if s == "Darwin":
|
"RESOURCEPATH", os.path.dirname(os.path.abspath(__file__)))
|
||||||
return "/usr/bin/open" # oeffnet Standardbrowser mit der URL
|
return os.path.join(base, "heron_gp_saml.py")
|
||||||
if s == "Windows":
|
|
||||||
return "cmd" # TODO: 'cmd /c start' verifizieren
|
|
||||||
return shutil.which("xdg-open") or "xdg-open"
|
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-Server (in-process) ------------------------------------------
|
||||||
|
_PAC_COND = " || ".join(f'shExpMatch(host, "{g}")' for g in INTERNAL_GLOBS)
|
||||||
PAC_BODY = (
|
PAC_BODY = (
|
||||||
"function FindProxyForURL(url, host) {\n"
|
"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'
|
f' return "SOCKS5 127.0.0.1:{PORT}";\n'
|
||||||
" }\n"
|
" }\n"
|
||||||
' return "DIRECT";\n'
|
' return "DIRECT";\n'
|
||||||
@@ -163,28 +180,46 @@ def start_pac_server() -> ThreadingHTTPServer:
|
|||||||
return srv
|
return srv
|
||||||
|
|
||||||
|
|
||||||
# ---------- Icon (generiertes "H"-Badge in Zustandsfarbe) --------------------
|
# ---------- Icon (Reiher-Silhouette in Zustandsfarbe) ------------------------
|
||||||
_ICON_CACHE: dict[str, QIcon] = {}
|
_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:
|
if color in _ICON_CACHE:
|
||||||
return _ICON_CACHE[color]
|
return _ICON_CACHE[color]
|
||||||
pm = QPixmap(44, 44)
|
m = _heron_mask()
|
||||||
|
if m.isNull(): # Fallback: einfacher Punkt
|
||||||
|
pm = QPixmap(22, 22)
|
||||||
pm.fill(Qt.GlobalColor.transparent)
|
pm.fill(Qt.GlobalColor.transparent)
|
||||||
p = QPainter(pm)
|
p = QPainter(pm)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
p.setBrush(QColor(color))
|
p.setBrush(QColor(color))
|
||||||
p.setPen(Qt.PenStyle.NoPen)
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
p.drawRoundedRect(4, 4, 36, 36, 9, 9)
|
p.drawEllipse(4, 4, 14, 14)
|
||||||
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()
|
p.end()
|
||||||
icon = QIcon(pm)
|
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
|
_ICON_CACHE[color] = icon
|
||||||
return icon
|
return icon
|
||||||
|
|
||||||
@@ -195,13 +230,13 @@ class VpnTray:
|
|||||||
self.app = app
|
self.app = app
|
||||||
self.oc: subprocess.Popen | None = None
|
self.oc: subprocess.Popen | None = None
|
||||||
self.browser: 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.state = "disconnected"
|
||||||
|
|
||||||
self.pac = start_pac_server()
|
self.pac = start_pac_server()
|
||||||
|
|
||||||
self.tray = QSystemTrayIcon()
|
self.tray = QSystemTrayIcon()
|
||||||
self.tray.setIcon(badge_icon("#9a9a9a"))
|
self.tray.setIcon(heron_icon("#9a9a9a"))
|
||||||
self.tray.setToolTip(f"{APP_NAME} – getrennt")
|
self.tray.setToolTip(f"{APP_NAME} – getrennt")
|
||||||
|
|
||||||
self.menu = QMenu()
|
self.menu = QMenu()
|
||||||
@@ -240,7 +275,7 @@ class VpnTray:
|
|||||||
"connected": ("#2e7d32", "Verbunden"),
|
"connected": ("#2e7d32", "Verbunden"),
|
||||||
"error": ("#cc3333", "Fehler"),
|
"error": ("#cc3333", "Fehler"),
|
||||||
}[state]
|
}[state]
|
||||||
self.tray.setIcon(badge_icon(color))
|
self.tray.setIcon(heron_icon(color))
|
||||||
self.tray.setToolTip(f"{APP_NAME} – {label}")
|
self.tray.setToolTip(f"{APP_NAME} – {label}")
|
||||||
self.act_status.setText(label)
|
self.act_status.setText(label)
|
||||||
self.act_connect.setEnabled(state in ("disconnected", "error"))
|
self.act_connect.setEnabled(state in ("disconnected", "error"))
|
||||||
@@ -248,48 +283,79 @@ class VpnTray:
|
|||||||
self.act_open.setEnabled(state == "connected")
|
self.act_open.setEnabled(state == "connected")
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
base = [
|
if self.auth_proc is not None:
|
||||||
_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
|
return
|
||||||
self.vpn_pw = pw
|
self._set_state("connecting")
|
||||||
send_pw = self.vpn_pw
|
# SAML-Login als eigener pyobjc/WKWebView-Prozess -> liefert Cookie als JSON.
|
||||||
cmd = base + ["--user", VPN_USER, "--passwd-on-stdin",
|
self.auth_proc = QProcess()
|
||||||
f"https://{SERVER}"]
|
# 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",
|
||||||
|
]
|
||||||
|
hip = _hipreport()
|
||||||
|
if hip:
|
||||||
|
cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway
|
||||||
|
cmd.append(SERVER)
|
||||||
try:
|
try:
|
||||||
self._log = open(LOGFILE, "wb")
|
self._log = open(LOGFILE, "wb")
|
||||||
self.oc = subprocess.Popen(
|
self.oc = subprocess.Popen(
|
||||||
cmd, stdin=subprocess.PIPE if send_pw else None,
|
cmd, stdin=subprocess.PIPE,
|
||||||
stdout=self._log, stderr=subprocess.STDOUT,
|
stdout=self._log, stderr=subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
if send_pw:
|
self.oc.stdin.write((cookie + "\n").encode())
|
||||||
self.oc.stdin.write((send_pw + "\n").encode())
|
|
||||||
self.oc.stdin.flush()
|
self.oc.stdin.flush()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self._set_state("error")
|
self._set_state("error")
|
||||||
QMessageBox.critical(None, APP_NAME, "openconnect nicht gefunden.")
|
QMessageBox.critical(None, APP_NAME, "openconnect nicht gefunden.")
|
||||||
return
|
return
|
||||||
|
# bleibt "connecting"; _tick erkennt den offenen SOCKS-Port
|
||||||
self._set_state("connecting")
|
|
||||||
|
|
||||||
def disconnect(self):
|
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()
|
self._kill_browser()
|
||||||
if self.oc and self.oc.poll() is None:
|
if self.oc and self.oc.poll() is None:
|
||||||
try:
|
try:
|
||||||
@@ -318,7 +384,7 @@ class VpnTray:
|
|||||||
argv = browser_argv() + [
|
argv = browser_argv() + [
|
||||||
f"--user-data-dir={PROFILE}",
|
f"--user-data-dir={PROFILE}",
|
||||||
f"--proxy-pac-url=http://127.0.0.1:{PACPORT}/{PAC_NAME}",
|
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",
|
"--no-first-run", "--no-default-browser-check",
|
||||||
"--disable-features=msSmartScreenProtection",
|
"--disable-features=msSmartScreenProtection",
|
||||||
"--test-type",
|
"--test-type",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Bauen: cd ~/apps && bash build_heron_app.sh
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
APP = ["heron_vpn_tray.py"]
|
APP = ["heron_vpn_tray.py"]
|
||||||
|
DATA_FILES = ["heron_gp_saml.py", "heron_mask.png"] # Auth-Prozess + Icon-Maske
|
||||||
OPTIONS = {
|
OPTIONS = {
|
||||||
"iconfile": "icon_heron.icns",
|
"iconfile": "icon_heron.icns",
|
||||||
"packages": ["PyQt6"],
|
"packages": ["PyQt6"],
|
||||||
@@ -23,6 +24,7 @@ OPTIONS = {
|
|||||||
setup(
|
setup(
|
||||||
app=APP,
|
app=APP,
|
||||||
name="HeronVPN",
|
name="HeronVPN",
|
||||||
|
data_files=DATA_FILES,
|
||||||
options={"py2app": OPTIONS},
|
options={"py2app": OPTIONS},
|
||||||
setup_requires=["py2app"],
|
setup_requires=["py2app"],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user