Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vpnmanager

Built with Claude License: BSD 2-Clause

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.

Funktionsprinzip

  • Ein Root-Daemon (vpnmanager-daemon) hält die pf-Anchor-Regeln und /etc/resolv.conf unter 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 (tun0 o. ä.) dürfen Verkehr senden/empfangen. Jede DNS-Anfrage, die nicht über den Tunnel geht, wird von pf schlicht verworfen - das ist der eigentliche Leak-Schutz, unabhängig davon, ob eine Anwendung resolv.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 von vpnmanager-setup falls noch nicht vorhanden. Ist openresolv nicht verfügbar, wird stattdessen /etc/resolv.conf direkt überschrieben (Original vorher gesichert, bei Trennung wiederhergestellt) - funktional gleichwertig, nur ohne openresolvs Robustheit bei mehreren DNS-Quellen gleichzeitig.
  • Trennst du dich gewollt (vpnmanager disconnect bzw. 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): pf prü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.

Installation (out of the box)

Ein Kommando, als root:

sudo ./install.sh

Das erledigt automatisch:

  1. pkg install openvpn python3 openresolv sowie das zur installierten Python-Version passende py3XX-tkinter-Paket (Version wird automatisch ermittelt, siehe unten)
  2. Anlegen eines dedizierten virtualenv unter /usr/local/vpnmanager-venv und Installation des Pakets darin
  3. Symlinks vpnmanager, vpnmanager-daemon, vpnmanager-setup nach /usr/local/bin
  4. Systemsetup via vpnmanager-setup (idempotent, mehrfach ausführbar):
    • Laufzeit-/State-Verzeichnisse anlegen
    • OpenVPN-Hook-Skript nach /usr/local/libexec/vpnmanager/hook.py installieren
    • Unix-Gruppe vpnmanager anlegen (für Nicht-root-CLI-Zugriff)
    • Falls /etc/pf.conf noch gar nicht existiert: automatische Erkennung der aktiven Netzwerkschnittstelle (über route -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 eine pf.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 (nur lo0 und das Tunnel-Interface), funktionieren also unabhängig davon, welche Netzwerkschnittstelle gerade aktiv ist.
    • anchor "vpnmanager" automatisch in /etc/pf.conf einfügen - dabei wird die bestehende Datei zuerst per Zeitstempel gesichert (/etc/pf.conf.vpnmanager-backup-<timestamp>), die Änderung dann mit pfctl -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 passende pfctl-Fehlermeldung samt Hinweis, die Zeile manuell zu platzieren.
    • pf aktivieren, falls noch nicht aktiv (sysrc pf_enable=YES, pfctl -e) - vorher wird zusätzlich /etc/rc.conf mit Zeitstempel gesichert (sysrc selbst legt kein Backup an)
    • rc.d-Dienst installieren und für den Boot aktivieren

Übersicht: welche Konfigurationsdateien werden gesichert?

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.

  1. 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.

Manuell / zum Nachvollziehen

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 start

Zum Testen ohne Dienst reicht auch direkt: sudo vpnmanager-daemon (läuft im Vordergrund, Logs zusätzlich unter /var/log/vpnmanager.log).

Grafische Oberfläche

Warum kein sudo vpnmanager-gui?

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-gui

Dunkles 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 idempotent

Zugangsdaten speichern (kein erneutes Eintippen)

Zugangsdaten 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 gespeichert

Ab dann reicht für dasselbe Profil:

vpnmanager connect ~/expressvpn/schweiz-zuerich.ovpn
# lädt automatisch die gespeicherten Zugangsdaten, kein -u/-p noetig

Verwaltung unabhängig vom Verbinden:

vpnmanager creds save ~/expressvpn/schweiz-zuerich.ovpn -u meinbenutzername
vpnmanager creds list
vpnmanager creds forget ~/expressvpn/schweiz-zuerich.ovpn

In 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.

Mehrsprachigkeit (Englisch / Deutsch / Spanisch)

CLI und GUI unterstützen Englisch, Deutsch und Spanisch. Die Sprache wird beim Start automatisch ermittelt, in dieser Reihenfolge:

  1. Umgebungsvariable VPNMANAGER_LANG (en, de, oder es)
  2. Systemsprache (LANG/LC_ALL, z. B. de_DE.UTF-8 -> de)
  3. Englisch als Rückfall

Manuell erzwingen:

VPNMANAGER_LANG=es vpnmanager status
VPNMANAGER_LANG=es vpnmanager-gui

In 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.

Nutzung

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 disconnect

Beim 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.

Bekannte Grenzen von v1

  • Wichtiger Fix (siehe unten): Die Lockdown-Phase (vor Tunnelaufbau) erlaubt jetzt explizit ausgehende DNS-Anfragen an die aktuell in /etc/resolv.conf eingetragenen Nameserver. Grund: Viele .ovpn-Profile (auch ExpressVPNs) geben für remote einen 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, den pf in 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 all ohne explizite inet/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 separate inet6-Regeln.
  • Es existiert kein automatisches "Reconnect bei Verbindungsabbruch" - der Daemon fällt fail-closed in den Lockdown-Zustand, ein erneutes vpnmanager connect ist 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 die pf-Regeln im Kernel und ein eventuell verwaister OpenVPN-Prozess unabhängig davon weiterlaufen - vpnmanager status würde dann die Unwahrheit sagen. Stürzt der Daemon ab, bleibt der Dienst deshalb bewusst gestoppt (service vpnmanager status zeigt das an); manuelles service vpnmanager start ist 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.

Erweiterbarkeit

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".

Lizenz

BSD 2-Clause License - siehe LICENSE. Copyright (c) 2026 Robert Illner (Nihjo).

Danksagung

Dieses Projekt ist über viele Gesprächsrunden hinweg mit Unterstützung von Claude (Anthropic) entstanden - von der Architekturplanung über den Code bis zur Fehlersuche.

About

A GUI for OpenVPN with support for pf and openresolv to initiate VPN connection via a graphic tool. for FreeBSD

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages