前言

本文介绍如何在 macOS 下使用 sing-box 替代 wireguard-tools + Xray + Proxifier 的组合方案,实现全局代理、多 VPN 网络共存与开机自启。

旧方案存在几个痛点:

  • 工具分散:需要 Xray(代理)+ Proxifier(全局代理)+ wireguard-tools(多 VPN)三个工具配合
  • 手动启动:每次开机需要分别启动 Proxifier,并手动执行 sudo wg-quick up 加入各个 VPN 网络
  • 虚拟机不兼容:Proxifier 基于 NetworkExtension 实现,无法将代理共享给基于 NAT 的 macOS 虚拟机(比如基于 Tart 的虚拟机)
  • 官方 WireGuard 限制:Mac App Store 的 WireGuard 客户端不支持同时连接多个 VPN 网络,所以 userspace wireguard 暂时是唯一解

sing-box 通过 TUN 模式在 L3 层接管流量,本质上是对系统路由表的配置。意味着:

  • 一个工具即可同时实现全局代理并实现多 WireGuard VPN 兼容
  • 基于 NAT 的虚拟机可自动继承宿主机的代理和 VPN 配置
  • 可通过 brew services 实现开机自启,无需手动介入

环境说明

网段规划

我需要同时接入两个 VPN 网络:

  • 家庭网络:192.168.188.0/24
  • 公司网络:172.31.0.0/16

DNS 解析策略

  • 内网 DNS:我在我家的内网,部署了内网 DNS 服务,服务器地址是:192.168.188.140,负责解析 .jinmiaoluo.com 下的内网服务,当域名在我自己的 DNS 上没有内网记录时,DNS 服务会自动通过公网获取可能存在的公网解析记录
  • 公司内网服务 都是 .company.com 域名,域名解析到 172.31.0.0/16 这类内网 IP,必须通过 VPN 才能访问

SSH 安全策略

所有公网服务器的 SSH 登录请求,默认走代理服务器,从而提供唯一的出口 IP。

所有公网暴露 SSH 服务的服务器,通过防火墙限制 SSH 端口只有代理服务器的出口 IP 才能访问。好处:

  • 彻底隐藏 SSH 服务:SSH 端口对互联网不可见,端口扫描无法发现
  • 消除暴力破解风险:攻击者无法尝试密码或密钥爆破
  • 简化密钥管理:即使使用弱密码或旧密钥,攻击面也被网络层阻断

下面的 sing-box 配置基于以上策略。

操作

  1. 通过 brew 安装 sing-box
brew install sing-box
  1. 移除了敏感性的 sing-box 配置如下。参考下面的配置添加你的配置即可。
{
  "log": {
    "level": "error",
    "timestamp": true
  },
  "dns": {
    "servers": [
      {
        "tag": "local-dns",
        "type": "udp",
        "server": "192.168.188.140",
        "server_port": 53,
        "detour": "home"
      },
      {
        "tag": "china-dns",
        "type": "https",
        "server": "223.5.5.5",
        "server_port": 443,
        "tls": {
          "server_name": "dns.alidns.com"
        }
      },
      {
        "tag": "proxy-dns",
        "type": "https",
        "server": "1.1.1.1",
        "detour": "proxy"
      },
      {
        "tag": "fakeip-dns",
        "type": "fakeip",
        "inet4_range": "198.18.0.0/15"
      }
    ],
    "rules": [
      {
        "domain_suffix": [".jinmiaoluo.com"],
        "action": "route",
        "server": "local-dns"
      },
      {
        "domain_suffix": [".company.com"],
        "action": "route",
        "server": "china-dns"
      },
      {
        "rule_set": "geosite-cn",
        "action": "route",
        "server": "china-dns"
      },
      {
        "query_type": ["A", "AAAA"],
        "action": "route",
        "server": "fakeip-dns"
      },
      {
        "action": "route",
        "server": "proxy-dns"
      }
    ],
    "strategy": "ipv4_only",
    "independent_cache": true
  },
  "endpoints": [
    {
      "tag": "home",
      "type": "wireguard",
      "private_key": "xxx",
      "address": [
        "10.66.153.175/16"
      ],
      "mtu": 1280,
      "peers": [
        {
          "public_key": "xxx",
          "pre_shared_key": "xxx",
          "address": "xxx",
          "port": 51820,
          "allowed_ips": [
            "10.66.0.0/16",
            "192.168.0.0/16"
          ],
          "persistent_keepalive_interval": 15
        }
      ]
    },
    {
      "tag": "company",
      "type": "wireguard",
      "private_key": "xxx",
      "address": ["10.99.128.68/16"],
      "mtu": 1280,
      "peers": [
        {
          "public_key": "xxx",
          "pre_shared_key": "xxx",
          "address": "xxx",
          "port": 51820,
          "allowed_ips": [
            "10.99.0.0/16",
            "172.31.0.0/16"
          ],
          "persistent_keepalive_interval": 15
        }
      ]
    }
  ],
  "inbounds": [
    {
      "tag": "tun-in",
      "type": "tun",
      "address": [
        "172.19.0.1/30"
      ],
      "mtu": 9000,
      "auto_route": true,
      "strict_route": true,
      "stack": "mixed"
    }
  ],
  "outbounds": [
    {
      "tag": "proxy",
      "type": "vless",
      "server": "xxx",
      "server_port": 443,
      "uuid": "xxx",
      "flow": "xtls-rprx-vision",
      "tls": {
        "enabled": true,
        "server_name": "proxy.example.com",
        "alpn": ["h2", "http/1.1"],
        "utls": {
          "enabled": true,
          "fingerprint": "chrome"
        }
      }
    },
    {
      "tag": "direct",
      "type": "direct"
    }
  ],
  "route": {
    "auto_detect_interface": true,
    "default_domain_resolver": "proxy-dns",
    "rules": [
      {
        "inbound": "tun-in",
        "action": "sniff",
        "timeout": "1s"
      },
      {
        "type": "logical",
        "mode": "or",
        "rules": [
          { "protocol": "dns" },
          { "port": 53 }
        ],
        "action": "hijack-dns"
      },
      {
        "rule_set": "geosite-category-ads-all",
        "action": "reject"
      },
      {
        "ip_cidr": [
          "10.66.0.0/16",
          "192.168.0.0/16"
        ],
        "outbound": "home"
      },
      {
        "ip_cidr": [
          "10.99.0.0/16",
          "172.31.0.0/16"
        ],
        "outbound": "company"
      },
      {
        "port": 123,
        "network": ["udp"],
        "outbound": "direct"
      },
      {
        "type": "logical",
        "mode": "or",
        "rules": [
          { "port": 853 },
          { "network": "udp", "port": 443 },
          { "protocol": "stun" }
        ],
        "action": "reject"
      },
      {
        "rule_set": [
          "geosite-google"
        ],
        "outbound": "proxy"
      },
      {
        "ip_is_private": false,
        "port": 22,
        "outbound": "proxy"
      },
      {
        "rule_set": ["geosite-cn", "geosite-private"],
        "outbound": "direct"
      },
      {
        "rule_set": "geoip-cn",
        "outbound": "direct"
      },
      {
        "domain_suffix": [
          ".jinmiaoluo.com",
          ".company.com"
        ],
        "outbound": "direct"
      },
      {
        "ip_is_private": true,
        "outbound": "direct"
      }
    ],
    "rule_set": [
      {
        "tag": "geosite-cn",
        "type": "remote",
        "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
        "download_detour": "proxy"
      },
      {
        "tag": "geosite-private",
        "type": "remote",
        "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-private.srs",
        "download_detour": "proxy"
      },
      {
        "tag": "geosite-google",
        "type": "remote",
        "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs",
        "download_detour": "proxy"
      },
      {
        "tag": "geosite-category-ads-all",
        "type": "remote",
        "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
        "download_detour": "proxy"
      },
      {
        "tag": "geoip-cn",
        "type": "remote",
        "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
        "download_detour": "proxy"
      }
    ],
    "final": "proxy"
  },
  "experimental": {
    "cache_file": {
      "enabled": true,
      "path": "cache.db",
      "store_fakeip": true
    }
  }
}
  1. 创建数据目录并写入配置文件
mkdir -p /opt/homebrew/var/lib/sing-box
tee /opt/homebrew/etc/sing-box/config.json > /dev/null << 'EOF'
# 将上面的 JSON 配置粘贴到这里
EOF
  1. (可选)调试配置

在启动服务前,可以通过手动运行 sing-box 来验证配置是否正确:

sudo /opt/homebrew/opt/sing-box/bin/sing-box run \
    --config /opt/homebrew/etc/sing-box/config.json \
    --directory /opt/homebrew/var/lib/sing-box

手动运行时日志会直接输出到终端,便于排查问题。如需更详细的日志,可以将配置中的 log.levelerror 改为 debug

  1. 启动服务
sudo brew services start sing-box

启动后即可完成全局代理 + 多 VPN 网络的配置。后续开机会自动启动。

补充说明

关于 sniff

配置中通过 route 规则 "action": "sniff"tun-in 入站流量启用协议嗅探,从 TLS SNI 或 HTTP Host 中提取真实域名,用于后续路由规则匹配。"timeout": "1s" 指定嗅探超时时间,超时后放弃嗅探、继续路由。

sniff 相关配置在 sing-box 1.11.0 版本发生了重大变更(将在 1.13.0 正式移除旧字段,详见 Migration):

  • inbound.sniff 已废弃,迁移到 route 规则的 "action": "sniff"
  • inbound.sniff_override_destination 已废弃且被移除,没有等效替代。开发者认为"用嗅探到的域名替换目标 IP"是从 V2Ray 继承的错误设计(参考)。新版 sniff 只提取域名用于路由匹配,不会替换目标 IP。这正好符合我们的需求——内网域名通过 local-dns 解析到内网 IP 后,目标地址始终保持为该内网 IP

关于 VPN 路由规则使用 192.168.0.0/16

在配置中,家庭网络的实际网段是 192.168.188.0/24,但 VPN 路由规则使用了更大的 192.168.0.0/16:

"ip_cidr": [
  "10.66.0.0/16",
  "192.168.0.0/16"
],
"outbound": "home"

这样设计的目的是利用路由前缀树(最长前缀匹配)的优先级原理:

  • 当在外面时:访问 192.168.188.x 会匹配 192.168.0.0/16 规则,流量走 VPN 回家
  • 当回家接入 WiFi 后:系统会添加 192.168.188.0/24 的本地路由
  • 192.168.188.0/24 比 192.168.0.0/16(24 VS 16)更具体,优先级更高
  • 结果:本地路由自动生效,VPN 路由规则被覆盖,内网访问不再绕行 VPN

这避免了在家时访问内网服务仍然绕行 VPN 的问题。

关于移动热点场景下的 DNS 配置

当 Mac 通过 iPhone 热点上网时,系统会自动使用 iPhone 分配的 DNS。这会导致一个问题:

  1. 查询 nas.jinmiaoluo.com
  2. 运营商 DNS 不知道内网记录,返回公网 IP(或 NXDOMAIN)
  3. 内网 DNS 服务器(192.168.188.140)的记录无法生效

虽然 sing-box 配置了 hijack-dns 规则来劫持 DNS 流量,但 macOS 在某些场景下可能绕过 TUN 接口直接查询系统 DNS。

解决方案:手动将系统 DNS 指向 TUN 接口地址,强制所有 DNS 查询经过 sing-box 处理。

操作路径:系统设置 > Wi-Fi > 点击已连接网络旁的 详细信息 > DNS > 添加 172.19.0.1

配置后,DNS 查询流程变为:

  1. 应用发起 DNS 查询,目标地址 172.19.0.1
  2. 流量进入 TUN 接口,被 hijack-dns 规则拦截
  3. sing-box DNS 模块按规则分流:.jinmiaoluo.com 走内网 DNS,其他按规则处理
  4. 内网 DNS 请求通过 WireGuard VPN 发出,返回正确的内网 IP

关于拦截 DoT、QUIC 和 STUN

配置中有一条路由规则拦截了三类流量:

{
  "type": "logical",
  "mode": "or",
  "rules": [
    { "port": 853 },
    { "network": "udp", "port": 443 },
    { "protocol": "stun" }
  ],
  "action": "reject"
}

为什么要拦截这三类流量:

  • 端口 853(DNS over TLS):部分应用(如 Firefox)内置了 DoT/DoH 客户端,会绕过系统 DNS 直接向公共 DNS 服务器发起加密查询。这些查询不经过 sing-box 的 DNS 劫持规则,导致内网域名解析失败、FakeIP 机制失效。拦截 853 端口后,应用会回退到普通 DNS(端口 53),被 hijack-dns 规则正常捕获
  • UDP 443(QUIC):QUIC 协议自带拥塞控制,当流量经过代理链路(VLESS over TLS)时,QUIC 的拥塞控制与代理链路的 TCP 拥塞控制会互相干扰,导致吞吐量下降。拦截 QUIC 后浏览器会自动回退到 HTTP/2 over TCP,代理链路只有一层拥塞控制,性能更好
  • STUN 协议(WebRTC):浏览器通过 STUN 协议进行 NAT 穿透,用于发现本机公网 IP。如果 STUN 请求绕过代理直连,会暴露真实 IP 地址。拦截 STUN 可以防止 WebRTC IP 泄露

总结

这是目前我在 macOS(26.2) 上能找到的比较完美的方案。同时解决下面的问题。

  • 全局代理:所有流量经过 TUN 接口,无需为每个应用单独配置代理
  • 多 VPN 网络共存:同时连接家庭和公司的 WireGuard VPN,按目标 IP 自动分流,并解决了路由冲突的问题
  • 完全自动化:配置完成后通过 brew services 开机自启,日常使用无需任何手动操作

2026 年 1 月 25 日深夜,于北京路 1200bookshop。