Ein VPN-Manager für FreeBSD mit echtem Kill-Switch über pf und
DNS-Leak-Schutz. v1 unterstützt OpenVPN (z. B. ExpressVPN-.ovpn-Profile);
die Architektur ist so gebaut, dass WireGuard/Cisco später als zusätzliches
Backend ergänzt werden können, ohne die pf-/DNS-Logik anzufassen.
- Ein Root-Daemon (
vpnmanager-daemon) hält diepf-Anchor-Regeln und/etc/resolv.confunter Kontrolle. Er ist der einzige Prozess, der erhöhte Rechte braucht. - Die CLI (
vpnmanager) spricht mit dem Daemon über einen Unix-Socket und braucht selbst kein Root. - Beim Verbindungsaufbau wird sofort eine Lockdown-Regel geladen: nur Loopback und der TLS-Handshake zum VPN-Server sind erlaubt.
- Sobald OpenVPNs
--up-Skript den Tunnel meldet, wird auf die Engaged-Regel umgeschaltet: nur noch Loopback und das Tunnel-Interface (tun0o. ä.) dürfen Verkehr senden/empfangen. Jede DNS-Anfrage, die nicht über den Tunnel geht, wird vonpfschlicht verworfen - das ist der eigentliche Leak-Schutz, unabhängig davon, ob eine Anwendungresolv.confüberhaupt respektiert. - Die vom Server gepushten Nameserver (
dhcp-option DNS ...) werden übernommen - bevorzugt über openresolv (resolvconf -a <tun-if> -x,-x= exklusiv: ignoriert währenddessen alle anderen registrierten DNS-Quellen wie DHCP), automatisch installiert vonvpnmanager-setupfalls noch nicht vorhanden. Istopenresolvnicht verfügbar, wird stattdessen/etc/resolv.confdirekt überschrieben (Original vorher gesichert, bei Trennung wiederhergestellt) - funktional gleichwertig, nur ohne openresolvs Robustheit bei mehreren DNS-Quellen gleichzeitig. - Trennst du dich gewollt (
vpnmanager disconnectbzw. Power-Button in der GUI), wird die vpnmanager-Anchor-Regel komplett entfernt (pfctl -a vpnmanager -f -mit leerem Regelwerk) - du bekommst dein normales Netz vollständig zurück, genau wie es ExpressVPNs eigener "Network Lock" im Normalmodus macht (Kill Switch blockiert dort standardmäßig nur bei unerwarteten Abbrüchen, nicht beim gewollten Trennen). - Stürzt OpenVPN dagegen unerwartet ab (Absturz, Auth-Fehler, Server-seitiger Abbruch), fällt der Daemon fail-closed zurück auf den Lockdown-Zustand statt offenen Klartext-Verkehr zuzulassen - dieselbe Unterscheidung, die ExpressVPNs Kill-Switch-Doku als "Normalmodus vs. Advanced/Always-on" beschreibt.
- State-Tabelle wird bei jedem Regelwechsel geleert (
pfctl -F state):pfprüft für bereits laufende Verbindungen zuerst seine State-Tabelle, bevor es das Regelwerk überhaupt konsultiert. Ohne diesen Flush könnte eine Verbindung, die schon vor dem Verbindungsaufbau lief (z. B. ein offener Browser-Tab mit einer WebRTC/STUN-Sitzung), unbemerkt über die alte, ungetunnelte Route weiterlaufen - ein Leck, das rein äußerlich wie ein WebRTC-Leck aussieht, tatsächlich aber eine veraltete pf-State ist. Jeder Übergang (Lockdown, Engage, Disengage, vollständiges Öffnen) erzwingt daher einen kompletten State-Flush, sodass jede Verbindung - auch bereits laufende - sich neu gegen das gerade aktive Regelwerk behaupten muss.
Ein Kommando, als root:
sudo ./install.shDas erledigt automatisch:
pkg install openvpn python3 openresolvsowie das zur installierten Python-Version passendepy3XX-tkinter-Paket (Version wird automatisch ermittelt, siehe unten)- Anlegen eines dedizierten virtualenv unter
/usr/local/vpnmanager-venvund Installation des Pakets darin - Symlinks
vpnmanager,vpnmanager-daemon,vpnmanager-setupnach/usr/local/bin - Systemsetup via
vpnmanager-setup(idempotent, mehrfach ausführbar):- Laufzeit-/State-Verzeichnisse anlegen
- OpenVPN-Hook-Skript nach
/usr/local/libexec/vpnmanager/hook.pyinstallieren - Unix-Gruppe
vpnmanageranlegen (für Nicht-root-CLI-Zugriff) - Falls
/etc/pf.confnoch gar nicht existiert: automatische Erkennung der aktiven Netzwerkschnittstelle (überroute -n get default) und Erzeugung einer minimalen Basis-pf.conf(einfache zustandsbehaftete Client-Firewall: ausgehend erlaubt, unaufgefordert eingehend blockiert) inklusive des vpnmanager-Anchors. Existiert bereits einepf.conf, wird sie nicht angefasst - nur die Anchor-Zeile wird bei Bedarf ergänzt (siehe nächster Punkt). Die Interface-Erkennung wirkt sich ausschließlich auf diese neu erzeugte Basisdatei aus - die eigentlichen Kill-Switch-Regeln des vpnmanager-Anchors selbst referenzieren nie eine physische Schnittstelle (nurlo0und das Tunnel-Interface), funktionieren also unabhängig davon, welche Netzwerkschnittstelle gerade aktiv ist. anchor "vpnmanager"automatisch in/etc/pf.confeinfügen - dabei wird die bestehende Datei zuerst per Zeitstempel gesichert (/etc/pf.conf.vpnmanager-backup-<timestamp>), die Änderung dann mitpfctl -nf(reiner Syntax-Check, ohne etwas zu laden) validiert, und nur bei Erfolg tatsächlich geschrieben und neu geladen. Ist die Zeile schon vorhanden, passiert nichts. Schlägt die Validierung fehl, wird nichts verändert und du bekommst die passendepfctl-Fehlermeldung samt Hinweis, die Zeile manuell zu platzieren.pfaktivieren, falls noch nicht aktiv (sysrc pf_enable=YES,pfctl -e) - vorher wird zusätzlich/etc/rc.confmit Zeitstempel gesichert (sysrcselbst legt kein Backup an)- rc.d-Dienst installieren und für den Boot aktivieren
| Datei | Wann | Wie |
|---|---|---|
/etc/pf.conf |
einmalig bei vpnmanager-setup |
Zeitstempel-Backup + pfctl -nf-Validierung vor dem Schreiben |
/etc/rc.conf |
einmalig bei vpnmanager-setup |
Zeitstempel-Backup vor den sysrc-Aufrufen |
/etc/resolv.conf |
bei jedem vpnmanager connect |
Backup zur Laufzeit durch den Daemon, automatische Wiederherstellung bei disconnect |
Alle Backups landen als <originaldatei>.vpnmanager-backup-<unix-timestamp>
neben dem Original und werden nicht automatisch gelöscht - bei mehrfachem
vpnmanager-setup-Aufruf sammeln sich also mit der Zeit mehrere
Backup-Dateien an, die du bei Bedarf manuell aufräumen kannst.
- Dienst starten (
service vpnmanager start)
install.sh fragt danach interaktiv, welche Benutzer der Gruppe
vpnmanager hinzugefügt werden sollen (Leerzeichen-getrennt; als Vorschlag
erscheint automatisch der Benutzer, der sudo aufgerufen hat - einfach
Enter drücken, um ihn zu übernehmen). Das ist nötig, damit vpnmanager /
vpnmanager-gui ohne sudo mit dem Daemon sprechen können - CLI und
GUI sind bewusst nicht für sudo gedacht (siehe unten, "Warum kein
sudo?"). Wer nicht sofort antworten will/kann, drückt einfach Enter ohne
Eingabe und holt es später manuell nach:
pw groupmod vpnmanager -m deinbenutzername(danach einmal neu einloggen, damit die Gruppenmitgliedschaft greift)
Erneuter Aufruf von vpnmanager-setup (z. B. nach einem Upgrade) ist
jederzeit gefahrlos möglich - jeder Schritt prüft vorher den aktuellen
Zustand.
Falls du lieber Schritt für Schritt vorgehst oder etwas debuggen willst,
sind das exakt die Einzelschritte, die install.sh automatisiert:
pkg install openvpn python3 openresolv py311-tkinter # Python-Version anpassen
python3 -m venv --system-site-packages /usr/local/vpnmanager-venv
/usr/local/vpnmanager-venv/bin/pip install .
ln -sf /usr/local/vpnmanager-venv/bin/vpnmanager /usr/local/bin/vpnmanager
ln -sf /usr/local/vpnmanager-venv/bin/vpnmanager-daemon /usr/local/bin/vpnmanager-daemon
ln -sf /usr/local/vpnmanager-venv/bin/vpnmanager-setup /usr/local/bin/vpnmanager-setup
ln -sf /usr/local/vpnmanager-venv/bin/vpnmanager-gui /usr/local/bin/vpnmanager-gui
vpnmanager-setup
service vpnmanager startZum Testen ohne Dienst reicht auch direkt: sudo vpnmanager-daemon (läuft
im Vordergrund, Logs zusätzlich unter /var/log/vpnmanager.log).
sudo vpnmanager-gui schlägt typischerweise fehl mit authorization required, but no authorisation protocol specified und/oder
tkinter.TclError: couldn't connect to display. Das liegt an X11, nicht
an vpnmanager: die Berechtigung für die grafische Anzeige ist an deinen
normalen Benutzer gebunden (~/.Xauthority); sobald sudo zu root
wechselt, hat der Prozess weder Zugriff auf diese Datei noch (je nach
sudo-Konfiguration) überhaupt eine gesetzte $DISPLAY-Variable - der
X-Server weist root deshalb ab.
Das ist auch architektonisch nicht nötig: CLI und GUI laufen bewusst als
normaler Benutzer und sprechen nur über einen Unix-Socket mit dem
root-Daemon (der wiederum über den rc.d-Dienst läuft, nicht über
sudo). Voraussetzung dafür ist lediglich Mitgliedschaft in der Gruppe
vpnmanager (von install.sh interaktiv abgefragt, siehe oben, oder
manuell per pw groupmod vpnmanager -m <benutzername> + neu einloggen).
Einfach vpnmanager-gui ohne sudo starten.
Neben der CLI gibt es eine schlanke Desktop-GUI (Tkinter, Python-Standard- bibliothek - keine zusätzlichen Abhängigkeiten):
vpnmanager-guiDunkles Theme mit großem rundem Power-Button in der Mitte (Klick startet
bzw. beendet die Verbindung), Statustext, Verbindungsdauer-Timer und einer
Server-Karte darunter, die das aktuell gewählte .ovpn-Profil anzeigt
("Ändern..." öffnet den Dateidialog). Anmeldedaten (Benutzername/Passwort +
"Speichern"-Option) liegen in einem eigenen Dialog hinter dem Button
"Anmeldedaten" oben rechts, ähnlich wie bei den meisten VPN-Client-Apps das
Login vom eigentlichen Verbinden getrennt ist. Farben/Layout orientieren
sich am verbreiteten "ein großer Kreis-Button" Bedienkonzept solcher Apps,
ohne fremde Logos, Wortmarken oder exakte Markenfarben zu übernehmen. Die
GUI ist reines Frontend - sie spricht über denselben Unix-Socket mit dem
Daemon wie die CLI und braucht selbst keine Root-Rechte (dafür aber eine
laufende grafische Sitzung, z. B. Xfce/KDE/GNOME auf FreeBSD, oder
X11-Forwarding per SSH: ssh -X).
Falls beim Start ModuleNotFoundError: No module named 'tkinter' erscheint:
install.sh ermittelt die tatsächlich installierte Python-Version
(python3 -c 'import sys; print(...)') und installiert automatisch das
passende py3XX-tkinter-Paket dafür - kein Rätselraten mit fest
einprogrammierten Versionsnummern mehr. Zusätzlich wird das virtualenv mit
--system-site-packages angelegt, denn Tkinter ist ein Systempaket
(pkg), kein pip-Paket - ein gewöhnliches venv sieht es sonst schlicht
nicht, selbst wenn es systemweit installiert ist (das war vermutlich die
eigentliche Ursache, falls die GUI vorher trotz installiertem Tkinter
nicht startete). Am Ende von install.sh steht eine explizite Prüfung
(python3 -c "import tkinter" im venv), die dir sofort sagt, ob die GUI
einsatzbereit ist.
Findet das Skript kein passendes Paket automatisch (z. B. bei einem sehr neuen oder sehr alten FreeBSD-Release), bleibt als Fallback:
pkg search tkinter # passendes Paket zur installierten Python-Version finden
pkg install py311-tkinter # Beispiel, Versionsnummer anpassen
sudo ./install.sh # danach erneut ausfuehren - alle Schritte sind idempotentZugangsdaten werden pro .ovpn-Profil (Dateiname als Schlüssel) in
/var/db/vpnmanager/credentials.xml gespeichert - Verzeichnis 0700,
Datei 0600, nur für root (also nur den Daemon selbst) lesbar.
Wichtig zur Sicherheit: Das ist Zugriffsschutz per Dateiberechtigung,
keine Verschlüsselung - das Passwort steht im Klartext in der Datei,
lesbar für jeden, der Root-Zugriff auf die Maschine hat (genau das gleiche
Vertrauensmodell wie OpenVPNs eigene auth-user-pass-Dateien). Für ein
Einzelplatzsystem, das nur dir gehört, ist das ein vernünftiger Kompromiss
zwischen Komfort und Aufwand. Falls du zusätzlichen Schutz gegen jemanden
mit Root-Zugriff auf derselben Maschine brauchst (z. B. Mehrbenutzer- oder
verwaltetes System), reicht das allein nicht aus - sag Bescheid, dann bauen
wir eine passphrasenbasierte Verschlüsselung obendrauf.
Beim Verbinden mit --save speichern:
vpnmanager connect ~/expressvpn/schweiz-zuerich.ovpn -u meinbenutzername --save
# Passwort wird einmalig abgefragt und danach gespeichertAb dann reicht für dasselbe Profil:
vpnmanager connect ~/expressvpn/schweiz-zuerich.ovpn
# lädt automatisch die gespeicherten Zugangsdaten, kein -u/-p noetigVerwaltung unabhängig vom Verbinden:
vpnmanager creds save ~/expressvpn/schweiz-zuerich.ovpn -u meinbenutzername
vpnmanager creds list
vpnmanager creds forget ~/expressvpn/schweiz-zuerich.ovpnIn der GUI (vpnmanager-gui) werden gespeicherte Zugangsdaten automatisch
in die Felder vorausgefüllt, sobald du ein Profil auswählst, für das
bereits welche hinterlegt sind; die Checkbox "Zugangsdaten für dieses
Profil speichern" steuert, ob eine neu eingegebene Kombination gespeichert
wird.
CLI und GUI unterstützen Englisch, Deutsch und Spanisch. Die Sprache wird beim Start automatisch ermittelt, in dieser Reihenfolge:
- Umgebungsvariable
VPNMANAGER_LANG(en,de, oderes) - Systemsprache (
LANG/LC_ALL, z. B.de_DE.UTF-8->de) - Englisch als Rückfall
Manuell erzwingen:
VPNMANAGER_LANG=es vpnmanager status
VPNMANAGER_LANG=es vpnmanager-guiIn der GUI gibt es zusätzlich ein Dropdown oben rechts (EN/DE/ES), das die Sprache zur Laufzeit umschaltet, ohne die App neu zu starten.
Wichtig zur Reichweite: Übersetzt sind alle CLI-Hilfetexte, Prompts
sowie sämtliche GUI-Beschriftungen, Dialoge und Meldungen. Nicht
übersetzt sind: Fehlermeldungen, die direkt vom Daemon/Backend kommen
(z. B. Ausnahmen aus OpenVPN oder Python selbst - diese sind meist
technischer Natur und für Diagnose/Debugging gedacht), sowie
Argparse-interne Systemtexte wie usage:/positional arguments: (kommen
aus Pythons Standardbibliothek und bleiben Englisch). Das
Übersetzungssystem ist ein leichtgewichtiges eigenes Modul
(vpnmanager/i18n.py, ein Dictionary pro Sprache) statt eines vollen
gettext/.po/.mo-Setups - für die Größe dieser App bewusst einfacher
gehalten, aber ohne Kompilierschritt bei der Installation.
vpnmanager connect ~/expressvpn-profiles/schweiz-zuerich.ovpn -u meinbenutzername
# Passwort wird sicher abgefragt (getpass), falls nicht per -p übergeben
vpnmanager status
# state: connected
# remote: 91.x.x.x:1194/udp
vpnmanager disconnectBeim Wechsel des Servers/Profils reicht vpnmanager connect <anderes.ovpn>
- vorher aber
disconnect, ein zweiter gleichzeitiger Tunnel wird bewusst abgelehnt (state: already connected), um Regel-Kollisionen zu vermeiden.
- Wichtiger Fix (siehe unten): Die Lockdown-Phase (vor Tunnelaufbau)
erlaubt jetzt explizit ausgehende DNS-Anfragen an die aktuell in
/etc/resolv.confeingetragenen Nameserver. Grund: Viele.ovpn-Profile (auch ExpressVPNs) geben fürremoteeinen Hostnamen statt einer IP-Adresse an. OpenVPN muss diesen Hostnamen selbst auflösen, sobald es startet - und das geschieht bereits während die Lockdown-Regeln aktiv sind. Ohne diese DNS-Ausnahme wurde genau diese Auflösung blockiert, der Tunnel kam nie zustande, und der komplette Internetzugang blieb gesperrt. Betroffen von dieser DNS-Ausnahme ist ausschließlich die Lockdown-Phase - sobald der Tunnel steht (Engage-Phase), gibt es weiterhin keine allgemeine DNS-Ausnahme, der eigentliche Leck-Schutz bleibt also unverändert bestehen. - Es wird nur der erste
remote-Eintrag im.ovpn-Profil für die Lockdown-Ausnahme verwendet. Bei Profilen mit mehreren Fallback-Servern kann es sein, dass OpenVPN beim Failover auf einen zweiten Server wechselt, denpfin der Lockdown-Phase noch nicht kennt. Für ExpressVPNs Einzelserver-Profile ist das unkritisch; bei Multi-Remote-Profilen ggf. die Lockdown-Regel manuell erweitern oder ein einzelnes Profil pro Server verwenden. - IPv6-Leaks:
block drop allohne expliziteinet/inet6-Angabe gilt in pf automatisch für beide Adressfamilien. Da weder die Engage-Regel noch die Lockdown-Regel eine "pass"-Ausnahme für IPv6 definieren (außer implizit für Loopback/Tunnel-Interface und falls der VPN-Server oder ein Nameserver selbst eine IPv6-Adresse hat), ist ein IPv6-Leak am Tunnel vorbei mit den aktuellen Regeln nicht möglich - auch ohne separateinet6-Regeln. - Es existiert kein automatisches "Reconnect bei Verbindungsabbruch" -
der Daemon fällt fail-closed in den Lockdown-Zustand, ein erneutes
vpnmanager connectist danach nötig. - Kein automatischer Neustart bei Daemon-Absturz (bewusste
Design-Entscheidung, kein Bug): Der komplette Verbindungszustand lebt
nur im Prozessspeicher des Daemons, nirgends persistiert. Ein
automatischer Neustart nach einem Absturz (z. B. via
daemon(8)s-r) würde einen neuen Prozess erzeugen, der sich fälschlich für "nicht verbunden" hält, während diepf-Regeln im Kernel und ein eventuell verwaister OpenVPN-Prozess unabhängig davon weiterlaufen -vpnmanager statuswürde dann die Unwahrheit sagen. Stürzt der Daemon ab, bleibt der Dienst deshalb bewusst gestoppt (service vpnmanager statuszeigt das an); manuellesservice vpnmanager startist dann nötig. - Diese Software wurde einer gründlichen Code-Review unterzogen (mehrere Bugs in der Absturz-/Race-Condition-Behandlung, der Zugangsdaten-Ablage, der Pfad-Auflösung und der pf-Regel-Validierung wurden dabei gefunden und behoben), aber sie hat noch keinen produktiven Langzeiteinsatz hinter sich. Vor dem produktiven Einsatz eigene Tests empfohlen, insbesondere für deinen konkreten OpenVPN-Server/-Provider.
Neue Backends (WireGuard, Cisco/ocserv, ...) implementieren
vpnmanager.backends.base.VPNBackend und werden in daemon.py anstelle
von (oder zusätzlich zu) OpenVPNBackend eingebunden. Die pf- und
DNS-Logik (pf_manager.py, dns_manager.py) bleibt dabei unverändert -
sie kennt nur "lockdown", "engage(tun_if, remote)" und "disengage".
BSD 2-Clause License - siehe LICENSE. Copyright (c) 2026 Robert Illner (Nihjo).
Dieses Projekt ist über viele Gesprächsrunden hinweg mit Unterstützung von Claude (Anthropic) entstanden - von der Architekturplanung über den Code bis zur Fehlersuche.