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 搞定:
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) % 4if pad != 4: clean += '=' * paddata = 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~09、group-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
保存后验证配置:
sing-box check -c /etc/sing-box/config.jsonsystemctl 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 切:
# 切到香港组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。
先看当前规则:
iptables -L 1PANEL_BASIC -n --line-numbers加一条放行 9090:
iptables -I 1PANEL_BASIC 1 -p tcp -m tcp --dport 9090 -j ACCEPT踩坑记录
corepack 不走环境变量代理
Node.js 24 的 corepack(装 pnpm)用的内置 fetch,不认 HTTP_PROXY 环境变量。设了也没用:
export HTTP_PROXY=http://127.0.0.1:2080 # corepack 不吃这套corepack prepare pnpm@latest --activate # 还是报 fetch 错误解决方案:不用 corepack,直接用 npm 装:
npm config set proxy http://127.0.0.1:2080npm config set https-proxy http://127.0.0.1:2080npm 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) % 4if pad != 4: clean += '=' * pad总结
搞完以后的架构:
内网设备 → <AI_HOST_IP>:2080 (mixed 入站) │ ▼ proxy Selector ┌───────┼───────┐ group-hk group-jp ... hysteria2 │ │ 香港01~09 日本01~08Dashboard 在 http://<AI_HOST_IP>:9090/,随时切节点。这台机器上所有 CLI 工具(npm、curl、docker pull)都可以加 HTTP_PROXY=http://127.0.0.1:2080 走代理出口。
整个配置过程一个 Python 脚本完成订阅解析 + 配置生成,以后更新节点只需要重新跑一遍脚本、重启 sing-box 就行。