家里用了全套 Unifi,网关是 UDM SE。但 UDM SE 没有 PPPoE Hardware Offload,导致用它拨号时单线程速率只有约 500Mbps(多线程可以跑满千兆)。如果改用光猫拨号,又会产生 Double NAT 问题,而且 UDM 也无法及时感知 IP 变化来触发 DDNS 更新。
那有什么办法可以 既用一台专门的设备来处理 PPPoE 拨号(享受硬件卸载的性能),又能让 UDM SE 直接拿到公网 IP 地址 呢?答案就是本文的主题 —— PPPoE Half Bridge。
PPPoE Half Bridge 并不是什么新鲜概念。部分国外运营商的光猫原生支持 PPPoE Half Bridge(也叫 PPPoE IP Extension、PPPoE IP Passthrough)。其核心思路是:
由一台前置设备(光猫或路由器)负责 PPPoE 拨号建立会话,但不使用获取到的公网 IP,而是通过 DHCP 将这个公网 IP "透传(Passthrough)"给下游网关。让下游网关以为自己直接获得了公网 IP。
对比几种常见方案:
| 方案 | 拨号设备 | 公网 IP 归属 | NAT 层数 | 适用场景 |
|---|---|---|---|---|
| 光猫拨号 | 光猫 | 光猫 | Double NAT | 简单省事但不灵活 |
| 路由器拨号/光猫桥接 | 路由器 | 路由器 | 单层 NAT | 最常见方案 |
| PPPoE Half Bridge | 前置设备 | 下游网关 | 单层 NAT | 本文方案 |
简单来说,PPPoE Half Bridge 让前置设备只做"拨号代理",真正的公网 IP 由下游网关持有和使用。这样做的好处是显而易见的:
本文分享一套基于 OpenWrt 的 PPPoE Half Bridge 实现方案,笔者已经稳定运行大半年,实测可靠。该方案也可以轻松迁移到 Debian 系的系统上。
PPPoE 拨号建立的是一个点对点连接(Point-to-Point)。拨号成功后,PPPoE 接口上的 IP 地址其实只是一个标识,并不像以太网接口那样是通信的必要条件。数据包的转发依赖的是 PPPoE Session 和路由表,而不是接口上绑定的 IP 地址。
因此我们可以:
ip addr flush)下游网关拿到公网 IP 后,所有出站流量经过前置设备的 PPPoE 接口转发到运营商,回程流量经 PPPoE 接口回到前置设备再转发给下游网关。整个过程不需要 NAT,前置设备只做二层/三层转发。
你需要一台 支持 PPPoE 硬件卸载(Hardware Offload)的 OpenWrt 设备,它负责拨号和转发。
我使用的硬件方案:
flow_offloading_hw),跑满千兆 CPU 纹丝不动Tip现在市面上也有原生支持 OpenWrt 的 10G XGPON 光猫,如果你的运营商支持 GPON 会更方便。不过上海电信是 EPON,没法用。
接口说明:
| 接口名 | 物理端口 | 用途 |
|---|---|---|
wan (br-wan) | eth2 | 连接光猫/猫棒,PPPoE 在此之上建立 |
ppp | pppoe-ppp | PPPoE 拨号接口 |
lann | eth1 | 连接下游网关(UDM SE)的 WAN 口 |
lan (br-lan) | lan1/lan2/lan3 | 管理口 |
在理解具体配置之前,先了解一下整体思路和数据流:
a.b.c.d);完成以上步骤后,下游网关就像直接拨号一样,拿到公网 IP、通过 PPPoE 隧道上网,而 OpenWrt 则变成了一个透明的"桥接拨号器"。
在进入核心操作之前,OpenWrt 的静态配置中有几个关键点需要注意:
network:
ppp) 的 defaultroute 必须设为 '0' —— 默认路由通过策略路由手动管理,PPPoE 自动添加默认路由会产生冲突lann 接口 (eth1) 先配一个占位 IP(如 192.168.2.1),拨号后动态修改dhcp:
odhcpd-ipv6only)替代 dnsmasq 作为 DHCPv4 服务器。odhcpd 小巧灵活,动态修改 DHCP 范围后重启秒级完成,非常适合这个场景lann 接口的 DHCP 配置为 start=2, limit=1,即池中只有一个可分配地址,后续动态调整 start 使这个唯一地址恰好是公网 IPleasetime 设为 60s,极短租约确保 IP 变化时快速更新firewall:
wann),不开 masq —— 因为 PPPoE 接口的 IP 会被清除,masquerade 找不到源地址会导致无法上网lann 和 wann 之间双向 forwardingflow_offloading_hw 硬件流量卸载以下是 PPPoE 拨号成功后,需要执行的一系列操作。假设拨号获取到的公网 IP 为 58.32.1.100,运营商分配的子网为 /22。
PPPoE 拨号成功后,首先通过 ifstatus 获取分配到的公网 IP:
然后根据子网掩码计算出网关地址。上海电信精品网分配的是 /22 子网(255.255.252.0),通过位运算将第三个八位组对齐到子网边界:
[!TIP] 如果你的运营商分配的子网掩码不同,需要修改这里的计算逻辑。比如 /24 子网直接用
$i1.$i2.$i3.1即可。
这是整个方案中最关键的一步。
删除了 PPPoE 接口上的公网 IP,但 PPPoE 会话本身不受影响。
为什么要删掉? 因为接下来我们要把相同网段的 IP 分配给下游网关。如果 OpenWrt 的 PPPoE 接口上还保留着公网 IP,就会造成"两个设备在同一个子网内拥有相同网段的 IP"的冲突,导致路由混乱。删除 PPPoE 接口的 IP 后,OpenWrt 上不再有这个网段的直连路由,流量走向就完全由我们手动添加的路由规则控制了。
为什么可以删掉? PPPoE 是一个点对点隧道协议,会话的维持依赖的是底层的 PPPoE session,而不是 IP 层的地址。只要 PPPoE session 没断,隧道就在,数据包就能通过这个隧道转发。IP 地址只是便于上层路由使用,不影响隧道本身。
由于 PPPoE 接口的 IP 已被删除,系统默认路由也随之失效。我们需要手动建立路由规则,告诉系统"从下游网关来的流量走 PPPoE 隧道出去"。
这里使用策略路由(Policy Routing):
pppoe-ppp 设备;eth1(下游网关接口)进入的流量,查 table 100 进行路由。这样,下游网关发出的流量就会通过 PPPoE 隧道转发到公网,而 OpenWrt 自身的管理流量不受影响。
Important这里所有的 IP 变更都是通过
ip/ifconfig命令直接操作的,不修改/etc/config/network。OpenWrt 重启后会自动恢复到默认配置,下次 PPPoE 拨号成功后重新执行这些操作即可。这是刻意的设计,保证系统的可恢复性。
将计算出的网关 IP(如 58.32.0.1)配置到 eth1 上,使 OpenWrt 的 eth1「冒充」运营商的网关。这样下游网关通过 DHCP 拿到公网 IP 后,默认网关自然就指向这个地址,路由就能正常工作了。
Note实测中发现直接修改 IP 有时不生效,需要先将接口
down再重新配置up才能稳定成功。如果你没有需要访问同子网邻居 IP 的需求,保持和运营商分配的子网掩码一致即可。如果有这个需求,你需要尽量缩短该子网大小,理论上你甚至可以用 /31 掩码——只要确保网关 IP 和你的公网 IP 在同一个子网里就行。
前置配置中,我们已经将 lann 接口的 odhcpd DHCP 配置为 start=2, limit=1,池子里只能分配一个 IP。现在要做的是:根据当前获取到的公网 IP,动态修改 start 值,使得 DHCP 分配出去的那个唯一的 IP 恰好就是公网 IP。
odhcpd 的 start 参数表示相对于网段起始地址的偏移量。配置完成后,重启 odhcpd 服务使其生效。
举个具体的例子:
58.32.1.100,子网 /22eth1 的网段为 58.32.0.0/22start = 356,limit = 158.32.0.0 + 356 = 58.32.1.100检查 PPPoE 所在防火墙 zone 的 masq(NAT)配置,确保为 0:
这一步至关重要。在 Step 2 中我们已经将 PPPoE 接口的 IP 地址 flush 掉了,而 masquerade 的工作原理是用出口接口的 IP 地址作为 SNAT 源地址。此时 PPPoE 接口上已经没有 IP,MASQUERADE 规则找不到可用的源地址,会导致所有经过 NAT 的数据包被直接丢弃,表现为完全无法上网。因此必须将 PPPoE 所在防火墙区域的 masq 设为 0,让流量以原始源 IP(即下游网关的公网 IP)直接通过 PPPoE 隧道转发,NAT 则完全交给下游网关处理。
完成以上所有配置后,需要让下游网关立即感知到新的 IP 地址。但当 OpenWrt 重新拨号(IP 发生变化)时,下游网关并不会主动刷新 DHCP,它要等到当前 lease 到期才会重新请求。但这里碰到了一个现实问题 —— 如何让下游网关立即重新请求 DHCP?
笔者尝试了以下方案:
最终想到一个简单快捷的方案:在 UDM SE 上部署一个简单的监听服务,当收到指定消息时,强制触发 DHCP 重新获取。
原理很简单:在 UDM 上用 nc 监听一个端口(如 99),收到 dhcp_renew 消息后,向 udhcpc(UDM 的 DHCP 客户端)发送 SIGUSR2(释放当前 lease)和 SIGUSR1(重新请求 lease)信号。
在 OpenWrt 拨号成功并完成所有配置后,发送一条消息即可:
IP 变化时下游网关几乎可以秒级更新。
以上所有步骤可以封装到一个 Shell 脚本中,并配置为 PPPoE 拨号成功后自动执行。在 OpenWrt 中,可以将脚本放到 /etc/ppp/ip-up.d/ 目录下,PPPoE 每次拨号成功都会自动触发。
这样,无论是首次拨号还是断线重拨,Half Bridge 配置都会自动完成,实现全自动无人值守。
创建 /etc/hotplug.d/iface/99-half-bridge:
每个函数都做了幂等性处理 —— 如果当前状态已经是目标状态,就跳过操作,避免重复执行带来的问题。
配置完成后的数据流路径:
出站(下游设备 → 互联网):
入站(互联网 → 下游设备):
前置设备全程不做 NAT,只做三层路由转发 + PPPoE 封装解封装,配合硬件流量卸载,性能开销几乎为零。
这套 PPPoE Half Bridge 方案本人已经稳定运行大半年,没有出过任何问题。它本质上是把"光猫 PPPoE IP Passthrough"的功能在 OpenWrt 上手动实现了一遍。
不过说实话,这个方案确实非常 Geek,涉及到 PPPoE、DHCP、策略路由、防火墙等多个模块的联动,很难抽象成一个通用的一键脚本。每个人的网络环境、运营商分配的子网、使用的硬件都不一样,需要根据实际情况调整。
Note其实我已经更换为 UCG Fiber —— Unifi 终于想通了,内置了 PPPoE 硬件加速。所以本篇算是填坑之作,给那些正在折腾 PPPoE Hardware Offload 的人指一条明路。
不过如果你用的是 OpenWrt XGPON 光猫这样的设备,这套方案依然很有价值 —— 你可以用它获得一个"伪 IPoE"宽带体验,让下游网关完全不感知 PPPoE 的存在。
有兴趣的朋友欢迎一起讨论折腾 🛠️
部署到 /etc/systemd/system/dhcp_listener.service,启用后可接收 OpenWrt 的 DHCP 刷新通知:
[光猫/猫棒] <--PPPoE--> [OpenWrt (BPI-R4)] <--DHCP--> [UDM SE]
eth2 eth1
wan_ip=$(ifstatus ppp | jsonfilter -e '@["ipv4-address"][0].address')
# → 58.32.1.100
IFS=. read i1 i2 i3 i4 <<< "$wan_ip"
gateway_ip="$i1.$i2.$(( i3 & 252 )).1"
# 58.32.1.100 → 1 & 252 = 0 → 网关 58.32.0.1
# 清除 PPPoE 接口上的 IP 地址,但 PPPoE 会话保持不断
ip addr flush dev pppoe-ppp
# 建立策略路由:来自 eth1(下游网关)的流量走 PPPoE 隧道
ip route add default dev pppoe-ppp table 100
ip rule add iif eth1 table 100
ifconfig eth1 down
ifconfig eth1 "$gateway_ip" netmask "255.255.252.0" up
# eth1 的 IP 变为 58.32.0.1/22
# 计算公网 IP 在 /22 子网中的偏移量
# 58.32.1.100 → 子网起始 58.32.0.0 → 偏移量 = 1*256 + 100 = 356
offset=$(( i3 * 256 + i4 - (i3 & 252) * 256 ))
# 修改 odhcpd 的 DHCP 起始地址并重启
uci -q set dhcp.lann.start="$offset"
uci -q commit dhcp
/etc/init.d/odhcpd restart
wan_idx=$(uci show firewall | grep -E "firewall.@zone\[[0-9]+\].name='wann'" \
| cut -d'[' -f2 | cut -d']' -f1)
uci set firewall.@zone[$wan_idx].masq='0'
uci commit firewall
/etc/init.d/firewall reload
while true; do
message=$(nc -l -p 99)
message=$(echo "$message" | tr -d "\n\r\t ")
if [ "$message" = "dhcp_renew" ]; then
if killall -SIGUSR2 udhcpc && killall -SIGUSR1 udhcpc; then
logger -t "dhcp_listener" "Successfully renewed DHCP"
fi
fi
done
echo "dhcp_renew" | nc 10.10.10.1 99
#!/bin/sh
[ "$ACTION" = "ifup" ] && [ "$INTERFACE" = "ppp" ] && {
/root/start-half-bridge.sh
}
下游设备 → UDM SE (NAT, 源IP=公网IP) → eth1 → 策略路由 table 100 → pppoe-ppp → 运营商
运营商 → pppoe-ppp → 路由表匹配目的IP → eth1 → UDM SE (NAT) → 下游设备
config interface 'loopback'
option device 'lo'
option proto 'static'
option ipaddr '127.0.0.1'
option netmask '255.0.0.0'
config globals 'globals'
option ula_prefix 'fd32:a520:fa07::/48'
option packet_steering '1'
config device
option name 'br-lan'
option type 'bridge'
list ports 'lan1'
list ports 'lan2'
list ports 'lan3'
config interface 'lan'
option device 'br-lan'
option proto 'static'
option ipaddr '10.10.10.10'
option netmask '255.255.255.0'
option gateway '10.10.10.1'
config interface 'lann'
option device 'eth1'
option proto 'static'
option defaultroute '0'
option ipaddr '192.168.2.1'
option netmask '255.255.255.0'
config device
option name 'br-wan'
option type 'bridge'
list ports 'wan'
list ports 'eth2'
config device
option name 'wan'
option macaddr 'fe:fe:b1:95:1f:1f'
config device
option name 'eth2'
option macaddr 'FE:FE:B1:95:1F:1D'
config interface 'wan'
option device 'br-wan'
option proto 'static'
option ipaddr '192.168.0.2'
option netmask '255.255.255.0'
config interface 'wan6'
option device 'br-wan'
option proto 'dhcpv6'
config interface 'ppp'
option proto 'pppoe'
option device 'br-wan'
option ipv6 '0'
option keepalive '0 1'
option username 'ad12345'
option password '12345'
option defaultroute '0'
config defaults
option input 'REJECT'
option output 'ACCEPT'
option forward 'REJECT'
option synflood_protect '1'
option flow_offloading '1'
option flow_offloading_hw '1'
config zone
option name 'lann'
option input 'ACCEPT'
option output 'ACCEPT'
option forward 'ACCEPT'
list network 'lann'
config zone
option name 'wann'
list network 'ppp'
option input 'REJECT'
option output 'ACCEPT'
option forward 'REJECT'
option mtu_fix '1'
config forwarding
option src 'lann'
option dest 'wann'
config forwarding
option src 'wann'
option dest 'lann'
config odhcpd 'odhcpd'
option maindhcp '1'
option leasefile '/tmp/hosts/odhcpd'
option leasetrigger '/usr/sbin/odhcpd-update'
option loglevel '4'
config dhcp 'lann'
option interface 'lann'
option dhcpv4 'server'
option leasetime '60s'
option start '2'
option limit '1'
option dns '223.5.5.5'
#!/bin/ash
# 日志函数
log() {
echo "$1"
logger -t pppoe-half-bridge "$1"
}
# 获取 WAN IP 并验证
get_wan_ip() {
local wan_ip=$(ifstatus ppp | jsonfilter -e '@["ipv4-address"][0].address')
if [ -z "$wan_ip" ]; then
log "获取 WAN IP 失败"
exit 1
fi
echo "$wan_ip"
}
# 计算网关地址(22位子网掩码)
get_gateway_address() {
local ip="$1"
IFS=. read i1 i2 i3 i4 << EOF
$ip
EOF
echo "$i1.$i2.$(( i3 & 252 )).1"
}
# 计算 DHCP 起始地址偏移量(22位子网掩码)
get_ip_offset() {
local ip="$1"
IFS=. read i1 i2 i3 i4 << EOF
$ip
EOF
echo "$(( i3 * 256 + i4 - (i3 & 252) * 256 ))"
}
# 配置 DHCP 服务
configure_dhcp_service() {
local start="$1"
local current_start=$(uci -q get dhcp.lann.start)
if [ "$current_start" = "$start" ]; then
log "DHCP 无需配置"
return
fi
uci -q set dhcp.lann.start="$start"
uci -q commit dhcp
/etc/init.d/odhcpd restart
log "DHCP 配置完成"
}
# 配置防火墙
configure_firewall() {
wan_idx=$(uci show firewall | grep -E "firewall.@zone\[[0-9]+\].name='wann'" | cut -d'[' -f2 | cut -d']' -f1)
if [ -z "$wan_idx" ]; then
log "未找到 wan zone"
return
fi
masq_val=$(uci -q get firewall.@zone[$wan_idx].masq)
if [ "$masq_val" = "0" ]; then
log "防火墙无需配置"
return
fi
uci set firewall.@zone[$wan_idx].masq='0'
uci commit firewall
/etc/init.d/firewall reload
log "防火墙配置完成"
}
# 配置 eth1 IP 地址
configure_eth1_ip() {
local gateway_ip="$1"
local current_ip=$(ip addr show dev eth1 | grep -w "inet" | awk '{print $2}')
if [ "$current_ip" = "$gateway_ip/22" ]; then
log "eth1 IP 无需配置"
return
else
ifconfig eth1 down
ifconfig eth1 "$gateway_ip" netmask "255.255.252.0" up
log "eth1 IP 配置完成"
fi
}
# 主函数
main() {
local wan_ip=$(get_wan_ip)
local gateway_ip=$(get_gateway_address "$wan_ip")
log "WAN IP: $wan_ip, 网关: $gateway_ip"
# Step 1 & 2: 清除 PPPoE 接口 IP,建立策略路由
ip addr flush dev pppoe-ppp
ip route add default dev pppoe-ppp table 100
ip rule add iif eth1 table 100
log "PPPoE 接口配置完成"
# Step 3: 将 eth1 配置为下游网关的网关
configure_eth1_ip "$gateway_ip"
# Step 4: 动态调整 DHCP 范围
configure_dhcp_service "$(get_ip_offset "$wan_ip")"
# Step 5: 确保 PPPoE zone 不做 NAT
configure_firewall
# Step 6: 通知下游网关刷新 DHCP
echo "dhcp_renew" | nc 10.10.10.1 99
log "UDM DHCP 刷新完成"
}
# 执行主函数
main
[Unit]
Description=DHCP Listener Service
[Service]
User=root
ExecStart=/bin/bash -c 'while true; do \
message=$(nc -l -p 99); \
message=$(echo "$message" | tr -d "\n\r\t "); \
if [ "$message" = "dhcp_renew" ]; then \
if killall -SIGUSR2 udhcpc && killall -SIGUSR1 udhcpc; then \
logger -t "dhcp_listener" "Successfully renewed DHCP"; \
fi; \
fi; \
done'
Restart=always
[Install]
WantedBy=multi-user.target