Das Problem
Du willst mehrere pfSense-Änderungen automatisiert und reproduzierbar durchführen:
- DNS-Redirect für Port 53 (Clients am Bypass hindern)
- DoT/DoQ-Block (Port 853, DNS-over-TLS/QUIC)
- DHCP-DNS-Server ändern
- pfBlockerNG DNSBL-Listen erweitern
Durch die GUI klicken ist eine Option. Für 8 NAT-Regeln, 8 Firewall-Regeln und 4 weitere Änderungen macht das keinen Spaß — und ist vor allem nicht reproduzierbar.
pfSense hat eine PHP-API die direkt auf config.xml operiert. Die Funktionen sind in den .inc-Dateien dokumentiert… oder genauer: nicht dokumentiert, aber vorhanden.
TL;DR
<?php
require_once("config.inc");
require_once("filter.inc"); // für filter_configure()
require_once("services.inc"); // für services_unbound_configure(), services_dhcpd_configure()
// Backup ZUERST
$backup_file = "/conf/config.xml.bak." . date("Ymd_His");
copy("/conf/config.xml", $backup_file);
// Config laden
$config = parse_config(true);
// Änderungen machen
$config['filter']['rule'][] = array(...);
// Speichern + Reload
write_config("Beschreibung der Änderung");
filter_configure();
Ausführen: php -f /tmp/dein-script.php auf der pfSense (als root per SSH).
Die Grundstruktur
Config lesen und schreiben
// RICHTIG: explicit true für reload aus Datei (nicht aus Cache)
$config = parse_config(true);
// Änderungen machen...
// Speichern
write_config("Commit-Message — erscheint in System > Config History");
// Reload (je nach Bereich)
filter_configure(); // Firewall-Regeln + NAT
services_unbound_configure(); // DNS/Unbound
services_dhcpd_configure(); // DHCP (KEA oder ISC)
Backup-Pfad
// RICHTIG: /conf/
$backup = "/conf/config.xml.bak." . date("Ymd_His");
copy("/conf/config.xml", $backup);
Nicht /cf/conf/ — das ist ein veralteter Symlink der auf älteren pfSense-Versionen existierte.
Idempotenz-Check (PFLICHT)
Jedes Script muss prüfen ob die Änderung bereits vorhanden ist:
$exists = false;
foreach ($config['filter']['rule'] as $existing) {
if (isset($existing['descr']) && $existing['descr'] === $descr) {
$exists = true;
echo "SKIP: {$descr}\n";
break;
}
}
if ($exists) continue;
// Nur dann hinzufügen
$config['filter']['rule'][] = array(...);
Ohne das läufst du das Script zweimal und hast doppelte Regeln.
Pitfall #1: Das Tracker-Format
Jede NAT- und Firewall-Regel braucht einen eindeutigen tracker-Wert. Falsch gemacht führt das zu Regeln die pfSense nicht erkennt oder doppelten Einträgen.
// FALSCH: Zufällige 16-stellige Hex-Strings
$tracker = bin2hex(random_bytes(8)); // → "a3f2b1c4d5e6f7a8" — falsch!
// RICHTIG: 10-stellige numerische Strings (Unix-Timestamp-Format)
$tracker_base = time();
$tracker_offset = 0;
function gen_tracker() {
global $tracker_base, $tracker_offset;
$tracker_offset++;
return (string)($tracker_base + $tracker_offset);
}
// → "1740614400", "1740614401", ...
pfSense generiert Tracker als numerische Strings im Timestamp-Bereich. Hex-Strings werden intern anders behandelt — beim nächsten GUI-Edit können Regeln verloren gehen.
Pitfall #2: associated-rule-id auf LAN-Interfaces
Bei NAT Port-Forwarding kannst du mit associated-rule-id: "pass" automatisch eine passende Firewall-Regel erstellen lassen. Das klingt bequem — funktioniert aber nur auf WAN-Interfaces.
// FALSCH für LAN-Interfaces:
$nat_rule = array(
"interface" => "lan",
"associated-rule-id" => "pass", // ⚠️ Erstellt KEINE gültige Regel auf LAN!
// ...
);
// RICHTIG: Separate Firewall-Pass-Regel erstellen
$nat_rule = array(
"interface" => "lan",
// Kein associated-rule-id
// ...
);
$fw_rule = array(
"type" => "pass",
"interface" => "lan",
"destination" => array("address" => "172.16.x.1", "port" => "53"),
// ...
);
Auf WAN hat pfSense dieses Muster weil eingehende NAT-Pakete durch die WAN-Regeln müssen. Auf LAN ist das anders — die Pass-Regel muss explizit als separate Firewall-Regel angelegt werden.
Pitfall #3: NAT Loop-Schutz mit NOT-Flag
Bei DNS-Redirect willst du Port 53 umleiten — aber NICHT wenn das Ziel bereits der Gateway selbst ist (sonst: Loop).
// FALSCH: Alle DNS-Pakete umleiten (Loop-Risiko!)
$destination = array("any" => "", "port" => "53");
// RICHTIG: Alles AUSSER dem Gateway umleiten (NOT-Flag)
$destination = array(
"not" => "", // Das ist der NOT-Operator
"address" => "172.16.x.1", // Gateway-IP (Ausnahme)
"port" => "53",
);
Das "not" => "" mit leerem String ist die pfSense-Syntax für den Negations-Operator in der Config. Ein Paket an 172.16.x.1:53 trifft die Regel damit NICHT — und geht direkt an Unbound.
Pitfall #4: KEA DHCP4 dnsserver
In pfSense 2.8.x ist KEA DHCP4 der neue Standard (statt ISC DHCP). Beim DNS-Server-Feld gibt es eine Eigenheit:
// Ein DNS-Server → String
$config['dhcpd']['opt2']['dnsserver'] = "172.16.x.1";
// Mehrere DNS-Server → Array
$config['dhcpd']['opt2']['dnsserver'] = array("172.16.x.1", "172.16.x.2");
Der Typ hängt davon ab ob ein oder mehrere Server konfiguriert sind. Idempotenz-Check muss beide Fälle behandeln:
$current = $config['dhcpd']['opt2']['dnsserver'] ?? null;
if (is_array($current)) {
// Array-Fall
} elseif (is_string($current)) {
// String-Fall
}
services_dhcpd_configure() erkennt automatisch ob KEA oder ISC Backend aktiv ist und ruft den richtigen Konfigurations-Prozess auf.
Pitfall #5: Block-Regeln müssen VOR Pass-Regeln stehen
pfSense evaluiert Firewall-Regeln von oben nach unten. Eine Default-Pass-All-Regel auf LAN würde DoT-Block-Regeln die danach kommen vollständig ignorieren.
// FALSCH: Anhängen (landen nach der Default-Pass-Regel)
$config['filter']['rule'][] = $block_rule;
// RICHTIG: Vorn einfügen (array_unshift, in richtiger Reihenfolge)
foreach (array_reverse($new_block_rules) as $rule) {
array_unshift($config['filter']['rule'], $rule);
}
Bei mehreren Block-Regeln: array_reverse + array_unshift in der Schleife sorgt dafür dass die Regeln in der gewünschten Reihenfolge vorn landen.
Das vollständige Pattern
<?php
require_once("config.inc");
require_once("filter.inc");
// 1. Backup
$backup = "/conf/config.xml.bak." . date("Ymd_His");
copy("/conf/config.xml", $backup);
echo "Backup: {$backup}\n";
// 2. Config laden
$config = parse_config(true);
// 3. Idempotenz-Check
$descr = "Meine Regel";
$exists = false;
foreach ($config['filter']['rule'] as $r) {
if (($r['descr'] ?? '') === $descr) { $exists = true; break; }
}
if ($exists) { echo "SKIP: bereits vorhanden\n"; exit(0); }
// 4. Tracker-Generator
$tracker_base = time(); $tracker_offset = 0;
function gen_tracker() {
global $tracker_base, $tracker_offset;
return (string)($tracker_base + ++$tracker_offset);
}
// 5. Regel hinzufügen
$config['filter']['rule'][] = array(
"type" => "block",
"interface" => "lan",
"ipprotocol" => "inet",
"protocol" => "tcp",
"source" => array("any" => ""),
"destination" => array("any" => "", "port" => "853"),
"descr" => $descr,
"log" => "",
"tracker" => gen_tracker(),
);
// 6. Speichern + Reload
write_config("Meine Änderung");
filter_configure();
echo "ADD: {$descr}\n";
// 7. Verifikation
$config = parse_config(true);
$found = 0;
foreach ($config['filter']['rule'] as $r) {
if (($r['descr'] ?? '') === $descr) $found++;
}
echo $found === 1 ? "VERIFIZIERT\n" : "WARNUNG: Regel nicht gefunden!\n";
pfBlockerNG-Listen per PHP erweitern
pfBlockerNG speichert DNSBL-Listen in $config['installedpackages']['pfblockerngdnsbl']['config']. Das Format:
$config['installedpackages']['pfblockerngdnsbl']['config'][] = array(
"aliasname" => "ADs_Extended",
"description" => "Zusätzliche DNSBL-Listen",
"row" => array(
array(
"format" => "auto",
"state" => "Enabled",
"url" => "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/hosts/pro.txt",
"header" => "Hagezi_Multi_Pro",
),
),
"action" => "unbound",
"cron" => "EveryDay",
"logging" => "enabled",
"order" => "default",
"dow" => "1",
);
write_config("DNSBL erweitert");
Nach dem Config-Write: pfBlockerNG muss noch die Listen herunterladen. Das triggert man per SSH:
php /usr/local/www/pfblockerng/pfblockerng.php update
Äquivalent zu “Force Reload - All” in der GUI.
Lessons Learned
- Tracker-Format kennen bevor du Regeln erzeugst — GUI-generierte Regeln als Vorlage nehmen
- Backup IMMER als erstes — config.xml ist die einzige Wahrheit, kein Staging
- Idempotenz von Anfang an — Script mehrfach ausführbar machen
- Verifikation am Ende —
parse_config(true)nochmal laden und prüfen - associated-rule-id nur auf WAN — auf LAN/OPT separate Pass-Regeln anlegen
- Block-Regeln vorn —
array_unshiftstatt[]für Sicherheitsregeln - NOT-Flag für Loop-Schutz — bei DNS-Redirect immer den Gateway ausnehmen
Checkliste für eigene pfSense PHP-Scripts
- Backup als erste Aktion
-
parse_config(true)mit explizitemtrue - Idempotenz-Check per
descr-Vergleich - Tracker: 10-stellige numerische Strings
- NAT auf LAN: kein
associated-rule-id, separate FW-Pass-Regel - Block-Regeln:
array_unshiftstatt append - Verifikation durch erneutes
parse_config(true)am Ende - Backup-Pfad:
/conf/(nicht/cf/conf/)