@yanglaofish/dsh-proxy-pro 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/LESSONS.md +49 -1
- package/lib/client.js +14 -1
- package/lib/index.js +89 -50
- package/lib/proxy-core.js +123 -22
- package/lib/tool-schemas.js +8 -0
- package/package.json +1 -1
package/docs/LESSONS.md
CHANGED
|
@@ -675,4 +675,52 @@ E 外部 undici 的 fetch + 同一 ProxyAgent -> OK 401
|
|
|
675
675
|
|
|
676
676
|
**结论**:本插件与 `dsh-http-proxy` 插件行**二选一**。库本身必须保留——插件 import 它的
|
|
677
677
|
`installProxyFromEnvironment` / `proxyRouteFor`,而 DSH 的 web_fetch 通道读的正是同一模块的
|
|
678
|
-
module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"会让本插件 import 直接失败。
|
|
678
|
+
module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"会让本插件 import 直接失败。
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## 24. 诊断结论要三态 + 可行动(2026-09-17)
|
|
683
|
+
|
|
684
|
+
**症状**:面板与 `proxy_test` 的「连通探测」只分可达/不可达两档——`HTTP 504`(网关/上游超时)
|
|
685
|
+
和 `HTTP 200` 都打印成「可达」,用户无法判断"到底能不能用"。
|
|
686
|
+
|
|
687
|
+
**改法**(1.0.3):
|
|
688
|
+
|
|
689
|
+
- `classifyProbeStatus(status)` 产出三态:
|
|
690
|
+
- `usable`:2xx/3xx,以及 **401**(探测不带凭证,服务健康就该回 401);
|
|
691
|
+
- `degraded`:403/404/405/429 等 4xx —— **路由通、这次调用会失败**;
|
|
692
|
+
- `unusable`:407、5xx(500/502/503/504)—— 路由或对端失败。
|
|
693
|
+
- 每个结论都带 `why`(可能原因)+ `fix`(挽救措施):面板显示三层
|
|
694
|
+
(连通探测 / 可能原因 / 挽救措施),`proxy_test` 输出 `Why:` / `Fix:` 行。
|
|
695
|
+
- `classifyTargetFailure` 同样补齐 why/fix,并按 ENOTFOUND / ECONNREFUSED / ETIMEDOUT /
|
|
696
|
+
ECONNRESET / UND_ERR_INVALID_ARG / 407 分别归类。
|
|
697
|
+
- 探测顺序:**HEAD →(405/501 时)GET `Range: bytes=0-0` 复测**。很多 API 只实现 GET,
|
|
698
|
+
单用 HEAD 会冤枉一个可用主机;复测结果里附 `note` 说明。
|
|
699
|
+
|
|
700
|
+
**教训**:诊断工具的返回值必须回答"能不能用"和"下一步怎么办"——只回一个状态码,等于把问题
|
|
701
|
+
又丢回给用户。另外 `additionalProperties: false` 的 schema 必须同步列出新字段
|
|
702
|
+
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## 25. 策略通道也要同一 undici 副本(2026-09-17)
|
|
707
|
+
|
|
708
|
+
**症状**:1.0.3 面板「连通探测」(不勾选「使用代理」)显示
|
|
709
|
+
`不可用:dispatcher rejected by this undici instance (UND_ERR_INVALID_ARG) — mixed undici copies`。
|
|
710
|
+
|
|
711
|
+
**根因**:1.0.2 只修了强制通道(`und.fetch` + 同副本 dispatcher,§22)。策略通道一直用 Node
|
|
712
|
+
内置 `fetch` 且不传 dispatcher —— 但全局 dispatcher 是 `installProxyFromEnvironment` 用 **app
|
|
713
|
+
undici** 装的,内置 fetch 拿它发请求 = A 组合(内置 fetch + 外部 Agent)→ 必然
|
|
714
|
+
UND_ERR_INVALID_ARG。之前没暴露,是因为面板默认勾选「使用代理」(强制通道)掩盖了它。
|
|
715
|
+
|
|
716
|
+
**修法**(1.0.4):策略通道同样走 app undici——
|
|
717
|
+
`policyProbe()` = `und.fetch(url, { dispatcher: und.getGlobalDispatcher() })`。
|
|
718
|
+
`getGlobalDispatcher()` 返回的正是 web_fetch 通道在用的全局 dispatcher,语义 = 模拟 web_fetch,
|
|
719
|
+
且永远同副本。三条探测路径现在统一:policy = 全局 dispatcher / forced-direct = 新建 Agent /
|
|
720
|
+
forced-proxy = 新建 ProxyAgent,全部由 `und.fetch` 发起。
|
|
721
|
+
|
|
722
|
+
**连带**:所有展示文案(short/why/fix/hint)中文化——面板是中文 UI,英文 why/fix 让人无法
|
|
723
|
+
行动;`proxy_test` 工具输出同步中文(模型照读)。教训:给用户看的文案别用英文"图省事",
|
|
724
|
+
判定结论必须能直接用。
|
|
725
|
+
|
|
726
|
+
**测试**:断言随中文文案同步翻新(/混用/、/超时/、/上游|超时/、/ECONNREFUSED/)。
|
package/lib/client.js
CHANGED
|
@@ -71,6 +71,8 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
".dsxprx-diagbtn{flex:none;padding:6px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-specific-tip);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer}",
|
|
72
72
|
".dsxprx-diagbtn:disabled{opacity:.5;cursor:not-allowed}",
|
|
73
73
|
".dsxprx-diagout{display:flex;flex-direction:column;gap:3px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-specific-tip);font-size:12px;line-height:17px;color:var(--dsw-alias-label-primary)}",
|
|
74
|
+
".dsxprx-diagwhy{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption)}",
|
|
75
|
+
".dsxprx-diagfix{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
|
|
74
76
|
".dsxprx-title{font-size:16px;font-weight:700;line-height:22px;color:var(--dsw-alias-label-primary)}",
|
|
75
77
|
".dsxprx-diagcheck{flex:none;display:flex;align-items:center;gap:5px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
|
|
76
78
|
".dsxprx-diagcheck input{width:14px;height:14px;accent-color:#4d6bfe;margin:0}",
|
|
@@ -295,6 +297,14 @@ window.__ModuleLoader__.load({
|
|
|
295
297
|
}
|
|
296
298
|
var diagUseProxy = useState(true);
|
|
297
299
|
var setDiagUseProxy = diagUseProxy[1];
|
|
300
|
+
/** Three-state headline for one probe verdict (usable / degraded / unusable). */
|
|
301
|
+
function probeHeadline(probe) {
|
|
302
|
+
if (!probe) return "未知";
|
|
303
|
+
if (probe.verdict === "usable") return "可用" + (probe.status === undefined ? "" : "(HTTP " + probe.status + ")");
|
|
304
|
+
if (probe.verdict === "degraded") return "降级:" + (probe.short || "未知");
|
|
305
|
+
if (probe.verdict === "unusable") return "不可用:" + (probe.short || "未知");
|
|
306
|
+
return probe.ok ? "可用" : (probe.short || "未知");
|
|
307
|
+
}
|
|
298
308
|
function runDiag() {
|
|
299
309
|
var target = diagUrl.trim();
|
|
300
310
|
if (!target) return;
|
|
@@ -408,7 +418,10 @@ window.__ModuleLoader__.load({
|
|
|
408
418
|
: [
|
|
409
419
|
react.createElement("span", { key: "c" }, "测试通道: " + (diag.channel === "proxy" ? "经代理(强制)" : diag.channel === "direct" ? "直连(强制绕过)" : "按当前策略")),
|
|
410
420
|
react.createElement("span", { key: "r" }, "策略判定: " + diag.route + (diag.bypassed ? "(NO_PROXY 命中)" : "")),
|
|
411
|
-
react.createElement("span", { key: "p" }, "连通探测: " + (diag.probe
|
|
421
|
+
react.createElement("span", { key: "p" }, "连通探测: " + probeHeadline(diag.probe)),
|
|
422
|
+
diag.probe && diag.probe.why ? react.createElement("span", { key: "w", className: "dsxprx-diagwhy" }, "可能原因: " + diag.probe.why) : null,
|
|
423
|
+
diag.probe && diag.probe.fix ? react.createElement("span", { key: "f", className: "dsxprx-diagfix" }, "挽救措施: " + diag.probe.fix) : null,
|
|
424
|
+
diag.probe && diag.probe.note ? react.createElement("span", { key: "n", className: "dsxprx-diagwhy" }, "备注: " + diag.probe.note) : null,
|
|
412
425
|
diag.hint ? react.createElement("span", { key: "h" }, "提示: " + diag.hint) : null,
|
|
413
426
|
]
|
|
414
427
|
)
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-htt
|
|
|
31
31
|
import { execFile } from 'node:child_process'
|
|
32
32
|
import { promisify } from 'node:util'
|
|
33
33
|
import {
|
|
34
|
+
classifyProbeStatus,
|
|
34
35
|
classifyTargetFailure,
|
|
35
36
|
keepaliveHint,
|
|
36
37
|
makeSystemProxyReader,
|
|
@@ -164,6 +165,46 @@ function getUndici() {
|
|
|
164
165
|
return undiciPromise
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
const PROBE_UA = 'dsh-proxy-pro/0.1'
|
|
169
|
+
const PROBE_TIMEOUT_MS = 6000
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 一个探测尝试:先 HEAD,端点不支持 HEAD(405/501)时再用 GET 复测——
|
|
173
|
+
* 很多 API 只实现 GET,只用 HEAD 会冤枉一个可用主机。`request` 是调用方
|
|
174
|
+
* 的 fetch:两条通道现在都用 app undici 的 `und.fetch` 配同副本 dispatcher
|
|
175
|
+
* (LESSONS §22/§25 —— 绝不要把两份副本混在一起)。
|
|
176
|
+
*
|
|
177
|
+
* @returns classifyProbeStatus / classifyTargetFailure 的判定。
|
|
178
|
+
*/
|
|
179
|
+
async function probeHttp(request, target, dispatcher) {
|
|
180
|
+
const attempt = async (method) => {
|
|
181
|
+
const controller = new AbortController()
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
|
183
|
+
try {
|
|
184
|
+
const init = { method, redirect: 'manual', signal: controller.signal, headers: { 'user-agent': PROBE_UA } }
|
|
185
|
+
if (method === 'GET') init.headers.range = 'bytes=0-0'
|
|
186
|
+
if (dispatcher !== undefined) init.dispatcher = dispatcher
|
|
187
|
+
const res = await request(target, init)
|
|
188
|
+
return { res }
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return { error }
|
|
191
|
+
} finally {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const head = await attempt('HEAD')
|
|
196
|
+
if (head.error !== undefined) return classifyTargetFailure(head.error)
|
|
197
|
+
const verdict = classifyProbeStatus(head.res.status)
|
|
198
|
+
if (verdict.kind !== 'http-error' || (head.res.status !== 405 && head.res.status !== 501)) return verdict
|
|
199
|
+
const get = await attempt('GET')
|
|
200
|
+
if (get.error !== undefined) return classifyTargetFailure(get.error)
|
|
201
|
+
const retried = classifyProbeStatus(get.res.status)
|
|
202
|
+
retried.note = retried.ok === true
|
|
203
|
+
? 'HEAD 被拒绝但 GET 可用 — 主机可用。'
|
|
204
|
+
: 'HEAD 被拒绝;GET 复测也遇到同类问题。'
|
|
205
|
+
return retried
|
|
206
|
+
}
|
|
207
|
+
|
|
167
208
|
/**
|
|
168
209
|
* Force the probe down one specific channel, bypassing the routing policy:
|
|
169
210
|
* `'proxy'` goes through the configured proxy unconditionally, `'direct'`
|
|
@@ -173,7 +214,9 @@ function getUndici() {
|
|
|
173
214
|
async function forceChannelProbe(url, channel, proxyUrl) {
|
|
174
215
|
const und = await getUndici()
|
|
175
216
|
if (und === null) {
|
|
176
|
-
return { ok: false, kind: 'unavailable', short: '
|
|
217
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '强制通道不可用(undici 未能加载)',
|
|
218
|
+
why: '宿主进程无法加载 app 侧 undici 包。',
|
|
219
|
+
fix: '取消勾选「使用代理」改用策略通道,或重启 DSH;仍出现请反馈插件版本。' }
|
|
177
220
|
}
|
|
178
221
|
let dispatcher
|
|
179
222
|
try {
|
|
@@ -181,10 +224,10 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
181
224
|
? new und.Agent({ connections: 1 })
|
|
182
225
|
: new und.ProxyAgent(proxyUrl)
|
|
183
226
|
} catch (error) {
|
|
184
|
-
return { ok: false, kind: 'bad-proxy', short:
|
|
227
|
+
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `代理地址无效:${String(error?.message ?? error)}`,
|
|
228
|
+
why: '当前生效的代理地址不是有效的 http(s) 代理 URL。',
|
|
229
|
+
fix: '在「代理配置」修复地址(mode = 自定义地址)或清空自定义地址。' }
|
|
185
230
|
}
|
|
186
|
-
const controller = new AbortController()
|
|
187
|
-
const timer = setTimeout(() => controller.abort(), 6000)
|
|
188
231
|
try {
|
|
189
232
|
// MUST issue the request with THIS undici copy. The host runs two copies —
|
|
190
233
|
// Node's built-in `fetch` and the app's `undici` package — and a dispatcher
|
|
@@ -192,45 +235,47 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
192
235
|
// ("invalid onRequestStart method"); the panel rendered that as a bare
|
|
193
236
|
// "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
|
|
194
237
|
// Agent/ProxyAgent above come from the same import, so they always agree.
|
|
195
|
-
|
|
196
|
-
method: 'HEAD',
|
|
197
|
-
redirect: 'manual',
|
|
198
|
-
signal: controller.signal,
|
|
199
|
-
dispatcher,
|
|
200
|
-
headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
|
|
201
|
-
})
|
|
202
|
-
// 407 is an authentication failure, not "reachable": reporting it as ok
|
|
203
|
-
// made the panel print 「可达(HTTP 407)」 and silenced the keepalive hint
|
|
204
|
-
// (review fix, 2026-09-17).
|
|
205
|
-
if (res.status === 407) {
|
|
206
|
-
return { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' }
|
|
207
|
-
}
|
|
208
|
-
return { ok: true, status: res.status }
|
|
209
|
-
} catch (error) {
|
|
210
|
-
return classifyTargetFailure(error)
|
|
238
|
+
return await probeHttp((u, i) => und.fetch(u, i), url, dispatcher)
|
|
211
239
|
} finally {
|
|
212
|
-
clearTimeout(timer)
|
|
213
240
|
try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
243
|
|
|
217
244
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
245
|
+
* 按已安装策略探测,与 web_fetch 走同一条路:用同一份 undici 的 fetch 加
|
|
246
|
+
* 全局 dispatcher。策略通道不能用 Node 内置 `fetch`——全局 dispatcher 由
|
|
247
|
+
* app 的 undici(installProxyFromEnvironment)安装,内置 fetch 拿它必然
|
|
248
|
+
* UND_ERR_INVALID_ARG(LESSONS §25,A 组合;面板当时显示"dispatcher
|
|
249
|
+
* rejected… mixed undici copies")。
|
|
250
|
+
*/
|
|
251
|
+
async function policyProbe(target) {
|
|
252
|
+
const und = await getUndici()
|
|
253
|
+
if (und === null) {
|
|
254
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '策略通道不可用(undici 未能加载)',
|
|
255
|
+
why: '宿主进程无法加载 app 侧 undici 包。', fix: '重启 DSH;仍出现请反馈插件版本。' }
|
|
256
|
+
}
|
|
257
|
+
const dispatcher = typeof und.getGlobalDispatcher === 'function' ? und.getGlobalDispatcher() : undefined
|
|
258
|
+
return probeHttp((u, i) => und.fetch(u, i), target, dispatcher)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 按 web_fetch 的方式探测一个目标:先给路由判定,再用策略通道发真实请求
|
|
263
|
+
* (app undici 的 fetch + 全局 dispatcher,见 policyProbe)。`channel` 选择
|
|
264
|
+
* 探测路径:'policy'(默认)、'proxy'(强制走代理)、'direct'(强制绕过)。
|
|
222
265
|
*/
|
|
223
266
|
async function probeTarget(url, snapshot, channel = 'policy') {
|
|
224
267
|
let parsed
|
|
225
268
|
try {
|
|
226
269
|
parsed = new URL(url)
|
|
227
270
|
} catch {
|
|
228
|
-
return { url, route: 'invalid', bypassed: false,
|
|
271
|
+
return { url, route: 'invalid', bypassed: false,
|
|
272
|
+
probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
|
|
273
|
+
why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' } }
|
|
229
274
|
}
|
|
230
275
|
let routeName = 'DIRECT'
|
|
231
276
|
try {
|
|
232
|
-
const
|
|
233
|
-
routeName =
|
|
277
|
+
const routeProbe = proxyRouteFor(parsed)
|
|
278
|
+
routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
|
|
234
279
|
} catch {
|
|
235
280
|
// proxyRouteFor unavailable in this composition → rely on state snapshot
|
|
236
281
|
}
|
|
@@ -242,27 +287,14 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
242
287
|
if (channel === 'proxy' || channel === 'direct') {
|
|
243
288
|
const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
|
|
244
289
|
const probe = proxyUrl === '' && channel === 'proxy'
|
|
245
|
-
? { ok: false, kind: 'bad-proxy', short: '
|
|
290
|
+
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
|
|
291
|
+
why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
|
|
292
|
+
fix: '打开代理,或在「代理配置」里修复地址/模式。' }
|
|
246
293
|
: await forceChannelProbe(parsed, channel, proxyUrl)
|
|
247
294
|
return { url, route: routeName, bypassed, channel, probe }
|
|
248
295
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const timer = setTimeout(() => controller.abort(), 6000)
|
|
252
|
-
const res = await fetch(parsed, {
|
|
253
|
-
method: 'HEAD',
|
|
254
|
-
redirect: 'manual',
|
|
255
|
-
signal: controller.signal,
|
|
256
|
-
headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
|
|
257
|
-
})
|
|
258
|
-
clearTimeout(timer)
|
|
259
|
-
if (res.status === 407) {
|
|
260
|
-
return { url, route: routeName, bypassed, probe: { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' } }
|
|
261
|
-
}
|
|
262
|
-
return { url, route: routeName, bypassed, probe: { ok: true, status: res.status } }
|
|
263
|
-
} catch (error) {
|
|
264
|
-
return { url, route: routeName, bypassed, probe: classifyTargetFailure(error) }
|
|
265
|
-
}
|
|
296
|
+
const probe = await policyProbe(parsed)
|
|
297
|
+
return { url, route: routeName, bypassed, probe }
|
|
266
298
|
}
|
|
267
299
|
|
|
268
300
|
// ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
|
|
@@ -486,12 +518,19 @@ function apply(ctx, config) {
|
|
|
486
518
|
output: {
|
|
487
519
|
schema: PROXY_TEST_SCHEMA,
|
|
488
520
|
render: (_args, value) => {
|
|
521
|
+
const p = value.probe ?? {}
|
|
522
|
+
const head = p.verdict === 'usable'
|
|
523
|
+
? `可用${p.status === undefined ? '' : `(HTTP ${p.status})`}`
|
|
524
|
+
: `${p.verdict === 'degraded' ? '降级' : '不可用'} — ${p.short ?? '未知'}`
|
|
489
525
|
const lines = [
|
|
490
|
-
|
|
491
|
-
`NO_PROXY
|
|
492
|
-
|
|
526
|
+
`路由: ${value.route}`,
|
|
527
|
+
`NO_PROXY 命中: ${value.bypassed}`,
|
|
528
|
+
`连通探测: ${head}`,
|
|
493
529
|
]
|
|
494
|
-
if (
|
|
530
|
+
if (p.why) lines.push(`可能原因: ${p.why}`)
|
|
531
|
+
if (p.fix) lines.push(`挽救措施: ${p.fix}`)
|
|
532
|
+
if (p.note) lines.push(`备注: ${p.note}`)
|
|
533
|
+
if (value.hint) lines.push(`提示: ${value.hint}`)
|
|
495
534
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
496
535
|
},
|
|
497
536
|
},
|
package/lib/proxy-core.js
CHANGED
|
@@ -254,47 +254,148 @@ export function summarize(snapshot) {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* NTLM keepalive
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
257
|
+
* NTLM keepalive 提示:SWG 类代理(netentsec 等)向 undici 索要 NTLM 认证,
|
|
258
|
+
* undici 无法协商;curl/浏览器最近一次经过同一代理的连接会保持一段已认证
|
|
259
|
+
* 窗口。仅在看到 407 或认证相关应答时才有意义。示例 URL 由调用方传入
|
|
260
|
+
* (生效代理地址),绝不硬编码——固定样例曾把开发者自己的公司代理泄漏进
|
|
261
|
+
* 每一条提示里。
|
|
262
262
|
*/
|
|
263
263
|
export function keepaliveHint(message = '', proxyUrl = '') {
|
|
264
264
|
const text = typeof message === 'string' ? message : ''
|
|
265
265
|
if (!/407|NTLM|proxy authentication|authentication required/i.test(text)) return ''
|
|
266
266
|
const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? proxyUrl : 'http://localhost:7890'
|
|
267
|
-
return
|
|
267
|
+
return `该代理要求 NTLM 认证,undici 无法自行协商。最近一次 curl/浏览器到同一代理的连接会保持一段已认证窗口——先执行一次 \`curl -x ${example} -I https://github.com\`,然后重试。`
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
/**
|
|
271
|
-
*
|
|
272
|
-
* `proxy_test` tool.
|
|
271
|
+
* 单个 HTTP 应答的三态判定,供面板、`proxy_test` 与 HTTP API 共用:
|
|
273
272
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
273
|
+
* - `usable` — 真实调用可用(2xx/3xx,以及 401:健康的 API 端点对
|
|
274
|
+
* 不带凭证的探测就该回 401)。
|
|
275
|
+
* - `degraded` — 拿到了 HTTP 应答(路由通),但这次调用多半会失败
|
|
276
|
+
* (403/404/405/429 等 4xx)。
|
|
277
|
+
* - `unusable` — 路由或对端失败(407 认证墙、目标 5xx、网关/上游超时)。
|
|
279
278
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
279
|
+
* 每个结论都带 `why`(可能原因)与 `fix`(挽救措施),让诊断直接可行动,
|
|
280
|
+
* 而不是丢一个状态码(2026-09-17:504 曾与 200 一样显示成「可达」)。
|
|
281
|
+
*
|
|
282
|
+
* @param status - 探测收到的 HTTP 状态码。
|
|
283
|
+
* @returns `{ ok, verdict, kind, status, short, why, fix }`(中文文案)。
|
|
284
|
+
*/
|
|
285
|
+
export function classifyProbeStatus(status) {
|
|
286
|
+
const code = Number(status)
|
|
287
|
+
const base = { status: code }
|
|
288
|
+
if (code === 407) {
|
|
289
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'auth', short: 'HTTP 407 — 代理要求 NTLM 认证',
|
|
290
|
+
why: '请求根本没到目标:代理拦下并索要 NTLM 认证,undici 无法协商。',
|
|
291
|
+
fix: '先用 curl/浏览器走同一代理过一次(如 curl -x <代理> -I https://github.com)打开认证窗口,再重试。' }
|
|
292
|
+
}
|
|
293
|
+
if (code >= 200 && code < 400) {
|
|
294
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: `HTTP ${code} — 目标正常响应`, why: '', fix: '' }
|
|
295
|
+
}
|
|
296
|
+
if (code === 401) {
|
|
297
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: 'HTTP 401 — 目标存活(需要凭证)',
|
|
298
|
+
why: '探测不带凭证,所以 401 是健康 API 端点的正常应答。',
|
|
299
|
+
fix: '真实调用需要有效 key/请求头;这条结果已经证明路由可用。' }
|
|
300
|
+
}
|
|
301
|
+
if (code === 403) {
|
|
302
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 403 — 目标拒绝了请求',
|
|
303
|
+
why: '已到达服务器但被拒绝:key 权限、IP 白名单、WAF,或探测 UA 被挡。',
|
|
304
|
+
fix: '对比直连/代理两侧,并检查该主机的 key/UA 规则。' }
|
|
305
|
+
}
|
|
306
|
+
if (code === 404) {
|
|
307
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 — 路径不存在',
|
|
308
|
+
why: '路由已到服务器,但该路径不存在(探测常打根路径)。',
|
|
309
|
+
fix: '用真实调用的完整路径复测;能通就忽略此结果。' }
|
|
310
|
+
}
|
|
311
|
+
if (code === 405 || code === 501) {
|
|
312
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 端点不支持 HEAD`,
|
|
313
|
+
why: '诊断用 HEAD 探测,有些 API/网关只实现 GET。',
|
|
314
|
+
fix: '面板已自动换 GET 复测;GET 成功即主机可用。' }
|
|
315
|
+
}
|
|
316
|
+
if (code === 429) {
|
|
317
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 429 — 被限流',
|
|
318
|
+
why: '服务器活着但在限流。',
|
|
319
|
+
fix: '稍后重试或降低请求频率;路由本身没问题。' }
|
|
320
|
+
}
|
|
321
|
+
if (code === 502) {
|
|
322
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 502 — 网关错误(bad gateway)',
|
|
323
|
+
why: '中间环节(代理或目标网关)拿到无效上游响应——常见是代理连不上该主机。',
|
|
324
|
+
fix: '对比直连/代理两侧;若失败在代理侧,把该主机保留在 NO_PROXY 的另一边。' }
|
|
325
|
+
}
|
|
326
|
+
if (code === 503) {
|
|
327
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 503 — 服务不可用',
|
|
328
|
+
why: '目标(或其网关)过载或维护中。',
|
|
329
|
+
fix: '稍后重试;这是服务端问题,与路由无关。' }
|
|
330
|
+
}
|
|
331
|
+
if (code === 504) {
|
|
332
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 504 — 网关/上游超时',
|
|
333
|
+
why: '请求已转发但上游迟迟无响应:目标慢或挂了,或代理到目标这一段坏了。',
|
|
334
|
+
fix: '对比直连/代理两侧并重试;只有某一侧超时,问题就在那一侧。' }
|
|
335
|
+
}
|
|
336
|
+
if (code >= 500) {
|
|
337
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'server-error', short: `HTTP ${code} — 目标服务端错误`,
|
|
338
|
+
why: '服务器自身报错;路由没问题。',
|
|
339
|
+
fix: '稍后重试;无需改动代理配置。' }
|
|
340
|
+
}
|
|
341
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 请求被拒绝`,
|
|
342
|
+
why: '路由通(拿到了 HTTP 应答),但这次请求本身不成功。',
|
|
343
|
+
fix: '检查真实调用用的方法、路径和请求头。' }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 把 fetch 的拒绝转成可行动的结论。裸的 `fetch failed` 掩盖了真正原因:
|
|
348
|
+
* 一份 undici 造的 dispatcher 交给另一份使用时,会抛 UND_ERR_INVALID_ARG
|
|
349
|
+
* ("invalid onRequestStart method")——1.0.2 之前强制通道诊断两个方向都
|
|
350
|
+
* 死在这里(LESSONS §22);1.0.4 又发现策略通道也在混两份副本
|
|
351
|
+
* (LESSONS §25)。把 cause code 亮出来,下一次报告就能指名道姓。
|
|
352
|
+
*
|
|
353
|
+
* @param error - `fetch` 抛出的拒绝。
|
|
354
|
+
* @returns 带 cause 信息的 `{ ok, verdict, kind, short, why, fix }`(中文文案)。
|
|
282
355
|
*/
|
|
283
356
|
export function classifyTargetFailure(error) {
|
|
284
357
|
const cause = error?.cause
|
|
285
358
|
const code = cause?.code ?? error?.code ?? ''
|
|
286
|
-
|
|
287
|
-
|
|
359
|
+
const message = String(error?.message ?? error)
|
|
360
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
361
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `DNS 解析失败(${code})`,
|
|
362
|
+
why: '主机名没解析出来——名字/DNS 错,或代理无法解析它。',
|
|
363
|
+
fix: '检查主机名;该主机若必须走代理,把它移出 NO_PROXY(反之加进去)。' }
|
|
364
|
+
}
|
|
365
|
+
if (code === 'ECONNREFUSED') {
|
|
366
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: '连接被拒绝(ECONNREFUSED)',
|
|
367
|
+
why: '有东西应答但拒绝连接:端口无人监听,或代理不在接受连接。',
|
|
368
|
+
fix: '检查端口与代理是否在运行;对比另一侧通道。' }
|
|
369
|
+
}
|
|
370
|
+
if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
|
371
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `直连不可达(${code})—需要走代理`,
|
|
372
|
+
why: '本机到目标的网络路径本身不可用。',
|
|
373
|
+
fix: '让该主机走代理(从 NO_PROXY 移除)再重试。' }
|
|
374
|
+
}
|
|
375
|
+
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ABORT_ERR' || error?.name === 'AbortError') {
|
|
376
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `超时前无应答(${code || error?.name || 'timeout'})`,
|
|
377
|
+
why: '到时间没应答——目标慢/挂,或代理静默丢弃该目标。',
|
|
378
|
+
fix: '对比直连/代理两侧并重试;只有某一侧超时,问题就在那一侧。' }
|
|
379
|
+
}
|
|
380
|
+
if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET' || code === 'EPIPE') {
|
|
381
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `连接被重置(${code})`,
|
|
382
|
+
why: '连接中途被切断——中间设备或 TLS 问题(公司拦截、MTU、对端重置)。',
|
|
383
|
+
fix: '重试并对比另一侧;若有 TLS 拦截,确认公司 CA 已被信任(NODE_EXTRA_CA_CERTS)。' }
|
|
288
384
|
}
|
|
289
385
|
if (code === 'UND_ERR_INVALID_ARG' || /invalid onRequestStart/i.test(String(cause?.message ?? ''))) {
|
|
290
|
-
return { ok: false, kind: 'dispatcher', short: `dispatcher
|
|
386
|
+
return { ok: false, verdict: 'unusable', kind: 'dispatcher', short: `dispatcher 被当前 undici 副本拒绝(${code || 'UND_ERR_INVALID_ARG'})—两份副本混用`,
|
|
387
|
+
why: '请求和它的 dispatcher 来自两份不同的 undici 副本;1.0.2 修过强制通道,1.0.4 修策略通道。',
|
|
388
|
+
fix: '更新插件;若仍出现,把插件版本连同这条信息一起反馈。' }
|
|
291
389
|
}
|
|
292
|
-
if (/407|NTLM|authentication required/i.test(
|
|
293
|
-
return { ok: false, kind: 'auth', short: '
|
|
390
|
+
if (/407|NTLM|authentication required/i.test(message)) {
|
|
391
|
+
return { ok: false, verdict: 'unusable', kind: 'auth', short: '代理要求 NTLM 认证',
|
|
392
|
+
why: '代理索要 NTLM,undici 无法单独完成协商。',
|
|
393
|
+
fix: '先用 curl/浏览器走同一代理过一次(打开认证窗口),再重试。' }
|
|
294
394
|
}
|
|
295
395
|
const detail = cause?.code
|
|
296
396
|
? `${cause.code}${cause?.message ? `: ${String(cause.message).slice(0, 90)}` : ''}`
|
|
297
397
|
: (cause?.message ?? '')
|
|
298
|
-
|
|
299
|
-
|
|
398
|
+
return { ok: false, verdict: 'unusable', kind: 'other', short: (detail ? `${message}(${detail})` : message).slice(0, 180),
|
|
399
|
+
why: '请求失败,探测器无法归类原因。',
|
|
400
|
+
fix: '对比直连/代理两侧并重试;用 proxy_status 查看生效路由。' }
|
|
300
401
|
}
|
package/lib/tool-schemas.js
CHANGED
|
@@ -68,9 +68,17 @@ export const PROXY_TEST_SCHEMA = {
|
|
|
68
68
|
required: true,
|
|
69
69
|
properties: {
|
|
70
70
|
ok: { type: 'boolean', required: true },
|
|
71
|
+
// Three-state verdict plus the actionable pair the panel and the tool
|
|
72
|
+
// render (2026-09-17: a 504 used to print as "reachable"). Every field
|
|
73
|
+
// must be listed here — the object is additionalProperties:false, so an
|
|
74
|
+
// undeclared field fails schema validation.
|
|
75
|
+
verdict: { type: 'string' },
|
|
71
76
|
status: { type: 'number' },
|
|
72
77
|
kind: { type: 'string' },
|
|
73
78
|
short: { type: 'string' },
|
|
79
|
+
why: { type: 'string' },
|
|
80
|
+
fix: { type: 'string' },
|
|
81
|
+
note: { type: 'string' },
|
|
74
82
|
},
|
|
75
83
|
},
|
|
76
84
|
hint: { type: 'string' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanglaofish/dsh-proxy-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "DSH proxy manager: route model requests, web fetches and spawned tools through the Windows system proxy or a custom address; per-URL route diagnosis, one-click toggle from the conversation header, a dedicated Settings page, and keepalive-aware 407 hints.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|