sing-box 客户端多节点配置:订阅解析 + 地区分组 + Web Dashboard

前言

内网那台 Debian 13 上一直跑着 sing-box,之前只配了一个 Hysteria2 节点,凑合能用。最近节点越来越多,订阅里塞了六七十个 —— 每次想切地区还得手动改配置重启,实在受不了。

正好 sing-box 支持 Selector 类型的 outbound 和 Clash API,能像 Clash 一样分组切换,再搭个 Web Dashboard 就能在浏览器里点来点去换节点了。这篇记录一下完整配置过程。

环境

  • Debian 13 (trixie),内网 IP <AI_HOST_IP>
  • sing-box v1.13.11(bash <(curl -fsSL https://sing-box.app/deb-install.sh) 安装)
  • Node.js 24 + nvm
  • 1Panel 面板(自带 iptables 防火墙)

第一步:解析订阅链接

订阅链接返回的是 base64 编码的节点列表,需要先解码。直接用 curl + Python 搞定:

Terminal window
curl -s --connect-timeout 10 '你的订阅链接' | python3 << 'PYEOF'
import sys, base64, urllib.parse, json
raw = sys.stdin.buffer.read().decode().strip()
clean = raw.replace('\n','').replace('\r','').replace(' ','')
pad = 4 - len(clean) % 4
if pad != 4: clean += '=' * pad
data = base64.b64decode(clean).decode()
items = [l.strip() for l in data.replace('\r\n','\n').split('\n') if l.strip()]
nodes = []
for line in items:
if '#' not in line or not line.startswith('trojan://'):
continue
uri_part, name = line.split('#', 1)
20 collapsed lines
name = urllib.parse.unquote(name)
parsed = urllib.parse.urlparse(uri_part)
params = urllib.parse.parse_qs(parsed.query)
sni = params.get('sni', [None])[0]
peer = params.get('peer', [None])[0]
insecure = params.get('allowInsecure', ['0'])[0] == '1'
nodes.append({
'tag': name,
'server': parsed.hostname,
'port': parsed.port,
'password': parsed.username,
'sni': sni or peer or parsed.hostname,
'insecure': insecure
})
print(f"Parsed {len(nodes)} trojan nodes")
with open('/tmp/trojan_nodes.json', 'w') as f:
json.dump(nodes, f, ensure_ascii=False, indent=2)
PYEOF

我的订阅解析出来是 64 个 Trojan 节点,覆盖香港、新加坡、台湾、日本、美国、马来西亚等多个地区。

第二步:生成分组配置

有了节点列表之后,自动按地区分组生成 Selector,再套一层顶层 Selector。用 Python 跑:

import json
with open('/tmp/trojan_nodes.json') as f:
nodes = json.load(f)
# 地区识别:通过节点标签中的关键词
def get_region(tag):
if '香港' in tag: return 'hk'
if '狮城' in tag or '新加坡' in tag: return 'sg'
if '台湾' in tag: return 'tw'
if '日本' in tag: return 'jp'
if '美国' in tag: return 'us'
if '马来西亚' in tag: return 'my'
return 'other'
50 collapsed lines
groups = {}
for n in nodes:
groups.setdefault(get_region(n['tag']), []).append(n)
outbounds = []
# 保留原有 Hysteria2 节点(如果有)
outbounds.append({
"type": "hysteria2",
"tag": "hysteria2",
"server": "你的服务器IP",
"server_port": 8443,
"password": "你的密码",
"tls": {"enabled": True, "server_name": "localhost", "insecure": True}
})
# 全部 Trojan 节点
for n in nodes:
outbounds.append({
"type": "trojan",
"tag": n['tag'],
"server": n['server'],
"server_port": n['port'],
"password": n['password'],
"tls": {
"enabled": True,
"server_name": n['sni'],
"insecure": n['insecure']
}
})
outbounds.append({"type": "direct", "tag": "direct"})
# 地区级 Selector 分组
regional_tags = []
for r, nlist in groups.items():
tag = f"group-{r}"
regional_tags.append(tag)
outbounds.append({
"type": "selector",
"tag": tag,
"outbounds": [n['tag'] for n in nlist]
})
# 顶层 Selector:选地区 or Hysteria2 or 直连
outbounds.append({
"type": "selector",
"tag": "proxy",
"outbounds": regional_tags + ['hysteria2', 'direct']
})

最终 proxy 这个 Selector 展开是:group-hk → 香港 01~09group-jp → 日本 01~08…… 两层结构,Dashboard 里先选大区再选具体节点。

第三步:完整配置

把上一步的 outbounds 列表嵌入完整配置,加上 Clash API、缓存、入站和路由:

{
"log": {"level": "info", "timestamp": true},
"inbounds": [
{
"type": "mixed",
"tag": "mixed-in",
"listen": "127.0.0.1",
"listen_port": 2080
}
],
"outbounds": [...],
"route": {
"auto_detect_interface": true,
"rules": [
{"ip_is_private": true, "outbound": "direct"}
16 collapsed lines
],
"final": "proxy"
},
"experimental": {
"clash_api": {
"external_controller": "0.0.0.0:9090",
"external_ui": "/var/lib/sing-box/ui",
"external_ui_download_url": "https://github.com/MetaCubeX/metacubexd/archive/gh-pages.zip",
"external_ui_download_detour": "proxy"
},
"cache_file": {
"enabled": true,
"path": "/var/lib/sing-box/cache.db"
}
}
}

几个关键点:

  • external_controller: 0.0.0.0:9090:必须监听 0.0.0.0,否则局域网其他设备连不上
  • external_ui_download_detour: proxy:让下载 Dashboard 走代理出站,不然 GitHub 连不上
  • cache_file:v1.8+ 以后必须配在 experimental 下,放在 clash_api 里会报 deprecated

保存后验证配置:

Terminal window
sing-box check -c /etc/sing-box/config.json
systemctl restart sing-box

第四步:Dashboard 部署

sing-box 支持自动下载 Dashboard。在 clash_api 里配上 external_ui_download_url,启动时会自动 fetch zip 解压到 external_ui 目录。

如果 GitHub 打不开,可以用镜像 URL 替换下载地址,或者手动下载 MetaCubeXD 的 gh-pages zip 丢进去。

部署好以后访问 http://<AI_HOST_IP>:9090/ 就能看到面板,所有节点和分组一目了然:

  • 顶层 Selector proxy 选地区
  • 地区 Selector(group-hk 等)选具体节点
  • 点击就能切,和 Clash Verge 体验一样

命令行切换

不想开浏览器也能用 curl 切:

Terminal window
# 切到香港组
curl -X PUT http://127.0.0.1:9090/proxies/proxy \
-H 'Content-Type: application/json' \
-d '{"name":"group-hk"}'
# 组内选具体节点
curl -X PUT http://127.0.0.1:9090/proxies/group-hk \
-H 'Content-Type: application/json' \
-d '{"name":"🇭🇰 香港 03"}'

第五步:防火墙放行 9090

我的 Debian 上跑着 1Panel,它的 iptables 规则只放行了 80/443/22/2080 等固定端口,9090 没在白名单里,所以局域网设备连不上 Dashboard。

先看当前规则:

Terminal window
iptables -L 1PANEL_BASIC -n --line-numbers

加一条放行 9090:

Terminal window
iptables -I 1PANEL_BASIC 1 -p tcp -m tcp --dport 9090 -j ACCEPT

踩坑记录

corepack 不走环境变量代理

Node.js 24 的 corepack(装 pnpm)用的内置 fetch,不认 HTTP_PROXY 环境变量。设了也没用:

Terminal window
export HTTP_PROXY=http://127.0.0.1:2080 # corepack 不吃这套
corepack prepare pnpm@latest --activate # 还是报 fetch 错误

解决方案:不用 corepack,直接用 npm 装:

Terminal window
npm config set proxy http://127.0.0.1:2080
npm config set https-proxy http://127.0.0.1:2080
npm install -g pnpm --force # --force 覆盖 corepack 留下的残留文件

--force 是因为之前 corepack 创建了 pnpx 文件导致 EEXIST 冲突。

feed 404

某些代理环境可能导致请求大小写异常,触发 Cloudflare 等 CDN 返回 404。确保连接的节点走 HTTPS 且 SNI 正确。

base64 解码 padding 问题

订阅返回的 base64 字符串如果带换行符,先 strip 掉空白,再补 padding 到 4 的倍数:

clean = raw.replace('\n','').replace('\r','').replace(' ','')
pad = 4 - len(clean) % 4
if pad != 4: clean += '=' * pad

总结

搞完以后的架构:

内网设备 → <AI_HOST_IP>:2080 (mixed 入站)
proxy Selector
┌───────┼───────┐
group-hk group-jp ... hysteria2
│ │
香港01~09 日本01~08

Dashboard 在 http://<AI_HOST_IP>:9090/,随时切节点。这台机器上所有 CLI 工具(npm、curl、docker pull)都可以加 HTTP_PROXY=http://127.0.0.1:2080 走代理出口。

整个配置过程一个 Python 脚本完成订阅解析 + 配置生成,以后更新节点只需要重新跑一遍脚本、重启 sing-box 就行。