Synopsis #
This chapter shows how to operate an OpenBSD edge with two or more upstream providers. It covers outbound policy using route-to
, symmetric return paths with reply-to
, per-interface NAT, and inbound publishing of services on multiple providers. Configuration lives in pf.conf(5)
and is managed with pfctl(8)
. Interface attributes are managed with ifconfig(8)
. Where automated failover is required, integrate link and reachability checks with ifstated(8)
. Use this pattern when you must split or steer traffic across providers, or need deterministic return paths for inbound NAT without using BGP.
Design Considerations #
- Topology. Use distinct physical interfaces per WAN. Example:
em0
for ISP-A andem3
for ISP-B. Keep a clear LAN interface for policy classification. - Addressing and gateways. Each WAN has its own gateway. Example: ISP-A
198.51.100.1
, ISP-B203.0.113.5
. - Asymmetry. Stateful filtering and NAT tie flows to the egress that created the state. Changing egress requires new states. Plan to clear affected states during failover.
- NAT alignment. Per-interface NAT is mandatory for multi-WAN. Avoid a single
egress
NAT when you intend to steer flows across multiple egresses. - Policy granularity. Classify by source networks, application ports, or destinations. Simpler is better. Start with per-subnet policies.
- Inbound services. Without BGP or external DNS steering, inbound presence is per-provider. Use
reply-to
to force responses back out the interface that received the connection. - Monitoring and automation. Detect failure of a specific provider before changing policy. Use ifstated(8) to trigger clean policy reloads and optional state resets.
- MTU. Mixed-access technologies (for example, PPPoE vs. Ethernet) can introduce fragmentation. Consider a conservative scrub with MSS clamping if you observe PMTUD problems.
Configuration #
The example below implements two uplinks with deterministic outbound and inbound behavior. Adjust interface names and addresses to match your environment.
Interface assumptions #
em0
— ISP-A, address198.51.100.10/29
, gateway198.51.100.1
em3
— ISP-B, address203.0.113.10/29
, gateway203.0.113.5
em1
— LAN,10.10.10.0/24
users and servers- Optional server to publish inbound:
10.10.10.50
(HTTPS)
PF policy with per-WAN NAT, route-to, and reply-to #
Place the following in /etc/pf.conf
. Load and inspect with pfctl(8)
. Syntax is defined in pf.conf(5)
.
## /etc/pf.conf — Multi-WAN and policy-based routing (example)
set skip on lo
# Interfaces and networks
lan = "em1"
wan_a = "em0"
wan_b = "em3"
lan_net = "10.10.10.0/24"
# Upstream gateways
gw_a = "198.51.100.1"
gw_b = "203.0.113.5"
# Optional convenience macros
web_svc = "10.10.10.50"
tcp_services = "{ 22, 80, 443 }"
# Conservative scrub; adjust as required
scrub in all max-mss 1452
# Per-interface NAT for each uplink
match out on $wan_a inet from $lan_net to any nat-to ($wan_a)
match out on $wan_b inet from $lan_net to any nat-to ($wan_b)
# Base policy
block all
# Inbound control-plane to the firewall itself (per-WAN reply-to for symmetry)
pass in on $wan_a proto { tcp, udp, icmp } to ($wan_a) \
reply-to ($wan_a $gw_a) keep state
pass in on $wan_b proto { tcp, udp, icmp } to ($wan_b) \
reply-to ($wan_b $gw_b) keep state
# Outbound policy from LAN:
# - Default via ISP-A
# - Specific subnet or application class via ISP-B
# Order matters: specific policies first, then the default.
# Example: send a source subnet (or specific hosts) via ISP-B
# Replace 10.10.20.0/24 with a real subset if applicable.
# pass in on $lan from 10.10.20.0/24 to any \
# route-to ($wan_b $gw_b) keep state
# Example: send bulk/backup traffic via ISP-B (by destination ports)
pass in on $lan proto { tcp, udp } from $lan_net to any port { 6881:6999, 873 } \
route-to ($wan_b $gw_b) keep state
# Default: everything else goes via ISP-A
pass in on $lan from $lan_net to any \
route-to ($wan_a $gw_a) keep state
# Inbound publishing of a HTTPS service on both providers
# rdr occurs before filtering; provide a pass rule that includes reply-to
# so return traffic follows the same ISP it arrived on.
# ISP-A publication
rdr on $wan_a proto tcp from any to ($wan_a) port 443 -> $web_svc
pass in on $wan_a proto tcp from any to $web_svc port 443 \
reply-to ($wan_a $gw_a) keep state
# ISP-B publication
rdr on $wan_b proto tcp from any to ($wan_b) port 443 -> $web_svc
pass in on $wan_b proto tcp from any to $web_svc port 443 \
reply-to ($wan_b $gw_b) keep state
# Allow LAN to firewall services as needed (SSH/HTTPS example)
pass in on $lan proto tcp from $lan_net to ($lan) port $tcp_services keep state
# Outbound from the firewall itself (administration, package fetches)
pass out on $wan_a inet to any keep state
pass out on $wan_b inet to any keep state
Notes #
route-to
applies where the packet enters PF. For traffic originating on the LAN, match it withpass in on $lan ... route-to (...)
.reply-to
on the WAN-side pass rules pins return traffic for inbound sessions to the same interface and gateway that received the connection.- NAT rules are written per uplink so that translation follows the egress selected by policy.
Operational toggles and state hygiene #
During planned failover or while testing, it is useful to adjust policy and reset only the flows that must move.
# pfctl -f /etc/pf.conf
# Atomic reload after any change
# ifconfig em0 down
# Simulate ISP-A failure (bring the interface back up after testing with: ifconfig em0 up)
# pfctl -k 0.0.0.0/0 -k 0.0.0.0/0
# Flush all IPv4 states so new paths can be chosen
# pfctl -K "10.10.10.0/24"
# Or selectively kill states for the LAN prefix only
# tcpdump -ni em3 icmp or port 443
# Observe traffic moving to ISP-B during a test
If you automate uplink health detection with ifstated(8)
, ensure that the action sequence includes a clean pfctl -f
of a prepared policy and a targeted state reset for affected prefixes or applications.
Verification #
Use PF counters, live captures, and system routes to verify that selection and symmetry work as intended.
- Rule counters and states with pfctl(8) :
$ pfctl -vvsr
# Verify that specific "route-to" rules are matching and increasing counters
$ pfctl -vvss | egrep 'em0|em3'
# Inspect states; egress interface and gateway selection appear in state details
- Path validation with traceroute(8) and ping(8) ):
$ traceroute -n 1.1.1.1
# From a LAN host through the firewall; expect ISP-A by default
$ traceroute -n 9.9.9.9
# For a destination matched by a policy to ISP-B, expect the ISP-B path
$ ping -n -c 3 8.8.8.8
# Basic reachability; repeat while toggling policies
- Interface-level observation with tcpdump(8) :
# tcpdump -ni em0 host 8.8.8.8 or port 443
# Confirm flows selected for ISP-A egress
# tcpdump -ni em3 host 9.9.9.9 or port 443
# Confirm flows selected for ISP-B egress
- System routes with route(8) :
$ route -n show -inet
# The system default route is not authoritative for PF "route-to" decisions,
# but it matters for traffic sourced by the firewall itself
Troubleshooting #
- Asymmetric return paths. Missing or incorrect
reply-to
on WAN pass rules for inbound services causes replies to leave via the wrong ISP. Addreply-to
on each WAN. - NAT mismatch. If clients on LAN lose connectivity when a policy switches egress, ensure per-interface
match out on $wan_* nat-to ($wan_*)
rules exist for each uplink. - Flows stick to old egress. Existing states pin to the original path. Kill only the affected states using
pfctl -K <subnet>
or perform a targeted application restart. - Performance issues after path change. MTU or MSS differences between providers can break PMTUD. Keep a conservative
scrub ... max-mss
during testing and refine later. - Policy does not match. Rule ordering matters. Place specific selectors before the default LAN routing rule. Confirm with
pfctl -vvsr
. - Firewall-originated traffic uses the wrong ISP.
route-to
does not affect traffic sourced by the firewall. Adjust the system default route with route(8) or add application-specific source addresses.