https://gitee.com/oniicyan/stun_bt

特别鸣谢 ie-12
本文在其多篇关于 STUN 的教程的基础上发展,并获得了良好的测试环境。
本文使用命令行进行配置,尽量对每个项目作出解释,因此内容可能略有繁琐。
理论上只需要复制粘贴即可完成配置,但也建议仔细阅读理解。
也可直接查看以下精简版教程
水平有限,如果谬误,还请不吝赐教。
按 Ctrl + S 键, 保存为 mhtml 文件后打开即可无尾巴复制

本方案可能不适用于 PT。
由于私有 Tracker 会统计用户的端口信息,若同时发布多个端口可能被判断为作弊。

前言
├─ 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
配置 NATMap
├─ OpenWrt 主路由 / 旁路由
├─ WSL2 旁路由
└─ 配置选项
其他
├─ 运行状况
└─ 配置多个实例

以下是关于 BT 下载与内网穿透的一些基本概念,可跳过
随着家庭宽带获取公网 IPv4 的门槛日渐提高,尽管 IPv6 已经实现了很大程度的普及,但对 IPv4 内网穿透的需求仍然很大。这里简单说一下目前 BT 下载所用到的穿透方式。

DHT (Distributed Hash Table)
BT 协议本身不借助任何外置工具,也能实现一定程度的内网穿透。BEP0005 DHT Protocol (http://bittorrent.org/beps/bep_0005.html) 中提及 "implied_port" 的可选参数。当 DHT 连接双方的 BT 应用均支持 implied_port 参数时,会把对方实际连接所用的端口(而不是对方主动提供的应用监听端口)作为节点信息发布。当其他用户通过 DHT 获得到该节点时,就会对 NAPT* 后的公网地址及端口发起连接。但这个方案仅限 UDP,因为 DHT 网络基于 UDP,通过 DHT 发布的端口也只能用在同样基于 UDP 的 uTP 协议。
* 涉及端口的 NAT (Network Address Translation) 又叫 NAPT (Network Address Port Translation) 或 PAT (Port Address Translation),大多数时候不区分

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 网关“只映射不过滤*”的特性。也就是说,运营商的 NAPT 网关只关心 IP 地址与端口的映射(一对多的锥型),而对于通过的流量,无论入站还是出站,除特殊情况外(比如 80 及 443 端口的入站)一律放行,这就是常说的 NAT1。基于此特性的内网穿透一般称为 “STUN 穿透”,比较流行的工具是 Lucky。
* 映射(Mapping)分为锥型(Cone)和对称型(Symmetric)
过滤(Filtering)分为不过滤,IP 过滤,IP 与端口过滤
两者的组合就是常说的 NAT1-4

传统 STUN 的局限性
BT 下载的 STUN 穿透有个最重要的问题,就是如何把穿透后的公网端口信息发布出去。一般来说,BT 软件向 Tracker 汇报的是自身的监听端口,因此最直接的方法就是修改软件设置中的端口——本文将此称为(针对 BT 的)“传统 STUN 穿透”。对于一些有提供 API 的软件,Lucky 等工具可利用通知脚本来进行热修改;对于支持不友好的软件,就需要强制关进程,修改配置文件后重开,非常不优雅。
另外,在 STUN 穿透下,TCP 与 UDP 即便使用同一源端口号,穿透后的公网端口也会不同。而一般 BT 应用只支持监听及汇报一个端口号,不区分 TCP 和 UDP,因此传统 STUN 穿透方案下,TCP 与 UDP 只能二选一。
上面提到 UDP 端口可以通过 DHT 或 PEX 发布,但除了发布效率较低之外,还有另外的因素可能导致 UDP 端口损失连通性。传统 STUN 穿透下,监听端口(穿透端口)与外部端口(CGNAT* 端口)不同,且不定期变动,因此需要利用 UPnP (NAT-PMP / PCP) 来控制网关的端口映射规则。而 BT 软件的 UPnP 只会请求内外一致的映射规则,因此使用传统 STUN 穿透时,为了避免冲突*,一般建议关闭软件的 UPnP 请求,由 STUN 工具一律管理。然而这可能会导致 DHT / PEX 的穿透失效,因为很多路由器的防火墙默认策略是“IP 与端口过滤”,也就是常说的 NAT3,BT 应用通过 UPnP 请求规则后,才能接受传入的连接。
* CGNAT (Carrier-grade NAT) 即运营商级 NAT,也就是俗称的“大内网”
* 多个外部端口可以被映射到同一个内部端口,因此映射规则不会冲突,但可能路由器的 UPnP 组件会引致意外的结果

nftables
nftables 是 Linux 内核的新一代数据包过滤框架,取代旧版的 xtables*,它可以对经过内核的数据包进行非常灵活且精细的操作。
本文在完成 STUN 穿透后,利用 nftables 在网关上对 BT 软件向 Tracker 汇报的数据包进行匹配与修改,以实现对 BT 软件透明的端口信息分发。与传统 STUN 穿透不同,本方案可按需修改汇报的端口,因此除了能同时支持 TCP + UDP 外,还能同时支持多宽带接入且都需要 STUN 穿透的场景。由于不需要修改 BT 软件的监听端口,与 BT 软件的 UPnP 请求不冲突,且只修改向 Tracker 汇报的端口,因此也不影响 DHT / PEX 的穿透的 UDP 端口连通性。
支持匹配 HTTP 及 UDP Tracker 的 announce 请求中的端口信息,并修改成 TCP 或 UDP 的穿透端口。由于 nftables 不能改变数据包的长度,因此 BT 软件的监听端口要求是任意的 5 位数。
暂不支持 HTTPS Tracker,未来考虑使用 Privoxy 配置代理服务器来重写 HTTPS 请求中的查询参数(Query),可能需要在下载设备上安装证书。
WS / WSS Tracker 尚未涉及。
* 理论上 iptables 也可以进行同样的操作,但不在本文讨论范围内

本文考虑以下3种使用场景
NATMap 运行在 OpenWrt 主路由上
首选推荐,无需 UPnP
除 OpenWrt 支持 nftables 外无其他要求
NATMap 运行在 OpenWrt 旁路由 / 二级路由*上
要求主路由开启 UPnP
若主路由的 UPnP 组件开启安全模式(要求请求地址与规则地址一致),需要在下载设备上搭建代理服务器,否则将对 BT 传入的流量额外进行一次 DNAT
NATMap 运行在 WSL2 旁路由上
除上述的 OpenWrt 旁路由的基本要求外,还需要满足 WSL2 的系统要求
下载设备通常是 Windows 宿主本身,但也可以为局域网上的其他设备提供旁路由功能
下载端可以是任意能运行 BT 软件的设备,包括 主路由 / 旁路由 / WSL2 虚拟机 自身运行 BT 应用的情况,包括原生程序和 Docker 等容器环境运行的 BT 应用。只要求正确填写 STUN 脚本开头的项目。
* 关于旁路由与二级路由的区别将在后文提及

本文在 OpenWrt 23.05 / WSL2 (Debian) 下进行测试
除系统内置的 nftables 及基本的 bash / ash 环境外,还需要安装以下组件
NATMap:本文使用的 STUN 工具,OpenWrt 可通过 opkg 安装,WSL2 需要从 GitHub 下载二进制文件
xxd:用作字符的十六进制转换,可通过 opkg 或 apt 安装
MiniUPnPc:UPnP 客户端,可通过 opkg 或 apt 安装
proxychains:命令行代理工具,路由器 UPnP 开启安全模式时需要,opkg 下的包名为 proxychains-ng,apt 下的包名为 proxychains4
dnsutils:DNS 组件,判断本机 IP 地址时需要,可通过 opkg 或 apt 安装;OpenWrt 下已预装,使用 WSL2 时需要另外安装
nano:文本编辑器,用作编辑配置文件,可通过 opkg 或 apt 安装;WSL2 下已预装,使用 OpenWrt 官方预编译固件时需要另外安装
若路由器的 UPnP 开启了安全模式,还将在下载设备上安装 3proxy 作为代理服务器使用

对于不持有 OpenWrt 网关的用户,本文将使用 WSL(Windows Subsystem for Linux,适用于 Linux 的 Windows 子系统)作为旁路由实现,需要开启 CPU 虚拟化功能。
要求使用 WSL2,启用桥接网络模式。
根据微软官方文档,启用桥接网络模式要求 Windows 11 22H2 或更高版本,但有报告称在 Windows 10 上也能启用。


本文将从 WSL2 的安装与配置出发,OpenWrt 用户可直接往下拉。
为了标准化流程,本文大部分操作在命令行下进行。

依次按下键盘的 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 安装完成后,输入 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 地址,即可使其经由虚拟网卡,实现高效的分流
得益于微软近年来的开发与优化,单网卡虚拟化的日常使用也不会感到明显的性能损耗
EnablePacketDirect 与 EnableIov 若受网卡支持,则可提供更好的性能
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-https 与 ca-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-https 与 ca-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 echo 与 tee 输出配置到 /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 改为你的主路由的 IP 地址
$PCIP 改为你要配置的静态 IP 地址,要求与主路由及 WSL2 处于同一网段
IP 地址的引号不能省略
如果你想配置的静态 IP 地址 $PCIP 与当前地址一致时,请先运行 Remove-NetIPAddress 删除后再 New-NetIPAddress 创建
$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") Remove-NetIPAddress 当前地址与静态地址一致时,删除 Windows 虚拟网卡的当前地址
New-NetIPAddress 给 Windows 虚拟网卡指定静态地址,此处把主路由设置为默认网关
Set-DnsClientServerAddress
指定静态地址会关闭 DHCP 服务,还需要设置 DNS 服务器
此处把首选 DNS 设为主路由,备用 DNS 设为 DNSPod (119.29.29.29)

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

单网卡用户执行以下命令
$WSLIP 改为 WSL2 的 IP 地址
$WSLIP="192.168.5.31"
route ADD -p 0.0.0.0 MASK 0.0.0.0 $WSLIP METRIC 1 route ADD 添加一条网关为 WSL2 的默认路由

多网卡用户可设置跃点数,指定日常使用的网卡
$WSLIP 改为 WSL2 的 IP 地址
$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 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 与转发。在正确配置的旁路由拓扑里,传入流量不需要经过旁路由,由主路由直接转发到用户设备。
“旁路由转发”与“主路由 NAT”等表述可以理解成“旁路由 / 转发”与“主路由 / NAT”
也可断句为 “旁 / 路由 / 转发” 与 “主 / 路由 / NAT”

Netfilter 中,网关转发 IP 数据包完整流程是
传入(Ingress) → 路由前拦截(Prerouting Hook) → 目标地址转换(DNAT) → 路由选择(Routing Decsion) → IP 转发时拦截(Forward Hook) → 路由后拦截(Postrouing Hook) → 源地址转换(SNAT) → 传出(Egress)
附图:https://people.netfilter.org/pablo/nf-hooks.png
只要数据包进入路由器(IP 网关),即使不进行 NAT,也要走完以上流程。这就是传入时减少一层网关的意义。

连接经过旁路由的透明代理转发时
用户 → 旁路由拦截 / 封装 / 发出 → 主路由 NAT → 一级路由转发 → 运营商
用户 ← 旁路由接收 / 解封 / 发回 ← 一级路由转发 ← 主路由 NAT ← 运营商

对于透明代理之类的旁路由应用场景,使用的是应用层转发。透明代理拦截用户流量后,会重新封装成隧道流量改道发出。这过程在网络层与传输层之上,最终会以旁路由自身的 IP 作为源地址发出,因此也不需要开启 IP 动态伪装进行 SNAT。
正确配置的透明代理应该只对特定流量进行拦截,其他流量原封不动地发出,这样在回程时就不需要经过旁路由及透明代理。
错误配置的旁路由还会破坏端口映射。当远程主机访问从主路由映射到 PC 的 1111 端口后,若响应流量被旁路由拦截并进行 SNAT,此时主路由的连接跟踪表记录了 1111 端口为 PC 所占用,对于旁路由 SNAT 转发的流量会分配 1112 作为源端口。远程主机接受到源端口 1112 的流量时,由于与先前的目标主机不一致,会作为未知流量而丢弃,连接将无法正常建立。


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 nano xxd miniupnpc proxychains-ng opkg update 更新软件包列表
opkg install 安装所需软件包
luci-app-natmap 为本文所用的 STUN 工具,本包将自动安装 CLI 程序和 LuCI 管理页
可直接安装中文管理页 luci-i18n-natmap-zh-cn
若不需要管理页,也可安装 natmap
其他请参照上述的「系统要求」部分
OpenWrt 主路由下,无需安装 miniupnpc 与 proxychains-ng

WSL2 旁路由
apt update
apt install 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 的最新版本
https://github.com/heiher/natmap/releases

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

配置代理服务器(可选)
运行在旁路由,且主路由 UPnP 组件开启安全模式的情况下需要
运行在主路由,或主路由 UPnP 组件未启用安全模式时,请跳过本节
为了最大发挥旁路由的优势,使传入的流量直接从主路由转发到下载设备,减少一次 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 的最新版本
https://github.com/3proxy/3proxy/releases
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 脚本为 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
粘贴以下「脚本内容」

脚本内容
更新:脚本已托管至仓库(https://gitee.com/oniicyan/stun_bt)
脚本的更新将不再同步至本文章
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
OWNNAME=$(echo $0 | awk -F / '{print$NF}' | awk -F . '{print$1}')
RELEASE=$(grep ^ID= /etc/os-release | awk -F '=' '{print$2}' | tr -d \")
STUNIFO=/tmp/$OWNNAME.info
OLDPORT=$(grep $L4PROTO $STUNIFO 2>/dev/null | awk -F ':| ' '{print$3}')
# 判断 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
echo $(date) $L4PROTO $WANADDR:$WANPORT '->' $OWNADDR:$LANPORT '->' $APPADDR:$APPPORT >>/tmp/$OWNNAME.log
# 防止脚本同时操作 nftables 导致冲突
[ $L4PROTO = udp ] && sleep 1 && \
[ $(($(date +%s) - $(grep tcp $STUNIFO | awk '{print$NF}'))) -lt 3 ] && sleep 3
# 初始化 nftables
nft add table ip STUN
nft add chain ip STUN BTTR { type filter hook postrouting priority filter \; }
nft flush chain ip STUN BTTR
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
STRAPP=0x706f72743d$(printf $APPPORT | xxd -p)
STRTCP=0x3d$(printf 30$(printf "$WANTCP" | xxd -p) | tail -c 10)
STRUDP=0x3d$(printf 30$(printf "$WANUDP" | xxd -p) | tail -c 10)
if [ -n "$WANTCP" ] && [ -n "$WANUDP" ]; then
SETSTR="numgen inc mod 2 map { 0 : $STRTCP, 1 : $STRUDP }"
elif [ -n "$WANTCP" ]; then
SETSTR=$STRTCP
elif [ -n "$WANUDP" ]; then
SETSTR=$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 insert rule ip STUN BTTR ip daddr . tcp dport @BTTR_HTTP goto BTTR_HTTP
nft add rule ip STUN BTTR meta l4proto tcp @ih,0,112 0x474554202f616e6e6f756e63653f add @BTTR_HTTP { ip daddr . tcp dport } goto BTTR_HTTP
for HANDLE in $(nft -a list chain ip STUN BTTR_HTTP | grep \"$OWNNAME\" | awk '{print$NF}'); do
nft delete rule ip STUN BTTR_HTTP handle $HANDLE
done
for OFFSET in $(seq 768 16 1056); do
nft add rule ip STUN BTTR_HTTP $OIFNAME ip saddr $APPADDR @ih,$OFFSET,80 $STRAPP @ih,$(($OFFSET+32)),48 set $SETSTR update @BTTR_HTTP { ip daddr . tcp dport } counter accept comment $OWNNAME
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 insert rule ip STUN BTTR ip daddr . udp dport @BTTR_UDP goto BTTR_UDP
nft add rule ip STUN BTTR meta l4proto udp @ih,0,64 0x41727101980 @ih,64,32 0 add @BTTR_UDP { ip daddr . udp dport } goto BTTR_UDP
nft delete rule ip STUN BTTR_UDP handle $(nft -a list chain ip STUN BTTR_UDP 2>/dev/null | grep \"$OWNNAME\" | awk '{print$NF}') 2>/dev/null
nft add rule ip STUN BTTR_UDP $OIFNAME ip saddr $APPADDR @ih,64,32 1 @ih,768,16 $APPPORT @ih,768,16 set $SETNUM update @BTTR_UDP { ip daddr . udp dport } counter accept comment $OWNNAME
# 判断脚本运行的环境,选择 DNAT 方式
# 先排除需要 UPnP 的情况
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
# 若未排除,则尝试直连 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)
# 清理不需要的规则
if [ $DNAT = 3 ]; then
[ "$RELEASE" = "openwrt" ] && uci -q delete firewall.${OWNNAME}_tcp
[ "$RELEASE" = "openwrt" ] && uci -q delete firewall.${OWNNAME}_udp
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep tcp | awk '{print$NF}') 2>/dev/null
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep udp | awk '{print$NF}') 2>/dev/null
fi
if [ $DNAT != 3 ]; then
[ -z "$WANTCP" ] && uci -q delete firewall.${OWNNAME}_tcp
[ -z "$WANUDP" ] && uci -q delete firewall.${OWNNAME}_udp
[ -z "$WANTCP" ] && \
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep tcp | awk '{print$NF}') 2>/dev/null
[ -z "$WANUDP" ] && \
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep udp | awk '{print$NF}') 2>/dev/null
fi
# 选择使用 uci 或 nft
SETDNAT() {
if [ -n "$*" ]; then
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep tcp | awk '{print$NF}') 2>/dev/null
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep udp | awk '{print$NF}') 2>/dev/null
uci -q delete firewall.stun_foo
uci -q delete firewall.${OWNNAME}_$L4PROTO
uci set firewall.${OWNNAME}_$L4PROTO=redirect
uci set firewall.${OWNNAME}_$L4PROTO.name=${OWNNAME}_${L4PROTO}_$1'->'$2
uci set firewall.${OWNNAME}_$L4PROTO.src=wan
uci set firewall.${OWNNAME}_$L4PROTO.proto=$L4PROTO
uci set firewall.${OWNNAME}_$L4PROTO.src_dport=$1
uci set firewall.${OWNNAME}_$L4PROTO.dest_port=$2
uci -q set firewall.${OWNNAME}_$L4PROTO.dest_ip=$3
uci commit firewall
fw4 -q reload
UCI=1
else
nft add chain ip STUN DNAT { type nat hook prerouting priority dstnat \; }
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep $L4PROTO | awk '{print$NF}') 2>/dev/null
fi
if [ "$RELEASE" = "openwrt" ] && [ "$UCI" != 1 ]; then
uci -q delete firewall.stun_foo && RELOAD=1
uci -q delete firewall.${OWNNAME}_tcp && RELOAD=1
uci -q delete firewall.${OWNNAME}_udp && RELOAD=1
if uci show firewall | grep =redirect >/dev/null; then
i=0
for CONFIG in $(uci show firewall | grep =redirect | awk -F = '{print$1}'); do
[ "$(uci -q get $CONFIG.enabled)" = 0 ] && let i++ && break
[ "$(uci -q get $CONFIG.src)" != "wan" ] && let i++
done
[ $(uci show firewall | grep =redirect | wc -l) -gt $i ] && RULE=1
fi
if [ "$RULE" != 1 ]; then
uci set firewall.stun_foo=redirect
uci set firewall.stun_foo.name=stun_foo
uci set firewall.stun_foo.src=wan
uci set firewall.stun_foo.mark=$RANDOM
RELOAD=1
fi
uci commit firewall
[ "$RELOAD" = 1 ] && fw4 -q reload
fi
}
# BT 应用运行在路由器下,使用 dnat
[ $DNAT = 1 ] || [ $DNAT = 4 ] && \
if [ "$RELEASE" = "openwrt" ] && [ -z "$IFNAME" ]; then
SETDNAT $LANPORT $APPPORT $APPADDR
else
SETDNAT
nft add rule ip STUN DNAT $IIFNAME $L4PROTO dport $LANPORT counter dnat ip to $APPADDR:$APPPORT comment $OWNNAME
fi
# BT 应用运行在路由器上,使用 redirect
[ $DNAT = 2 ] && \
if [ "$RELEASE" = "openwrt" ] && [ -z "$IFNAME" ]; then
SETDNAT $LANPORT $APPPORT
else
SETDNAT
nft add rule ip STUN DNAT $IIFNAME $L4PROTO dport $LANPORT counter redirect to :$APPPORT comment $OWNNAME
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"*
GWLADDR 代表主路由 LAN IPv4 地址
APPADDR 代表下载设备的 IPv4 地址,允许主路由或旁路由本身运行 BT 应用
由于路由器可能存在多个不同地址的网络接口,当 BT 应用运行在路由器上时,为确保正确匹配数据包,建议设置程序绑定 APPADDR 作为通信地址
APPPORT 代表下载设备上运行的 BT 应用的监听端口,要求 5 位数端口(10000 - 65535)
* 如需为多个接口配置穿透,请看文末
以上 4 个变量需要用户按照实际情况修改
WANADDR=$1
WANPORT=$2
LANPORT=$4
L4PROTO=$5
OWNADDR=$6
OWNNAME=$(echo $0 | awk -F / '{print$NF}' | awk -F . '{print$1}')
RELEASE=$(grep ^ID= /etc/os-release | awk -F '=' '{print$2}' | tr -d \")
STUNIFO=/tmp/$OWNNAME.info
OLDPORT=$(grep $L4PROTO $STUNIFO 2>/dev/null | awk -F ':| ' '{print$3}') 无需填写的基本变量
WANADDR 为穿透后的公网地址
WANPORT 为穿透后的公网端口
LANPORT 为穿透前的内网端口
L4PROTO 为穿透的传输层协议,TCP 或 UDP
OWNADDR 为穿透时的内网源地址
以上 5 个变量由 STUN 工具传递参数获得,使用 NATMap 以外的方案时,请注意参数顺序
OWNNAME 从执行路径中截取脚本名称,用作识别实例,默认为 stun_hath,具体看文末
RELEASE 识别当前 Linux 发行版的 ID
STUNIFO 指定穿透信息保存的位置,默认为 /tmp/stun_bt.info *
OLDPORT 从保存的穿透信息中读取上次穿透时的内网端口,仅用作清理 UPnP 旧规则
* OpenWrt 使用 tmpfs,保存在 tmp 文件夹可避免往闪存写入文件损耗寿命
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 的 NATMap 配置使用本脚本的 TCP 及 UDP 穿透条目各一个,否则将以最后触发的一个为准
其他 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
echo $(date) $L4PROTO $WANADDR:$WANPORT '->' $OWNADDR:$LANPORT '->' $APPADDR:$APPPORT >>/tmp/$OWNNAME.log 更新保存穿透信息
sed 删除上次的穿透信息,与当前协议对应
echo 保存当前穿透信息到 /tmp/stun_bt.info
echo 添加当前穿透信息到 /tmp/stun_bt.log
[ $L4PROTO = udp ] && sleep 1 && \
[ $(($(date +%s) - $(grep tcp $STUNIFO | awk '{print$NF}'))) -lt 3 ] && sleep 3 防止脚本同时操作 nftables 导致冲突
若当前脚本对应的协议为 UDP,则等待 1 秒后,若当前时间与 TCP 信息中的时间戳相差小于 3 秒,则继续等待 3 秒
也就是说,TCP 脚本不延后,而 UDP 脚本则始终延后 1-4 秒
若你发现 nftables 规则异常,可把等待时间调大
nft add table ip STUN
nft add chain ip STUN BTTR { type filter hook postrouting priority filter \; }
nft flush chain ip STUN BTTR
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 table 在 IPv4 族中添加名为 STUN 的规则表,重复添加不作改变
如未来有其他利用到 nftables 的 STUN 场景,可能会共用同一个表
nft add chain 在 IPv4 族 / STUN 规则表下创建 BTTR 链,重复添加不作改变
nftables 采用 族 / 表 / 链 的三层结构,具体规则收纳在规则链下
type filter 表示对所有包进行匹配(相对于 type nat 只对每个连接的首包匹配)
hook postrouting 表示在路由选择后拦截数据包
priority filter 表示在同一拦截点下,该规则链的优先级
该链由 HTTP 与 UDP,以及未来可能支持的其他 Tracker 共用
通过网关的流量将在此进行匹配,如识别为支持的 Tracker 汇报包,则引导其前往(goto)修改端口信息的专用规则链中
nft flush chain 在修改规则前,先把链清空
从穿透信息中分别加载 TCP 与 UDP 的公网端口,赋值到 WANTCP 与 WANUDP
Tracker 规则中,需要对端口的表达进行加工
if 判断是否指定了接口,如有则分别为 IIFNAME 与 OIFNAME 赋值

以上是 STUN 脚本初始化的部分
以下是匹配 Tracker 汇报请求与修改端口内容的部分

HTTP Tracker
STRAPP=0x706f72743d$(printf $APPPORT | xxd -p)
STRTCP=0x3d$(printf 30$(printf "$WANTCP" | xxd -p) | tail -c 10)
STRUDP=0x3d$(printf 30$(printf "$WANUDP" | xxd -p) | tail -c 10) 对端口的表达进行加工
向 HTTP Tracker 汇报端口时,在 GET 请求中传递 "port=" 的查询参数(Query)
nftables 支持二进制形式的数据包匹配和修改,而 HTTP 中的查询参数为 ASCII 编码的字符,因此需要进行转换
由于 nftables 不支持修改数据包长度,而汇报端口可能是 1025 - 65535(1024 以下为知名端口),字符长度不同会导致数据包长度发生变化
为此,本脚本要求用户把 BT 应用的监听端口设置为 10000 - 65535,使得软件汇报时,将始终发送 5 位数的端口长度的数据包
而当穿透的公网端口为 4 位数时,将在前面补上一个 0 后再修改(如 "port=06889"),Tracker 可正常识别
printf 作为字符打印 BT 应用的监听端口,xxd 将其转换为十六进制形式
最后赋值时加上 0x706f72743d,即 ASCII 中的 "port="
printf 作为字符打印 TCP 与 UDP 端口,xxd 将其转换为十六进制形式,并始终在前面加上 30(即 ASCII 中的 "0"),最后 tail 截取尾部 10 位,即 5 个 ASCII 字符
当字符为 06889 时,截取 06889;当字符为 016889 时,截取 16889
最后赋值时加上 0x3d,即 ASCII 中的等号 "="
nftables 的二进制操作以 16 bit 单位进行,当修改范围非 16 的倍数时,需要或 (OR) 运算
但 map 映射集里不允许进行运算,因此把前面字符 "port=" 的等号补上来凑数
使用 printf 不换行打印,是为了避免换行符会被 xxd 转换
if [ -n "$WANTCP" ] && [ -n "$WANUDP" ]; then
SETSTR="numgen inc mod 2 map { 0 : $STRTCP, 1 : $STRUDP }"
elif [ -n "$WANTCP" ]; then
SETSTR=$STRTCP
elif [ -n "$WANUDP" ]; then
SETSTR=$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 端口号
nft add set ip STUN BTTR_HTTP "{ type ipv4_addr . inet_service; flags dynamic; timeout 1h; }"
nft add chain ip STUN BTTR_HTTP nft add set 添加一个集,用来保存已识别的 HTTP Tracker 的 IP 地址与端口
type ipv4_addr . inet_service 表示保存类型为 IPv4地址:端口
flags dynamic 表示动态集;集在创建后,通过规则来添加,更新或删除元素
timeout 1h 表示每个元素在 1 小时后超时
nft add chain 在 IPv4 族 / STUN 表下添加 BTTR_HTTP 链,重复添加不作改变
nft insert rule ip STUN BTTR ip daddr . tcp dport @BTTR_HTTP goto BTTR_HTTP
nft add rule ip STUN BTTR meta l4proto tcp @ih,0,112 0x474554202f616e6e6f756e63653f add @BTTR_HTTP { ip daddr . tcp dport } goto BTTR_HTTP
for HANDLE in $(nft -a list chain ip STUN BTTR_HTTP | grep \"$OWNNAME\" | awk '{print$NF}'); do
nft delete rule ip STUN BTTR_HTTP handle $HANDLE
done nft insert rule 插入规则到 ip STUN BTTR 链的顶端
nft add rule 添加规则到 ip STUN BTTR 链的底端
若数据包的目标地址及 TCP 目标端口与 @BTTR_HTTP 集中的记录匹配,则直接前往 HTTP 改包专用链;没有匹配的数据包将进入下一条规则
若数据包的传输协议为 TCP,0 到 112 位荷载为 0x474554202f616e6e6f756e63653f 的数据包,将记录其目标地址与目标端口到 @BTTR_HTTP 集中,计数后前往 HTTP 改包专用链
该荷载表示 "GET /announce?",即 HTTP Tracker 汇报信息的头部
for 循环清理旧规则,根据注释(comment)识别
for OFFSET in $(seq 768 16 1056); do
nft add rule ip STUN BTTR_HTTP $OIFNAME ip saddr $APPADDR @ih,$OFFSET,80 $STRAPP @ih,$(($OFFSET+32)),48 set $SETSTR update @BTTR_HTTP { ip daddr . tcp dport } counter accept comment $OWNNAME
done BT 应用向 HTTP Tracker 传递的前 3 个查询参数为 info_hash, peer_id, port
每款 BT 软件的参数顺序都不同,但很幸运的是这 3 个的位置是固定的
由于 ASCII 转码的关系,前两个参数的比特长度并不固定,但有一定的规律
经过不同软件的抓包发现,port 参数的起点范围为 768 到 1056 位,间隔 16 位
也就是说,需要用 (1056-768)/16+1=19 条规则进行覆盖

for 循环添加 HTTP 改包规则,由于 OpenWrt 不支持三表达式,这里用 seq 替代
匹配出站接口为 $OIFNAME(若有),IPv4 源地址为 $APPADDR 的数据包
指定 OFFSET 从 768 到 1056,步进值 16
从 $OFFSET 的位置起匹配 80 位,内容为 $STRAPP,即 "port="
从 $OFFSET 的位置起后移 32 位,修改为 $SETSTR,即 "="
$OIFNAME $APPADDR $STRAPP 对于不同的实例,这三个变量至少有一个不同
改包后更新目标地址与目标端口到 @BTTR_HTTP,计数并离开该链,无需再匹配后续规则
comment 添加注释 $OWNNAME,用作识别规则

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 add set 添加一个集,用来保存已识别的 UDP Tracker 的 IP 地址与端口
集的属性与 HTTP Tracker 相同
nft add chain 在 IPv4 族 / STUN 表下添加 BTTR_UDP 链,重复添加不作改变
nft insert rule ip STUN BTTR ip daddr . udp dport @BTTR_UDP goto BTTR_UDP
nft add rule ip STUN BTTR meta l4proto udp @ih,0,64 0x41727101980 @ih,64,32 0 add @BTTR_UDP { ip daddr . udp dport } goto BTTR_UDP
nft delete rule ip STUN BTTR_UDP handle $(nft -a list chain ip STUN BTTR_UDP 2>/dev/null | grep \"$OWNNAME\" | awk '{print$NF}') 2>/dev/null nft insert rule 插入规则到 ip STUN BTTR 链的顶端
nft add rule 添加规则到 ip STUN BTTR 链的尾端
若数据包的目标地址及 UDP 目标端口与 @BTTR_UDP 集中的记录匹配,则直接前往 UDP 改包专用链;没有匹配的数据包将进入下一条规则
若数据包的传输协议为 UDP,0 到 64 位荷载为 0x41727101980,64 到 96(64 + 32)位荷载为 0 的数据包,将记录其目标地址与目标端口到 @BTTR_UDP 集中,计数后前往 UDP 改包专用链
0x41727101980 为与 UDP Tracker 建立请求时的固定连接 ID(幻方常数),后续的 0 代表连接(connect)请求
脚本中把这两个数据区分开来,但它们的偏移量是相连的
添加规则后,nftables 会自动把它转换成 @ih,0,96 0x4172710198000000000
nft delete rule 清理旧规则,根据注释(comment)识别
nft add rule ip STUN BTTR_UDP $OIFNAME ip saddr $APPADDR @ih,64,32 1 @ih,768,16 $APPPORT @ih,768,16 set $SETNUM update @BTTR_UDP { ip daddr . udp dport } counter accept comment $OWNNAME 由于 BEP0015 为 UDP Tracker 规范了每个数据的偏移量,因此只需要一条规则
匹配出站接口为 $OIFNAME(若有),IPv4 源地址为 $APPADDR 的数据包
从偏移量 64 起匹配 32 位,数据为 1,代表通告(announce)请求
从偏移量 768 起匹配 16 位,数据为 BT 应用的监听端口号 $APPPORT
从偏移量 768 起修改 16 位,数据为 $SETNUM,自动替换 TCP 或 UDP 穿透端口
$OIFNAME $APPADDR $STRAPP 对于不同的实例,这三个变量至少有一个不同
改包后更新目标地址与目标端口到 @BTTR_UDP,计数并离开该链,无需再匹配后续规则
comment 添加注释 $OWNNAME,用作识别规则

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 先设置 DNAT 标志为 0
两个 for 循环分别用 ip 与 nslookup 命令判断两轮
当本机 IP $LANADDR 与主路由 IP $GWLADDR 一致时,则表示 NATMap 运行在主路由上,设置 DNAT 标志为 1,若已设置则离开 for 循环
当下载设备的 IP $APPADDR 与主路由 IP $GWLADDR 一致时,则表示 NATMap 与 BT 应用均运行在主路由上,设置 DNAT 标志为 2
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 若 DNAT 标志为 0,则表示 NATMap 运行在旁路由上,通过 UPnP 请求映射规则
若存在之前的穿透端口 $OLDPORT 或 $DISPORT,则进行清理
upnpc 请求一个带有描述的映射规则,包含传输协议,公网端口 → 内网端口 → 应用端口
映射规则为内网端口到下载设备的监听端口,公网端口的映射在运营商的 NAT 网关上完成
grep 查找输出内容,若判断添加规则成功,则设置 DNAT 标志为 3
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 若 DNAT 标志为 0,则表示 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) 若 DNAT 标志为 0,则表示 UPnP 代理请求添加规则失败
upnpc 向主路由请求目标地址为自身的映射规则,之后再进行二次映射到下载设备
@ 为本机地址,映射到本机内网端口 $LANPORT,而非直达下载设备的 $APPPORT
最后设置 DNAT 标志为 4
if [ $DNAT = 3 ]; then
[ "$RELEASE" = "openwrt" ] && uci -q delete firewall.${OWNNAME}_tcp
[ "$RELEASE" = "openwrt" ] && uci -q delete firewall.${OWNNAME}_udp
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep tcp | awk '{print$NF}') 2>/dev/null
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep udp | awk '{print$NF}') 2>/dev/null
fi
if [ $DNAT != 3 ]; then
[ -z "$WANTCP" ] && uci -q delete firewall.${OWNNAME}_tcp
[ -z "$WANUDP" ] && uci -q delete firewall.${OWNNAME}_udp
[ -z "$WANTCP" ] && \
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep tcp | awk '{print$NF}') 2>/dev/null
[ -z "$WANUDP" ] && \
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep udp | awk '{print$NF}') 2>/dev/null
fi 配置 DNAT 前,先清理不需要的规则
若 DNAT 标志为 3,则表示使用 UPnP 进行端口映射,删除本实例的 uci 及 nft 规则
若 DNAT 标志不为 3,则表示使用 DNAT 进行端口映射
对于本实例未启用的传输协议,删除 uci 及 nft 的对应规则
SETDNAT() {
if [ -n "$*" ]; then
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep tcp | awk '{print$NF}') 2>/dev/null
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep udp | awk '{print$NF}') 2>/dev/null
uci -q delete firewall.stun_foo
uci -q delete firewall.${OWNNAME}_$L4PROTO
uci set firewall.${OWNNAME}_$L4PROTO=redirect
uci set firewall.${OWNNAME}_$L4PROTO.name=${OWNNAME}_${L4PROTO}_$1'->'$2
uci set firewall.${OWNNAME}_$L4PROTO.src=wan
uci set firewall.${OWNNAME}_$L4PROTO.proto=$L4PROTO
uci set firewall.${OWNNAME}_$L4PROTO.src_dport=$1
uci set firewall.${OWNNAME}_$L4PROTO.dest_port=$2
uci -q set firewall.${OWNNAME}_$L4PROTO.dest_ip=$3
uci commit firewall
fw4 -q reload
UCI=1
else
nft add chain ip STUN DNAT { type nat hook prerouting priority dstnat \; }
nft delete rule ip STUN DNAT handle $(nft -a list chain ip STUN DNAT 2>/dev/null | grep \"$OWNNAME\" | grep $L4PROTO | awk '{print$NF}') 2>/dev/null
fi
if [ "$RELEASE" = "openwrt" ] && [ "$UCI" != 1 ]; then
uci -q delete firewall.stun_foo && RELOAD=1
uci -q delete firewall.${OWNNAME}_tcp && RELOAD=1
uci -q delete firewall.${OWNNAME}_udp && RELOAD=1
if uci show firewall | grep =redirect >/dev/null; then
i=0
for CONFIG in $(uci show firewall | grep =redirect | awk -F = '{print$1}'); do
[ "$(uci -q get $CONFIG.enabled)" = 0 ] && let i++ && break
[ "$(uci -q get $CONFIG.src)" != "wan" ] && let i++
done
[ $(uci show firewall | grep =redirect | wc -l) -gt $i ] && RULE=1
fi
if [ "$RULE" != 1 ]; then
uci set firewall.stun_foo=redirect
uci set firewall.stun_foo.name=stun_foo
uci set firewall.stun_foo.src=wan
uci set firewall.stun_foo.mark=$RANDOM
RELOAD=1
fi
uci commit firewall
[ "$RELOAD" = 1 ] && fw4 -q reload
fi
} SETDNAT 定义函数,在后续调用
该函数将根据调用时传递的参数,来判断使用 uci 还是 nft 配置 DNAT

当有传递参数时,使用 uci 配置 DNAT
nft delete rule 清理 nft 旧规则,根据注释(comment)识别
uci -q delete 清理 uci 填充规则,配置名为 stun_foo
uci -q delete 清理 uci 旧规则,根据 $OWNNAME 与 $L4PROTO 识别
uci set 设置 DNAT 规则,配置名为 实例名称_传输协议 ,类型为 redirect
name 规则名为 实例名称_传输协议_内网端口_程序端口
src 源区域为 wan,如使用自定义防火墙区域请自行修改
proto 传输协议为 $L4PROTO,对应当前脚本正在处理的协议
src_dport 源端口为内网端口,此内网指运营商的电信级 NAT 内部网络
dest_port 目标端口为程序端口,即 BT 应用的监听端口
dest_ip 目标地址为下载设备的 IPv4 地址,BT 应用运行在路由器上时将留空
uci commit 提交修改
fw4 -q reload 重载防火墙后,设置 UCI 标志为 1

当无传递参数时,使用 nft 配置 DNAT
nft add chain 在 IPv4 族 / STUN 表 下创建 DNAT 链,重复添加不作改变
type nat 表示对每个连接的首包进行匹配,后续数据包直接从连接跟踪表参照 NAT 信息
hook prerouting 表示在路由前拦截数据包
priority dstnat 表示在同一拦截点下,该规则链的优先级
该链的属性为 DNAT 的标准,独立创建方便管理;若有其他 STUN 场景将共用此链
nft delete rule 清理 nft 旧规则,根据注释(comment)识别

OpenWrt 下,可能需要额外的放行规则
若当前发行版 ID 为 openwrt,且 UCI 标志不为 1(未使用 uci 创建规则)时,进行检测
uci -q delete 分别清理 uci 旧规则,若存在则设置 RELOAD 标志为 1
若存在用户自定义的端口转发规则,且至少有一条启用,则设置 RULE 标志为 1
若 RULE 标志为 1,则无需额外规则;否则使用 uci 填充一条端口转发规则
uci set 设置重定向规则,并指定名称,源区域与随机的防火墙标记
最后,设置 RELOAD 标志为 1
该规则的目的是让 OpenWrt 自动创建放行 DNAT 流量的规则
由于无需匹配任何实际流量,所以指定了随机的防火墙标记
即使随机标记与用户规则碰撞,由于未指定操作,并不会修改数据包
uci commit 提交修改
若 RELOAD 标志为 1,fw4 -q reload 重载防火墙
[ $DNAT = 1 ] || [ $DNAT = 4 ] && \
if [ "$RELEASE" = "openwrt" ] && [ -z "$IFNAME" ]; then
SETDNAT $LANPORT $APPPORT $APPADDR
else
SETDNAT
nft add rule ip STUN DNAT $IIFNAME $L4PROTO dport $LANPORT counter dnat ip to $APPADDR:$APPPORT comment $OWNNAME
fi 若 DNAT 标志为 1 或 4,表示 NATMap 运行在主路由上,或 NATMap 运行在旁路由上,但无法通过 UPnP 请求直达规则,使用 dnat 进行端口映射
若当前发行版 ID 为 openwrt,且未指定网络接口 $IFNAME,则使用 uci 创建规则
调用函数 SETDNAT,传递参数 $LANPORT $APPPORT $APPADDR
若当前发行版 ID 非 openwrt,或指定了网络接口 $IFNAME,则使用 nft 创建规则
调用函数 SETDNAT,不传递参数
nft add rule 添加规则到 ip STUN DNAT 链
入站接口为 $IIFNAME(若有)的 TCP 或 UDP 数据包,若其目标端口为内网端口 $LANPORT,则转发至下载设备的 BT 应用 $APPADDR:$APPPORT 上
comment 添加注释 $OWNNAME,用作识别规则
[ $DNAT = 2 ] && \
if [ "$RELEASE" = "openwrt" ] && [ -z "$IFNAME" ]; then
SETDNAT $LANPORT $APPPORT
else
SETDNAT
nft add rule ip STUN DNAT $IIFNAME $L4PROTO dport $LANPORT counter redirect to :$APPPORT comment $OWNNAME
fi 若 DNAT 标志为 2,则表示 NATMap 与 BT 应用均运行在主路由上,使用 redirect
若当前发行版 ID 为 openwrt,且未指定网络接口 $IFNAME,则使用 uci 创建规则
调用函数 SETDNAT,传递参数 $LANPORT $APPPORT
与上面规则相比,未指定 $APPADDR,将创建目标地址为主路由本身的规则
若当前发行版 ID 非 openwrt,或指定了网络接口 $IFNAME,则使用 nft 创建规则
调用函数 SETDNAT,不传递参数
nft add rule 添加规则到 ip STUN DNAT 链
入站接口为 $IIFNAME(若有)的 TCP 或 UDP 数据包,若其目标端口为内网端口 $LANPORT,重定向至本机的 BT 应用监听端口 $APPPORT
comment 添加注释 $OWNNAME,用作识别规则

最后
chmod +x /usr/stun_bt.sh 请记得给脚本赋予执行权限

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

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.log cat 查看穿透信息 /tmp/stun_bt.info
cat 查看穿透记录 /tmp/stun_bt.log
穿透记录若显示短时间内频繁更换端口,通常是运营商的问题,用户无法干预
nft list table ip STUN nft list table 列出 STUN 表的规则,可通过计数器确认数据包的数量和流量
upnpc -i -l upnpc 列出主路由的 UPnP 信息
请求列表不要求地址一致,可用链路上的任意设备查询,也可以通过网页访问
请求列表可能会失败,但并不代表规则不存在
cat /tmp/proxychains.conf cat 查看命令行代理的配置文件 /tmp/proxychains.conf,仅在 UPnP 启用安全模式下生成

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

配置多个实例
本脚本以文件名为依据区分实例,一个实例包括 NATMap 的 TCP 与 UDP 穿透条目各一个
在配置多个实例时,需要修改文件名 /usr/stun_bt.sh
支持配置多个接口及多个 BT 应用,包括在同一设备上配置多个应用
需要正确填写 IFNAME APPADDR APPPORT
例:
/usr/stun_bt_wanct_PC1_BC.sh + /usr/stun_bt_wancm_PC1_BC.sh
IFNAME=pppoe-wanct + IFNAME=pppoe-wancm
APPADDR=192.168.1.168 + APPADDR=192.168.1.168
APPPORT=12345 + APPPORT=12345
/usr/stun_bt_PC1_BC.sh + /usr/stun_bt_PC2_BC.sh
IFNAME= + IFNAME=
APPADDR=192.168.1.168 + APPADDR=192.168.1.169
APPPORT=12345 + APPPORT=12345
/usr/stun_bt_PC1_BC.sh + /usr/stun_bt_PC1_qB.sh
IFNAME= + IFNAME=
APPADDR=192.168.1.168 + APPADDR=192.168.1.168
APPPORT=12345 + APPPORT=23456
/usr/stun_bt_wanct_PC1_BC.sh + /usr/stun_bt_wancm_PC2_qB.sh
IFNAME=pppoe-wanct + IFNAME=pppoe-wancm
APPADDR=192.168.1.168 + APPADDR=192.168.1.169
APPPORT=12345 + APPPORT=23456