利用 nftables 修改向 Tracker 汇报的端口,任意 BT 软件全自动内网穿透

本文原发布于 bilibili
由于B站专栏限制编辑次数,显示功能孱弱,复制文本带尾巴等,在此重发并修正。

 前言
  ├─ DHT (Distributed Hash Table)
  ├─ PEX (Peer Exchange)
  ├─ STUN (Session Traversal Utilities for NAT)
  ├─ 传统 STUN 的局限性
  └─ nftables
 基本场景
  ├─ 1. NATMap 运行在 OpenWrt 主路由上
  ├─ 2. NATMap 运行在 OpenWrt 旁路由 / 二级路由上
  └─ 3. NATMap 运行在 WSL2 旁路由上
 系统要求
 安装 WSL2
  ├─ 启用 Hyper-V
  └─ 安装 WSL2 Linux 发行版
 配置 WSL2
  ├─ 配置桥接网络
  ├─ 配置 WSL2 开机自启动
  ├─ 修改 WSL2 Linux 发行版的软件源
  ├─ 配置 WSL2 Linux 发行版的自启动
  ├─ 重启 WSL2 Linux 发行版
  └─ 配置 WSL2 Linux 发行版为旁路由
 扩展阅读:关于旁路由
 准备工作
  ├─ OpenWrt 主路由 / 旁路由
  ├─ WSL2 旁路由
  └─ 配置代理服务器(可选)
 配置 STUN 脚本
  ├─ 脚本内容
  ├─ 初始化
  ├─ HTTP Tracker
  ├─ UDP Tracker
  ├─ DNAT
  └─ UPnP
 配置 NATMap
  ├─ OpenWrt 主路由 / 旁路由
  ├─ WSL2 旁路由
  └─ 配置选项
 其他
  ├─ 运行状况
  └─ 配置多个实例
3個讚

特别鸣谢 ie-12

本文在其多篇关于 STUN 的教程的基础上发展,并获得了良好的测试环境。

本文使用命令行进行配置,尽量对每个项目作出解释,因此内容可能略有繁琐。

理论上只需要复制粘贴即可完成配置,但也建议仔细阅读理解。

水平有限,如果谬误,还请不吝赐教。


前言

随着家庭宽带获取公网 IPv4 的门槛日益提高,尽管 IPv6 已经实现了很大程度的普及,但 IPv4 内网穿透的需求仍然很大。这里简单说一下目前 BT 下载所用到的穿透方式。

* 以下是关于 BT 下载与内网穿透的一些基本概念,可跳过 *


DHT (Distributed Hash Table)

BT 协议本身不借助任何外置工具,也能实现一定程度的内网穿透。BEP0005 DHT Protocol 中提及 “implied_port” 的可选参数。当 DHT 连接双方的 BT 应用均支持 implied_port 参数时,会把对方实际连接所用的端口(而不是对方主动提供的应用监听端口)作为节点信息发布。当其他用户通过 DHT 获得到该节点时,就会对 NAPT[1] 后的公网地址及端口发起连接。但这个方案仅限 UDP,因为 DHT 网络基于 UDP,通过 DHT 发布的端口也只能用在同样基于 UDP 的 uTP 协议。


PEX (Peer Exchange)

除了 DHT 的 implied_port 外,部分 BT 软件在进行节点交换时,也会把对端的节点信息中的端口替换成实际连接所用的公网端口,而不是对端汇报的监听端口。目前已确认比特彗星与 libtorrent(及 qBittorrent 等基于 libtorrent 的应用)支持这个特性。由于 TCP 握手会被用户防火墙校验,且应用发起 TCP 连接的源端口很可能不一致,因此该特性一般也针对 UDP 。libtorrent 的源码中显示,仅在使用 uTP 进行连接时才会替换 PEX 的端口信息。


STUN (Session Traversal Utilities for NAT)

由于一些场景下基于 UDP 的 uTP 协议传输效果不太理想,很多人希望对 TCP 进行内网穿透。因前面提及 TCP 握手和源端口不一致的问题,传统的 P2P 打洞方案对 TCP 连接的实现较为困难。对于 TCP 穿透,目前主要利用运营商的 NAPT 网关“只映射不过滤”[2]的特性。也就是说,运营商的 NAPT 网关只关心 IP 地址与端口的映射(一对多的锥型),而对于通过的流量,无论入站还是出站,除特殊情况外(比如 80 及 443 端口的入站)一律放行,这就是常说的 NAT1。基于此特性的内网穿透一般称为 “STUN 穿透”,比较流行的工具是 Lucky。


传统 STUN 的局限性

BT 下载的 STUN 穿透有个最重要的问题,就是如何把穿透后的公网端口信息发布出去。一般来说,BT 软件向 Tracker 汇报的是自身的监听端口,因此最直接的方法就是修改软件设置中的端口——本文将此称为(针对 BT 的)“传统 STUN 穿透”。对于一些有提供 API 的软件,Lucky 等工具可利用通知脚本来进行热修改;对于支持不友好的软件,就需要强制关进程,修改配置文件后重开,非常不优雅。

另外,在 STUN 穿透下,TCP 与 UDP 即便使用同一源端口号,穿透后的公网端口也会不同。而一般 BT 应用只支持监听及汇报一个端口号,不区分 TCP 和 UDP,因此传统 STUN 穿透方案下,TCP 与 UDP 只能二选一。

上面提到 UDP 端口可以通过 DHT 或 PEX 发布,但除了发布效率较低之外,还有另外的因素可能导致 UDP 端口损失连通性。传统 STUN 穿透下,监听端口(穿透端口)与外部端口(CGNAT[3] 端口)不同,且不定期变动,因此需要利用 UPnP (NAT-PMP / PCP)[4] 来控制网关的端口映射规则。而 BT 软件的 UPnP 只会请求内外一致的映射规则,因此使用传统 STUN 穿透时,为了避免冲突[5],一般建议关闭软件的 UPnP 请求,由 STUN 工具一律管理。然而这可能会导致 DHT / PEX 的穿透失效,因为很多路由器的防火墙默认策略是“IP 与端口过滤”,也就是常说的 NAT3,BT 应用通过 UPnP 请求规则后,才能接受传入的连接。


nftables

nftables 是 Linux 内核的新一代数据包过滤框架,取代旧版的 xtables[6],它可以对经过内核的数据包进行非常灵活且精细的操作。

本文在完成 STUN 穿透后,利用 nftables 在网关上对 BT 软件向 Tracker 汇报的数据包进行匹配与修改,以实现对 BT 软件透明的端口信息分发。与传统 STUN 穿透不同,此方案不需要修改 BT 软件的监听端口,与 BT 软件的 UPnP 请求不冲突,并且可以实现 STUN 穿透的 TCP 与 UDP,以及 DHT / PEX 穿透的 UDP,总计 3 个 IPv4 端口的公网访问。

支持匹配 HTTP 及 UDP Tracker 的 announce 请求中的端口信息,并修改成 TCP 或 UDP 的穿透端口。由于 nftables 不能改变数据包的长度,因此 BT 软件的监听端口要求是任意的 5 位数。

暂不支持 HTTPS Tracker,未来考虑使用 Privoxy 配置代理服务器来重写 HTTPS 请求中的查询参数(Query),可能需要在下载设备上安装证书。

WS / WSS Tracker 尚未涉及。


  1. 涉及端口的 NAT (Network Address Translation) 又叫 NAPT (Network Address Port Translation) 或 PAT (Port Address Translation),大多数时候不区分 ↩︎

  2. 映射(Mapping)分为锥型(Cone)和对称型(Symmetric);过滤(Filtering)分为不过滤,IP 过滤,IP 与端口过滤;两者的组合就是常说的 NAT1-4 ↩︎

  3. CGNAT (Carrier-grade NAT) 即运营商级 NAT,也就是俗称的“大内网” ↩︎

  4. 利用 UPnP 协议实现端口控制的协议叫 NAT-PMP (NAT Port Mapping Protocol) 或 PCP (Port Control Protocol),其中 PCP 是 NAT-PMP 的后继;本文提及的 UPnP 指的是 NAT-PMP / PCP ↩︎

  5. 多个外部端口可以被映射到同一个内部端口,因此映射规则不会冲突,但可能路由器的 UPnP 组件会引致意外的结果 ↩︎

  6. 理论上 iptables 也可以进行同样的操作,但不在本文讨论范围内 ↩︎

基本场景

本文考虑以下3种使用场景

  1. NATMap 运行在 OpenWrt 主路由上
    首选推荐,无需 UPnP
    除 OpenWrt 支持 nftables 外无其他要求
  2. NATMap 运行在 OpenWrt 旁路由 / 二级路由[1]
    要求主路由开启 UPnP
    若主路由的 UPnP 组件开启安全模式(要求请求地址与规则地址一致),需要在下载设备上搭建代理服务器,否则将对 BT 传入的流量额外进行一次 DNAT
  3. NATMap 运行在 WSL2 旁路由上
    除上述的 OpenWrt 旁路由的基本要求外,还需要满足 WSL2 的系统要求
    下载设备通常是 Windows 宿主本身,但也可以为局域网上的其他设备提供旁路由功能

下载端可以是任意能运行 BT 软件的设备,包括 主路由 / 旁路由 / WSL2 虚拟机 自身运行 BT 应用的情况,包括原生程序和 Docker 等容器环境运行的 BT 应用。只要求正确填写 STUN 脚本开头的项目。


  1. 关于旁路由与二级路由的区别将在后文提及 ↩︎

系统要求

本文在 OpenWrt 23.05 / WSL2 (Debian) 下进行测试

除系统内置的 nftables 及基本的 bash / ash 环境外,还需要安装以下组件

NATMap:本文使用的 STUN 工具,OpenWrt 可通过 opkg 安装,WSL 需要从 GitHub 下载二进制文件

xxd:用作字符的十六进制转换,可通过 opkgapt 安装

MiniUPnPc:UPnP 客户端,可通过 opkgapt 安装

proxychains:命令行代理工具,路由器 UPnP 开启安全模式时需要,opkg 下的包名为 proxychains-ngapt 下的包名为 proxychains4

dnsutils :DNS 组件,判断本机 IP 地址时需要,可通过 opkgapt 安装;OpenWrt 下已预装,使用 WSL2 时需要另外安装

nano:文本编辑器,用作编辑配置文件,可通过 opkgapt 安装;WSL2 下已预装,使用 OpenWrt 官方预编译固件时需要另外安装

若路由器的 UPnP 开启了安全模式,还将在下载设备上安装 3proxy 作为代理服务器使用


对于不持有 OpenWrt 网关的用户,本文将使用 WSL(Windows Subsystem for Linux,适用于 Linux 的 Windows 子系统)作为旁路由实现,需要开启 CPU 虚拟化功能。

要求使用 WSL2,启用桥接网络模式。

根据微软官方文档,启用桥接网络模式要求 Windows 11 22H2 或更高版本,但有报告称在 Windows 10 上也能启用。

本文将从 WSL2 的安装与配置出发,OpenWrt 用户可直接往下拉。

为了标准化流程,本文大部分操作在命令行下进行。


安装 WSL2

依次按下键盘的 Win + X 键A 键;或右键点击开始按钮,打开 终端管理员(A)

确认标题是否显示 管理员: Windows PowerShell

如无特别说明,本文在 Windows 中的操作默认通过管理员权限的 PowerShell 执行

Windows Terminal 内可使用左键选定,右键点击的方式进行复制粘贴


启用 Hyper-V

确认 Hyper-V 是否已启用

Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V

State 显示为 Enabled 即为已启用

若显示 Disabled,执行以下命令启用

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All

需要重启

启用 Hyper-V 不需要开启 CPU 虚拟化,但后续的 WSL2 安装需要

建议在重启时确认 UEFI (BIOS) 中的虚拟化设置


安装 WSL2 Linux 发行版
wsl --update
wsl -v

先升级 WSL 组件,可能需要一点时间

升级后运行 wsl -v 确认版本

可选使用 wsl --update --pre-release 更新预发布版

wsl -l -o
wsl --install -d Debian

在线获取可用的 WSL2 Linux 发行版列表后,选择 Debian 安装

本文基于 Debian,但只要支持 nftables,用其他发行版亦可(操作细节可能稍有不同)

可能需要重启

安装过程中会提示输入用户名和密码,不需要跟 Windows 的用户信息一致

若未开启 CPU 虚拟化,将在设置用户名和密码后提示错误

配置 WSL2

WSL2 安装完成后,输入 exit 返回,或重新打开 PowerShell 继续执行后面的操作


配置桥接网络
Get-NetAdapter
New-VMSwitch -SwitchName "VETH" -NetAdapterName "以太网" -AllowManagementOS $True -EnablePacketDirect $True -EnableIov $True

Get-NetAdapter
获取网络适配器信息,确认连接路由器所用的网卡名称
本文默认网卡名称为“以太网”,请按照实际情况修改

New-VMSwitch
把物理网卡创建为 2 个虚拟网卡,其中一个作为 Windows 使用,另一个将在后续分配给 WSL2
两个虚拟网卡均与路由器在同一链路上

AllowManagementOS
允许 Windows 与虚拟机(WSL2)共享一个物理网卡
这在单网卡的设备上是必须的,否则 Windows 将无可用的网络适配器

若你有多个可连接到路由器的网卡,则 AllowManagementOS 是可选,但仍然建议开启

单网卡下,Windows 的所有流量都需要经过虚拟网卡,出站流量需要经过 WSL2 旁路由的 IP 转发,会有一定的性能损耗
多网卡下,可以为物理网卡和虚拟网卡分别配置跃点数,使主物理网卡作为日常使用的首选,虚拟网卡作为旁路,提供 WSL2 的 STUN 专用
BT 软件的设置中绑定 WSL2 的 IP 地址,即可使其经由虚拟网卡,实现高效的分流
得益于微软近年来的开发与优化,单网卡虚拟化的日常使用也不会感到明显的性能损耗

EnablePacketDirectEnableIov 若受网卡支持,则可提供更好的性能
EnablePacketDirect 如因不支持而提示创建失败 (0x80070057),请删除该参数

cd ~
New-Item .wslconfig
notepad .\.wslconfig

cd 切换到用户目录,一般 PowerShell 启动时就位于用户目录,此命令可省略

New-Item 创建 WSL 的自定义配置文件 .wslconfig

notepad 使用记事本编辑配置文件 .wslconfig
由于 PowerShell 输出文本使用 UTF-16 LE 编码,且可能无法修改为 UTF-8 NoBOM
这将导致一些程序无法正确识别文本,因此使用记事本而不是在 PowerShell 内修改配置文件

[wsl2]
networkingMode=bridged
vmSwitch=VETH
ipv6=true

粘贴以上内容到 .wslconfig 并保存

networkingMode 指定网络类型为 bridged(桥接)

vmSwitch 指定网卡为上面创建的虚拟网卡,注意名称一致

ipv6 配置为可选,删除本行即禁用,也可改为 false


配置 WSL2 开机自启动
New-Item WSL_AutoStart.vbs
notepad .\WSL_AutoStart.vbs

New-Item 继续在用户目录创建 VBS 脚本 WSL_AutoStart.vbs

notepad 使用记事本编辑 VBS 脚本 WSL_AutoStart.vbs

createobject("wscript.shell").run "wsl",0

将此行粘贴到 WSL_AutoStart.vbs 中并保存,用作后台静默运行 wsl 命令

$action = New-ScheduledTaskAction -Execute "$env:USERPROFILE\WSL_AutoStart.vbs"
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask WSL_AutoStart -InputObject $task

逐条执行以上命令,注意 $ 号不能缺少

此操作将创建一个名为 “WSL_AutoStart” 的任务计划,在系统启动时触发,运行上面创建的 VBS 静默运行脚本

taskschd

打开任务计划程序,从左侧导航栏选择“任务计划程序库”
找到并双击刚才创建的 “WSL_AutoStart”(通常在列表最底下)
在下方的“安全选项”中,选择“不管用户是否登录都要运行(W)”并确定
会弹出窗口要求输入 Windows 用户密码(非 WSL2 中的用户密码)
* 该操作无法在 PowerShell 命令行下完成 *


PowerShell 下的操作到此暂告一段落,后续的命令将在 WSL2 内执行

wsl

在 PowerShell 内执行 wsl,或点击终端标签页右侧的下箭头,打开 Debian

sudo -i

sudo 登录 root 用户,要求输入当前 WSL2 用户的密码
密码输入时不会有回显反馈,打错的话直接按 Enter 键重试

本文在 WSL2 中的命令默认以 root 权限执行


修改 WSL2 Linux 发行版的软件源

Debian 的官方软件源在国外,网络体验极差,建议更改为国内软件源
这里采用的是 清华大学开源软件镜像站

cp /etc/apt/sources.list /etc/apt/sources.list.bak
sed -i 's_http:\/\/.*debian\.org_https://mirrors.tuna.tsinghua.edu.cn_' /etc/apt/sources.list
apt update
apt upgrade

cp 备份 /etc/apt/sources.list/etc/apt/sources.list.bak
sed/etc/apt/sources.list 中的默认服务器地址改为清华镜像
apt update 更新软件包列表
apt upgrade 升级已安装的软件包,升级过程中会确认,输入 Y (不分大小写)


若软件包列表更新失败,可能是 Debian 没有预装 HTTPS 支持
需要从官方源安装 apt-transport-httpsca-certificates

mv /etc/apt/sources.list.bak /etc/apt/sources.list
apt update
apt install apt-transport-https ca-certificates

mv 移动 /etc/apt/sources.list.bak/etc/apt/sources.list
apt update 更新软件包列表
apt install 安装 apt-transport-httpsca-certificates

软件包列表更新时可能会在中途卡着,这时候可以按下 Ctrl + C 键中止更新,直接执行后面的安装命令(可能需要尝试多次)

成功后再重新尝试开始的操作


配置 WSL2 Linux 发行版的自启动

WSL2 的开机自启动与其他 Linux 发行版稍有差别,需要利用自启动脚本 /sbin/mount.rc

echo 'none none rc defaults 0 0' | tee -a /etc/fstab
touch /sbin/mount.rc
chmod +x /sbin/mount.rc

echotee 输出配置到 /etc/fstab
touch 创建自启动脚本 /sbin/mount.rc
chomd 赋予自启动脚本 /sbin/mount.rc 执行权限

nano /sbin/mount.rc

nano 编辑自启动脚本 /sbin/mount.rc

粘贴以下内容,启动时将修改内核参数

#!/bin/bash

sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1

#!/bin/bash 指定脚本的解释器,不可缺少

sysctl 修改内核参数
net.ipv4.ip_forward=1 表示开启 IPv4 转发,这样 Linux 才能作为网关使用
net.ipv6.conf.all.forwarding=1 表示开启 IPv6 转发;本文不涉及也不影响原有的 IPv6 环境,此参数是考虑到用户以后可能出现的 IPv6 需求

nano 下,按下 Ctrl + O 键保存,Enter 键确认,Ctrl + X 键退出编辑


重启 WSL2 Linux 发行版

WSL2 的初期配置就此完成,重启确认配置

输入 exit 返回,或重新打开 PowerShell

wsl --shutdown

关闭 WSL2 Linux 发行版,等待 8 秒

配置更改的 8 秒规则
必须等到运行你的 Linux 发行版的子系统完全停止运行并重启,配置设置更新才会显示。 这通常需要关闭发行版 shell 的所有实例后大约 8 秒。

wsl

确认 WSL2 是否正常启动
如果系统不支持 WSL2 的桥接网络模式,或虚拟网卡配置不正确,将会提示错误

ip a
sysctl net.ipv4.ip_forward
sysctl net.ipv6.conf.all.forwarding

ip a
查看网络接口的 IP 地址,这个地址将在配置旁路由时用到
此时 eth0 接口的 IPv4 地址应该与路由器及下载设备同网段(通常是 192.168.x.x)

sysctl
查看 IPv4 与 IPv6 转发的内核参数,自启动脚本正常执行时,应显示
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1

桥接网络模式下,WSL2 的 IP 地址一般不会发生变化
如需固定 WSL2 的 IP 地址,建议在主路由上绑定,一般 Linux 发行版的配置方法很可能不适用于 WSL2


配置 WSL2 Linux 发行版为旁路由

在确认 WSL2 的网络正常工作后,即可把 Windows 或局域网其他设备的网关地址改为 WSL2 的局域网地址,作为旁路由使用

以下命令在 PowerShell 中以管理员权限执行


为防止 IP 意外变动,请先给 Windows 的虚拟网卡指定静态 IP 地址

  • 注意,这里配置的是 Windows 上的静态 IP 地址,并非 WSL2 的
Get-NetAdapter

Get-NetAdapter 获取网卡列表,确认刚才创建的虚拟网卡,名称应该是 “vEthernet (VETH)”

$GWIP="192.168.5.1"
$PCIP="192.168.5.241"
Remove-NetIPAddress -InterfaceAlias "vEthernet (VETH)" -IPAddress $PCIP -PrefixLength 24 -DefaultGateway $GWIP -Confirm:$false
New-NetIPAddress -InterfaceAlias "vEthernet (VETH)" -IPAddress $PCIP -PrefixLength 24 -DefaultGateway $GWIP
Set-DnsClientServerAddress -InterfaceAlias "vEthernet (VETH)" -ServerAddresses ($GWIP,"119.29.29.29")
  • $GWIP 改为你的主路由的 IP 地址
  • $PCIP 改为你要配置的静态 IP 地址,要求与主路由及 WSL2 处于同一网段
  • IP 地址的引号不能省略
  • 如果你想配置的静态 IP 地址 $PCIP 与当前地址一致时,请先运行 Remove-NetIPAddress 删除后再 New-NetIPAddress 创建

Remove-NetIPAddress 当前地址与静态地址一致时,删除 Windows 虚拟网卡的当前地址

New-NetIPAddress 给 Windows 虚拟网卡指定静态地址,此处把主路由设置为默认网关

Set-DnsClientServerAddress
指定静态地址会关闭 DHCP 服务,还需要设置 DNS 服务器
此处把首选 DNS 设为主路由,备用 DNS 设为 DNSPod (119.29.29.29)


对于单网卡与多网卡用户,后面的操作会有不同


单网卡用户执行以下命令

$WSLIP="192.168.5.31"
route ADD -p 0.0.0.0 MASK 0.0.0.0 $WSLIP METRIC 1
  • $WSLIP 改为 WSL2 的 IP 地址

route ADD 添加一条网关为 WSL2 的默认路由


多网卡用户可设置跃点数,指定日常使用的网卡

$WSLIP="192.168.5.31"
Get-NetAdapter
Set-NetIPInterface -InterfaceAlias "vEthernet (VETH)" -InterfaceMetric 1000
route ADD -p 0.0.0.0 MASK 0.0.0.0 $WSLIP METRIC 1 IF <接口索引>
  • $WSLIP 改为 WSL2 的 IP 地址

Get-NetAdapter
获取网卡列表,确认虚拟网卡 “vEthernet (VETH)” 的接口索引(ifIndex),添加默认路由时需要指定

Set-NetIPInterface
设置虚拟网卡跃点数为 1000,只有绑定该接口的程序才会使用该网卡,不影响主网卡的日常使用

route ADD 添加一条网关为 WSL2 的默认路由,多网卡下需要指定接口索引


WSL2 网关的跃点数为 1,会优先于上一步配置的主路由网关

这样配置的目的是,当 WSL2 发生故障时,会转移到主路由作为默认网关,不会丢失网络

此方法同样适用于 WSL2 以外的旁路由场景


WSL2 现在已经作为旁路由用作 Windows 的 IPv4 默认网关
但由于 WSL2 上除了开启转发之外什么也没配置,所以看上去并没有任何区别

扩展阅读:关于旁路由

* 以下内容仅代表个人观点,可跳过 *


“旁路由”是近年来兴起的一个概念,但它并不是一个严谨的术语,相关词汇还有诸如单臂路由、旁路网关、透明代理等。它的目的是不改变或无法改变上级网络拓扑的前提下,实现一些主路由无法提供的功能。本文示范的修改 BT 汇报端口便是其中一例。

“旁路由”应该是为了与二级路由区分而诞生的。但这里又涉及另一个问题,通常在家庭网络中,路由器的路由表其实只有一条默认路由,一些家用路由器甚至无法配置路由表;在家庭网络中,它更主要的是作为 NAT 网关,让多台设备共享网络。当然,现代家庭路由器还有个很重要的功能,就是提供无线网络。但无论如何,路由似乎都不是主目的。

很多人简单地认为,与主路由同网段的就是旁路由,不同网段的就是二级路由。当然,词汇本身没有严谨的定义,也就没有对错之别,但在以 NAT 为主的语境下,旁路由与二级路由的区别应该是有无 NAT。本文的用语中,不考虑网关与用户设备及路由器是否处于同链路或同网段上,把不进行 NAT 的网关称为“旁路由”,对于进行 NAT 的网关,视作“二级路由”。实际上,无论旁路由还是二级路由都可以作为主路由的功能拓展。之所以区分它们,更多的是出于拓扑与效率上的考虑。

家庭 NAT 网关基于 IP 动态伪装(Masquerading,无需指定地址的 SNAT)为多台设备共享一个可访问互联网的 IP 地址,以应对网络地址数量不足的问题。NAT 网关在传出时需要对源地址,在传入时需要对目标地址进行修改(分别是 SNAT 与 DNAT),修改数据包会受设备处理能力的限制。而数据包经过网关需要转发,也受转发能力的限制。虽然现代设备的性能非常强悍,大多数时候不会让用户有感,但总的来说,减少 NAT 或转发,对网络效率都是有利无弊的。


连接经过有 NAT 的二级路由时

用户 → 二级路由 NAT → 二级路由转发 → 一级路由 NAT → 一级路由转发 → 运营商

用户 ← 一级路由转发 ← 二级路由 NAT ← 一级路由转发 ← 一级路由 NAT ← 运营商


连接经过无 NAT 的旁路由时

用户 → 旁路由转发 → 主路由 NAT → 主路由转发 → 运营商

用户 ← 主路由转发 ← 主路由 NAT ← 运营商


可以看出,比起二级路由,旁路由在传出时少一次 NAT,在传入时少一次 NAT 与转发[1]。在正确配置的旁路由拓扑里,传入流量不需要经过旁路由,由主路由直接转发到用户设备。


Netfilter 中,网关转发 IP 数据包完整流程是

传入(Ingress) → 路由前拦截(Prerouting Hook) → 目标地址转换(DNAT) → 路由选择(Routing Decsion) → IP 转发时拦截(Forward Hook) → 路由后拦截(Postrouing Hook) → 源地址转换(SNAT) → 传出(Egress)

附图

只要数据包进入路由器(IP 网关),即使不进行 NAT,也要走完以上流程。这就是传入时减少一层网关的意义。


连接经过旁路由的透明代理转发时

用户 → 旁路由拦截 / 封装 / 发出 → 主路由 NAT → 一级路由转发 → 运营商

用户 ← 旁路由接收 / 解封 / 发回 ← 一级路由转发 ← 主路由 NAT ← 运营商


对于透明代理之类的旁路由应用场景,使用的是应用层转发。透明代理拦截用户流量后,会重新封装成隧道流量改道发出。这过程在网络层与传输层之上,最终会以旁路由自身的 IP 作为源地址发出,因此也不需要开启 IP 动态伪装进行 SNAT。

正确配置的透明代理应该只对特定流量进行拦截,其他流量原封不动地发出,这样在回程时就不需要经过旁路由及透明代理。

错误配置的旁路由还会破坏端口映射。当远程主机访问从主路由映射到 PC 的 1111 端口后,若响应流量被旁路由拦截并进行 SNAT,此时主路由的连接跟踪表记录了 1111 端口为 PC 所占用,对于旁路由 SNAT 转发的流量会分配 1112 作为源端口。远程主机接受到源端口 1112 的流量时,由于与先前的目标主机不一致,会作为未知流量而丢弃,连接将无法正常建立。


  1. “旁路由转发”与“主路由 NAT”等表述可以理解成“旁路由 / 转发”与“主路由 / NAT”。也可断句为“旁 / 路由 / 转发”与“主 / 路由 / NAT” ↩︎

OpenWrt 用户请从此处开始

对于 OpenWrt 的配置,本文将在 SSH 命令行下进行
在 LuCI WEB 管理页内可完成大部分等效的操作,但在此不作赘述


准备工作

不同场景下所需安装的软件包稍有不同

Windows Terminal 下可使用鼠标右键复制粘贴

包括 Windows Terminal 在内的终端工具的通用快捷键是

Ctrl + Insert 复制

Shift + Insert 粘贴


OpenWrt 主路由 / 旁路由
sed -i 's_downloads.openwrt.org_mirrors.tuna.tsinghua.edu.cn/openwrt_' /etc/opkg/distfeeds.conf

sed 替换软件源,同样使用 清华大学开源软件镜像站
若使用第三方软件源,此命令不会生效也不会冲突

opkg update
opkg install luci-app-natmap xxd miniupnpc proxychains-ng nano

opkg update 更新软件包列表

opkg install 安装所需软件包
luci-app-natmap 为本文所用的 STUN 工具,本包将自动安装 CLI 程序和 LuCI 管理页
其他请参照上述的「系统要求」部分

OpenWrt 主路由下,无需安装 miniupnpcproxychains-ng


WSL2 旁路由
apt update
apt install wget xxd miniupnpc proxychains4 dnsutils

apt update 更新软件包列表

apt install 安装所需软件包
软件包说明请参照上述的「系统要求」部分

WSL2 旁路由下,NATMap 无法通过包管理器安装,需要手动下载

curl -Lo /usr/bin/natmap https://github.com/heiher/natmap/releases/download/20240303/natmap-linux-x86_64
chmod +x /usr/bin/natmap

curl 从 GitHub 下载二进制文件,保存到 /usr/bin/natmap

chmod 赋予 NATMap 执行权限

可在以下地址查看 NATMap 的最新版本

Releases · heiher/natmap · GitHub

本文使用 NATMap 作为 STUN 穿透方案,但只要支持通知脚本的,用其他工具也能实现
需要注意,更换穿透工具后可能需要修改后续 STUN 脚本的参数


配置代理服务器(可选)

为了最大发挥旁路由的优势,使传入的流量直接从主路由转发到下载设备,减少一次 DNAT 与转发,需要让旁路由(假设 192.168.5.31)通过 UPnP 向主路由请求一条目标地址为下载设备(假设192.168.5.241)的映射规则

若主路由的 UPnP 组件开启了安全模式,会限制客户端只可请求与自身 IP 一致的映射规则

为此,需要在下载设备上配置代理服务器提供给旁路由请求 UPnP 时使用

本文展示在 Windows 下配置 3proxy 的过程


以下命令在 PowerShell 中执行

$URL = 'https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4-x64.zip'
$TMP = New-TemporaryFile
Invoke-WebRequest -OutFile "$TMP.zip" $URL
"$TMP.zip" | Expand-Archive -DestinationPath $env:USERPROFILE\3proxy
"$TMP.zip" | Remove-Item

$URL 指定 3proxy 的下载地址

$TMP 新建一个临时文件,用作保存 3proxy 的保存路径

Invoke-WebRequest 下载 3proxy 压缩包到用户文件夹
由于网络原因,下载过程可能较慢甚至失败,需要多次尝试

"$TMP.zip" | Expand-Archive 解压 3proxy 到用户文件夹的子目录

"$TMP.zip" | Remove-Item 删除下载的 3poxy 压缩包

可在以下地址查看 3proxy 的最新版本

Releases · 3proxy/3proxy · GitHub

New-Item .\3proxy\bin64\3proxy.cfg
notepad .\3proxy\bin64\3proxy.cfg

New-Item 在 3proxy.exe 所在目录新建配置文件 3proxy.cfg

notepad 使用记事本编辑配置文件
由于 PowerShell 输出文本使用 UTF-16 LE 编码,会导致 3proxy 无法识别,因此使用记事本而不是命令行进行编辑

proxy

输入 proxy 并保存,3proxy 只需要一个单词就可以配置代理服务器

.\3proxy\bin64\3proxy.exe --install .\3proxy\bin64\3proxy.cfg

继续在 PowerShell 内执行以上命令,把 3proxy 作为服务在后台启动
会弹出确认窗口,运行后可能会报错,但不代表失败

netstat -an | findstr 3128

netstat 查看并 findstr 寻找 3128 的监听端口

TCP 0.0.0.0:3128 0.0.0.0:0 LISTENING

显示以上消息代表安装并运行成功

net stop 3proxy
net start 3proxy

使用以上命令停止或启动 3proxy

配置 STUN 脚本

STUN 脚本为 OpenWrt 与 WSL2 Debian 通用
脚本内容不多,但会用较大篇幅进行说明


脚本内容
touch /usr/stun-bt.sh
chmod +x /usr/stun-bt.sh
nano /usr/stun-bt.sh

touch 创建 STUN 脚本 /usr/stun-bt.sh

chmod 赋予执行权限

nano 编辑 STUN 脚本 /usr/stun-bt.sh
粘贴以下脚本内容

  • OpenWrt 下不支持中文显示,可以把注释内容删除后保存
  • 由于 Windows 与 Unix 的换行符不同,使用系统记事本编辑会导致错误
  • 如要使用 Windows 编辑,请选择支持 Unix 格式的文本编辑器
# 以下变量需按要求填写
IFNAME=					# 指定接口,可留空;仅在多 WAN 时需要;拨号接口的格式为 "pppoe-wancm"
GWLADDR=192.168.1.1		# 主路由 LAN 的 IPv4 地址
APPADDR=192.168.1.168	# 下载设备的 IPv4 地址,允许主路由或旁路由本身运行 BT 应用
APPPORT=12345			# BT 应用的监听端口,HTTP 改包要求 5 位数端口

WANADDR=$1
WANPORT=$2
LANPORT=$4
L4PROTO=$5
OWNADDR=$6
STUNIFO=/tmp/stun-bt.info
OLDPORT=$(grep $L4PROTO $STUNIFO 2>/dev/null | awk -F ':| ' '{print$3}')
RELEASE=$(grep ^ID= /etc/os-release | awk -F '=' '{print$2}' | tr -d \")

# 判断 TCP 或 UDP 的穿透是否启用
# 清理穿透信息中没有运行的协议
touch $STUNIFO
case $RELEASE in
	openwrt)
		for SECTION in $(uci show natmap | grep $0 | awk -F . '{print$2}'); do
			if [ "$(uci -q get natmap.$SECTION.enable)" = 1 ]; then
				case $(uci get natmap.$SECTION.udp_mode) in
					0) SECTTCP=$SECTION ;;
					1) SECTUDP=$SECTION ;;
				esac
			fi
		done
		[ $(uci -q get natmap.$SECTTCP) ] || ( \
		DISPORT="$(grep tcp $STUNIFO | awk -F ':| ' '{print$3}') tcp"; sed -i '/'tcp'/d' $STUNIFO )
		[ $(uci -q get natmap.$SECTUDP) ] || ( \
		DISPORT="$(grep udp $STUNIFO | awk -F ':| ' '{print$3}') udp"; sed -i '/'udp'/d' $STUNIFO )
		;;
	*)
		ps aux | grep $0 | grep "\-h" || ( \
		DISPORT="$(grep tcp $STUNIFO | awk -F ':| ' '{print$3}') tcp"; sed -i '/'tcp'/d' $STUNIFO )
		ps aux | grep $0 | grep "\-u" || ( \
		DISPORT="$(grep udp $STUNIFO | awk -F ':| ' '{print$3}') udp"; sed -i '/'udp'/d' $STUNIFO )
		;;
esac

# 更新保存穿透信息
sed -i '/'$L4PROTO'/d' $STUNIFO
echo $L4PROTO $WANADDR:$WANPORT '->' $OWNADDR:$LANPORT '->' $APPADDR:$APPPORT $(date +%s) >>$STUNIFO

# 防止脚本同时操作 nftables 导致冲突
[ $L4PROTO = udp ] && sleep 1 && \
[ $(($(date +%s) - $(grep tcp $STUNIFO | awk '{print$NF}'))) -lt 2 ] && sleep 2

# 初始化 nftables
nft add table ip STUN
nft delete chain ip STUN BTTR 2>/dev/null
nft create chain ip STUN BTTR { type filter hook postrouting priority filter \; }
WANTCP=$(grep tcp $STUNIFO | awk -F ':| ' '{print$3}')
WANUDP=$(grep udp $STUNIFO | awk -F ':| ' '{print$3}')
if [ -n "$IFNAME" ]; then
	IIFNAME="iifname $IFNAME"
	OIFNAME="oifname $IFNAME"
fi

# HTTP Tracker
STRTCP=$(printf 30$(printf "$WANTCP" | xxd -p) | tail -c 10)
STRUDP=$(printf 30$(printf "$WANUDP" | xxd -p) | tail -c 10)
if [ -n "$WANTCP" ] && [ -n "$WANUDP" ]; then
	SETSTR="numgen inc mod 2 map { 0 : 0x3d$STRTCP, 1 : 0x3d$STRUDP }"
elif [ -n "$WANTCP" ]; then
	SETSTR=0x3d$STRTCP
elif [ -n "$WANUDP" ]; then
	SETSTR=0x3d$STRUDP
fi
nft add set ip STUN BTTR_HTTP "{ type ipv4_addr . inet_service; flags dynamic; timeout 1h; }"
nft add chain ip STUN BTTR_HTTP
nft flush chain ip STUN BTTR_HTTP
nft insert rule ip STUN BTTR $OIFNAME ip saddr $APPADDR ip daddr . tcp dport @BTTR_HTTP counter goto BTTR_HTTP
nft add rule ip STUN BTTR $OIFNAME ip saddr $APPADDR meta l4proto tcp @ih,0,112 0x474554202f616e6e6f756e63653f add @BTTR_HTTP { ip daddr . tcp dport } counter goto BTTR_HTTP
for OFFSET in $(seq 768 16 1040); do
	nft add rule ip STUN BTTR_HTTP @ih,$OFFSET,40 0x706f72743d @ih,$(($OFFSET+32)),48 set $SETSTR update @BTTR_HTTP { ip daddr . tcp dport } counter accept
done

# UDP Tracker
if [ -n "$WANTCP" ] && [ -n "$WANUDP" ]; then
	SETNUM="numgen inc mod 2 map { 0 : $WANTCP, 1 : $WANUDP }"
elif [ -n "$WANTCP" ]; then
	SETNUM=$WANTCP
elif [ -n "$WANUDP" ]; then
	SETNUM=$WANUDP
fi
nft add set ip STUN BTTR_UDP "{ type ipv4_addr . inet_service; flags dynamic; timeout 1h; }"
nft add chain ip STUN BTTR_UDP
nft flush chain ip STUN BTTR_UDP
nft insert rule ip STUN BTTR $OIFNAME ip saddr $APPADDR ip daddr . udp dport @BTTR_UDP counter goto BTTR_UDP
nft add rule ip STUN BTTR $OIFNAME ip saddr $APPADDR meta l4proto udp @ih,0,64 0x41727101980 @ih,64,32 0 add @BTTR_UDP { ip daddr . udp dport } counter goto BTTR_UDP
nft add rule ip STUN BTTR_UDP @ih,64,32 1 @ih,768,16 $APPPORT @ih,768,16 set $SETNUM update @BTTR_UDP { ip daddr . udp dport } counter

# 判断脚本运行的环境,选择 DNAT 方式
# 先排除需要 UPnP 的情况
DNAT=0
for LANADDR in $(ip -4 a show dev br-lan | grep inet | awk '{print$2}' | awk -F '/' '{print$1}'); do
	[ "$LANADDR" = $GWLADDR ] && DNAT=1
done
for LANADDR in $(nslookup -type=A $HOSTNAME | grep Address | grep -v :53 | awk '{print$2}'); do
	[ "$LANADDR" = $GWLADDR ] && DNAT=1
done
[ $APPADDR = $GWLADDR ] && DNAT=2

# 若未排除,则尝试直连 UPnP
if [ $DNAT = 0 ]; then
	[ -n "$OLDPORT" ] && upnpc -i -d $OLDPORT $L4PROTO
	[ -n "$DISPORT" ] && upnpc -i -d $DISPORT
	upnpc -i -e "STUN BT $L4PROTO $WANPORT->$LANPORT->$APPPORT" -a $APPADDR $APPPORT $LANPORT $L4PROTO | \
	grep $APPADDR | grep $APPPORT | grep $LANPORT | grep -v failed
	[ $? = 0 ] && DNAT=3
fi

# 直连失败,则尝试代理 UPnP
if [ $DNAT = 0 ]; then
	PROXYCONF=/tmp/proxychains.conf
	echo [ProxyList] >$PROXYCONF
	echo http $APPADDR 3128 >>$PROXYCONF
	[ -n "$OLDPORT" ] && proxychains -f $PROXYCONF upnpc -i -d $OLDPORT $L4PROTO
	[ -n "$DISPORT" ] && proxychains -f $PROXYCONF upnpc -i -d $DISPORT
	proxychains -f $PROXYCONF \
	upnpc -i -e "STUN BT $L4PROTO $WANPORT->$LANPORT->$APPPORT" -a $APPADDR $APPPORT $LANPORT $L4PROTO | \
	grep $APPADDR | grep $APPPORT | grep $LANPORT | grep -v failed
	[ $? = 0 ] && DNAT=3
fi

# 代理失败,则启用本机 UPnP
[ $DNAT = 0 ] && (upnpc -i -e "STUN BT $L4PROTO $WANPORT->$LANPORT" -a @ $LANPORT $LANPORT $L4PROTO; DNAT=4)

# 初始化 DNAT 链
if [ $DNAT != 3 ]; then
	[ -z "$WANTCP" ] && nft delete chain ip STUN BTDNAT_tcp 2>/dev/null
	[ -z "$WANUDP" ] && nft delete chain ip STUN BTDNAT_udp 2>/dev/null
	nft delete chain ip STUN BTDNAT_$L4PROTO 2>/dev/null
	nft create chain ip STUN BTDNAT_$L4PROTO { type nat hook prerouting priority dstnat \; }
fi

# BT 应用运行在路由器下,使用 dnat
if [ $DNAT = 1 ] || [ $DNAT = 4 ]; then
	nft add rule ip STUN BTDNAT_$L4PROTO $IIFNAME $L4PROTO dport $LANPORT counter dnat ip to $APPADDR:$APPPORT
	[ "$RELEASE" = "openwrt" ] && \
	if ! nft list chain inet fw4 forward | grep 'ct status dnat' >/dev/null; then
		HANDLE=$(nft -a list chain inet fw4 forward | grep jump | awk 'NR==1{print$NF}')
		nft insert rule inet fw4 forward handle $HANDLE ct status dnat counter accept
	fi
fi

# BT 应用运行在路由器上,使用 redirect
if [ $DNAT = 2 ]; then
	nft add rule ip STUN BTDNAT_$L4PROTO $IIFNAME $L4PROTO dport $LANPORT counter redirect to :$APPPORT
	[ "$RELEASE" = "openwrt" ] && \
	if ! nft list chain inet fw4 input | grep 'ct status dnat' >/dev/null; then
		HANDLE=$(nft -a list chain inet fw4 input | grep jump | grep -v "tcp flags" | awk 'NR==1{print$NF}')
		nft insert rule inet fw4 input handle $HANDLE ct status dnat counter accept
	fi
fi

nano 下,按 Ctrl + O 键保存,Enter 键确认,Ctrl + X 键退出编辑

chmod +x /usr/stun-bt.sh

保存脚本后,运行 chmod 赋予执行权限
我在测试的时候就经常忘了这一步


以下是脚本内容的详细说明


初始化
IFNAME=
GWLADDR=192.168.1.1
APPADDR=192.168.1.168
APPPORT=12345

需要填写的基本变量

IFNAME 指定接口,可留空;仅在多 WAN 时需要;拨号接口的格式为 "pppoe-wancm"[1]

GWLADDR 代表主路由 LAN IPv4 地址

APPADDR 代表下载设备的 IPv4 地址,允许主路由或旁路由本身运行 BT 应用

APPPORT 代表下载设备上运行的 BT 应用的监听端口,要求 5 位数端口(10000 - 65535

以上 4 个变量需要用户按照实际情况修改

WANADDR=$1
WANPORT=$2
LANPORT=$4
L4PROTO=$5
OWNADDR=$6
STUNIFO=/tmp/stun-bt.info
OLDPORT=$(grep $L4PROTO $STUNIFO 2>/dev/null | awk -F ':| ' '{print$3}')
RELEASE=$(grep ^ID= /etc/os-release | awk -F '=' '{print$2}' | tr -d \")

无需填写的基本变量

WANADDR 为穿透后的公网地址

WANPORT 为穿透后的公网端口

LANPORT 为穿透前的内网端口

L4PROTO 为穿透的传输层协议,TCP 或 UDP

OWNADDR 为穿透时的内网源地址

以上 5 个变量由 STUN 工具传递参数获得,使用 NATMap 以外的方案时,请注意参数顺序

STUNIFO 指定穿透信息保存的位置,默认为 /tmp/stun-bt.info[2]

OLDPORT/tmp/stun-bt.info 中读取上次穿透时的内网端口,仅用作清理 UPnP 旧规则

RELEASE 识别当前 Linux 发行版的 ID


touch $STUNIFO
case $RELEASE in
	openwrt)
		for SECTION in $(uci show natmap | grep $0 | awk -F . '{print$2}'); do
			if [ "$(uci -q get natmap.$SECTION.enable)" = 1 ]; then
				case $(uci get natmap.$SECTION.udp_mode) in
					0) SECTTCP=$SECTION ;;
					1) SECTUDP=$SECTION ;;
				esac
			fi
		done
		[ $(uci -q get natmap.$SECTTCP) ] || ( \
		DISPORT="$(grep tcp $STUNIFO | awk -F ':| ' '{print$3}') tcp"; sed -i '/'tcp'/d' $STUNIFO )
		[ $(uci -q get natmap.$SECTUDP) ] || ( \
		DISPORT="$(grep udp $STUNIFO | awk -F ':| ' '{print$3}') udp"; sed -i '/'udp'/d' $STUNIFO )
		;;
	*)
		ps aux | grep $0 | grep "\-h" || ( \
		DISPORT="$(grep tcp $STUNIFO | awk -F ':| ' '{print$3}') tcp"; sed -i '/'tcp'/d' $STUNIFO )
		ps aux | grep $0 | grep "\-u" || ( \
		DISPORT="$(grep udp $STUNIFO | awk -F ':| ' '{print$3}') udp"; sed -i '/'udp'/d' $STUNIFO )
		;;
esac

判断 TCP 或 UDP 的穿透是否启用,清理穿透信息中没有运行的协议端口

当前发行版 ID 为 openwrt 时,通过 uci 命令查看 NATMap 的配置文件
其他 Linux 发行版下,一律通过 ps aux​ 查看调用本脚本的进程来判断
若需要其他判断方式,请自行添加 case

OpenWrt 下,先 for 循环列出带有本通知脚本的配置段落(section)序号
若该序号的配置段落已启用,则通过 udp_mode 的选项来判断是 TCP 或 UDP,并分别把段落序号赋值到变量中,最后判断段落是否存在,记录废弃端口并清理穿透信息

以上判断式允许 OpenWrt 的 NATMap 配置使用本脚本的 TCP 及 UDP 穿透条目各一个,否则将以最后触发的一个为准
若需要为多个 BT 应用进行配置,请查看本文最后

其他 Linux 发行版下,通过 NATMap 的运行参数进行判断
-h 表示 TCP,-u 表示 UDP,分别判断并清理
注意 NATMap 允许在 UDP 模式下启用 -h 参数,虽无意义但可以执行
自行配置 NATMap 时,请考虑与脚本的耦合性

无论 OpenWrt 还是 WSL2,都以当前脚本路径作为首个判断依据
若需要同时为多个 BT 应用进行配置,请区分 STUN 脚本的保存路径,具体看文末


sed -i '/'$L4PROTO'/d' $STUNIFO
echo $L4PROTO $WANADDR:$WANPORT '->' $OWNADDR:$LANPORT '->' $APPADDR:$APPPORT $(date +%s) >>$STUNIFO

更新保存穿透信息

sed 替换上次的穿透信息,与当前协议对应

echo 保存本次的穿透信息


[ $L4PROTO = udp ] && sleep 1 && \
[ $(($(date +%s) - $(grep tcp $STUNIFO | awk '{print$NF}'))) -lt 2 ] && sleep 2

防止脚本同时操作 nftables 导致冲突
若当前脚本对应的协议为 UDP,则等待 1 秒后,若当前时间与 TCP 信息中的时间戳相差小于 2 秒,则继续等待 2
也就是说,TCP 脚本不延后,而 UDP 脚本则始终延后 1-3 秒
若你发现 nftables 规则异常,可把等待时间调大


nft add table ip STUN
nft delete chain ip STUN BTTR 2>/dev/null
nft create chain ip STUN BTTR { type filter hook postrouting priority filter \; }
WANTCP=$(grep tcp $STUNIFO | awk -F ':| ' '{print$3}')
WANUDP=$(grep udp $STUNIFO | awk -F ':| ' '{print$3}')
if [ -n "$IFNAME" ]; then
	IIFNAME="iifname $IFNAME"
	OIFNAME="oifname $IFNAME"
fi

初始化 nftables

nft add tableIPv4 族中添加名为 STUN 的规则表,重复添加不作改变

如未来有其他利用到 nftables 的 STUN 场景,可能会共用同一个表

nft delete chain 在创建规则链前,先删除原有链

nft create chainIPv4 族 / STUN 规则表下创建 BTTR 链
nftables 采用 族 / 表 / 链 的三层结构,具体规则收纳在规则链下
type filter 表示对所有包进行匹配(相对于 type nat 只对每个连接的首包匹配)
hook postrouting 表示在路由选择后拦截数据包
priority filter 表示在同一拦截点下,该规则链的优先级
该链由 HTTP 与 UDP,以及未来可能支持的其他 Tracker 公用
通过网关的流量将在此进行匹配,如识别为支持的 Tracker 汇报包,则引导其前往(goto)修改端口信息的专用规则链中

从穿透信息中分别加载 TCP 与 UDP 的公网端口,赋值到 WANTCPWANUDP
Tracker 规则中,需要对端口的表达进行加工

if 判断是否指定了接口,如有则分别为 IIFNAMEOIFNAME 赋值


HTTP Tracker
STRTCP=$(printf 30$(printf "$WANTCP" | xxd -p) | tail -c 10)
STRUDP=$(printf 30$(printf "$WANUDP" | xxd -p) | tail -c 10)

对端口的表达进行加工

向 HTTP Tracker 汇报端口时,在 GET 请求中传递 "port=<端口>" 的查询参数(Query)
nftables 支持二进制形式的数据包匹配和修改,而 HTTP 中的查询参数为 ASCII 编码的字符,因此需要进行转换

由于 nftables 不支持修改数据包长度,而汇报端口可能是 1025 - 655351024 以下为知名端口),字符长度不同会导致数据包长度发生变化
为此,本脚本要求用户把BT应用的监听端口设置为 10000 - 65535,使得软件汇报时,将始终发送保持 5 位数的端口长度的数据包
而当穿透的公网端口为 4 位数时,将在前面补上一个 0 后再修改(如 "port=06889"),Tracker 可正常识别

printf 作为字符打印 TCP 与 UDP 端口,xxd 将其转换为十六进制形式,并始终在前面加上 30(即 ASCII 中的 "0"),最后 tail 截取尾部 10 位,即 5 个 ASCII 字
当字符为 06889 时,截取 06889;当字符为 016889 时,截取 16889
使用 printf 不换行打印,是为了避免换行符会被 xxd 转换


if [ -n "$WANTCP" ] && [ -n "$WANUDP" ]; then
	SETSTR="numgen inc mod 2 map { 0 : 0x3d$STRTCP, 1 : 0x3d$STRUDP }"
elif [ -n "$WANTCP" ]; then
	SETSTR=0x3d$STRTCP
elif [ -n "$WANUDP" ]; then
	SETSTR=0x3d$STRUDP
fi

if 判断当前已穿透端口是 TCP 、UDP 还是 BOTH (TCP + UDP)
当穿透 BOTH 时,TCP 与 UDP 轮流修改,否则将单一修改

numgen 表示生成数字(number generator),inc 表示递增(increase),mod 表示求余(modulo),除数为 2
当生成数字 11 时,11 mod 2 余数为 1;下次生成数字 12,12 mod 2 余数为 0

map 映射集指定:当余数 0 时修改为 TCP 端口号,余数 1 时修改为 UDP 端口号
0x 表示十六进制表达,3d 为 ASCII 字符等号 "="
nftables 的二进制操作以 16 bit 单位进行,当修改范围非 16 的倍数时,需要或 (OR) 运算
map 映射集里不允许进行运算,因此把前面字符 "port=" 的等号补上来凑数


nft add set ip STUN BTTR_HTTP "{ type ipv4_addr . inet_service; flags dynamic; timeout 1h; }"
nft add chain ip STUN BTTR_HTTP
nft flush chain ip STUN BTTR_HTTP

nft add set 添加一个集,用来保存已识别的 HTTP Tracker 的 IP 地址与端口

type ipv4_addr . inet_service 表示保存类型为 IPv4地址:端口
flags dynamic 表示动态集;集在创建后,通过规则来添加,更新或删除元素
timeout 1h 表示每个元素在 1 小时后超时

nft add chainIPv4 族 / STUN 表下添加 BTTR_HTTP 链,重复添加不作改变

nft flush chain 在修改规则前,先把链清空


nft insert rule ip STUN BTTR ip daddr . tcp dport @BTTR_HTTP counter goto BTTR_HTTP
nft add rule ip STUN BTTR ip saddr $APPADDR meta l4proto tcp @ih,0,112 0x474554202f616e6e6f756e63653f add @BTTR_HTTP { ip daddr . tcp dport } counter goto BTTR_HTTP

nft insert rule 插入规则到 ip STUN BTTR 链的顶端

nft add rule 添加规则到 ip STUN BTTR 链的底端

出站接口为 $OIFNAME(若有)的 IPv4 TCP 数据包,若其目标地址与目标端口与 @BTTR_HTTP 集中的记录匹配,则直接前往 HTTP 改包专用链;没有匹配的数据包将进入下一条规则

出站接口为 $OIFNAME(若有),IPv4 源地址为下载设备,传输协议为 TCP,0112 位荷载为 0x474554202f616e6e6f756e63653f 的数据包,将记录其目标地址与目标端口到 @BTTR_HTTP 集中,计数后前往 HTTP 改包专用链
该荷载表示 "GET /announce?",即 HTTP Tracker 汇报信息的头部


for OFFSET in $(seq 768 16 1040); do
	nft add rule ip STUN BTTR_HTTP @ih,$OFFSET,40 0x706f72743d @ih,$(($OFFSET+32)),48 set $SETSTR update @BTTR_HTTP { ip daddr . tcp dport } counter accept
done

BT 应用向 HTTP Tracker 传递的前 3 个查询参数为 info_hash, peer_id, port
每款 BT 软件的参数顺序都不同,但很幸运的是这 3 个的位置是固定的
由于 ASCII 转码的关系,前两个参数的比特长度并不固定,但有一定的规律

经过不同软件的抓包发现,port 参数的起点范围为 7681040 位,间隔 16
也就是说,只需要 (1040-768)/16+1=18 条规则即可覆盖

使用 for 循环添加 HTTP 改包规则,由于 OpenWrt 不支持三表达式,这里用 seq 替代
指定 OFFSET7681040,步进值 16
$OFFSET 的位置起匹配 40 位,内容为 0x706f72743d,即 "port="
$OFFSET 的位置起后移 32 位,修改为 $SETSTR,即 "=12345" (参照本节开头)
改包后更新目标地址与目标端口到 @BTTR_HTTP,计数并离开该链,无需再匹配后续规则


UDP Tracker

最初为了通用性,Tracker 使用了最流行的 HTTP 协议来传输
在 BT 下载流行后,诞生了更为轻量高效的 UDP Tracker
UDP 规则基本结构与 HTTP 规则一致,且更简洁


if [ -n "$WANTCP" ] && [ -n "$WANUDP" ]; then
	SETNUM="numgen inc mod 2 map { 0 : $WANTCP, 1 : $WANUDP }"
elif [ -n "$WANTCP" ]; then
	SETNUM=$WANTCP
elif [ -n "$WANUDP" ]; then
	SETNUM=$WANUDP
fi

if 判断式与 HTTP Tracker 相似

唯一不同的是,UDP Tracker 用二进制而不是 ASCII 表达端口,因此无需转换


nft add set ip STUN BTTR_UDP "{ type ipv4_addr . inet_service; flags dynamic; timeout 1h; }"
nft add chain ip STUN BTTR_UDP
nft flush chain ip STUN BTTR_UDP

nft add set 添加一个集,用来保存已识别的 UDP Tracker 的 IP 地址与端口

集的属性与 HTTP Tracker 相同

nft add chainIPv4 族 / STUN 表下添加 BTTR_UDP 链,重复添加不作改变

nft flush chain 在修改规则前,先把链清空


nft insert rule ip STUN BTTR $OIFNAME ip saddr $APPADDR ip daddr . udp dport @BTTR_UDP counter goto BTTR_UDP
nft add rule ip STUN BTTR $OIFNAME ip saddr $APPADDR meta l4proto udp @ih,0,64 0x41727101980 @ih,64,32 0 add @BTTR_UDP { ip daddr . udp dport } counter goto BTTR_UDP

nft insert rule 插入规则到 ip STUN BTTR 链的顶端

nft add rule 添加规则到 ip STUN BTTR 链的底端

出站接口为 $OIFNAME(若有)的 IPv4 UDP 数据包,若其目标地址与目标端口与 @BTTR_UDP 集中的记录匹配,则直接前往 UDP 改包专用链;没有匹配的数据包将进入下一条规则

出站接口为 $OIFNAME(若有),IPv4 源地址为下载设备,传输协议为 UDP,064 位荷载为 0x41727101980649664 + 32)位荷载为 0 的数据包,将记录其目标地址与目标端口到 @BTTR_UDP 集中,计数后前往 UDP 改包专用链
0x41727101980 为与 UDP Tracker 建立请求时的固定连接 ID(幻方常数),后续的 0 代表连接(connect)请求
脚本中把这两个数据区分开来,但它们的偏移量是相连的
添加规则后,nftables 会自动把它转换成 @ih,0,96 0x4172710198000000000


nft add rule ip STUN BTTR_UDP @ih,64,32 1 @ih,768,16 $APPPORT @ih,768,16 set $SETNUM update @BTTR_UDP { ip daddr . udp dport } counter

由于 BEP0015 为 UDP Tracker 规范了每个数据的偏移量,因此只需要一条规则

从偏移量 64 起匹配 32 位,数据为 1,代表通告(announce)请求
从偏移量 768 起匹配 16 位,数据为 BT 应用的监听端口号 $APPPORT
从偏移量 768 起修改 16 位,数据为 $SETNUM,自动替换 TCP 或 UDP 穿透端口
改包后更新目标地址与目标端口到 @BTTR_UDP 并计数

本规则没有离开链(accept)的操作,因为已是该链唯一的一条规则


DNAT

需要根据运行的环境,选择 DNAT 的实现方式

DNAT=0
for LANADDR in $(ip -4 a show dev br-lan | grep inet | awk '{print$2}' | awk -F '/' '{print$1}'); do
	[ $DNAT = 1 ] && break
	[ "$LANADDR" = $GWLADDR ] && DNAT=1
done
for LANADDR in $(nslookup -type=A $HOSTNAME | grep Address | grep -v :53 | awk '{print$2}'); do
	[ $DNAT = 1 ] && break
	[ "$LANADDR" = $GWLADDR ] && DNAT=1
done
[ $APPADDR = $GWLADDR ] && DNAT=2

先设置标记 DNAT0

两个 for 循环分别用 ipnslookup 命令判断两轮

当本机 IP $LANADDR 与主路由 IP $GWLADDR 一致时,则表示 NATMap 运行在主路由上,设置标记 DNAT1,若已设置则离开 for 循环

当下载设备的 IP $APPADDR 与主路由 IP $GWLADDR 一致时,则表示 NATMap 与 BT 应用均运行在主路由上,设置标记 DNAT2


if [ $DNAT = 0 ]; then
	[ -n "$OLDPORT" ] && upnpc -i -d $OLDPORT $L4PROTO
	[ -n "$DISPORT" ] && upnpc -i -d $DISPORT
	upnpc -i -e "STUN BT $L4PROTO $WANPORT->$LANPORT->$APPPORT" -a $APPADDR $APPPORT $LANPORT $L4PROTO | \
	grep $APPADDR | grep $APPPORT | grep $LANPORT | grep -v failed
	[ $? = 0 ] && DNAT=3
fi

若标记 $DNAT0,则表示 NATMap 运行在旁路由上,通过 UPnP 请求映射规则

若存在之前的穿透端口 $OLDPORT$DISPORT,则进行清理

upnpc 请求一个带有描述的映射规则,包含传输协议,公网端口 → 内网端口 → 应用端口
映射规则为内网端口到下载设备的监听端口,公网端口的映射在运营商的 NAT 网关上完成

grep 查找输出内容,若判断添加规则成功,则设置标记 DNAT3


if [ $DNAT = 0 ]; then
	PROXYCONF=/tmp/proxychains.conf
	echo [ProxyList] >$PROXYCONF
	echo http $APPADDR 3128 >>$PROXYCONF
	[ -n "$OLDPORT" ] && proxychains -f $PROXYCONF upnpc -i -d $OLDPORT $L4PROTO
	[ -n "$DISPORT" ] && proxychains -f $PROXYCONF upnpc -i -d $DISPORT
	proxychains -f $PROXYCONF \
	upnpc -i -e "STUN BT $L4PROTO $WANPORT->$LANPORT->$APPPORT" -a $APPADDR $APPPORT $LANPORT $L4PROTO | \
	grep $APPADDR | grep $APPPORT | grep $LANPORT | grep -v failed
	[ $? = 0 ] && DNAT=3
fi

若标记 $DNAT0,则表示 UPnP 直连请求添加规则失败

PROXYCONF 指定 proxychains 的配置文件 /tmp/proxychains.conf
echo 写入代理协议、服务器地址、端口至配置文件 /tmp/proxychains.conf
注意协议和端口,3proxy 的 proxy 代理服务器为 http 协议,默认端口为 3128

之后通过 proxychains 重复之前的操作

若主路由的 UPnP 开启了安全模式,则只允许客户端请求映射到自身地址的规则
使用旁路由时,为了减少 NAT 转发,理想情况是让主路由直接把远程传入的连接发到下载设备的监听端口上
为了自动化,该操作将由旁路由来完成,这会导致请求地址与目标地址不一致的情况
为了解决这个问题,本文采用的代理服务器的方法,经由下载设备发起 UPnP 请求


[ $DNAT = 0 ] && (upnpc -i -e "STUN BT $L4PROTO $WANPORT->$LANPORT" -a @ $LANPORT $LANPORT $L4PROTO; DNAT=4)

若标记 $DNAT0,则表示 UPnP 代理请求添加规则失败

upnpc 向主路由请求目标地址为自身的映射规则,之后再进行二次映射到下载设备
@ 为本机地址,映射到本机内网端口 $LANPORT,而非直达下载设备的 $APPPORT

最后设置标记 DNAT4


if [ $DNAT != 3 ]; then
	[ -z "$WANTCP" ] && nft delete chain ip STUN BTDNAT_tcp 2>/dev/null
	[ -z "$WANUDP" ] && nft delete chain ip STUN BTDNAT_udp 2>/dev/null
	nft delete chain ip STUN BTDNAT_$L4PROTO 2>/dev/null
	nft create chain ip STUN BTDNAT_$L4PROTO { type nat hook prerouting priority dstnat \; }
fi

配置 nftables 的 DNAT 规则前,先初始化 DNAT 链

若标记 $DNAT 不为 3,则表示需要使用 nftables 进行端口映射
这包括 NATMap 运行在主路由上,以及 NATMap 运行在旁路由上,但无法请求直达规则时

判断是否存在 TCP 或 UDP 端口,不存在则删除对应协议的规则链

nft delete chain 在创建规则链前,先删除原有链

nft create chainIPv4 族 / STUN 表下创建 BTDNAT_tcpBTDNAT_udp
type nat 表示对每个连接的首包进行匹配,后续数据包直接从连接跟踪表参照 NAT 信息
hook prerouting 表示在路由前拦截数据包
priority dstnat 表示在同一拦截点下,该规则链的优先级
该链的属性为 DNAT 的标准,独立创建只为方便管理


if [ $DNAT = 1 ] || [ $DNAT = 4 ]; then
	nft add rule ip STUN BTDNAT_$L4PROTO $IIFNAME $L4PROTO dport $LANPORT counter dnat ip to $APPADDR:$APPPORT
	[ "$RELEASE" = "openwrt" ] && \
	if ! nft list chain inet fw4 forward | grep 'ct status dnat' >/dev/null; then
		HANDLE=$(nft -a list chain inet fw4 forward | grep jump | awk 'NR==1{print$NF}')
		nft insert rule inet fw4 forward handle $HANDLE ct status dnat counter accept
	fi
fi

若标记 $DNAT14,则表示 NATMap 运行在主路由上,或 NATMap 运行在旁路由上,但无法请求直达规则,使用 dnat 进行端口映射

nft add rule 添加规则到 ip STUN BTDNAT_tcpBTDNAT_udp
入站接口为 $IIFNAME(若有)的 TCP 或 UDP 数据包,若其目标端口为内网端口 $LANPORT,则转发至下载设备的 BT 应用 $APPADDR:$APPPORT
映射规则为内网端口到下载设备的监听端口,公网端口的映射在运营商的 NAT 网关上完成

若本机为 OpenWrt,由于默认规则不允许转发,需要另外放行
若系统链 inet fw4 forward 下不存在 ct status dnat 的规则,则在跳转到默认链前使其放行
nft -a list 定位到默认的跳转规则
nft insert rule 在默认的跳转规则前插入放行规则


if [ $DNAT = 2 ]; then
	nft add rule ip STUN BTDNAT_$L4PROTO $IIFNAME $L4PROTO dport $LANPORT counter redirect to :$APPPORT
	[ "$RELEASE" = "openwrt" ] && \
	if ! nft list chain inet fw4 input | grep 'ct status dnat' >/dev/null; then
		HANDLE=$(nft -a list chain inet fw4 input | grep jump | grep -v "tcp flags" | awk 'NR==1{print$NF}')
		nft insert rule inet fw4 input handle $HANDLE ct status dnat counter accept
	fi
fi

若标记 $DNAT2,则表示 NATMap 与 BT 应用均运行在主路由上,使用 redirect

nft add rule 添加规则到 ip STUN BTDNAT_tcp 或 **BTDNAT_udp链 入站接口为$IIFNAME(若有)的 TCP 或 UDP 数据包,若其目标端口为内网端口 $LANPORT,重定向至本机的 BT 应用监听端口 $APPPORT`
映射规则为内网端口到下载设备的监听端口,公网端口的映射在运营商的 NAT 网关上完成

若本机为 OpenWrt,由于默认规则不允许入站,需要另外放行
若系统链 inet fw4 input 下不存在 ct status dnat 的规则,则在跳转到默认链前使其放行
nft -a list 定位到默认的跳转规则
nft insert rule 在默认的跳转规则前插入放行规则


最后

chmod +x /usr/stun-bt.sh

请记得给脚本赋予执行权限


  1. 如需为多个接口配置穿透,请看文末 ↩︎

  2. OpenWrt 使用 tmpfs,保存在 tmp 文件夹可避免往闪存写入文件损耗寿命 ↩︎

配置 NATMap

不同场景下的配置方法不同


OpenWrt 主路由 / 旁路由
nano /etc/config/natmap

nano 编辑配置文件 /etc/config/natmap

config natmap
	option udp_mode '0'
	option family 'ipv4'
	option interval '25'
	option stun_server 'turn.cloudflare.com'
	option http_server 'qq.com'
	option port '0'
	option notify_script '/usr/stun-bt.sh'
	option enable '1'

config natmap
	option udp_mode '1'
	option family 'ipv4'
	option interval '25'
	option stun_server 'turn.cloudflare.com'
	option http_server 'qq.com'
	option port '0'
	option notify_script '/usr/stun-bt.sh'
	option enable '1'

nano 下,按 Ctrl + O 键保存,Enter 键确认,Ctrl + X 键退出编辑

/etc/init.d/natmap restart

重启生效


WSL2 旁路由
nano /sbin/mount.rc

nano 编辑 WSL2 Linux 发行版的自启动脚本 /sbin/mount.rc

natmap -d -4 -k 25 -s turn.cloudflare.com -h qq.com -e "/usr/stun-bt.sh"
natmap -d -4 -k 25 -s turn.cloudflare.com -u -e "/usr/stun-bt.sh"

在末尾加上此两行

nano 下, 按 Ctrl + O 键保存,Enter 键确认,Ctrl + X 键退出编辑

wsl --shutdown
wsl

返回 PowerShell,关闭 WSL2 Linux 发行版,8 秒后启动即可生效


配置选项

family=ipv4 / -4:为避免意外的问题,建议指定穿透类型为 IPv4

interval=25 / -k 25:保活间隔为 25 秒,一般假定 NAT 网关的老化周期为 30

stun_server / -s:此处建议使用 Cloudflare 的 TURN 服务器

http_server / -h:TCP 模式时需要指定;「配置 STUN 脚本 / 初始化」 中也有提及,无论是否使用 TCP 模式,均可指定该参数

udp_mode / -u:UDP 模式的开关,不指定时则代表 TCP 模式(默认)

port=0 / -b 0:绑定的端口,0 为不指定,使用随机的本地端口(默认);本文利用 STUN 脚本管理端口,为避免意外的占用,不建议指定

notify_script / -e:指定通知脚本

「配置 STUN 脚本 / 初始化」 中也有提及,在需要同时配置多个 BT 软件的穿透时,可通过 STUN 脚本的保存路径来区分不同的实例,此时还需要替换脚本的端口信息保存路径(/tmp/stun-bt.info

其他

运行状况
cat /tmp/stun-bt.info

cat 查看穿透信息 /tmp/stun-bt.info 确认运行状况

nft list table ip STUN

nft list table 列出 STUN 表的规则,可通过计数器确认数据包的数量和流量

upnpc -i -l

upnpc 列出主路由的 UPnP 信息

请求列表不要求地址一致,可用链路上的任意设备查询,也可以通过网页访问
请求列表可能会失败,但并不代表规则不存在

cat /tmp/proxychains.conf

cat 查看命令行代理的配置文件 /tmp/proxychains.conf,仅在 UPnP 启用安全模式下生成


其他运行信息,请留意系统日志


配置多个实例

为多个 BT 软件配置穿透时,需要修改以下内容

/usr/stun-bt.sh

/tmp/stun-bt.info

为多个接口配置配置穿透时,需要修改以下内容

/usr/stun-bt.sh

/tmp/stun-bt.info

IFNAME=


以 STUN 脚本的路径(文件名)为依据区分实例,一个实例包括 NATMap 的 TCP 与 UDP 穿透条目各一个

可在一个接口上配置多个 BT 应用,也可以为一个 BT 应用配置多个接口

例:

/usr/stun-bt_PC1.sh		+	/usr/stun-bt_PC2.sh
/tmp/stun-bt_PC1.info	+	/tmp/stun-bt_PC2.info

或

/usr/stun-bt_wanct.sh	+	/usr/stun-bt_wancm.sh
/tmp/stun-bt_wanct.info	+	/tmp/stun-bt_wancm.info
IFNAME=pppoe-wanct		+	IFNAME=pppoe-wancm