OpenVPN-DCO@pfSense 2.9.0 CE

From
Revision as of 12:45, 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 <code>tmux</code> ausführen (<code>tmux new -s build</code>, lösen mit <code>Strg+b d</code>, zurück mit <code>tmux attach -t build</code>).
</syntaxhighlight

-----

=== 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):

<pre>gunzip netgate-installer-v1.2-RELEASE-amd64.iso.gz</pre>
==== 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 <code>default</code>
* '''„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 = <code>vtnet0</code> (DHCP), LAN überspringen

==== 2.3 Zugriff im Labor ====

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

<pre>pfSsh.php playback enablesshd
pfctl -d                       # Firewall aus, bis zum nächsten Regel-Reload/Neustart</pre>
* SSH: <code>ssh admin@&lt;WAN-IP&gt;</code>, danach Menüpunkt 8
* Web: <code>https://&lt;WAN-IP&gt;</code>, Login <code>admin</code> / <code>pfsense</code>
* 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):

<pre># 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</pre>

-----

=== 4. Passenden FreeBSD-Quellstand bestimmen ===

<blockquote>'''Alle Befehle in diesem Abschnitt auf der Build-VM''' (FreeBSD 15.1, als <code>root</code>). Der Quellbaum liegt danach unter <code>/usr/src-main</code>.
</blockquote>
<blockquote>'''Abkürzung für pfSense CE 2.9.0:''' Der passende Stand ist bekannt (<code>7386c0675c8</code>, siehe Referenzwerte). Dann genügt '''4.1 (Klonen)''', danach direkt weiter mit Abschnitt 5. Alle Blöcke, die mit <code># OPTIONAL</code> beginnen, dienen nur dazu, den Stand für eine neue pfSense-Version herzuleiten oder zu kontrollieren.
</blockquote>
==== 4.1 FreeBSD <code>main</code> klonen ====

<pre># Build-VM
git clone --filter=blob:none https://github.com/freebsd/freebsd-src.git /usr/src-main
cd /usr/src-main</pre>
<code>--filter=blob:none</code> lädt Dateiinhalte erst bei Bedarf und spart mehrere GB.

==== 4.2 Zeitfenster der <code>__FreeBSD_version</code> finden ''(optional bei bekannter Version)'' ====

<pre># 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</pre>
<code>--since</code> begrenzt die Suche auf neuere Commits. Ohne die Begrenzung durchsucht <code>-G</code> die gesamte Geschichte von <code>param.h</code>, und Git lädt wegen <code>--filter=blob:none</code> 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 <code>uname -a</code>).

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

<pre># 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</pre>
Der pfSense-Zweig wurde innerhalb dieses Fensters von <code>main</code> abgezweigt (das Build-Datum liegt später, die Basis ist älter).

Den Hash des letzten Commits mit 1600018 liefert:

<pre># OPTIONAL bei bekannter Version (nur zur Herleitung/Kontrolle)
# Build-VM, in /usr/src-main
git rev-parse --short 255538cd906^    # ergibt 7386c0675c8</pre>
<code>^</code> 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)'' ====

<pre># 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</pre>
Kritisch wären neue oder entfernte Felder in Strukturen (z. B. <code>struct ifnet</code>, <code>struct mbuf</code>), 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 <code>struct ifnet</code>, eine neue Inline-Funktion in <code>mbuf.h</code>).

<blockquote>'''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.
</blockquote>

-----

=== 5. Modul bauen ===

<blockquote>'''Alle Befehle in diesem Abschnitt auf der Build-VM''' (FreeBSD 15.1, als <code>root</code>), im Quellbaum <code>/usr/src-main</code>.
</blockquote>
<pre># 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</pre>
Unbekannte Optionen/Geräte in der pfSense-Konfiguration finden ''(optional bei bekannter Version: für 2.9.0 ist das Ergebnis <code>NETGRAPH_ETF</code>)'':

<pre># 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</pre>
<code>CONFIG_AUTOGENERATED</code> ist ein Fehlalarm (wird von <code>config(8)</code> akzeptiert). Bei 2.9.0 ist nur <code>NETGRAPH_ETF</code> unbekannt; auskommentieren:

<pre># Build-VM
cd /usr/src-main
sed -i '' 's/^options[[:space:]]*NETGRAPH_ETF/#&/' sys/amd64/conf/PFSENSE</pre>
Bauen (im <code>tmux</code>, damit der Build weiterläuft, falls die SSH-Verbindung abbricht):

<pre># Build-VM: tmux-Sitzung starten (oder mit "tmux attach -t build" wieder aufnehmen)
tmux new -s build</pre>
Lösen der Sitzung, ohne den Build abzubrechen: <code>Strg+b</code>, dann <code>d</code>. Wieder verbinden: <code>tmux attach -t build</code>. Alle Sitzungen anzeigen: <code>tmux ls</code>.

In der tmux-Sitzung:

<pre># 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</pre>
Nach dem Build die tmux-Sitzung beenden:

<pre># Build-VM, in der tmux-Sitzung
exit</pre>
<code>exit</code> schließt die Sitzung endgültig. Soll sie für spätere Builds erhalten bleiben, stattdessen mit <code>Strg+b</code>, dann <code>d</code> lösen.

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

Ergebnis:

<pre># 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)</pre>

-----

=== 6. Modul auf der pfSense laden ===

<pre># 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/</pre>
<pre># pfSense
cp /tmp/if_ovpn.ko /boot/modules/
kldload -v /boot/modules/if_ovpn.ko
kldstat | grep ovpn
dmesg | tail -20</pre>
'''Nicht in <code>/boot/loader.conf(.local)</code> eintragen.''' Nach einem Neustart oder Update startet das System so garantiert ohne das Modul. Nach einem Neustart bei Bedarf erneut <code>kldload if_ovpn</code>.


-----

=== 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 (<code>disable_dco = ENABLED</code> 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:

{| class="wikitable"
|-
! 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. <code>/24</code> mit Topology ''Subnet'', '''kein /30'''
|-
| Compression
| aus (''Refuse any non-stub compression'')
|-
| Client Specific Overrides
| '''keine <code>iroute</code>-Einträge''' (unter FreeBSD mit DCO nicht unterstützt)
|}

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

<pre>Note: --data-ciphers-fallback with cipher 'AES-256-CBC' disables data channel offload.</pre>
Kontrolle der erzeugten Konfiguration (wird aus der GUI generiert, nicht von Hand bearbeiten):

<pre>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</pre>
Optional, für einen hybriden Post-Quanten-Handshake im Kontrollkanal (unabhängig von DCO), unter ''Custom options'':

<pre>tls-version-min 1.3;tls-groups SecP384r1MLKEM1024:secp521r1</pre>

-----

=== 8. Interface-Problem und pfSense-Patch ===

==== 8.1 Das Problem ====

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

<pre>dco_set_ifmode: failed to set ifmode=00008002: Invalid argument (errno=22)
DCO device ovpns1 already exists, won't be destroyed at shutdown</pre>
<code>ifconfig ovpns1</code> zeigt dann weiterhin <code>groups: tun openvpn</code>. '''Dieser Zustand ist defekt.''' OpenVPN läuft, kann aber keine Daten übertragen.

Unterscheidung der Modi am Interface:

{| class="wikitable"
|-
! Modus
! <code>ifconfig ovpnsN</code>
|-
| klassisch (<code>tun</code>)
| <code>groups: tun openvpn</code>, <code>Opened by PID …</code>
|-
| DCO (<code>if_ovpn</code>)
| <code>groups: openvpn</code>
|}

==== 8.2 Die Lösung: Patch für <code>/etc/inc/openvpn.inc</code> ====

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

* <code>openvpn_dco_usable()</code> entscheidet pro Instanz, ob DCO genutzt wird. Bedingungen: <code>if_ovpn</code> geladen, Device-Mode <code>tun</code>, 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 <code>disable-dco</code> (bzw. <code>fragment</code>, <code>comp-lzo</code>, <code>compress</code>) in den Custom options.
* Hat das vorhandene <code>ovpnsN</code> den falschen Typ, wird die Instanz beendet (mit Warten auf das Prozessende) und das Interface entfernt (mit Warten, bis es wirklich weg ist). <code>tap</code>-Interfaces werden nie angefasst.
* Mit DCO wird <code>ifconfig ovpn create</code> statt <code>tun</code> verwendet und <code>dev-node</code> weggelassen. Schlägt das Anlegen fehl, fällt der Code mit einer Warnung auf <code>tun</code> zurück.
* Ohne DCO wird explizit <code>disable-dco</code> in die Konfiguration geschrieben, damit OpenVPN nie eigenmächtig umschaltet.

Verhalten:

{| class="wikitable"
|-
! Situation
! Interface
! Konfiguration
|-
| Modul nicht geladen (z. B. nach Neustart)
| <code>tun</code>
| <code>dev-node</code> + <code>disable-dco</code>
|-
| Modul geladen, Instanz DCO-tauglich
| <code>ovpn</code> (DCO)
| ohne <code>dev-node</code>, ohne <code>disable-dco</code>
|-
| Modul geladen, aber <code>disable-dco</code> in Custom options
| <code>tun</code>
| <code>dev-node</code> + <code>disable-dco</code>
|-
| Modul geladen, Instanz nicht DCO-tauglich (z. B. CBC-Fallback, TCP)
| <code>tun</code>
| <code>dev-node</code> + <code>disable-dco</code>
|}

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

Im Systemlog erscheint bei DCO der Hinweis:

<pre>php-cgi[…]: OpenVPN ovpns1: using data channel offload (if_ovpn)</pre>
==== 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.

# '''System → Patches → „+ Add New Patch“'''
#* Description: <code>OpenVPN DCO (if_ovpn)</code>
#* Patch Contents: kompletter Inhalt von <code>pfsense-openvpn-dco.patch</code>
#* '''Path Strip Count: <code>1</code>''', '''Base Directory: <code>/</code>'''
#* Auto Apply: vorerst nicht anhaken
# '''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: <code>cd / &amp;&amp; patch -p1 -b &lt; /tmp/pfsense-openvpn-dco.patch</code> (zurück mit <code>patch -R -p1 &lt; …</code>).

==== 8.4 Testen ====

<pre>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</pre>
Laborergebnis mit pfSense CE 2.9.0 (Patch-Fassung v4):

{| class="wikitable"
|-
! Test
! Ergebnis
|-
| Neustart ohne Modul
| <code>tun openvpn</code>, <code>dev-node</code> + <code>disable-dco</code> ✔
|-
| Modul laden, Dienst neu starten
| automatisch <code>openvpn</code> (DCO) ✔
|-
| <code>disable-dco</code> eintragen → entfernen → wieder eintragen (je ''Save'')
| <code>tun</code> → <code>openvpn</code> → <code>tun</code> ✔, mehrfach wiederholt
|}


-----

=== 9. Funktionstest mit Client (Build-VM) ===

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

<pre>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</pre>
Client-Zertifikat und -Schlüssel im Webinterface exportieren (''System → Certificates → Certificates'', Symbole „Export Certificate“ und „Export Key“) und als <code>client.crt</code> / <code>client.key</code> ablegen; <code>chmod 600 client.key</code>.

<code>/usr/local/etc/openvpn/client.conf</code>:

<pre>client
dev tun
proto udp4
remote &lt;pfSense-IP&gt; 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</pre>
Die Richtung bei <code>tls-auth</code> ist <code>1</code> (Server: <code>0</code>); <code>auth</code> muss zum Server passen. Verwendet der Server <code>tls-crypt</code>, entsprechend <code>tls-crypt …</code> ohne Richtung.

<pre>openvpn --config /usr/local/etc/openvpn/client.conf
# zweites tmux-Fenster:
ping -c 5 10.8.0.1</pre>
Erwartete Logzeilen:

<pre>Control Channel: TLSv1.3, … key agreement: SecP384r1MLKEM1024
DCO device tun0 opened
Initialization Sequence Completed
Data Channel: cipher 'AES-256-GCM', peer-id: 0</pre>
'''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 <code>disable-dco</code> in den Custom options vom DCO-Betrieb ausnehmen.


-----

=== Anhang: <code>pfsense-openvpn-dco.patch</code> ===
<syntaxhighlight lang="diff">
--- 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";