Load Balancing und Failover zwischen zwei Internetleitungen ist eine prima Sache, so gewinnt man einerseits Redundanz und andererseits kann man zumindest in der Theorie die Geschwindigkeit beider Internetleitungen kombinieren. In der Praxis funktioniert letzteres natürlich nur, wenn mehrere Down- oder Uploads laufen oder diese zumindest mehrere Streams verwenden, allerdings ist das bei zunehmend mehr Anwendungen standardmäßig der Fall, darunter insbesondere auch Game-Launcher wie z.B. Steam. Natürlich benötigt man dafür kompatible Hardware, normale Consumer-Router leisten das nicht (es sei denn, man verwendet OpenWRT), aber damit wollen wir uns heute nicht beschäftigen, denn das habe ich bereits hier und hier getan – wer sich für die Umsetzung eines solchen Setups interessiert, der kann diese dort nachlesen. Kurz gesagt: Ich verwende OPNsense als Firewall und Router hinter zwei Internetverbindungen (DSL und Vodafone Kabel).
An sich ist der Prozess für den Multi-WAN Betrieb gut dokumentiert (siehe z.B. hier im OPNsense Wiki), allerdings üblicherweise nur für IPv4. Dort ist das Ganze sehr viel einfacher umzusetzen, da in der Regel NAT verwendet wird, sprich: die Clients im LAN haben keine global routbare Adresse und Anwendungen erwarten das auch nicht. Bei IPv6 verhält sich die Sache natürlich anders, hier ist es üblich, dass jeder Client eine global routbare Adresse hat – NAT ist bei IPv6 zwar grundsätzlich möglich, geht aber eigentlich gegen den Sinn des Protokolls, da es ja endlich wieder Ende-zu-Ende Verbindungen möglich machen soll. Für den Multi-WAN Betrieb birgt das aber einige Probleme, die ich im Folgenden nochmal kurz erläutern und dann eine Lösung mittels NPTv6 vorstellen werde.
Um das Problem bei IPv6 und Load Balancing zu verstehen, stellen wir uns vor, dass wir je ein Präfix von beiden Providern erhalten haben, z.B. 2001:db8:aaaa::/48 von Provider A und 2001:db8:bbbb::/48 von Provider B. Hosts in unserem LAN bekommen also nun zwei globale Adressen, hier zum Beispiel 2001:db8:aaaa::100 und 2001:db8:bbbb::100. Beim Absenden eines Pakets schreibt der Host die Quelladresse in den Header und wählt dafür eine seiner beiden Adressen aus. Allerdings weiß er nicht, welches Interface bzw. welchen Provider der Router wählt, um das Paket tatsächlich an den Empfänger weiterzuschicken, da dies bei Load Balancing dynamisch geschieht. Wenn der Host also über 2001:db8:aaaa::100 (Provider A) eine Webseite aufrufen möchte, der Router das Paket allerdings über Provider B rausschickt, wird Provider B das Paket in der Regel verwerfen, da es nicht zu seinem Präfix gehört (Anti-Spoofing). Und selbst wenn das Paket nicht verworfen würde, würde der Zielserver die Antwort an den im Paket angegebenen Absender, also die Adresse bei Provider A zurücksenden, wodurch die Firewall des Routers das Paket verwerfen würde, da es zu keiner bestehenden Session passt.
NPTv6 (Prefix Translation) löst dieses Problem, indem der Host jetzt nur noch eine einzige Adresse aus einem rein internen LAN-Präfix bekommt (in etwa vergleichbar mit privaten Adressräumen in IPv4, also z.B. 192.168.X.X), beispielsweise fd12:3456:789a::100. Der Host verwendet nun zum Verschicken von Anfragen also zunächst seine lokale Adresse, welche natürlich nicht global geroutet werden kann, weshalb der Router jetzt beim Weiterschicken des Pakets das Präfix (aber nicht den Adressteil selber) in das Präfix des jeweiligen Interfaces umschreibt, über welches das Paket tatsächlich rausgeht. Aus fd12:3456:789a::100 wird so beispielsweise 2001:db8:aaaa::100 oder 2001:db8:bbbb::100. So sieht der Provider immer die passende Absenderadresse (die Pakete werden akzeptiert) und die Antworten kommen auch an das richtige Interface des Routers zurück. Für den Multi-WAN Einsatz zuhause ist das mehr oder weniger die einzige praktikable Lösung bei IPv6 – manuell festgelegtes Source Based Routing ist komplex, fehleranfällig und ermöglicht kein echtes Load Balancing, und „echtes“ Multihoming mit eigenem Adressraum über BGP wäre zwar die technisch sauberste Lösung, benötigt aber mindestens einen teuren Business-Anschluss.
NPTv6 ist mit der OPNsense jedoch zum Glück sehr einfach einzurichten, sodass Load Balancing anschließend nicht nur mit IPv4, sondern auch mit IPv6 funktioniert. Ich gehe im Folgenden davon aus, dass der Multi-WAN Betrieb mit IPv4 bereits so eingerichtet wurde, wie es die Dokumentation beschreibt. Hierzu haben wir eine Gateway Group mit beiden IPv4-Gateways angelegt und die gleiche Tier-Stufe zugewiesen (für Load Balancing). Dabei ist uns womöglich schon aufgefallen, dass wir hier nicht gleichzeitig auch IPv6-Gateways hinzufügen können, also müssen wir als nächstes eine weitere Gateway Group anlegen, die wir analog zur ersten konfigurieren, allerdings mit IPv6.

Falls nicht schon geschehen, muss für jedes WAN Interface zusätzlich zum IPv4-Gateway unter System > Gateways > Configuration noch ein IPv6-Gateway eingerichtet werden. Damit das funktioniert, müssen unsere WAN-Interfaces natürlich eine IPv6-Adresse besitzen, in meinem Fall werden diese per DHCP von zwei vorgelagerten FritzBoxen zugewiesen, bei denen die OPNsense als Exposed Host eingestellt wird. Wichtig ist im nächsten Schritt außerdem, dass die OPNsense mindestens ein /64 Prefix zugewiesen bekommt, welches dann auf das interne /64 Prefix gemappt werden kann. Hierfür müssen wir in der FritzBox unter Heimnetz > Netzwerk > Netzwerkeinstellungen > Weitere Einstellungen den DHCPv6-Server in der FRITZ!Box aktivieren und den Haken bei „DNS-Server, Präfix (IA_PD) und IPv6-Adresse (IA_NA) zuweisen“ setzen. Wenn ihr das gemacht habt, stellt auch in den Freigaben nochmal sicher, dass die OPNsense auch per IPv6 als Exposed Host freigegeben ist. Solltet ihr keine FritzBox verwenden müsst ihr nur erreichen, dass eure Firewall ein Präfix zugewiesen bekommt.
Damit das mit dem Präfix und vor allem der Präfixgröße von /64 (welches der kleinsten möglichen Größe entspricht, weshalb ihr auch kein Problem haben solltet eins zu bekommen, egal bei welchem Provider ihr seid) funktioniert, müsst ihr dem vorgelagerten Router jetzt noch mitteilen, dass die OPNsense ein solches Präfix möchte. Das macht ihr unter Interfaces > [Euer WAN Interface] wie folgt:

Wichtig ist hier vor allem die Option „Send Prefix hint“, weil sie der FritzBox mitteilt „Ich möchte nur ein /64 Präfix“. Unter Interfaces > Overview > Details (auf euren WAN Interfaces) könnt ihr dann sehen, ob es geklappt hat, unter „Dynamic IPv6 prefix received“ sollte nun ein /64er-Netz stehen. Das /64er Netz raubt uns zwar Flexibilität, aber ist weniger komplex und sollte an praktisch jedem Anschluss verfügbar sein.
Jetzt müsst ihr (falls noch nicht geschehen) sicherstellen, dass Geräte in eurem LAN auch lokale IPv6-Adressen aus einem /64er Netz zugewiesen bekommen. Dazu muss euer LAN-Interface zunächst eine statische IPv6-Adresse besitzen, was ihr unter Interfaces > [Euer LAN Interface] einstellen könnt. Ihr könnt eine solche Adresse und die dazugehörige Range beispielsweise hier generieren, was auch den Vorteil hat, dass es z.B. im VPN-Betrieb nie zu Überschneidungen mit anderen Netzen kommen sollte. Anschließend müsst ihr noch einen DHCPv6-Server konfigurieren und aktivieren, ich verwende mittlerweile Kea, es funktioniert aber auch mit ISC genau so. Es empfiehlt sich, den Pool ein wenig einzugrenzen, zwingend nötig ist das aber nicht.

Die Vorarbeit ist damit getan, jetzt kommt der entscheidende Teil – die Konfiguration von NPTv6. Wechselt hierfür zu Firewall > NAT > NPTv6. Jetzt legt ihr zwei NPTv6-Regeln an, einmal pro WAN-Interface:

Das „Internal Prefix“ ist dabei euer internes Präfix für das LAN, das „External Prefix“ entspricht dem Präfix, welches die OPNsense von dem vorgelagerten Router zugewiesen bekommen hat. Wenn ihr nicht sicher seid, dann könnt ihr dieses Präfix jederzeit unter Interfaces > Overview > Details nachsehen. Das war’s schon, jetzt sollte IPv6 funktionieren. Prüfen könnt ihr das beispielsweise über https://test-ipv6.com/.
Einen gravierenden Nachteil hat diese Lösung aber: mit diesen Einstellungen funktioniert es nur solange, bis euer Provider euch ein andes Präfix zuteilt. Normalerweise passiert das sehr selten, aber dennoch ist es natürlich nervig, in dem Fall immer manuell die Regel anpassen zu müssen. Dem könnt ihr vorbeigen, indem ihr ein Skript erstellt, welches das Präfix ausliest und bei Bedarf anpasst. Dieses Skript führt ihr dann z.B. per Cronjob regelmäßig aus. Ich verwende dazu folgendes Skript (Disclaimer: um das Skript zu erstellen, wurde teilweise ein LLM verwendet. Es funktioniert bei mir seit mehreren Wochen problemlos, aber ich übernehme keine Haftung für Fehler):
#!/bin/sh
# Update NPTv6 external prefixes when WAN PD changes
# ========= CONFIG (edit these) ==================================================
# Set where the API lives (if you are using a non-standard port for the Web GUI, this is important)
API_ADDR="127.0.0.1"
API_SCHEME="https"
API_PORT="4433"
# Set your API key and secret: System - Access - Users (create key/secret)
API_KEY="1LfLx4mT+6KT6GoUT16LC8ia4gI4VIW/qlDPcbB3tK2ddWkiMjsKoLEu6gS6RwYHaHt3NWGBP5/UN+Xq"
API_SECRET="BHhGUu0JJn1/DFXZBdpVgceqJ1WTP9+liuDNG39R4UzBgnRji2m+6Mk5j9xc/pkYD2Ib7K72gycolQfC"
# Debugging Options
DRYRUN="0" # set to 1 to see what would change
DEBUG="0"
# Process only these real NICs (recommended): leave empty to auto-detect
IFACES="igc2 igc3"
# Optional REAL->LOGICAL overrides, one per line (usually NOT needed):
# e.g.:
# OVERRIDES="igc2=opt1
# igc3=wan"
OVERRIDES=""
# =================================================================================
# Build a clean API base from scheme/host/port (strip any accidental path on API_ADDR)
API_HOST_ONLY="$(printf "%s" "$API_ADDR" | sed -E 's#/.*##')"
API_BASE="${API_SCHEME}://${API_HOST_ONLY}:${API_PORT}"
# curl_raw: call an API path and include HTTP code in the last line for easy parsing.
curl_raw() { # $1=method $2=path [$3=data]
if [ -n "$3" ]; then
curl -sk -u "$API_KEY:$API_SECRET" -H 'Content-Type: application/json' \
-w '\nHTTP %{http_code}\n' -X "$1" "${API_BASE}/$2" -d "$3"
else
curl -sk -u "$API_KEY:$API_SECRET" \
-w '\nHTTP %{http_code}\n' -X "$1" "${API_BASE}/$2"
fi
}
# is_json: returns success if stdin parses as JSON; used to sanity-check API responses.
is_json() { python3 -c 'import sys,json; json.load(sys.stdin)' 2>/dev/null ; }
# pd_for_if: obtain current delegated /64 for an interface (via ifctl; falls back to /tmp file).
pd_for_if() {
IF="$1"
PD="$(ifctl -6pi "$IF" 2>/dev/null | awk '/^prefix[[:space:]]/ {print $2; exit}')"
[ -z "$PD" ] && [ -r "/tmp/${IF}_prefixv6" ] && PD="$(cat "/tmp/${IF}_prefixv6")"
echo "$PD"
}
# build_map: parse /conf/config.xml to map REAL ifname → LOGICAL name (e.g., igc3 -> wan).
build_map() {
awk '
BEGIN{inif=0;cur=""}
/<interfaces>/ {inif=1; next}
/<\/interfaces>/ {inif=0; cur=""; next}
inif {
if ($0 ~ /^[[:space:]]*<[^\/][^>]*>[[:space:]]*$/) {
name=$0; sub(/^[[:space:]]*</,"",name); sub(/[[:space:]]*>[[:space:]]*$/,"",name)
if (name!="interfaces" && name!="if") cur=name
}
if (cur!="" && $0 ~ /<if>/) {
dev=$0; sub(/^.*<if>/,"",dev); sub(/<\/if>.*$/,"",dev)
print dev "=" cur
}
if (cur!="" && $0 ~ ("</" cur ">")) cur=""
}' /conf/config.xml
}
MAP="$(build_map)"
[ -n "$OVERRIDES" ] && MAP="$(printf "%s\n%s\n" "$OVERRIDES" "$MAP")"
# logical_for_real: return the logical interface name (wan/optX) for a real NIC name.
logical_for_real() { printf "%s\n" "$MAP" | awk -F= -v R="$1" '$1==R{print $2; exit}'; }
# === Fetch the NPT grid once (searchRule) so we don't hit the API repeatedly =========
RESP="$(curl_raw GET api/firewall/npt/searchRule)"
CODE="$(printf "%s" "$RESP" | sed -n '$s/.*HTTP //p')"
BODY="$(printf "%s" "$RESP" | sed '$d')"
if [ "$CODE" != "200" ] || ! printf "%s" "$BODY" | is_json ; then
echo "[ERR] searchRule failed HTTP $CODE. Body:" >&2
printf "%s\n" "$BODY" >&2
exit 1
fi
TMP="/tmp/.npt_rows.$$"; printf "%s" "$BODY" > "$TMP"
# prepare_iface: given a logical iface and target PD, produce a compact summary and a clean "rule" payload.
# Output lines: UUID=..., CURR=..., CHANGED=0/1, PAYLOAD=<json>; or STATUS=NOROW if no NPT rule for iface.
prepare_iface() { # $1=logical $2=newpd
python3 - "$1" "$2" "$TMP" 2>/dev/null <<'PY'
import sys,json
iface=sys.argv[1].strip().lower()
newpd=sys.argv[2]
path=sys.argv[3]
with open(path,"r") as f:
data=json.load(f)
row=None
for r in data.get("rows",[]):
if str(r.get("interface","")).lower()==iface:
row=r; break
if not row:
print("STATUS=NOROW"); sys.exit(0)
uuid = str(row.get("uuid",""))
curr = str(row.get("destination_net",""))
# Keep only model fields the controller expects; strip UI-only keys like %interface.
keep = {
"enabled","log","sequence","interface",
"source_net","destination_net","trackif",
"categories","description"
}
clean = {k: row.get(k,"") for k in keep}
clean["destination_net"] = newpd
changed = (curr != newpd)
# IMPORTANT: controller expects top-level "rule"
payload = {"rule": clean}
print("UUID="+uuid)
print("CURR="+curr)
print("CHANGED="+("1" if changed else "0"))
print("PAYLOAD="+json.dumps(payload, separators=(",",":")))
PY
}
# apply_changes: tell OPNsense to reload filter/NAT after we’ve saved rules.
apply_changes() {
RESP="$(curl_raw POST api/firewall/npt/apply)"
CODE="$(printf "%s" "$RESP" | sed -n '$s/.*HTTP //p')"
[ "$CODE" = "200" ] || configctl filter reload >/dev/null 2>&1 || true
}
echo "[INFO] API_BASE=${API_BASE}"
ANY=0
# If IFACES not set, auto-pick likely WAN NICs (pppoe/igc/igb/em/vmx/vtnet).
if [ -z "$IFACES" ]; then
IFACES="$(printf "%s\n" "$MAP" | awk -F= '{print $1}' | grep -E '^(pppoe|igc|igb|em|vmx|vtnet)' | sort -u)"
fi
for IF in $IFACES; do
LOG="$(logical_for_real "$IF")"
[ -z "$LOG" ] && { [ "$DEBUG" = "1" ] && echo "[$IF] no logical mapping -> skip" >&2; continue; }
PD="$(pd_for_if "$IF")"
[ -z "$PD" ] && { echo "[$IF/$LOG] no PD found -> skip" >&2; continue; }
OUT="$(prepare_iface "$LOG" "$PD")"
# No NPT row for this interface → nothing to do.
printf "%s\n" "$OUT" | grep -q '^STATUS=NOROW' && { echo "[$IF/$LOG] no NPT row for interface='$LOG' -> skip" >&2; continue; }
UUID="$(printf "%s\n" "$OUT" | awk -F= '/^UUID=/{print $2; exit}')"
CURR="$(printf "%s\n" "$OUT" | awk -F= '/^CURR=/{print $2; exit}')"
CHGD="$(printf "%s\n" "$OUT" | awk -F= '/^CHANGED=/{print $2; exit}')"
PAYL="$(printf "%s\n" "$OUT" | sed -n 's/^PAYLOAD=//p')"
[ -z "$UUID" ] && { echo "[$IF/$LOG] row found but missing uuid -> skip" >&2; [ "$DEBUG" = "1" ] && printf "%s\n" "$OUT"; continue; }
# Skip when the external prefix already matches the current PD.
if [ "$CHGD" = "0" ]; then
echo "[$IF/$LOG] up-to-date (destination_net=$PD)"
continue
fi
echo "[$IF/$LOG] $UUID: destination_net ${CURR:-'(empty)'} -> $PD"
if [ "$DEBUG" = "1" ]; then
echo "[$IF/$LOG] payload preview:"
printf "%s\n" "$PAYL" | python3 -m json.tool 2>/dev/null
fi
if [ "$DRYRUN" = "1" ]; then
echo "[$IF/$LOG] DRYRUN: would POST api/firewall/npt/setRule/$UUID"
ANY=1
else
RESP="$(curl_raw POST "api/firewall/npt/setRule/$UUID" "$PAYL")"
CODE="$(printf "%s" "$RESP" | sed -n '$s/.*HTTP //p')"
BODY="$(printf "%s" "$RESP" | sed '$d')"
if [ "$CODE" != "200" ]; then
echo "[$IF/$LOG] setRule failed HTTP $CODE body=$BODY" >&2
continue
fi
# Expect {"result":"saved"} (or sometimes {"status":"ok"}) when update succeeded.
echo "$BODY" | grep -Eq '"result":"saved"|"status":"ok"' || {
echo "[$IF/$LOG] setRule did not return saved/ok; body=$BODY" >&2
continue
}
ANY=1
fi
done
# Apply once if any rule changed; otherwise report no changes.
[ "$ANY" -eq 1 ] && { [ "$DRYRUN" = "1" ] && echo "[APPLY] DRYRUN skip" || { apply_changes; echo "[APPLY] done"; }; } || echo "No changes needed."
# Clean up temporary file holding the grid JSON.
rm -f "$TMP"
Erstellt für das Skript den Ordner /usr/local/opnsense/scripts/custom und legt es dort ab – dafür müsst ihr euch via SSH mit der Firewall verbinden. Damit das Skript funktioniert, müsst ihr ihm einen API-Key mit zugehörigem Secret geben, welches ihr unter System > Access > Users erstellen könnt. Wenn ihr wollt, könnt ihr einen User mit entsprechend beschränkten Rechten anlegen, ich habe jedoch einfach einen Schlüssel für den Root-User erstellt, da bei dem Skript ja ohnehin alles auf der Firewall selbst bleibt. Beachtet außerdem, dass der Port im Skript mit dem der Web UI eurer OPNsense übereinstimmt. Das Skript kann automatisch alle Interfaces mit gesetzter NPTv6-Regel erkennen und entsprechend bearbeiten, aber es ist sicherer, wenn ihr unter IFACES="" die gewünschten Interfaces manuell festlegt. Die Overrides solltet ihr in der Regel nicht brauchen. Denkt auch daran, das Skript per chmod ausführbar zu machen.
Um erstmal zu testen, ob alles korrekt funktioniert, könnt ihr dann DRYRUN="1" setzen und das Skript anschließend manuell in der Shell ausführen. Sofern alles gut aussieht, setzt DRYRUN wieder auf 0. Wenn ihr ganz sicher gehen wollt, ändert noch eine oder mehrere NPTv6 Regeln absichtlich ab, sodass das External Target falsch gesetzt ist. Wenn ihr nun das Skript ausführt, sollte es die Regeln wieder korrigieren, was ihr anschließend in der Web UI kontrollieren könnt.
Jetzt müsst ihr nur noch sicherstellen, dass das Skript auch regelmäßig ausgeführt wird. Die einfachste Methode hierfür ist ein Cronjob, der z.B. alle 15 Minuten das Skript aufruft – da es nur dann tatsächlich etwas ändert wenn sich das Präfix auch wirklich verändert hat, ist das okay so und auf jeden Fall schneller und angenehmer, als die Änderung immer manuell vornehmen zu müssen. Den Cronjob selbst können wir über die GUI von OPNsense anlegen, aber zuvor müssen wir die passende configd-Aktion erstellen. Dazu legen wir die Datei an und füllen sie mit dem folgenden Inhalt:/usr/local/opnsense/service/conf/actions.d/actions_nptv6.conf
[nptv6.autoupdate]
command:/usr/bin/lockf -k -t 0 /var/run/nptv6-autoupdate.lock /usr/local/opnsense/scripts/custom/nptv6-autoupdate.sh
parameters:
type:script
message:Run NPTv6 auto-update
description:NPTv6 auto-update external prefixes
Startet anschließend noch configd mit dem Befehl service configd restart neu. Jetzt können wir unter System > Settings > Cron den entsprechenden Job anlegen, um das Skript alle 15 Minuten auszuführen:

Und das wars! IPv6 sollte jetzt korrekt mit beiden Anschlüssen funktionieren und manuelles Eingreifen sollte nicht mehr notwendig sein. Grundsätzlich sollte das Skript auch nach Updates von OPNsense funktionieren, allerdings empfiehlt es sich insbesondere nach Major-Updates, die Funktion einmal manuell zu testen und im Zweifel vor dem Update den Cronjob zu deaktivieren, bis die Funktion des Skripts sichergestellt ist. Falls etwas kaputtgeht, hat sich wahrscheinlich nur der Name eines Felds oder Endpoints geändert, was entsprechend leicht im Skript zu beheben wäre.

Eine wichtige Einschränkung hat das Skript in seiner aktuellen Version: Es fragt die NPTv6 Regeln pro physischem Interface ab und prüft bzw. ändert jeweils nur die erste zurückgegebene Regel. Für unser Szenario, in dem wir nur ein einzelnes /64-er Netz pro Interface haben, ist dies auch das gewünschte Verhalten. Komplizierter wird es, wenn es mehrere NPTv6-Regeln pro Interface gibt, damit das wie gewünscht funktioniert, müsste man das Skript umschreiben. Für Umgebungen mit mehreren /64-Netzen pro WAN-Interface ist es also nicht geeignet, aber das steht auf meiner To-Do Liste, sofern ich die beiden Anschlüsse dazu bringen kann, mir größere Präfixe zuzuweisen. Sofern ich hier zu neuen Ergebnissen komme, werde ich hier berichten.
