OpenVPN-DCO@pfSense 2.9.0 CE

From
Revision as of 12:46, 28 October 2026 by Wolfm (talk | contribs) (→1.4 Werkzeuge)
Jump to navigation Jump to search

Überblick

Es werden zwei VMs verwendet, beide im libvirt-NAT-Netz default (192.168.122.0/24):

VM System Zweck
Build-VM FreeBSD 15.1-RELEASE Kompiliert das Modul; dient später auch als OpenVPN-Testclient
Test-VM pfSense CE 2.9.0 Liefert Kernel-Informationen; dort wird das Modul getestet

Ablauf:

  1. Build-VM aufsetzen
  2. pfSense-Test-VM aufsetzen
  3. Kernel-Informationen der pfSense auslesen
  4. Passenden FreeBSD-Quellstand bestimmen
  5. Modul bauen
  6. Modul auf der pfSense laden und prüfen
  7. OpenVPN-Server DCO-tauglich konfigurieren
  8. pfSense-Patch für das Interface-Problem einspielen
  9. Funktionstest mit Client

Referenzwerte für pfSense CE 2.9.0

Merkmal Wert
Kernel (uname -a) FreeBSD 16.0-CURRENT #12 RELENG_2_9_0-n256132-d8e3138ecf52, gebaut am 17.08.2026
kern.osreldate 1600018
Kernelkonfiguration pfSense, vollständig in kern.conftxt eingebettet (417 Zeilen)
FreeBSD-main-Fenster mit 1600018 1569e2334ab (30.04.2026) bis 255538cd906 (19.06.2026, Sprung auf 1600019)
Verwendeter Build-Stand 7386c0675c8 (= 255538cd906^, letzter Commit mit 1600018)
Abweichung der Konfiguration zu main nur options NETGRAPH_ETF (für if_ovpn irrelevant)

Bei einem pfSense-Update ändern sich diese Werte, die Schritte 3 bis 6 sind dann zu wiederholen.



1. Build-VM aufsetzen (FreeBSD 15.1)

1.1 Image und VM

Von der FreeBSD-Downloadseite das VM-Image für amd64 laden (Spalte „VM“, Format .qcow2 für virt-manager/KVM). Die UFS- oder ZFS-Variante ist gleichermaßen geeignet.

Platte vor dem ersten Start vergrößern, da das Image nur ca. 6 GB groß ist:

xz -d FreeBSD-15.1-RELEASE-amd64-ufs.qcow2.xz
qemu-img resize FreeBSD-15.1-RELEASE-amd64-ufs.qcow2 60G

In virt-manager:

  • „Vorhandenes Laufwerksabbild importieren“ und das .qcow2-Image auswählen
  • Betriebssystem: „Generic OS“ bzw. „Unknown OS“ wählen, da FreeBSD 15.1 in der Liste nicht vorhanden ist
  • Speicher: 8192 MB, CPUs: 4–8
  • Vor dem letzten Schritt „Konfiguration vor der Installation bearbeiten“ anhaken, dann:
    • Festplatte → Erweiterte Optionen → Festplattenbus: VirtIO
    • NIC → Gerätemodell: virtio
    • „Übernehmen“, danach „Installation beginnen“

1.2 Erster Start

Login: root, kein Passwort. Die Konsole hat US-Tastaturbelegung (Y/Z vertauscht), umstellen mit kbdcontrol -l de und dauerhaft mit sysrc keymap=de.

1.3 SSH (für bequemes Kopieren von Befehlen)

Nur für Labor-VMs im NAT-Netz geeignet:

passwd
sysrc sshd_enable=YES
echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
ssh-keygen -A                  # Hostschlüssel erzeugen, sonst startet sshd nicht
service sshd start
ifconfig vtnet0 | grep inet

Vom Host: ssh root@<IP der Build-VM>

Hängt die VM nach einem Neustart im Single-User-Modus (Prompt root@:~ # ohne Hostnamen, kein Netz), mit exit den normalen Start fortsetzen, ggf. vorher fsck -y /.

1.4 Werkzeuge

pkg install -y git tmux

Lange Builds in tmux ausführen (tmux new -s build, lösen mit Strg+b d, zurück mit tmux attach -t build).


2. pfSense-Test-VM aufsetzen (pfSense CE 2.9.0)

2.1 Installer

pfSense CE wird nur noch über den Netgate Installer verteilt: kostenlos im Netgate-Shop, Variante „AMD64 ISO IPMI/Virtual Machines“. Es ist ein Online-Installer, die VM braucht beim Installieren Internet.

Die Datei im Terminal entpacken, nicht mit dem Archivprogramm des Desktops (das versucht auch die ISO selbst auszupacken):

gunzip netgate-installer-v1.2-RELEASE-amd64.iso.gz

2.2 VM

  • „Lokales Installationsmedium (ISO)“ und die entpackte ISO auswählen
  • Betriebssystem: „Generic OS“ bzw. „Unknown OS“
  • Speicher: 4096 MB, CPUs: 2, Platte: 20 GB, eine NIC im Netz default
  • „Konfiguration vor der Installation bearbeiten“ anhaken und wie bei der Build-VM Festplattenbus VirtIO und NIC-Gerätemodell virtio einstellen
  • Im Installer: pfSense CE, Version 2.9.0
  • WAN = vtnet0 (DHCP), LAN überspringen

2.3 Zugriff im Labor

Konsolenmenü 8 (Shell). Für SSH und Webinterface über das WAN-Interface:

pfSsh.php playback enablesshd
pfctl -d                       # Firewall aus, bis zum nächsten Regel-Reload/Neustart
  • SSH: ssh admin@<WAN-IP>, danach Menüpunkt 8
  • Web: https://<WAN-IP>, Login admin / pfsense
  • Im Setup-Wizard unter „Configure WAN Interface“ „Block RFC1918 Private Networks“ abwählen



3. Kernel-Informationen der pfSense auslesen

Die eingebettete Kernelkonfiguration der pfSense auf die Build-VM holen (nur lesend, auch auf Produktivsystemen unbedenklich):

# auf der pfSense (Shell, Menüpunkt 8)
sysctl -n kern.conftxt > /tmp/pfSense.conf

# auf der Build-VM
scp admin@<pfSense-IP>:/tmp/pfSense.conf /root/pfSense.conf

4. Passenden FreeBSD-Quellstand bestimmen

Alle Befehle in diesem Abschnitt auf der Build-VM (FreeBSD 15.1, als root). Der Quellbaum liegt danach unter /usr/src-main.

Abkürzung für pfSense CE 2.9.0: Der passende Stand ist bekannt (7386c0675c8, siehe Referenzwerte). Dann genügt 4.1 (Klonen), danach direkt weiter mit Abschnitt 5. Alle Blöcke, die mit # OPTIONAL beginnen, dienen nur dazu, den Stand für eine neue pfSense-Version herzuleiten oder zu kontrollieren.

4.1 FreeBSD main klonen

# Build-VM
git clone --filter=blob:none https://github.com/freebsd/freebsd-src.git /usr/src-main
cd /usr/src-main

--filter=blob:none lädt Dateiinhalte erst bei Bedarf und spart mehrere GB.

4.2 Zeitfenster der __FreeBSD_version finden (optional bei bekannter Version)

# OPTIONAL bei bekannter Version (nur zur Herleitung/Kontrolle)
# Build-VM, in /usr/src-main
git --no-pager log --since=2026-01-01 --format='%h %cd %s' --date=short \
    -G'#define __FreeBSD_version 16000(18|19)' -- sys/sys/param.h

--since begrenzt die Suche auf neuere Commits. Ohne die Begrenzung durchsucht -G die gesamte Geschichte von param.h, und Git lädt wegen --filter=blob:none jede alte Version einzeln von GitHub nach; das kann mehrere Minuten dauern. Bei einer späteren pfSense-Version das Datum entsprechend anpassen (etwa ein halbes Jahr vor dem Build-Datum aus uname -a).

Das Ergebnis zeigt den Commit, der 1600018 setzt, und den, der auf 1600019 erhöht. Kontrolle:

# OPTIONAL bei bekannter Version (nur zur Herleitung/Kontrolle)
# Build-VM, in /usr/src-main
git --no-pager show 255538cd906 -- sys/sys/param.h | grep FreeBSD_version

Der pfSense-Zweig wurde innerhalb dieses Fensters von main abgezweigt (das Build-Datum liegt später, die Basis ist älter).

Den Hash des letzten Commits mit 1600018 liefert:

# OPTIONAL bei bekannter Version (nur zur Herleitung/Kontrolle)
# Build-VM, in /usr/src-main
git rev-parse --short 255538cd906^    # ergibt 7386c0675c8

^ bedeutet in Git „der Commit direkt davor“.

4.3 Prüfen, ob die Wahl des Commits im Fenster eine Rolle spielt (optional bei bekannter Version)

# OPTIONAL bei bekannter Version (nur zur Herleitung/Kontrolle)
# Build-VM, in /usr/src-main

# Änderungen am Modul selbst
git --no-pager log --oneline 1569e2334ab..255538cd906 -- \
    sys/net/if_ovpn.c sys/net/if_ovpn.h sys/modules/if_ovpn

# Änderungen an Headern, auf die das Modul zugreift
git --no-pager log -p 1569e2334ab..255538cd906 -- \
    sys/net/if_var.h sys/net/if_private.h sys/sys/mbuf.h \
    sys/netinet/in_pcb.h sys/netinet/udp_var.h sys/opencrypto/cryptodev.h \
    sys/net/vnet.h sys/sys/nv.h

Kritisch wären neue oder entfernte Felder in Strukturen (z. B. struct ifnet, struct mbuf), weil sich dann Speicher-Offsets verschieben. Bei 2.9.0 gab es im Fenster keine Änderung am Modul und nur zwei harmlose Header-Commits (ein Kommentar in struct ifnet, eine neue Inline-Funktion in mbuf.h).

Restrisiko: Netgates nicht veröffentlichte Kerneländerungen können nicht geprüft werden. Ob das Modul binär passt, zeigt erst der Lade- und Lasttest.


5. Modul bauen

Alle Befehle in diesem Abschnitt auf der Build-VM (FreeBSD 15.1, als root), im Quellbaum /usr/src-main.

# Build-VM
cd /usr/src-main
git checkout 7386c0675c8        # = 255538cd906^, der Commit vor dem Sprung auf 1600019
ls sys/net/if_ovpn.c sys/modules/if_ovpn   # OPTIONAL: Kontrolle, ob die Modulquellen da sind

cp /root/pfSense.conf sys/amd64/conf/PFSENSE

Unbekannte Optionen/Geräte in der pfSense-Konfiguration finden (optional bei bekannter Version: für 2.9.0 ist das Ergebnis NETGRAPH_ETF):

# OPTIONAL bei bekannter Version (nur zur Herleitung/Kontrolle)
# Build-VM
cd /usr/src-main/sys
for o in $(awk '$1=="options"{sub(/=.*/,"",$2); print $2}' amd64/conf/PFSENSE); do
  grep -qwE "^$o" conf/options conf/options.amd64 || echo "option: $o"
done
for d in $(awk '$1=="device"{print $2}' amd64/conf/PFSENSE); do
  grep -qwE "$d" conf/files conf/files.amd64 conf/files.x86 || echo "device: $d"
done

CONFIG_AUTOGENERATED ist ein Fehlalarm (wird von config(8) akzeptiert). Bei 2.9.0 ist nur NETGRAPH_ETF unbekannt; auskommentieren:

# Build-VM
cd /usr/src-main
sed -i '' 's/^options[[:space:]]*NETGRAPH_ETF/#&/' sys/amd64/conf/PFSENSE

Bauen (im tmux, damit der Build weiterläuft, falls die SSH-Verbindung abbricht):

# Build-VM: tmux-Sitzung starten (oder mit "tmux attach -t build" wieder aufnehmen)
tmux new -s build

Lösen der Sitzung, ohne den Build abzubrechen: Strg+b, dann d. Wieder verbinden: tmux attach -t build. Alle Sitzungen anzeigen: tmux ls.

In der tmux-Sitzung:

# Build-VM, in /usr/src-main
cd /usr/src-main
make -j$(sysctl -n hw.ncpu) kernel-toolchain 2>&1 | tee /root/toolchain.log
make -j$(sysctl -n hw.ncpu) buildkernel KERNCONF=PFSENSE MODULES_OVERRIDE=if_ovpn \
    2>&1 | tee /root/buildkernel.log

Nach dem Build die tmux-Sitzung beenden:

# Build-VM, in der tmux-Sitzung
exit

exit schließt die Sitzung endgültig. Soll sie für spätere Builds erhalten bleiben, stattdessen mit Strg+b, dann d lösen.

kernel-toolchain baut den passenden Compiler aus dem Quellbaum (einmalig, 30–90 min). buildkernel baut den Kernel mit der pfSense-Konfiguration, damit das Modul mit identischen Optionen (RSS, RATELIMIT, VIMAGE …) kompiliert wird; mit vorhandener Toolchain dauert das wenige Minuten.

Ergebnis:

# Build-VM
find /usr/obj/usr/src-main -name if_ovpn.ko -exec ls -l {} \;
# …/sys/PFSENSE/modules/usr/src-main/sys/modules/if_ovpn/if_ovpn.ko  (ca. 90 KB)

6. Modul auf der pfSense laden

# Build-VM
scp /usr/obj/usr/src-main/amd64.amd64/sys/PFSENSE/modules/usr/src-main/sys/modules/if_ovpn/if_ovpn.ko \
    admin@<pfSense-IP>:/tmp/
# pfSense
cp /tmp/if_ovpn.ko /boot/modules/
kldload -v /boot/modules/if_ovpn.ko
kldstat | grep ovpn
dmesg | tail -20

Nicht in /boot/loader.conf(.local) eintragen. Nach einem Neustart oder Update startet das System so garantiert ohne das Modul. Nach einem Neustart bei Bedarf erneut kldload if_ovpn.



7. OpenVPN-Server DCO-tauglich konfigurieren

Die CE-Oberfläche bietet keine DCO-Option. OpenVPN 2.7 nutzt DCO automatisch, sobald das Modul geladen ist und die Konfiguration verträglich ist; andernfalls schaltet es DCO still ab (disable_dco = ENABLED im Log, bei Verbosity ≥ 4). Mit dem Patch aus Abschnitt 8 trifft pfSense diese Entscheidung selbst nach denselben Regeln und schreibt sie eindeutig in die Konfiguration.

Voraussetzungen für DCO:

Einstellung Wert
Protocol UDP
Data Encryption Algorithms nur AES-GCM und/oder CHACHA20-POLY1305
Fallback Data Encryption Algorithm AES-256-GCM (Standard ist AES-256-CBC, das verhindert DCO)
Modus TLS-basiert (SSL/TLS, ggf. mit User Auth), kein Shared Key
Tunnelnetz z. B. /24 mit Topology Subnet, kein /30
Compression aus (Refuse any non-stub compression)
Client Specific Overrides keine iroute-Einträge (unter FreeBSD mit DCO nicht unterstützt)

Der Fallback-Algorithmus wird automatisch in data-ciphers aufgenommen. Typische Logmeldung, wenn er falsch steht:

Note: --data-ciphers-fallback with cipher 'AES-256-CBC' disables data channel offload.

Kontrolle der erzeugten Konfiguration (wird aus der GUI generiert, nicht von Hand bearbeiten):

grep -iE 'cipher|topology|dev' /var/etc/openvpn/server1/config.ovpn
grep -iE 'disable_dco|offload|note|dco|error' /var/log/openvpn.log | tail -20

Optional, für einen hybriden Post-Quanten-Handshake im Kontrollkanal (unabhängig von DCO), unter Custom options:

tls-version-min 1.3;tls-groups SecP384r1MLKEM1024:secp521r1

8. Interface-Problem und pfSense-Patch

8.1 Das Problem

pfSense CE legt vor dem Start von OpenVPN ein klassisches tun-Interface an und benennt es in ovpnsN um (dev-node /dev/tunN in der Konfiguration). DCO benötigt aber ein if_ovpn-Interface. Ist das Modul geladen und die Konfiguration DCO-tauglich, übernimmt OpenVPN das vorhandene tun-Interface und scheitert:

dco_set_ifmode: failed to set ifmode=00008002: Invalid argument (errno=22)
DCO device ovpns1 already exists, won't be destroyed at shutdown

ifconfig ovpns1 zeigt dann weiterhin groups: tun openvpn. Dieser Zustand ist defekt. OpenVPN läuft, kann aber keine Daten übertragen.

Unterscheidung der Modi am Interface:

Modus ifconfig ovpnsN
klassisch (tun) groups: tun openvpn, Opened by PID …
DCO (if_ovpn) groups: openvpn

8.2 Die Lösung: Patch für /etc/inc/openvpn.inc

Die Datei pfsense-openvpn-dco.patch (vollständig im Anhang) erweitert die Funktion, in der pfSense die OpenVPN-Konfiguration schreibt und das Interface anlegt:

  • openvpn_dco_usable() entscheidet pro Instanz, ob DCO genutzt wird. Bedingungen: if_ovpn geladen, Device-Mode tun, UDP, kein Shared-Key-Modus, alle Data-Ciphers und der Fallback aus AES-128-GCM / AES-256-GCM / CHACHA20-POLY1305, keine Kompression, Tunnelnetz größer als /30, und kein disable-dco (bzw. fragment, comp-lzo, compress) in den Custom options.
  • Hat das vorhandene ovpnsN den falschen Typ, wird die Instanz beendet (mit Warten auf das Prozessende) und das Interface entfernt (mit Warten, bis es wirklich weg ist). tap-Interfaces werden nie angefasst.
  • Mit DCO wird ifconfig ovpn create statt tun verwendet und dev-node weggelassen. Schlägt das Anlegen fehl, fällt der Code mit einer Warnung auf tun zurück.
  • Ohne DCO wird explizit disable-dco in die Konfiguration geschrieben, damit OpenVPN nie eigenmächtig umschaltet.

Verhalten:

Situation Interface Konfiguration
Modul nicht geladen (z. B. nach Neustart) tun dev-node + disable-dco
Modul geladen, Instanz DCO-tauglich ovpn (DCO) ohne dev-node, ohne disable-dco
Modul geladen, aber disable-dco in Custom options tun dev-node + disable-dco
Modul geladen, Instanz nicht DCO-tauglich (z. B. CBC-Fallback, TCP) tun dev-node + disable-dco

disable-dco in den Custom options dient damit als Notschalter pro Server, ganz ohne Änderung an der Oberfläche.

Im Systemlog erscheint bei DCO der Hinweis:

php-cgi[…]: OpenVPN ovpns1: using data channel offload (if_ovpn)

8.3 Einspielen über das Webinterface

Das Paket System Patches ist in pfSense CE 2.9.0 vorinstalliert und taucht deshalb nicht unter „Available Packages“ auf.

  1. System → Patches → „+ Add New Patch“
    • Description: OpenVPN DCO (if_ovpn)
    • Patch Contents: kompletter Inhalt von pfsense-openvpn-dco.patch
    • Path Strip Count: 1, Base Directory: /
    • Auto Apply: vorerst nicht anhaken
  2. Save, dann in der Liste Debug (Probelauf), danach Apply.

Eine neuere Fassung des Patches einspielen: zuerst Revert, dann über das Bleistift-Symbol den Inhalt ersetzen, Save, Debug, Apply.

Alternativ in der Shell: cd / && patch -p1 -b < /tmp/pfsense-openvpn-dco.patch (zurück mit patch -R -p1 < …).

8.4 Testen

kldstat | grep ovpn || kldload if_ovpn
pfSsh.php playback svc restart openvpn server 1
ifconfig ovpns1 | grep groups                                        # openvpn
grep -E 'dev-node|disable-dco' /var/etc/openvpn/server1/config.ovpn  # keine Ausgabe
grep 'if_ovpn' /var/log/system.log | tail -2

Laborergebnis mit pfSense CE 2.9.0 (Patch-Fassung v4):

Test Ergebnis
Neustart ohne Modul tun openvpn, dev-node + disable-dco ✔
Modul laden, Dienst neu starten automatisch openvpn (DCO) ✔
disable-dco eintragen → entfernen → wieder eintragen (je Save) tun → openvpn → tun ✔, mehrfach wiederholt



9. Funktionstest mit Client (Build-VM)

FreeBSD 15.1 bringt if_ovpn im Standardkernel mit; die Build-VM nutzt als Client damit ebenfalls DCO.

pkg install -y openvpn iperf3
kldload if_ovpn                         # dauerhaft: sysrc kld_list+=if_ovpn
mkdir -p /usr/local/etc/openvpn
cd /usr/local/etc/openvpn
scp admin@<pfSense-IP>:/var/etc/openvpn/server1/tls-auth ta.key
scp 'admin@<pfSense-IP>:/var/etc/openvpn/server1/ca/*.0' ca.crt

Client-Zertifikat und -Schlüssel im Webinterface exportieren (System → Certificates → Certificates, Symbole „Export Certificate“ und „Export Key“) und als client.crt / client.key ablegen; chmod 600 client.key.

/usr/local/etc/openvpn/client.conf:

client
dev tun
proto udp4
remote <pfSense-IP> 1194
nobind
ca   /usr/local/etc/openvpn/ca.crt
cert /usr/local/etc/openvpn/client.crt
key  /usr/local/etc/openvpn/client.key
tls-auth /usr/local/etc/openvpn/ta.key 1
auth SHA256
remote-cert-tls server
data-ciphers AES-256-GCM:CHACHA20-POLY1305
tls-version-min 1.3
tls-groups SecP384r1MLKEM1024:secp521r1
explicit-exit-notify 1
verb 4

Die Richtung bei tls-auth ist 1 (Server: 0); auth muss zum Server passen. Verwendet der Server tls-crypt, entsprechend tls-crypt … ohne Richtung.

openvpn --config /usr/local/etc/openvpn/client.conf
# zweites tmux-Fenster:
ping -c 5 10.8.0.1

Erwartete Logzeilen:

Control Channel: TLSv1.3, … key agreement: SecP384r1MLKEM1024
DCO device tun0 opened
Initialization Sequence Completed
Data Channel: cipher 'AES-256-GCM', peer-id: 0

Laborergebnis (pfSense CE 2.9.0): Tunnel aufgebaut, DCO auf Server und Client aktiv, hybrider PQ-Handshake, Ping ohne Verluste (ca. 1,5 ms). Kein Absturz.



Wichtige Hinweise für den Betrieb

  • Nach jedem pfSense-Update muss das Modul neu gebaut werden. Ein unpassendes Modul kann den Kernel zum Absturz bringen.
  • Modul und Patch gehören zusammen. Ohne Patch schaltet ein DCO-tauglicher Server (z. B. mit GCM-Fallback) beim Laden des Moduls in den defekten Zustand aus Abschnitt 8.1.
  • Nach einem Update zuerst prüfen, ob der Patch noch angewendet werden kann (System → Patches → Debug). Ändert Netgate die betroffenen Codestellen, muss der Patch angepasst werden.
  • Einzelne Server lassen sich jederzeit mit disable-dco in den Custom options vom DCO-Betrieb ausnehmen.



Anhang: pfsense-openvpn-dco.patch

--- a/etc/inc/openvpn.inc
+++ b/etc/inc/openvpn.inc
@@ -209,6 +209,134 @@
    return($list);
 }
 
+/*
+ * DCO patch (local, not from Netgate): helpers for OpenVPN Data Channel
+ * Offload with a self-built if_ovpn.ko on pfSense CE.
+ */
+
+/* Is the if_ovpn kernel module loaded? */
+function openvpn_dco_module_loaded() : bool {
+   $out = [];
+   $rc = 1;
+   exec('/sbin/kldstat -q -n if_ovpn', $out, $rc);
+   return ($rc === 0);
+}
+
+/*
+ * Can this instance use DCO? Mirrors the restrictions of OpenVPN and the
+ * FreeBSD if_ovpn driver. If anything is unclear, answer "no" so the
+ * instance keeps working in classic tun mode.
+ */
+function openvpn_dco_usable(string $mode, array $settings) : bool {
+   if (!openvpn_dco_module_loaded()) {
+       return false;
+   }
+   /* DCO only supports layer 3 (tun) */
+   if (array_get_path($settings, 'dev_mode', 'tun') !== 'tun') {
+       return false;
+   }
+   /* DCO only supports UDP */
+   if (strncasecmp(array_get_path($settings, 'protocol', ''), 'UDP', 3) !== 0) {
+       return false;
+   }
+   /* DCO requires a TLS-based mode, no static shared key */
+   if (array_get_path($settings, 'mode', '') === 'p2p_shared_key') {
+       return false;
+   }
+   /* All data ciphers and the fallback cipher must be supported by DCO */
+   $dco_ciphers = ['AES-128-GCM', 'AES-256-GCM', 'CHACHA20-POLY1305'];
+   $ciphers = preg_split('/[,:]/', array_get_path($settings, 'data_ciphers', ''), -1, PREG_SPLIT_NO_EMPTY);
+   $ciphers[] = array_get_path($settings, 'data_ciphers_fallback', '');
+   foreach ($ciphers as $cipher) {
+       if (!in_array(strtoupper(trim($cipher)), $dco_ciphers, true)) {
+           return false;
+       }
+   }
+   /* No compression */
+   if (array_get_path($settings, 'allow_compression', 'no') !== 'no') {
+       return false;
+   }
+   /* /30 or smaller peer-to-peer tunnel networks are not DCO compatible */
+   $tunnel_network = array_get_path($settings, 'tunnel_network', '');
+   if (!empty($tunnel_network)) {
+       list($ip, $cidr) = openvpn_gen_tunnel_network($tunnel_network);
+       if (is_numeric($cidr) && ((int)$cidr >= 30)) {
+           return false;
+       }
+   }
+   /* Per-instance opt-out and options that disable DCO anyway */
+   if (preg_match('/(^|[;\s])(disable-dco|fragment|comp-lzo|compress)\b/',
+       array_get_path($settings, 'custom_options', ''))) {
+       return false;
+   }
+   return true;
+}
+
+/*
+ * Does the interface exist right now? Asks the kernel directly instead of
+ * does_interface_exist(), which may answer from a cached interface list
+ * and then still reports an interface that was just destroyed.
+ */
+function openvpn_dco_if_exists(string $ifname) : bool {
+   $out = [];
+   $rc = 1;
+   exec('/sbin/ifconfig ' . escapeshellarg($ifname) . ' >/dev/null 2>&1', $out, $rc);
+   return ($rc === 0);
+}
+
+/*
+ * Stop the OpenVPN instance and remove its interface, waiting until both
+ * are really gone. Without waiting, the exiting process can still hold or
+ * remove the interface while the new one is being created.
+ */
+function openvpn_dco_stop_and_destroy(string $ifname, string $pidfile) : void {
+   $pf = escapeshellarg($pidfile);
+   if (file_exists($pidfile)) {
+       mwexec("/bin/pkill -TERM -F {$pf}");
+       /* wait up to 10 s for the process to exit */
+       for ($i = 0; $i < 100; $i++) {
+           $out = [];
+           $rc = 1;
+           exec("/bin/pgrep -F {$pf}", $out, $rc);
+           if ($rc !== 0) {
+               break;
+           }
+           usleep(100000);
+       }
+       if ($rc === 0) {
+           mwexec("/bin/pkill -KILL -F {$pf}");
+           usleep(500000);
+       }
+   }
+   /* remove the interface and wait up to 5 s until it is gone */
+   for ($i = 0; $i < 50 && openvpn_dco_if_exists($ifname); $i++) {
+       if (($i % 10) == 0) {
+           exec("/sbin/ifconfig " . escapeshellarg($ifname) . " destroy >/dev/null 2>&1");
+       }
+       usleep(100000);
+   }
+   if (openvpn_dco_if_exists($ifname)) {
+       syslog(LOG_WARNING, "OpenVPN {$ifname}: could not remove interface to switch DCO mode");
+   }
+   /* refresh pfSense's cached interface list */
+   does_interface_exist($ifname, true);
+}
+
+/* Kind of an existing interface: 'tun', 'tap', 'ovpn' or null if missing */
+function openvpn_if_kind(string $ifname) : ?string {
+   if (!openvpn_dco_if_exists($ifname)) {
+       return null;
+   }
+   foreach (['tun', 'tap'] as $group) {
+       $out = [];
+       exec('/sbin/ifconfig -g ' . escapeshellarg($group) . ' 2>/dev/null', $out);
+       if (in_array($ifname, array_map('trim', $out), true)) {
+           return $group;
+       }
+   }
+   return 'ovpn';
+}
+
 function openvpn_name(string $mode, array $settings, string $type = 'if') : string {
    switch ($type) {
        case 'if':
@@ -836,16 +964,47 @@
    $tunname = openvpn_name($mode, $settings, 'dev');
    $devname = openvpn_name($mode, $settings);
 
-   /* is our device already configured */
-   if (!does_interface_exist($devname)) {
+   /* DCO patch: decide between if_ovpn (DCO) and classic tun */
+   $use_dco = openvpn_dco_usable($mode, $settings);
+
+   /*
+    * DCO patch: if the existing interface has the wrong kind (tun while
+    * DCO is wanted, or if_ovpn while it is not), stop the instance and
+    * remove the interface so it is recreated below. tap is never touched.
+    */
+   $cur_kind = openvpn_if_kind($devname);
+   if (($use_dco && ($cur_kind === 'tun')) ||
+       (!$use_dco && ($cur_kind === 'ovpn'))) {
+       openvpn_dco_stop_and_destroy($devname,
+           g_get('varrun_path') . "/openvpn_{$mode_id}.pid");
+   }
+
+   /* is our device already configured (DCO patch: uncached check) */
+   if (!openvpn_dco_if_exists($devname)) {
+
+       $srcif = $tunname;
+       if ($use_dco) {
+           /* DCO patch: create an if_ovpn interface instead of tun */
+           $out = [];
+           $rc = 1;
+           exec("/sbin/ifconfig ovpn create", $out, $rc);
+           $srcif = trim($out[0] ?? '');
+           if (($rc !== 0) || empty($srcif)) {
+               syslog(LOG_WARNING, "OpenVPN {$devname}: could not create if_ovpn interface, falling back to tun (no DCO)");
+               $use_dco = false;
+               $srcif = $tunname;
+           }
+       }
 
        /* create the tap device if required */
-       if (!file_exists("/dev/{$tunname}")) {
+       if (!$use_dco && !file_exists("/dev/{$tunname}")) {
            exec("/sbin/ifconfig " . escapeshellarg($tunname) . " create");
        }
 
        /* rename the device */
-       mwexec("/sbin/ifconfig " . escapeshellarg($tunname) . " name " . escapeshellarg($devname));
+       mwexec("/sbin/ifconfig " . escapeshellarg($srcif) . " name " . escapeshellarg($devname));
+       /* DCO patch: refresh pfSense's cached interface list */
+       does_interface_exist($devname, true);
 
        /* add the device to the openvpn group */
        mwexec("/sbin/ifconfig " . escapeshellarg($devname) . " group openvpn");
@@ -898,7 +1057,16 @@
    }
 
    $conf .= "dev-type {$settings['dev_mode']}\n";
-   $conf .= "dev-node /dev/{$tunname}\n";
+   if ($use_dco) {
+       /* DCO patch: OpenVPN takes over the existing if_ovpn interface */
+       syslog(LOG_NOTICE, "OpenVPN {$devname}: using data channel offload (if_ovpn)");
+   } else {
+       $conf .= "dev-node /dev/{$tunname}\n";
+       /* DCO patch: never switch to DCO on its own on a tun interface */
+       if (!preg_match('/(^|[;\s])disable-dco\b/', array_get_path($settings, 'custom_options', ''))) {
+           $conf .= "disable-dco\n";
+       }
+   }
    $conf .= "writepid {$pfile}\n";
    $conf .= "#user nobody\n";
    $conf .= "#group nobody\n";