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

  1. Tracker-Format kennen bevor du Regeln erzeugst — GUI-generierte Regeln als Vorlage nehmen
  2. Backup IMMER als erstes — config.xml ist die einzige Wahrheit, kein Staging
  3. Idempotenz von Anfang an — Script mehrfach ausführbar machen
  4. Verifikation am Endeparse_config(true) nochmal laden und prüfen
  5. associated-rule-id nur auf WAN — auf LAN/OPT separate Pass-Regeln anlegen
  6. Block-Regeln vornarray_unshift statt [] für Sicherheitsregeln
  7. 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 explizitem true
  • Idempotenz-Check per descr-Vergleich
  • Tracker: 10-stellige numerische Strings
  • NAT auf LAN: kein associated-rule-id, separate FW-Pass-Regel
  • Block-Regeln: array_unshift statt append
  • Verifikation durch erneutes parse_config(true) am Ende
  • Backup-Pfad: /conf/ (nicht /cf/conf/)