使用 OpenWrt 策略路由把流量转发到旁路网关

发布:更新:

前言🔗

最近一段时间,小山重新规划了家里的网络,更换了主路由,并把之前主路由上运行的一些软件卸载到了旁路由。

我目前的旁路由是基于 ArchLinux 构建的,如果你对此感兴趣可以查看《基于 ArchLinux 构建旁路由》。下面的教程依然是以基于 OpenWrt 的旁路由为准。

常见的使用旁路由的方法有两种,第一种是把设备的网关更改旁路由的 IP,第二种是用 DHCP 为设备下发网关,两种方法都是让设备的所有流量转发到旁路由处理。无论用哪种方法设置设备的网关,都会导致两个问题,其一,如果旁路由掉线,设备将无法访问互联网,其二,IPv4 的端口转发会失效,虽然有变通的解决方法,但是都称不上完美。

如果要解决上面提到的问题,需要用第三种方法:策略路由。什么是策略路由?通过配置规则将流量路由到不同的目标/网关,它可以在设备端实现,也可以在路由端实现,这种方法的好处在于,即使旁路由掉线,也不会影响正常上网。经过这段时间的摸索,我整理出了一套在 OpenWrt 上实现策略路由的方案,将特定的流量从主路由转发到旁路由。

开始之前,请确保你的旁路由可以正常工作并且运行模式是 Redir-Host,主路由正在运行的 OpenWrt 版本不低于 24.10.0,我没有在低版本测试过,还需要熟悉 SSH 和终端的使用,之后的大部分操作都需要运行命令。

如果你之前使用的方法是手动设置网关或 DHCP 下发网关,请先还原这些设置。

除非另有说明,否则所有操作都是在主路由上进行。

启用旁路由的 IP 动态伪装🔗

为了避免主路由的防火墙规则陷入循环和一些其他问题,首先需要在旁路由启用 IP 动态伪装。

打开旁路由的 OpenWrt 管理界面,转到「网络」->「防火墙」,然后选中区域「lan」的「IP 动态伪装」选项框,最后保存并应用。

创建路由和规则🔗

首先添加一个路由表和路由规则,将特定流量转发到特定网关。

给序号 288 的路由表创建别名 bypass:echo '288 bypass' >> /etc/iproute2/rt_tables

添加一个路由到 bypass 路由表,并将网关设置为旁路由,务必将 %gateway% 更改为你的旁路由 IP 地址:

uci set network.bypass_route=route
uci set network.bypass_route.interface='lan'
uci set network.bypass_route.target='0.0.0.0/0'
uci set network.bypass_route.gateway='%gateway%'
uci set network.bypass_route.table='bypass'
uci commit

创建路由规则,将包含特定标记的流量转发到 bypass 路由表:

uci set network.bypass_rule=rule
uci set network.bypass_rule.in='lan'
uci set network.bypass_rule.lookup='bypass'
uci set network.bypass_rule.mark='114514'
uci set network.bypass_rule.priority='30000'
uci commit

执行命令 /etc/init.d/network reload 重新加载路由,然后转到 OpenWrt 管理界面查看路由是否已加载。

OpenWrt LuCI 路由状态界面

刚刚创建的路由规则只转发包含特定标记的流量,所以要让 nftables 给特定的流量写入标记。不过在此之前,我们先为 nftables 创建一些地址集合。

创建 nftables 地址集合🔗

下载并创建国内 IPv4 地址段集合:

wget -O /etc/luci-uploads/chnroutes.txt https://ispip.clang.cn/all_cn.txt
uci set firewall.cn_ipv4_ipset=ipset
uci set firewall.cn_ipv4_ipset.name='cn_ipv4_ipset'
uci set firewall.cn_ipv4_ipset.family='ipv4'
uci set firewall.cn_ipv4_ipset.loadfile='/etc/luci-uploads/chnroutes.txt'
uci add_list firewall.cn_ipv4_ipset.match='dest_net'
uci commit

我们还需要创建一个 MAC 地址集合,用于检查流量来源,从而实现只处理特定设备的流量。

创建 MAC 地址集合:

uci set firewall.bypass_devices=ipset
uci set firewall.bypass_devices.name='bypass_devices'
uci set firewall.bypass_devices.family='ipv4'
uci add_list firewall.bypass_cn_ipset.match='mac'
uci commit

MAC 地址集合默认是空的,可以手动在 OpenWrt 管理界面添加地址,如下图所示:

OpenWrt LuCI 防火墙集合设置界面

比如需要让电脑和手机的流量转发到旁路由,添加这两个设备的 MAC 地址即可。需要注意的是,如果 WiFi 启用了隐私 MAC 地址,则需要添加对应的隐私 MAC 地址。

下一步也是最关键的一步,创建 nftables 处理规则,给特定的流量写入标记,让路由规则可以工作。

创建 nftables 处理规则🔗

创建并编辑规则文件 /etc/nftables.d/99-bypass.nft,文件内容如下:

set bypass_lan_ipv4_ipset {
    type ipv4_addr
    flags interval
    auto-merge
    elements = {
        0.0.0.0/8,
        10.0.0.0/8,
        100.64.0.0/10,
        127.0.0.0/8,
        169.254.0.0/16,
        172.16.0.0/12,
        192.0.0.0/24,
        192.0.2.0/24,
        192.88.99.0/24,
        192.168.0.0/16,
        198.18.0.0/15,
        198.51.100.0/24,
        203.0.113.0/24,
        224.0.0.0/4,
        233.252.0.0/24,
        240.0.0.0/4,
        255.255.255.255/32
    }
}

chain bypass_prerouting {
    type filter hook prerouting priority mangle; policy accept
    ct direction reply return
    ether saddr @bypass_devices meta nfproto ipv4 meta l4proto { tcp, udp } goto bypass_rules
}

chain bypass_rules {
    meta l4proto { tcp, udp } th dport { 53 } goto bypass_mark
    ip daddr @bypass_lan_ipv4_ipset return
    ip daddr @cn_ipv4_ipset return
    goto bypass_mark
}

chain bypass_mark {
    meta mark set 114514 counter
}

现在执行命令 /etc/init.d/firewall reload 重新加载防火墙规则,然后转到 OpenWrt 管理界面查看防火墙规则是否已加载。

OpenWrt LuCI 防火墙状态界面 1

如果确认无误,现在可以访问 https://www.ipip.nethttps://ip.sb 确认策略路由是否工作正常。如果不工作,可以试着重来一遍,以及确保设备的 MAC 地址已经添加到集合。

还有一种特殊情况也会导致策略路由无法正常工作,旁路由如果通过一个中继路由连接到主路由,中继路由有一些特殊设置,会导致流量无法转发,要解决这个问题,可以再在主路由上为特定流量启用 IP 动态伪装。

添加防火墙规则为特定流量启用 IP 动态伪装:

uci set firewall.bypass_masq=nat
uci set firewall.bypass_masq.name='Bypass-MASQUERADE'
uci set firewall.bypass_masq.family='ipv4'
uci set firewall.bypass_masq.src='lan'
uci set firewall.bypass_masq.target='MASQUERADE'
uci set firewall.bypass_masq.mark='114514'
uci add_list firewall.bypass_masq.proto='all'
uci commit
/etc/init.d/firewall reload

创建网关状态检查脚本🔗

你可能注意到了,上面的 nftables 规则跳过了目标 IP 是国内 IP 的流量,但是没有跳过目标端口是 53 的流量,原因是因为要做到无感故障转移,即使旁路由掉线了,设备的网络也不会故障,不过 DNS 的故障转移要用一种特殊的方法,下一步再细说。

首先创建一个网关状态检查脚本,这个脚本会不停的检查网关可用性,如果发现网关不可用,会临时禁用路由规则,当网关可用时重新启用规则。

创建并编辑文件 /etc/init.d/check-bypass-gateway,文件内容如下:

#!/bin/sh /etc/rc.common

START=99
STOP=01
USE_PROCD=1

_SCRIPT="/tmp/$(basename "${initscript:-check-bypass-gateway}")-helper.uc"

start_service() {
    if [ -e "$_SCRIPT" ]; then
        rm "$_SCRIPT"
    fi 
    cat > "$_SCRIPT" <<'EOF'
#!/usr/bin/env ucode
import * as uci from 'uci';
import * as uloop from 'uloop';

const gateway = uci.cursor().get('network','bypass_route', 'gateway');
let connectable = 1;

if (!uloop.init()) {
    die(`Initialization failure: ${uloop.error()}\n`);
}

function gateway_online() {
    for (let i = 0; i < 3; i++) {
        if (system(`ping -c 1 -W 1 '${gateway}' >/dev/null 2>&1`) == 0) return true
    }
    return false;
}

function network_reload() {
    system('service network reload');
}

function main() {
    if (gateway_online()) {
        if (connectable == 0) {
            sleep(3000);
            const ctx = uci.cursor();
            ctx.delete('network', 'bypass_rule', 'disabled');
            ctx.commit();
            network_reload();
            connectable = 1;
            printf(`The gateway is back online! (${gateway})\n`);
        }
        uloop.timer(3000, main);
    } else {
        if (connectable == 1) {
            const ctx = uci.cursor();
            ctx.set('network', 'bypass_rule', 'disabled', true);
            ctx.commit();
            network_reload();
            connectable = 0;
            printf(`The gateway is unreachable! (${gateway})\n`);
        }
        uloop.timer(5000, main);
    }
}

main();
uloop.run();
gc('start', 43200);
EOF
    chmod 0700 "$_SCRIPT"

    procd_open_instance
    procd_set_param command "$_SCRIPT"
    procd_set_param term_timeout 60
    procd_set_param stdout 1
    procd_set_param respawn 0 1 1
    procd_close_instance
}

添加执行权限:chmod +x /etc/init.d/check-bypass-gateway

启动脚本:/etc/init.d/check-bypass-gateway enable && /etc/init.d/check-bypass-gateway start

现在你可以重启旁路由并检查路由规则是否被禁用。

需要注意的是,这个脚本默认并不会在系统升级时保留,除此之外,其他的设置和文件默认都会在系统升级时保留。

修改 DHCP 给设备下发 DNS🔗

以下指南假设主路由的 DNS 是污染的,如果你的主路由已经配置了无污染的 DNS,那么可以略过。

刚才提到了要用特殊的方法做到 DNS 的故障转移,原因是,如果给设备下发主路由的 DNS,那么解析结果是污染的,如果给设备下发旁路由的 DNS,如果旁路由掉线,设备会无法使用 DNS 解析。

针对这个问题,我的解决方法是,通过 DHCP 给设备下发一个可用的公共 DNS,由于目标端口是 53 的所有流量会被转发到旁路由,就可以在旁路由上将其拦截到本地。如果旁路由正常工作,发送到这个公共 DNS 的查询会被拦截,如果旁路由掉线,则不会经过任何处理。

如果你使用的是 ImmortalWrt 固件,请先关闭「DNS 重定向」功能,此选项位于「服务」->「DHCP/DNS」->「常规」。

首先修改主路由的 DHCP 选项,给标签为 bypass_onlydns 的客户端下发指定的 DNS,这里用的是百度的公共 DNS:

uci add_list dhcp.lan.dhcp_option='tag:bypass_onlydns,6,180.76.76.76'
uci commit
/etc/init.d/dnsmasq restart

创建并编辑文件 /etc/nftables.d/99-bypass.sh,这个脚本的作用是给集合里的 MAC 自动设置标签,文件内容如下:

#!/bin/sh

cat <<'EOF' | ucode -
import * as digest from 'digest';
import * as fs from 'fs';
import * as uci from 'uci';

const ctx = uci.cursor();
const g_macs = ctx.get('firewall', 'bypass_devices', 'entry');
const g_tag = 'bypass_onlydns';
let g_markd_macs = [];
let g_restart_dnsmasq = false;

ctx.foreach('dhcp', 'host', (section) => {
    if (!exists(section, 'mac')) return;
    const tags = type(section['tag']) == 'array' ? section['tag'] : [section['tag']];
    let markd = false;
    for (let tag in tags) {
        if (tag == g_tag) {
            markd = true;
            break;
        }
    }
    if (markd) {
        const mac = type(section['mac']) == 'array' ? section['mac'] : [section['mac']];
        push(g_markd_macs, ...map(mac, (v) => lc(v)));
    }
});

ctx.foreach('dhcp', 'dnsmasq', (section) => {
    const cfg = section['.name'];
    const main_conf = `/tmp/etc/dnsmasq.conf.${cfg}`;
    const conf = `/tmp/dnsmasq.${cfg}.d/bypass.conf`;
    for (let i = 0; i < 10; i++) {
        if (fs.access(fs.dirname(conf))) break;
        sleep(2000);
    }
    const conf_old_sum = digest.md5_file(conf);
    let data = "";
    for (let mac in g_macs) {
        mac = lc(mac);
        if (mac in g_markd_macs) continue;
        data += `dhcp-host=${mac},set:${g_tag}\n`;
    }
    const conf_new_sum = digest.md5(data);
    if (conf_old_sum != conf_new_sum) {
        fs.writefile(conf, data);
        g_restart_dnsmasq = true;
    }
});

if (g_restart_dnsmasq) {
    system('service dnsmasq restart');
}
EOF

exit 0

如果你已经为特定设备分配了静态 DHCP 租约,则需要手动为设备添加标签 bypass_onlydns

将这个脚本加入到防火墙,当防火墙启动/重新加载时,脚本会自动运行:

uci set firewall.bypass_script=include
uci set firewall.bypass_script.type='script'
uci set firewall.bypass_script.path='/etc/nftables.d/99-bypass.sh'
uci commit
/etc/init.d/firewall reload

现在重新让设备获取 DHCP 并检查 DNS 是否是 180.76.76.76

然后在旁路由上将 180.76.76.76 的 53 端口拦截到本地,在旁路由执行下面的命令:

uci set firewall.intercept_dns.target='DNAT'
uci set firewall.intercept_dns.name='Intercept-BaiduDNS'
uci set firewall.intercept_dns.src='lan'
uci set firewall.intercept_dns.src_dport='53'
uci set firewall.intercept_dns.src_dip='180.76.76.76'
uci commit
/etc/init.d/firewall reload

现在可以在设备上使用 nslookup 命令查询 DNS 结果并检查是否和旁路由返回的 DNS 结果一致。

至此,所有工作都完成了,尽情享受吧!策略路由不仅可以搭配旁路由使用,还可以做到单线 IPTV 复用等,总之是个很有用的特性。如果遇到问题,可以加入 QQ 群或在下方评论区与我探讨。


评论由 giscus 驱动,基于 GitHub Discussions。