WireGuard 客户端魔改:对端节点动态更新

Wireguard 是一款开源、跨平台的虚拟局域网工具,其相比较 OpenVPN 速度更快、协议设计简单高效,易于使用,且安全性更高。

Wireguard 采用 Peer-To-Peer 构建拓扑,只需在两台需要通信的设备上安装 Wireguard,然后分别配置好对端的公钥和 IP 地址,即可建立安全隧道。就代理这类典型用途而言,某台对端将扮演“服务器”角色,通过路由转发实现其他节点流量的 NAT,其通常是一台带有公网弹性 IP 的 VPC。由于 Wireguard 协议公开,因此流量特征很容易探测,由于 IP:Port 格式构成的端点中,端口通常是被封禁的重灾区,因此迫切需要一种机制实现端口的动态更新,就好像域名和 DNS 之于 IP 解耦那样。

Wireguard 客户端由 Wireguard Inc 开源,其都是基于 wireguard-go 这个模块通过 IPC 通信实现通道操作的,在 iOS 和 macOS 下 UI 操作还经过 NetworkExtension 中转了一道。为了实现动态变更端口,只需要在平台原生代码(Go,Swift)调用 wireguard-go 模块时将配置结构体转换为 wg-quick 配置文本的过程中动态替换和修改配置文件中的端口号即可。

Windows 的 GUI 魔改

Wireguard Windows 客户端的 GUI 界面是 go 调用 Win32 API 实现的,原本代码封装的很好,因此只需要在读取到加密配置文件后,根据隧道名来动态生成对端 IP,然后将消息发送给 wireguard-go 模块以创建隧道即可。下面的代码实现了一种从配置文件名中解析 BASE PORT,然后加上当天是今年的第几天来生成对端端口的方法。

// parser.go
//如果 name 以 -auto+number 结尾,那么将 endpoint 更改为 number + time.Now().YearDay()
if strings.Contains(name, "-auto+") {
    //从 name 中提取 -auto+number 中的 number,为纯数字
    number := strings.Split(name, "-auto+")[1]
    port, err := parsePort(number)
    if err != nil {
        return nil, err
    }
    newPort := uint16(time.Now().YearDay()) + port
    e.Port = newPort
    peer.Endpoint = *e
} else {
    peer.Endpoint = *e
}

对应的服务端需要定时更新暴露的端口(Ubuntu):

base_port=10086
echo "Wireguard port updater start at $(date +%Y-%m-%d\ %H:%M:%S)"
port_now=$(cat /etc/wireguard/wg0.conf | awk -F ' *= *' '/ListenPort/ {print $2}')
echo "Now Port is $port_now"
new_port=$(expr `date '+%j'` + $base_port)
echo "The New Port will be $new_port"
/bin/systemctl stop wg-quick@wg0.service
sleep 1
sed -i '/^ListenPort =/s/^ListenPort =.*/ListenPort = '"$new_port"'/' /etc/wireguard/wg0.conf
sleep 1
echo "Deny $port_now on ufw, Allow $new_port on ufw"
ufw deny $port_now/udp
ufw allow $new_port/udp
echo "Start Wireguard service"
/bin/systemctl start wg-quick@wg0.service
wg
echo "Done update port"

相关的代码参见 Github - Windows,二进制安装包参见 天翼云盘

macOS 和 iOS 的 GUI 魔改

在 WireguardKit 的 PacketTunnelSettingsGenerator.swift 中,修改如下两个函数,以按照我们的逻辑生成 Endpoint 节点端口。注

意, WireguardKit 调用 WireguardKitGo 后端,但 WireguardApp(包括 macOS 和 iOS 前端)则并不直接调用 WireguardKit,其中间还夹带了一个 WireguardNetworkExtension,在这个过程中,通道名称丢失了,因此不能像 Windows 那样根据 -auto+number 来实现一般静态通道和动态通道模式,只能无脑将原有端口看做动态端口基数。不过好处是,在不启动 Wireguard 前端 App 的情况下,iOS 的 VPN 设置或 macOS 的网络设置中,可直接启动唤起 Go 后端启动通道。

class PacketTunnelSettingsGenerator {
    ...

    func endpointUapiConfiguration() -> (String, [EndpointResolutionResult?]) {
        var resolutionResults = [EndpointResolutionResult?]()
        var wgSettings = ""

        assert(tunnelConfiguration.peers.count == resolvedEndpoints.count)
        for (peer, resolvedEndpoint) in zip(self.tunnelConfiguration.peers, self.resolvedEndpoints) {
            wgSettings.append("public_key=\(peer.publicKey.hexKey)\n")

            let result = resolvedEndpoint.map(Self.reresolveEndpoint)
            if case .success((_, let resolvedEndpoint)) = result {
                if case .name = resolvedEndpoint.host { assert(false, "Endpoint is not resolved") }
                // 改动发生在这里
                let port = Calendar.current.ordinality(of: .day, in: .year, for: Date())! + Int(resolvedEndpoint.port.rawValue)
                let newEndpoint = Endpoint(host: resolvedEndpoint.host, port: port)
                wgSettings.append("endpoint=\(newEndpoint.stringRepresentation)\n")
            }
            resolutionResults.append(result)
        }

        return (wgSettings, resolutionResults)
    }

    func uapiConfiguration() -> (String, [EndpointResolutionResult?]) {
        var resolutionResults = [EndpointResolutionResult?]()
        var wgSettings = ""
        wgSettings.append("private_key=\(tunnelConfiguration.interface.privateKey.hexKey)\n")
        if let listenPort = tunnelConfiguration.interface.listenPort {
            wgSettings.append("listen_port=\(listenPort)\n")
        }
        if !tunnelConfiguration.peers.isEmpty {
            wgSettings.append("replace_peers=true\n")
        }
        assert(tunnelConfiguration.peers.count == resolvedEndpoints.count)
        for (peer, resolvedEndpoint) in zip(self.tunnelConfiguration.peers, self.resolvedEndpoints) {
            wgSettings.append("public_key=\(peer.publicKey.hexKey)\n")
            if let preSharedKey = peer.preSharedKey?.hexKey {
                wgSettings.append("preshared_key=\(preSharedKey)\n")
            }

            let result = resolvedEndpoint.map(Self.reresolveEndpoint)
            if case .success((_, let resolvedEndpoint)) = result {
                if case .name = resolvedEndpoint.host { assert(false, "Endpoint is not resolved") }
                // 改动发生在这里
                let port = Calendar.current.ordinality(of: .day, in: .year, for: Date())! + Int(resolvedEndpoint.port.rawValue)
                let newEndpoint = Endpoint(host: resolvedEndpoint.host, port: port)
                wgSettings.append("endpoint=\(newEndpoint.stringRepresentation)\n")
            }
            resolutionResults.append(result)

            let persistentKeepAlive = peer.persistentKeepAlive ?? 0
            wgSettings.append("persistent_keepalive_interval=\(persistentKeepAlive)\n")
            if !peer.allowedIPs.isEmpty {
                wgSettings.append("replace_allowed_ips=true\n")
                peer.allowedIPs.forEach { wgSettings.append("allowed_ip=\($0.stringRepresentation)\n") }
            }
        }
        // 这里的日志可以检查我们的改动是否生效
        wg_log(.info, message: "wgSetting now is\n\(wgSettings)")
        return (wgSettings, resolutionResults)
    }

    ...

Demo

相关的代码参见 Github - macOS and iOS。注意,编译时需要按照 README.md 的方法,此外为全局项目添加用户定义变量 PATH,添加 go 路径。

macOS 下的一种无 GUI 简单方法

如果不需要 GUI,macOS 下有直接基于 CLI 的 wireguard-tools 可用,其背后同样是基于跨端的 wireguard-go 实现的。只需要简单的 brew install wireguard-tools 即可。和 Linux 类似,直接在 /opt/homebrew/etc/wireguard/wg0.conf 下配置好 wg-quick 配置,然后直接 wg-quick up/down wg0 即可。

因此,简单的 Bash 脚本就能像服务端一样每天自动更新端口:

base_port=10086
echo "Wireguard port updater start at $(date +%Y-%m-%d\ %H:%M:%S)"
endpoint=$(awk '/Endpoint/{print}' /opt/homebrew/etc/wireguard/wg0.conf)
ip=$(echo $endpoint | awk -F'[: ]+' '{print $3}')
port=$(echo $endpoint | awk -F'[: ]+' '{print $4}')
day=$(date +%j)
new_port=$(($day + $base_port))
sed -i '' "s/\(:[0-9]\{1,5\}\)/:$new_port/g" /opt/homebrew/etc/wireguard/wg0.conf
sudo wg-quick up wg0
echo "Done update port and start vpn"

使用这种方法会直接创建一个静态路由,将基本所有流量导向 utunX:netstat -rn 可以看到,在关闭 wg-quick down wg0 后会删除这条路由。注意,使用此方法不需要安装 GUI 版本,同时也没有网络扩展,因此无法在网络偏好设置控制 Wireguard。

CyberMe 提供了一个 macOS 下的状态栏图标,点击以触发 netstat -rn 显示路由,可通过判断是否存在网关为 utunX 的路由来知晓现在 VPN 状态,点击菜单可执行上述脚本以自动启停 wg-quick。

实事求是的讲,如果觉得命令行复杂,以至于需要状态栏图标的地步,还不如直接魔改 macOS 和 iOS 客户端来的方便。