dsh-web-fetch-vpn
让 DSH 的 web_fetch 在开着 TUN / fake-IP 代理(Clash、Surge、sing-box 等)时照常工作。
现象
VPN 一开,web_fetch 对任何网址都报:
网页获取 · Error: URL hostname "api-docs.deepseek.com" resolves to a non-public IP address
关掉 VPN 立刻正常。web_search 不受影响(它走 DeepSeek API,不做本机地址校验)。
根因
@deepseek-ai/dsh-web-fetch-http 是一条有意的 SSRF 防护:先把域名解析一次,只要答案里出现非公网单播地址就整组拒绝,再把校验过的地址钉进连接,防止解析与连接之间被换地址。
TUN 模式 + fake-IP 的代理会拦截 DNS,对任何域名都返回 198.18.0.0/15 里的合成地址(本机实测 api-docs.deepseek.com → 198.18.0.240)。这个地址不是目的地,隧道会在下一次 TCP connect 时把它路由到真实服务器 —— 但它在地址分类上属于「保留段」,于是被上面的防护全部拒掉,浏览器能力在 VPN 期间整体消失。
做法
插件只替换「解析策略」这一步,其余(跳转、字节上限、编码解码、系统代理路由、错误码)仍然由官方 provider 负责:
| 情况 | 处理 |
|---|
| 系统 DNS 返回的地址全是公网 | 原样通过,和官方行为完全一致 |
答案全在 tunnelCidrs(默认 198.18.0.0/15) | 视为可连接:隧道才是这些地址的真实目的地 |
| 答案里混有真正的私网 / 回环 / 链路本地 | 仍然拦截(防护没有被削弱) |
答案是 0.0.0.0、被污染或被 DNS 广告过滤拦掉 | 回退到 DNS-over-HTTPS,钉住真实公网地址 |
| 钉住 fake-IP 后连接失败(隧道没接管这个域名) | 对该主机强制 DoH 再重试一次,成功后 2 分钟内记住 |
它不注册新 provider,web.fetchProvider: http 保持不变 —— 因此关掉这个 bundle 就等于还原官方行为,不需要改任何配置。
模式
| mode | 行为 |
|---|
auto(默认) | 接受隧道网段;连接失败时用 DoH 重试一次 |
passthrough | 接受隧道网段,但从不重试(失败即失败) |
doh | 跳过系统 DNS,直接用 DoH 解析真实地址(最快,但会绕过内网 split-horizon DNS) |
system | 完全保持官方规则,用于对比排查 |
配置
配置在插件自己的 cordis.patch.yml 里(也可以在 profile 的 cordis.patch.yml 里按 row id web-fetch-vpn 覆盖):
- insert:
- id: web-fetch-vpn
name: dsh-web-fetch-vpn
config:
mode: auto
tunnelCidrs:
- 198.18.0.0/15 # Clash / Surge / sing-box 默认 fake-IP 段
dohEndpoints:
- https://dns.alidns.com/resolve
- https://doh.pub/dns-query
- https://cloudflare-dns.com/dns-query
- https://dns.google/resolve
dohTimeoutMs: 4000
retryWithDoh: true
log: false # true 会把每次解析决策打到实例控制台
| 字段 | 默认 | 说明 |
|---|
mode | auto | 见上表 |
tunnelCidrs | 198.18.0.0/15 | 代理用来合成答案的网段;不确定就看你代理的 fake-ip-range |
dohEndpoints | 阿里 / 腾讯 / Cloudflare / Google | 按顺序尝试,第一个给出公网地址的胜出 |
dohTimeoutMs | 4000 | 单个 DoH 查询预算 |
cacheTtlMs | 60000 | DoH 结果缓存,避免一次页面抓取打多次查询 |
preferDoh | false | true = 所有域名都先走 DoH(等价于全局 doh 模式,但保留系统 DNS 兜底) |
retryWithDoh | true | 连接失败后是否用 DoH 重试一次 |
log | false | 解析决策日志 |
也可以用环境变量临时改:DSH_WEB_FETCH_VPN_MODE=system、DSH_WEB_FETCH_VPN_LOG=1。
验证
# 纯策略测试 + 本机 DNS/DoH 实测(不联网部分也能跑:加 --offline)
node test/selftest.mjs
# 端到端:同一台机器上先复现官方报错,再用本插件的策略真的抓下页面
node test/e2e-live.mjs https://api-docs.deepseek.com/
本机实测输出(2026-10-17):
system DNS: 198.18.0.240 (tunnel)
DoH: 43.169.32.32
stock error: WEB_BLOCKED_URL URL hostname "api-docs.deepseek.com" resolves to a non-public IP address
decorated status 200, html, 49271 chars
排障过程与「热加载到底谁负责」的结论见 TROUBLESHOOT-2026-10-17-hot-reload.md。
安装 / 卸载
安装就是「往 profile 里加一个 bundle」:
- 把本目录 junction 到 profile 的
node_modules:
mklink /J "D:\dsh-v2\inst-3080\home\profiles\web\node_modules\dsh-web-fetch-vpn" "D:\桌面\DSH与自动化\dsh-web-fetch-vpn"
先建 junction,让包在下一步之前就可解析。
home\profiles\web\package.json 里加依赖 "dsh-web-fetch-vpn": "link:D:/桌面/DSH与自动化/dsh-web-fetch-vpn",并把 dsh-web-fetch-vpn 追加到 dsh.profile.bundles。
- 保存即可:base bundle 的
@deepseek-ai/dsh-hmr 监听 profile manifest,dsh.profile.bundles 一变就重合成并当场装载,不需要重启(本机 2026-10-17 实测,见 TROUBLESHOOT-2026-10-17-hot-reload.md)。冷启动同样成立;只有 HMR 被禁用或重合成报错时才需要 node D:\dsh-v2\inst-3080\launch.mjs --restart。
例外:改 package.json 的 exports / files(例如新增 locale/*.json)必须重启。Node 按进程缓存 package.json 的 exports,运行中的实例看不到新增的导出;而插件页文案走 app-boot 的 readPluginMeta(),它要靠 exports 才能解析到 locale/en.json,于是卡片会一直回退成包名 + manifest 英文简介(详见排障单附录 A)。
卸载:把 bundle 从 dsh.profile.bundles 里去掉(或用插件页的开关关掉),保存即热生效;关掉即恢复官方行为,无需改其它配置。
边界
- 不碰
web_search:搜索走 API,本来就不受 fake-IP 影响。
- 系统代理模式(
HTTP_PROXY/HTTPS_PROXY)本来就不做本机解析,插件不会介入。
doh / preferDoh 会用公网 DNS 的答案,可能绕过内网 split-horizon 域名;默认 auto 只在系统答案不可用时才这么做。
- 纯 Node 内置模块实现(
node:net 的 BlockList、node:dns、全局 fetch),没有 npm 依赖,也不锁定 DSH 版本;唯一依赖的运行时契约是官方 provider 实例上的 resolveAddresses 字段(探测不到就原样放过并记一条日志)。