Eine einfache, testgetrieben entwickelte Desktop-GUI (Tkinter) zur Verwaltung
von pf-Firewallregeln unter FreeBSD – im Funktionsumfang angelehnt an
ufw/GUFW unter Ubuntu: Allow/Deny-Regeln nach Port, Protokoll, Richtung
und optional Interface, die sich aktivieren/deaktivieren statt nur
löschen lassen, konfigurierbare Standardrichtlinien (Eingehend/Ausgehend),
eine kleine Liste bekannter Dienste zum schnellen Ausfüllen, optionales
Logging pro Regel, sowie Backup/Rollback des gesamten Zustands.
Code, Tests, Installer und diese Dokumentation wurden testgetrieben gemeinsam mit Claude (Anthropic) entwickelt: Robert Illner (Nihjo) hat Anforderungen, Architektur und Reviews vorgegeben, Claude hat den Großteil des Codes geschrieben. Vor einem produktiven Einsatz auf einer echten FreeBSD-Firewall gilt wie bei jedem Firewall-Tool: selbst prüfen, testen, verstehen.
src/pyfw/
models.py Rule- und Policy-Domänenmodell + Validierung
(kein pf, kein Tk)
known_services.py Kleine Liste bekannter Dienste (SSH, HTTP, ...) für
das "Bekannter Dienst"-Feld beim Regel-Hinzufügen
(GUFWs "Simple"-Reiter)
config_generator.py Rule-Liste + Policy -> pf-Regeltext (mit "quick" für
ufw-artige Auswertungsreihenfolge: erste passende
Regel gewinnt; deaktivierte Regeln werden nur
auskommentiert, nicht geladen)
rule_store.py Persistiert Regeln (nummeriert wie ufw) und die
Standardrichtlinie als JSON; sichert vor jeder
Änderung automatisch den vorherigen Stand
(Backup/Rollback)
pf_manager.py Kapselt pfctl-Aufrufe hinter einer injizierbaren
Runner-Funktion (testbar ohne echtes pf/root)
controller.py Bindet Store + Generator + PfManager zusammen;
einzige Schicht, die die GUI aufruft
gui.py Tkinter-Oberfläche, ruft ausschließlich controller.py
tests/ unittest-Tests für alle Module außer gui.py
Jede Schicht unterhalb von gui.py ist vollständig ohne Tkinter, ohne
root-Rechte und ohne echtes pf testbar (Subprocess-Aufrufe werden per
Dependency Injection gefaked).
Übernommen: Regeln aktivieren/deaktivieren ohne Löschen, konfigurierbare Standardrichtlinien für Eingehend/Ausgehend, ein Dropdown mit bekannten Diensten zum schnellen Vorausfüllen, ein Logging-Flag pro Regel.
Bewusst nicht übernommen (würde den MVP-Rahmen sprengen): ein "Routed"-
Policy-Feld für NAT/Routing (pyfw macht kein NAT), ein "Report"-Reiter
mit Live-Verbindungen/offenen Ports, graduelle Logging-Stufen
(Off/Low/Medium/High/Full wie bei ufw/rsyslog – pf kennt nur
log-an/log-aus pro Regel, keine Schweregrade), IP-/Subnetz-Einschränkung
pro Regel (aktuell immer from any to any).
pyfw schreibt niemals in /etc/pf.conf – Regeln werden ausschließlich
über einen eigenen, benannten Anchor geladen (pfctl -a pyfw -f <datei>),
damit die restliche pf-Konfiguration des Systems unangetastet bleibt.
Damit die im Anchor geladenen Regeln aber überhaupt ausgewertet werden,
muss der Anchor einmalig im Hauptregelwerk aufgerufen werden. Trage
dazu folgende Zeile in /etc/pf.conf ein:
anchor "pyfw"
und lade das Hauptregelwerk einmal neu (service pf reload bzw.
pfctl -f /etc/pf.conf). Voraussetzung ist außerdem, dass pf grundsätzlich
aktiviert ist (pf_enable="YES" in /etc/rc.conf, pf-Kernelmodul geladen).
pyfw verwaltet danach nur noch den Regel-Inhalt dieses einen Anchors –
genau wie ufw nur seine eigenen iptables-Chains verwaltet und nicht das
gesamte Regelwerk des Systems.
Die Buttons „Aktivieren“/„Deaktivieren“ in der GUI rufen pfctl -e
bzw. pfctl -d auf und schalten damit pf global ein/aus (analog zu
ufw enable/ufw disable). Auf einem System, das pf auch für andere
Zwecke (NAT, weitere Anchors) nutzt, wirkt sich das auf die gesamte
Firewall aus, nicht nur auf pyfw-Regeln.
pfctl benötigt root-Rechte – die GUI muss entsprechend gestartet werden
(z. B. via doas/sudo).
Im entpackten Projektverzeichnis (dort, wo pyproject.toml liegt):
doas sh install.sh # richtig installieren
doas sh install.sh --dry-run # nur anzeigen, was passieren würdeDer Installer
- installiert
python312, das Tkinter-Binding undpipperpkg, - legt eine virtuelle Umgebung unter
/usr/local/pyfw/venvan und installiert pyfw dort hinein, - erzeugt den Startbefehl
/usr/local/bin/pyfw-gui, - setzt
pf_enable="YES"in/etc/rc.conf(übersysrc, idempotent), - trägt die einmalig nötige
anchor "pyfw"-Zeile in/etc/pf.confein – mit automatischem Backup und einer Syntaxprüfung (pfctl -nf) vor jedem Neuladen; schlägt die Prüfung fehl, wird die Originaldatei automatisch wiederhergestellt und nichts angewendet.
Regeln und die daraus erzeugte pf-Regeldatei liegen bewusst im
nutzereigenen ~/.config/pyfw/, nicht in einem separaten root-only-
Systemverzeichnis – nur so kann PYFW_USE_DOAS=1 (siehe unten)
tatsächlich ohne root-Rechte für den Tkinter-Prozess funktionieren.
Rückbau mit doas sh uninstall.sh (fragt vor dem Entfernen der
Anchor-Zeile nach, Backup wird angelegt; Altlasten aus Installationen
vor diesem Design ebenfalls, ~/.config/pyfw/ bleibt unangetastet).
pkg install python312 py312-tkinter py312-pip
python3.12 -m venv .venv
. .venv/bin/activate
pip install -e .[dev]
pytest # führt alle Tests in tests/ aus (unittest-basiert,
# von pytest automatisch erkannt)Aktivieren/Deaktivieren-Buttons sowie der Regel-Umschalter sind farbig
(grün = bewirkt eine Aktivierung, rot = bewirkt eine Deaktivierung) -
über ttk.Style mit dem Theme "clam" gesetzt, damit die Farben
theme-unabhängig zuverlässig angezeigt werden.
doas pyfw-guipyfw-gui startet auch ohne root - das Fenster öffnet sich normal,
aber jeder pfctl-Aufruf (Regel anwenden, Firewall aktivieren/
deaktivieren) schlägt dann mit "Permission denied" fehl, da pfctl
selbst root-Rechte braucht. pyfw-gui warnt davor direkt beim Start,
falls weder als root noch mit PYFW_USE_DOAS=1 (siehe unten) gestartet
wurde.
Trage in /usr/local/etc/doas.conf ein (Benutzername anpassen):
permit nopass DEINBENUTZER as root cmd /sbin/pfctl
und starte dann als normaler Nutzer:
PYFW_USE_DOAS=1 pyfw-guiNur die einzelnen pfctl-Aufrufe werden dann privilegiert, nicht der
gesamte Tkinter-Prozess. Der Installer richtet diese doas-Regel bewusst
nicht automatisch ein, da das Vergeben von root-Rechten für einen
Nutzer eine bewusste, manuelle Entscheidung sein sollte.
Vor jeder Änderung (Regel hinzufügen/löschen/umschalten, Standardrichtlinie
ändern) sichert RuleStore automatisch den bisherigen Gesamtzustand
(Standard: die letzten 20 Stände, ältere werden automatisch gelöscht).
Über den Button „Letzte Sicherung wiederherstellen“ in der GUI (oder
controller.rollback()) lässt sich der vorherige Stand wiederherstellen
– einschließlich der angezeigten Regelliste und der Policy-Dropdowns,
nicht nur der bei pf geladenen Regeldatei: alles ist dieselbe Quelle
(rules.json), daher bleibt es automatisch synchron. Der wiederhergestellte
Stand wird anschließend automatisch erneut bei pf angewendet.
Auch ein Rollback selbst ist nicht destruktiv: der gerade rückgängig
gemachte Stand wird vor dem Zurücksetzen ebenfalls als Sicherung
angelegt, sodass man bei Bedarf wieder vorwärts gehen kann (über
list_backups()/restore_backup(pfad), gezielt für einen bestimmten
älteren Stand statt nur den jeweils letzten).
Für Backups von /etc/pf.conf (dem Anchor-Aufrufpunkt) siehe den
Abschnitt zum Installer oben – das ist unabhängig davon und wird nur
von install.sh/uninstall.sh angefasst, nicht vom laufenden Programm.
Lehnt pfctl einen neuen Regelsatz ab (z. B. wegen eines ungültigen
Interface-Namens), bleiben Regelliste, Standardrichtlinie und die
auf der Platte liegende pf-Regeldatei konsistent beim zuletzt
erfolgreich geladenen Stand – nichts zeigt eine Änderung als übernommen
an, die bei pf tatsächlich nie aktiv wurde. Das gilt für add_rule,
delete_rule, set_rule_enabled und set_policy gleichermaßen.
{
"policy": {"incoming": "deny", "outgoing": "allow"},
"next_number": 2,
"rules": [
{"number": 1, "rule": {"action": "allow", "direction": "in",
"protocol": "tcp", "port": 22, "interface": null,
"comment": "SSH", "enabled": true, "log": false}}
]
}next_number wird persistiert, damit Regelnummern auch nach einem
Neustart nicht versehentlich wiederverwendet werden, wenn die zuletzt
angelegte(n) Regel(n) zwischenzeitlich gelöscht wurden. Fehlt der
Schlüssel (Dateien aus einer sehr frühen pyfw-Version), wird er beim
Laden aus den vorhandenen Regelnummern rekonstruiert.
Rule.from_dict() setzt enabled/log auf sinnvolle Standardwerte
(true/false), falls ein Eintrag diese Schlüssel noch nicht enthält.
- pf-Tables und Anchors als eigene Verwaltungseinheiten
- NAT/Redirect-Regeln, "Routed"-Policy
- Regeln nach IP-Adresse/Subnetz einschränken (aktuell immer
from any to any) - IPv6
- "Report"-Ansicht mit aktuell offenen Ports/Verbindungen (wie GUFWs Report-Reiter)
BSD-2-Clause, siehe LICENSE. Copyright (c) 2026 Robert Illner (Nihjo).