dsh-proxy-routing 0.4.1
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/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.i18n.yaml +4 -0
- package/README.md +193 -0
- package/README.zh.md +193 -0
- package/cordis.patch.yml +6 -0
- package/lib/actions.js +61 -0
- package/lib/als.js +8 -0
- package/lib/client.js +480 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +194 -0
- package/lib/control.js +199 -0
- package/lib/discovery.js +89 -0
- package/lib/fetch-router.js +77 -0
- package/lib/index.js +296 -0
- package/lib/llm-router.js +63 -0
- package/lib/probe.js +68 -0
- package/lib/proxy/connect.js +166 -0
- package/lib/proxy/decode.js +60 -0
- package/lib/proxy/errors.js +26 -0
- package/lib/proxy/http11.js +133 -0
- package/lib/proxy/http2.js +96 -0
- package/lib/proxy/noproxy.js +92 -0
- package/lib/proxy/parse.js +22 -0
- package/lib/proxy/request.js +206 -0
- package/lib/proxy/stream.js +179 -0
- package/lib/proxy-env.js +173 -0
- package/lib/routes.js +75 -0
- package/lib/rpc.js +110 -0
- package/lib/settings.js +60 -0
- package/lib/shell-router.js +201 -0
- package/lib/status.js +61 -0
- package/lib/tools.js +453 -0
- package/package.json +114 -0
- package/tools/gen-cert.sh +13 -0
- package/tools/proxy-probe.mjs +49 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// lib/index.js — dsh-proxy-routing plugin entry (host half)
|
|
2
|
+
//
|
|
3
|
+
// Runtime state is sourced from the official `proxy-routing` settings namespace.
|
|
4
|
+
// The settings-file provider owns `$DSH_HOME/settings.yaml`, YAML parsing,
|
|
5
|
+
// atomic writes, and external change watching.
|
|
6
|
+
import { canonicalDefaults, profileConfigured, serializeCanonical, toProxy, validateCanonical } from "./config.js";
|
|
7
|
+
import { ControlPlane } from "./control.js";
|
|
8
|
+
import { FetchRouter } from "./fetch-router.js";
|
|
9
|
+
import { LlmRouter } from "./llm-router.js";
|
|
10
|
+
import { ShellRouter } from "./shell-router.js";
|
|
11
|
+
import { resolveAgentRoute, resolveProviderRoute } from "./routes.js";
|
|
12
|
+
import { buildStatus } from "./status.js";
|
|
13
|
+
import { probeProxy } from "./probe.js";
|
|
14
|
+
import { registerProxyTools, installApprovalGate } from "./tools.js";
|
|
15
|
+
import { PROXY_ROUTING_NAMESPACE, ProxyRoutingSettingsSchema, settingsUnavailableError } from "./settings.js";
|
|
16
|
+
import { installProxyRpc } from "./rpc.js";
|
|
17
|
+
|
|
18
|
+
export const name = "proxy-routing";
|
|
19
|
+
export const inject = ["tools", "settings", "subprocess", "systemPrompt"];
|
|
20
|
+
|
|
21
|
+
const ONBOARDING_SECTION = "proxy-routing:onboarding";
|
|
22
|
+
|
|
23
|
+
function sessionIsFullAccess(context) {
|
|
24
|
+
const events = context?.agent?.session?.events;
|
|
25
|
+
if (Array.isArray(events)) {
|
|
26
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
27
|
+
const event = events[index];
|
|
28
|
+
if (event?.type === "sandbox/mode") return event?.data?.mode === "danger-full-access";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function onboardingText(scope, context) {
|
|
35
|
+
const profile = scope.get()?.profiles?.find((entry) => entry?.id === "default");
|
|
36
|
+
if (!profileConfigured(profile)) {
|
|
37
|
+
const discovery = sessionIsFullAccess(context)
|
|
38
|
+
? "当前会话是 Full Access:先调用 net_proxy_discover,检查代理环境变量和有限的本机候选端口;只使用探测成功且用户确认用途的候选,不要自动启用。"
|
|
39
|
+
: "当前会话不是 Full Access:不要主动扫描本机或猜测地址;先向用户询问已经运行的 HTTP/SOCKS5 代理 protocol、host、port。";
|
|
40
|
+
return [
|
|
41
|
+
"Proxy routing is installed, but the default proxy endpoint is not configured; Agent traffic remains direct.",
|
|
42
|
+
discovery,
|
|
43
|
+
"Use net_proxy_probe before requesting net_proxy_enable; enabling changes Agent routing and may require human approval.",
|
|
44
|
+
].join(" ");
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
"Proxy routing has a configured default endpoint.",
|
|
48
|
+
"Use net_proxy_status to inspect the effective route and net_proxy_probe to verify connectivity before troubleshooting network requests.",
|
|
49
|
+
"Use net_proxy_disable to restore direct Agent routing when needed; provider overrides remain independent.",
|
|
50
|
+
].join(" ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function installOnboarding(ctx, scope) {
|
|
54
|
+
const systemPrompt = ctx.systemPrompt ?? ctx.get?.("systemPrompt");
|
|
55
|
+
if (!systemPrompt || typeof systemPrompt.section !== "function") return undefined;
|
|
56
|
+
return systemPrompt.section({
|
|
57
|
+
name: ONBOARDING_SECTION,
|
|
58
|
+
order: 120,
|
|
59
|
+
text: (context) => onboardingText(scope, context),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** DSH composition base; user values live in ctx.settings. */
|
|
64
|
+
export const Config = ProxyRoutingSettingsSchema;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
68
|
+
* @param {object} config Cordis plugin defaults (reserved for compatibility)
|
|
69
|
+
*/
|
|
70
|
+
export function apply(ctx, config = {}) {
|
|
71
|
+
const settings = ctx.settings ?? ctx.get?.("settings");
|
|
72
|
+
if (!settings || typeof settings.register !== "function") throw settingsUnavailableError();
|
|
73
|
+
|
|
74
|
+
const base = config && typeof config === "object" ? config : {};
|
|
75
|
+
const scope = settings.register(PROXY_ROUTING_NAMESPACE, ProxyRoutingSettingsSchema, {
|
|
76
|
+
base,
|
|
77
|
+
validate: validateCanonical,
|
|
78
|
+
});
|
|
79
|
+
const canonical = validateCanonical(scope.get() ?? canonicalDefaults());
|
|
80
|
+
const logger = ctx.logger ?? console;
|
|
81
|
+
const log = (level, message) => {
|
|
82
|
+
try { logger[level]?.(message); } catch {}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Settings writes use the official raw-user-layer APIs. Internal markers are
|
|
86
|
+
// removed only when the write is known not to have reached storage.
|
|
87
|
+
const canonicalSignature = (value) => serializeCanonical(validateCanonical(value));
|
|
88
|
+
const expectedSettingsCommits = [];
|
|
89
|
+
const markInternalCommit = (value) => {
|
|
90
|
+
const token = { signature: canonicalSignature(value), durable: false };
|
|
91
|
+
expectedSettingsCommits.push(token);
|
|
92
|
+
return token;
|
|
93
|
+
};
|
|
94
|
+
const removeInternalCommit = (token) => {
|
|
95
|
+
const index = expectedSettingsCommits.indexOf(token);
|
|
96
|
+
if (index >= 0) expectedSettingsCommits.splice(index, 1);
|
|
97
|
+
};
|
|
98
|
+
const consumeInternalCommit = (value) => {
|
|
99
|
+
const signature = canonicalSignature(value);
|
|
100
|
+
const index = expectedSettingsCommits.findIndex((token) => token.signature === signature);
|
|
101
|
+
if (index < 0) return false;
|
|
102
|
+
expectedSettingsCommits.splice(index, 1);
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
const isObject = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
106
|
+
const sameJson = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
107
|
+
const readRawSettings = () => {
|
|
108
|
+
const descriptor = settings.describe?.({ redactSecrets: false })
|
|
109
|
+
?.find((entry) => String(entry.ns) === String(PROXY_ROUTING_NAMESPACE));
|
|
110
|
+
return {
|
|
111
|
+
section: isObject(descriptor?.user) ? structuredClone(descriptor.user) : {},
|
|
112
|
+
revision: Number.isInteger(descriptor?.revision) ? descriptor.revision : undefined,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
let rawSettings = readRawSettings();
|
|
116
|
+
|
|
117
|
+
ctx.on?.("settings/document-updated", (ns) => {
|
|
118
|
+
if (String(ns) !== String(PROXY_ROUTING_NAMESPACE)) return;
|
|
119
|
+
rawSettings = readRawSettings();
|
|
120
|
+
for (const token of [...expectedSettingsCommits]) {
|
|
121
|
+
if (canonicalSignature(scope.get()) === token.signature) removeInternalCommit(token);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
// Turn resolved canonical changes into a sparse raw user section. Profiles
|
|
127
|
+
// are handled by id/index so their inherited noProxy/timeout defaults are
|
|
128
|
+
// not materialized merely because another profile field changed.
|
|
129
|
+
const rawProfilesFor = (beforeProfiles, afterProfiles, currentProfiles) => {
|
|
130
|
+
const current = Array.isArray(currentProfiles) ? currentProfiles : [];
|
|
131
|
+
return afterProfiles.map((afterProfile, index) => {
|
|
132
|
+
const beforeProfile = beforeProfiles[index] ?? {};
|
|
133
|
+
const currentProfile = current.find((entry) => entry?.id === afterProfile.id) ?? current[index] ?? {};
|
|
134
|
+
const rawProfile = isObject(currentProfile) ? structuredClone(currentProfile) : {};
|
|
135
|
+
for (const key of new Set([...Object.keys(beforeProfile), ...Object.keys(afterProfile)])) {
|
|
136
|
+
if (sameJson(beforeProfile[key], afterProfile[key])) continue;
|
|
137
|
+
if (key in afterProfile) rawProfile[key] = structuredClone(afterProfile[key]);
|
|
138
|
+
else delete rawProfile[key];
|
|
139
|
+
}
|
|
140
|
+
rawProfile.id = afterProfile.id;
|
|
141
|
+
return rawProfile;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const rawObjectFor = (before, after, current) => {
|
|
145
|
+
const result = isObject(current) ? structuredClone(current) : {};
|
|
146
|
+
for (const key of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
|
|
147
|
+
if (sameJson(before?.[key], after?.[key])) continue;
|
|
148
|
+
if (!(key in after)) delete result[key];
|
|
149
|
+
else if (key === "profiles" && Array.isArray(after[key])) {
|
|
150
|
+
result[key] = rawProfilesFor(before?.[key] ?? [], after[key], current?.[key]);
|
|
151
|
+
} else if (isObject(before?.[key]) && isObject(after[key])) {
|
|
152
|
+
result[key] = rawObjectFor(before[key], after[key], current?.[key]);
|
|
153
|
+
} else {
|
|
154
|
+
result[key] = structuredClone(after[key]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (after?.version !== undefined && result.version === undefined) result.version = after.version;
|
|
158
|
+
return result;
|
|
159
|
+
};
|
|
160
|
+
const writeSettings = async (next, previous, expectedRevision) => {
|
|
161
|
+
const before = readRawSettings();
|
|
162
|
+
const rawNext = rawObjectFor(previous, next, before.section);
|
|
163
|
+
if (sameJson(rawNext, before.section)) return { before, after: before };
|
|
164
|
+
const token = markInternalCommit(next);
|
|
165
|
+
try {
|
|
166
|
+
if (typeof settings.replace === "function") {
|
|
167
|
+
await settings.replace(PROXY_ROUTING_NAMESPACE, rawNext, expectedRevision ?? before.revision);
|
|
168
|
+
} else {
|
|
169
|
+
await scope.replace(rawNext);
|
|
170
|
+
}
|
|
171
|
+
token.durable = true;
|
|
172
|
+
const after = readRawSettings();
|
|
173
|
+
rawSettings = after;
|
|
174
|
+
return { before, after };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const after = readRawSettings();
|
|
177
|
+
const durable = token.durable || (before.revision !== undefined && after.revision !== before.revision);
|
|
178
|
+
if (durable) {
|
|
179
|
+
token.durable = true;
|
|
180
|
+
error.settingsPersisted = true;
|
|
181
|
+
error.settingsRevision = after.revision;
|
|
182
|
+
} else {
|
|
183
|
+
error.settingsPersisted = false;
|
|
184
|
+
removeInternalCommit(token);
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const plane = new ControlPlane({
|
|
191
|
+
canonical,
|
|
192
|
+
persist: async (next, previous, capture, expectedSettingsRevision) => {
|
|
193
|
+
const result = await writeSettings(next, previous, expectedSettingsRevision);
|
|
194
|
+
capture?.(result.before.section, result.before.revision);
|
|
195
|
+
return { compensation: result.before.section, revision: result.after.revision };
|
|
196
|
+
},
|
|
197
|
+
compensate: async (previousSection, previousCanonical, _failed, expectedRevision) => {
|
|
198
|
+
const token = markInternalCommit(previousCanonical ?? validateCanonical(previousSection));
|
|
199
|
+
try {
|
|
200
|
+
if (typeof settings.replace === "function") {
|
|
201
|
+
await settings.replace(PROXY_ROUTING_NAMESPACE, previousSection, expectedRevision);
|
|
202
|
+
} else {
|
|
203
|
+
await scope.replace(previousSection);
|
|
204
|
+
}
|
|
205
|
+
token.durable = true;
|
|
206
|
+
rawSettings = readRawSettings();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (!token.durable) removeInternalCommit(token);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
logger,
|
|
213
|
+
});
|
|
214
|
+
const fetchRouter = new FetchRouter({ getAgentRoute: () => resolveAgentRoute(plane.canonical) });
|
|
215
|
+
const llmRouter = new LlmRouter({
|
|
216
|
+
getProviderRoute: (providerId) => resolveProviderRoute(plane.canonical, providerId),
|
|
217
|
+
getAgentRoute: () => resolveAgentRoute(plane.canonical),
|
|
218
|
+
getRevision: () => plane.revision,
|
|
219
|
+
});
|
|
220
|
+
const shellRouter = new ShellRouter({
|
|
221
|
+
getAgentRoute: () => resolveAgentRoute(plane.canonical),
|
|
222
|
+
getRevision: () => plane.revision,
|
|
223
|
+
});
|
|
224
|
+
plane.addParticipant(fetchRouter);
|
|
225
|
+
plane.addParticipant(shellRouter);
|
|
226
|
+
|
|
227
|
+
const migration = {
|
|
228
|
+
from: "settings",
|
|
229
|
+
configured: profileConfigured(canonical.profiles[0]),
|
|
230
|
+
};
|
|
231
|
+
const control = {
|
|
232
|
+
getStatus: () => buildStatus({
|
|
233
|
+
canonical: plane.canonical,
|
|
234
|
+
revision: plane.revision,
|
|
235
|
+
migration,
|
|
236
|
+
fetchRouter,
|
|
237
|
+
shellRouter,
|
|
238
|
+
}),
|
|
239
|
+
getSettingsRevision: () => rawSettings.revision,
|
|
240
|
+
mutate: ({ apply: mutate, persist = true, expectedRevision, expectedSettingsRevision }) => plane.mutate({ apply: mutate, persist, expectedRevision, expectedSettingsRevision }),
|
|
241
|
+
probe: (proxyCfg, opts) => probeProxy(toProxy(proxyCfg), opts),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let settingsWatchDispose;
|
|
245
|
+
let onboardingDispose;
|
|
246
|
+
let disposed = false;
|
|
247
|
+
try {
|
|
248
|
+
settingsWatchDispose = scope.watch((next, previous) => {
|
|
249
|
+
const previousRaw = rawSettings;
|
|
250
|
+
rawSettings = readRawSettings();
|
|
251
|
+
if (disposed || consumeInternalCommit(next)) return;
|
|
252
|
+
return plane.reloadSettings({
|
|
253
|
+
next,
|
|
254
|
+
previous,
|
|
255
|
+
previousRaw: previousRaw.section,
|
|
256
|
+
previousRawRevision: rawSettings.revision,
|
|
257
|
+
}).catch((error) => {
|
|
258
|
+
log("warn", "[proxy-routing] settings 热更无法应用,已保留上一成功运行状态");
|
|
259
|
+
throw error;
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
onboardingDispose = installOnboarding(ctx, scope);
|
|
263
|
+
fetchRouter.install();
|
|
264
|
+
llmRouter.register(ctx);
|
|
265
|
+
shellRouter.register(ctx);
|
|
266
|
+
registerProxyTools(ctx, control);
|
|
267
|
+
installApprovalGate(ctx);
|
|
268
|
+
installProxyRpc(ctx, control);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
settingsWatchDispose?.();
|
|
271
|
+
onboardingDispose?.();
|
|
272
|
+
void llmRouter.dispose();
|
|
273
|
+
shellRouter.dispose();
|
|
274
|
+
fetchRouter.dispose();
|
|
275
|
+
void plane.dispose();
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Test and diagnostics hooks.
|
|
280
|
+
ctx._proxyControl = control;
|
|
281
|
+
ctx._proxySettingsScope = scope;
|
|
282
|
+
ctx._plane = plane;
|
|
283
|
+
|
|
284
|
+
log("info", `[proxy-routing] 配置: settings.yaml#proxy-routing(settings,revision ${plane.revision})`);
|
|
285
|
+
|
|
286
|
+
return async () => {
|
|
287
|
+
if (disposed) return;
|
|
288
|
+
disposed = true;
|
|
289
|
+
settingsWatchDispose?.();
|
|
290
|
+
onboardingDispose?.();
|
|
291
|
+
await plane.dispose();
|
|
292
|
+
llmRouter.dispose();
|
|
293
|
+
shellRouter.dispose();
|
|
294
|
+
fetchRouter.dispose();
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// lib/llm-router.js — llm/stream 惰性迭代器路由。
|
|
2
|
+
// 以 { prepend: true } 注册为最外层 waterfall 中间件;provider→route 在首次
|
|
3
|
+
// 迭代操作时惰性解析(waterfall 已同步完成,任何改写 options.provider 的
|
|
4
|
+
// 中间件都已在场);next/return/throw 全部在 AsyncLocalStorage 上下文内执行,
|
|
5
|
+
// 取消/错误/提前 return 均不泄漏上下文。
|
|
6
|
+
import { providerStore } from "./als.js";
|
|
7
|
+
|
|
8
|
+
/** 惰性异步迭代器包装:上下文只解析一次、迭代器只创建一次。 */
|
|
9
|
+
export function wrapAsyncIterable(resolveContext, sourceFactory) {
|
|
10
|
+
let context;
|
|
11
|
+
let iterator;
|
|
12
|
+
const run = (op) => {
|
|
13
|
+
if (!context) context = resolveContext();
|
|
14
|
+
if (!iterator) iterator = sourceFactory()[Symbol.asyncIterator]();
|
|
15
|
+
return providerStore.run(context, () => op(iterator));
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
[Symbol.asyncIterator]() {
|
|
19
|
+
return {
|
|
20
|
+
next: () => run((it) => it.next()),
|
|
21
|
+
return: () => run((it) => (it.return ? it.return() : Promise.resolve({ done: true }))),
|
|
22
|
+
throw: (error) => run((it) => (it.throw ? it.throw(error) : Promise.reject(error))),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class LlmRouter {
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} deps
|
|
31
|
+
* @param {(providerId:string)=>object} deps.getProviderRoute provider 路由快照
|
|
32
|
+
* @param {()=>object} deps.getAgentRoute Agent 路由快照
|
|
33
|
+
* @param {()=>number} deps.getRevision 当前配置修订号
|
|
34
|
+
*/
|
|
35
|
+
constructor({ getProviderRoute, getAgentRoute, getRevision }) {
|
|
36
|
+
this.name = "llm-router";
|
|
37
|
+
this.getProviderRoute = getProviderRoute;
|
|
38
|
+
this.getAgentRoute = getAgentRoute;
|
|
39
|
+
this.getRevision = getRevision;
|
|
40
|
+
this.generation = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
listener = (options, next) => {
|
|
44
|
+
const generation = ++this.generation;
|
|
45
|
+
return wrapAsyncIterable(() => this.resolve(options, generation), () => next());
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
resolve(options, generation) {
|
|
49
|
+
const provider = options && typeof options.provider === "string" ? options.provider : null;
|
|
50
|
+
const route = provider ? this.getProviderRoute(provider) : this.getAgentRoute();
|
|
51
|
+
return { route, provider, revision: this.getRevision(), generation };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 注册 waterfall 监听(prepend → 最外层包裹)。 */
|
|
55
|
+
register(ctx) {
|
|
56
|
+
this.disposeListener = ctx.on("llm/stream", this.listener, { prepend: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dispose() {
|
|
60
|
+
this.disposeListener?.();
|
|
61
|
+
this.disposeListener = undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/lib/probe.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// lib/probe.js — 代理连通性 + 延迟探测(不改配置)
|
|
2
|
+
//
|
|
3
|
+
// probeProxy(proxy, opts):
|
|
4
|
+
// 1. TCP 连代理(测代理可达 + TCP 延迟)
|
|
5
|
+
// 2. 经代理请求目标(测完整链路 + HTTP 状态)
|
|
6
|
+
// 返回 { ok, connectMs, totalMs, httpStatus, target, error? }
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { proxiedFetch } from "./proxy/request.js";
|
|
9
|
+
|
|
10
|
+
/** 默认探测目标:Google 的 204 端点(轻量、全球可达性好)。 */
|
|
11
|
+
const DEFAULT_TARGET = "https://www.gstatic.com/generate_204";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 对配置的代理做一次连通 + 延迟探测。
|
|
15
|
+
* @param {{protocol:string,host:string,port:number,username?:string,password?:string,noProxy?:string[],timeout?:number}} proxy
|
|
16
|
+
* @param {{target?:string, timeout?:number, signal?:AbortSignal}} [opts]
|
|
17
|
+
*/
|
|
18
|
+
export async function probeProxy(proxy, { target = DEFAULT_TARGET, timeout = 15000, signal } = {}) {
|
|
19
|
+
const res = { ok: false, target, connectMs: -1, totalMs: -1, httpStatus: -1 };
|
|
20
|
+
const t0 = performance.now();
|
|
21
|
+
try {
|
|
22
|
+
// 1) TCP 到代理
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
const sock = net.connect({ host: proxy.host, port: proxy.port });
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
try { sock.destroy(); } catch {}
|
|
27
|
+
reject(new Error("proxy TCP timeout"));
|
|
28
|
+
}, timeout);
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
try { sock.destroy(); } catch {}
|
|
32
|
+
reject(Object.assign(new Error("aborted"), { code: "ABORT_ERR", name: "AbortError" }));
|
|
33
|
+
};
|
|
34
|
+
if (signal) {
|
|
35
|
+
if (signal.aborted) {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
try { sock.destroy(); } catch {}
|
|
38
|
+
return reject(Object.assign(new Error("aborted"), { code: "ABORT_ERR", name: "AbortError" }));
|
|
39
|
+
}
|
|
40
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
41
|
+
}
|
|
42
|
+
sock.once("connect", () => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
45
|
+
res.connectMs = Math.round(performance.now() - t0);
|
|
46
|
+
try { sock.destroy(); } catch {}
|
|
47
|
+
resolve();
|
|
48
|
+
});
|
|
49
|
+
sock.once("error", (e) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
52
|
+
reject(e);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 2) 经代理请求目标
|
|
57
|
+
const r = await proxiedFetch(target, { method: "GET", signal }, proxy, globalThis.fetch);
|
|
58
|
+
res.httpStatus = r.status;
|
|
59
|
+
res.totalMs = Math.round(performance.now() - t0);
|
|
60
|
+
res.ok = res.httpStatus < 400;
|
|
61
|
+
if (!res.ok) res.error = `target returned HTTP ${res.httpStatus}`;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
res.totalMs = Math.round(performance.now() - t0);
|
|
64
|
+
if (e && e.code === "ABORT_ERR") res.error = "aborted";
|
|
65
|
+
else res.error = (e && e.message) || String(e);
|
|
66
|
+
}
|
|
67
|
+
return res;
|
|
68
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// lib/proxy/connect.js — 代理底层连接:TCP 连接代理 → HTTP CONNECT / SOCKS5 隧道
|
|
2
|
+
//
|
|
3
|
+
// 两种隧道都返回"已与目标建立通道"的裸 socket,供上层做 TLS(HTTPS 目标)
|
|
4
|
+
// 或直接发 HTTP/1.1 请求(HTTP 目标)。握手期间支持 AbortSignal 取消与
|
|
5
|
+
// 空闲超时;CONNECT 读完整有界头块并把随响应一同到达的早字节回灌 socket。
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { proxyError, abortError } from "./errors.js";
|
|
8
|
+
import { parseStatusLine } from "./parse.js";
|
|
9
|
+
import { ByteStream } from "./stream.js";
|
|
10
|
+
|
|
11
|
+
/** CONNECT 响应头块上限(含状态行,超限视为畸形)。 */
|
|
12
|
+
export const MAX_CONNECT_HEADERS = 64 * 1024;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* TCP 连接代理服务器(可取消、可设连接超时)。
|
|
16
|
+
* @param {{host:string,port:number}} proxy
|
|
17
|
+
* @param {AbortSignal} [signal]
|
|
18
|
+
* @param {{connectTimeoutMs?:number}} [opts]
|
|
19
|
+
* @returns {Promise<import("node:net").Socket>}
|
|
20
|
+
*/
|
|
21
|
+
export function connectProxy(proxy, signal, { connectTimeoutMs = 15000 } = {}) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const sock = net.connect({ host: proxy.host, port: proxy.port });
|
|
24
|
+
sock.setNoDelay(true);
|
|
25
|
+
let settled = false;
|
|
26
|
+
const timer = connectTimeoutMs > 0
|
|
27
|
+
? setTimeout(() => {
|
|
28
|
+
try { sock.destroy(); } catch {}
|
|
29
|
+
done(proxyError("ECONNECT_PROXY", `proxy connect ${proxy.host}:${proxy.port} timed out`));
|
|
30
|
+
}, connectTimeoutMs)
|
|
31
|
+
: null;
|
|
32
|
+
timer?.unref?.();
|
|
33
|
+
const done = (err, val) => {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
if (timer) clearTimeout(timer);
|
|
37
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
38
|
+
err ? reject(err) : resolve(val);
|
|
39
|
+
};
|
|
40
|
+
const onAbort = () => {
|
|
41
|
+
try { sock.destroy(); } catch {}
|
|
42
|
+
done(abortError());
|
|
43
|
+
};
|
|
44
|
+
if (signal) {
|
|
45
|
+
if (signal.aborted) {
|
|
46
|
+
try { sock.destroy(); } catch {}
|
|
47
|
+
return done(abortError());
|
|
48
|
+
}
|
|
49
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
50
|
+
}
|
|
51
|
+
sock.once("connect", () => done(null, sock));
|
|
52
|
+
sock.once("error", (e) => done(proxyError("ECONNECT_PROXY", `proxy connect ${proxy.host}:${proxy.port} failed: ${e.message}`, e)));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Proxy-Authorization 头(仅当配置了凭据)。 */
|
|
57
|
+
function proxyAuthHeader(proxy) {
|
|
58
|
+
if (!proxy.username && !proxy.password) return "";
|
|
59
|
+
return "Proxy-Authorization: Basic " + Buffer.from(`${proxy.username || ""}:${proxy.password || ""}`).toString("base64") + "\r\n";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* HTTP 代理 CONNECT 隧道(HTTPS 目标)。成功返回已通到目标的裸 socket。
|
|
64
|
+
* 读完整有界头块;状态行后随响应到达的隧道早字节通过 unshift 回灌 socket。
|
|
65
|
+
* @param {{host:string,port:number,username?:string,password?:string}} proxy
|
|
66
|
+
* @param {string} targetHost
|
|
67
|
+
* @param {number} targetPort
|
|
68
|
+
* @param {AbortSignal} [signal]
|
|
69
|
+
* @param {{handshakeTimeoutMs?:number}} [opts]
|
|
70
|
+
*/
|
|
71
|
+
export async function httpConnect(proxy, targetHost, targetPort, signal, { handshakeTimeoutMs = 15000 } = {}) {
|
|
72
|
+
const sock = await connectProxy(proxy, signal);
|
|
73
|
+
const authority = `${targetHost}:${targetPort}`;
|
|
74
|
+
const stream = new ByteStream(sock, { idleMs: handshakeTimeoutMs });
|
|
75
|
+
try {
|
|
76
|
+
sock.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${proxyAuthHeader(proxy)}\r\n`);
|
|
77
|
+
const statusLine = await stream.readLine();
|
|
78
|
+
const status = parseStatusLine(statusLine);
|
|
79
|
+
if (status === 0) throw proxyError("EPARSE", `malformed CONNECT status line: ${statusLine.slice(0, 80)}`);
|
|
80
|
+
|
|
81
|
+
// 完整有界头块(直到空行);累计超限视为畸形响应
|
|
82
|
+
let headerBytes = statusLine.length + 2;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const line = await stream.readLine();
|
|
85
|
+
headerBytes += line.length + 2;
|
|
86
|
+
if (headerBytes > MAX_CONNECT_HEADERS) throw proxyError("EHEADER", "CONNECT response headers too large");
|
|
87
|
+
if (line === "") break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!(status >= 200 && status < 300)) {
|
|
91
|
+
stream.detach();
|
|
92
|
+
try { sock.destroy(); } catch {}
|
|
93
|
+
throw proxyError("ECONNECT_TARGET", `CONNECT to ${authority} via proxy failed: HTTP ${status}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 头块之后的剩余字节 = 隧道早字节:暂停并 unshift 回 readable 队列,
|
|
97
|
+
// 让后续 TLS 消费者先读到它们。
|
|
98
|
+
const early = stream.takeBuf();
|
|
99
|
+
stream.detach();
|
|
100
|
+
if (early.length > 0) {
|
|
101
|
+
try { sock.pause(); } catch {}
|
|
102
|
+
sock.unshift(early);
|
|
103
|
+
}
|
|
104
|
+
return sock;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
stream.detach();
|
|
107
|
+
try { sock.destroy(); } catch {}
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* SOCKS5 隧道:方法协商(支持 RFC 1929 user/pass)→ CONNECT。成功返回裸 socket。
|
|
114
|
+
* @param {{host:string,port:number,username?:string,password?:string}} proxy
|
|
115
|
+
* @param {string} targetHost
|
|
116
|
+
* @param {number} targetPort
|
|
117
|
+
* @param {AbortSignal} [signal]
|
|
118
|
+
* @param {{handshakeTimeoutMs?:number}} [opts]
|
|
119
|
+
*/
|
|
120
|
+
export async function socksConnect(proxy, targetHost, targetPort, signal, { handshakeTimeoutMs = 15000 } = {}) {
|
|
121
|
+
const sock = await connectProxy(proxy, signal);
|
|
122
|
+
const fail = (msg) => {
|
|
123
|
+
try { sock.destroy(); } catch {}
|
|
124
|
+
throw proxyError("ECONNECT_TARGET", msg);
|
|
125
|
+
};
|
|
126
|
+
// 主机名长度上限:在任何协议 I/O 之前快速失败
|
|
127
|
+
const hostBuf = Buffer.from(targetHost, "utf8");
|
|
128
|
+
if (hostBuf.length > 255) return fail(`SOCKS5: hostname too long (${hostBuf.length} bytes, max 255)`);
|
|
129
|
+
const stream = new ByteStream(sock, { idleMs: handshakeTimeoutMs });
|
|
130
|
+
try {
|
|
131
|
+
// 方法协商:无凭据 → 0x00 no-auth;有凭据 → 0x02 user/pass
|
|
132
|
+
const wantAuth = !!(proxy.username || proxy.password);
|
|
133
|
+
sock.write(Buffer.from([0x05, 0x01, wantAuth ? 0x02 : 0x00]));
|
|
134
|
+
const verMethod = await stream.readExactly(2);
|
|
135
|
+
if (verMethod[0] !== 0x05) return fail(`SOCKS5: bad version ${verMethod[0]}`);
|
|
136
|
+
if (verMethod[1] === 0x02) {
|
|
137
|
+
const user = Buffer.from(proxy.username || "", "utf8");
|
|
138
|
+
const pass = Buffer.from(proxy.password || "", "utf8");
|
|
139
|
+
sock.write(Buffer.concat([Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass]));
|
|
140
|
+
const authResp = await stream.readExactly(2);
|
|
141
|
+
if (authResp[0] !== 0x01 || authResp[1] !== 0x00) return fail(`SOCKS5: auth failed (code ${authResp[1]})`);
|
|
142
|
+
} else if (verMethod[1] !== 0x00) {
|
|
143
|
+
return fail(`SOCKS5: server requires auth (method ${verMethod[1]})`);
|
|
144
|
+
}
|
|
145
|
+
// CONNECT:统一用域名形式 ATYP=0x03(IP 目标同样可表达,避免 IPv4/IPv6 分支)
|
|
146
|
+
const req = Buffer.alloc(4 + 1 + hostBuf.length + 2);
|
|
147
|
+
req[0] = 0x05; req[1] = 0x01; req[2] = 0x00; req[3] = 0x03; req[4] = hostBuf.length;
|
|
148
|
+
hostBuf.copy(req, 5);
|
|
149
|
+
req.writeUInt16BE(targetPort, 5 + hostBuf.length);
|
|
150
|
+
sock.write(req);
|
|
151
|
+
const head = await stream.readExactly(4);
|
|
152
|
+
if (head[0] !== 0x05 || head[1] !== 0x00) {
|
|
153
|
+
return fail(`SOCKS5: connect to ${targetHost}:${targetPort} failed, code ${head[1]}`);
|
|
154
|
+
}
|
|
155
|
+
// 吃掉 BND.ADDR / BND.PORT(ATYP: 1=IPv4, 4=IPv6, 3=域名)
|
|
156
|
+
const atyp = head[3];
|
|
157
|
+
const alen = atyp === 0x01 ? 4 : atyp === 0x04 ? 16 : 1;
|
|
158
|
+
await stream.readExactly(alen + 2);
|
|
159
|
+
stream.detach();
|
|
160
|
+
return sock;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
stream.detach();
|
|
163
|
+
try { sock.destroy(); } catch {}
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// lib/proxy/decode.js — 响应体解码 sink:按 content-encoding 建解压流,
|
|
2
|
+
// 把压缩字节流式解压进 ReadableStream controller(HTTP/1.1 与 HTTP/2 共用)。
|
|
3
|
+
// 解压输出设有上限(防 decompression bomb)。
|
|
4
|
+
import { createDecoder } from "./parse.js";
|
|
5
|
+
import { proxyError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
/** 解压输出上限(默认 512MB,超过即终止并报错)。 */
|
|
8
|
+
export const DEFAULT_MAX_DECODED = 512 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 构建「解压流 → ReadableStream controller」的公共 sink。
|
|
12
|
+
* @param {ReadableStreamDefaultController} controller
|
|
13
|
+
* @param {string} ce content-encoding(小写;''/unknown → 不解压)
|
|
14
|
+
* @param {{maxOutputBytes?:number}} [opts]
|
|
15
|
+
* @returns {{dec:import("node:stream").Transform|null, write:(c:Uint8Array)=>Promise<void>, finish:()=>Promise<void>, destroy:()=>void}}
|
|
16
|
+
*/
|
|
17
|
+
export function makeBodyController(controller, ce, { maxOutputBytes = DEFAULT_MAX_DECODED } = {}) {
|
|
18
|
+
const dec = createDecoder(ce);
|
|
19
|
+
let decDoneResolve = null;
|
|
20
|
+
const decDone = new Promise((r) => (decDoneResolve = r));
|
|
21
|
+
let closed = false;
|
|
22
|
+
let outBytes = 0;
|
|
23
|
+
const close = () => {
|
|
24
|
+
if (closed) return;
|
|
25
|
+
closed = true;
|
|
26
|
+
try { controller.close(); } catch {}
|
|
27
|
+
decDoneResolve?.();
|
|
28
|
+
};
|
|
29
|
+
const onData = (c) => {
|
|
30
|
+
outBytes += c.length;
|
|
31
|
+
if (maxOutputBytes > 0 && outBytes > maxOutputBytes) {
|
|
32
|
+
try { dec?.destroy(); } catch {}
|
|
33
|
+
try { controller.error(proxyError("EBODY", "decompressed response body exceeds output cap")); } catch {}
|
|
34
|
+
decDoneResolve?.();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try { controller.enqueue(new Uint8Array(c)); } catch {}
|
|
38
|
+
};
|
|
39
|
+
if (dec) {
|
|
40
|
+
dec.on("data", onData);
|
|
41
|
+
dec.on("end", close);
|
|
42
|
+
dec.on("error", (e) => { try { controller.error(e); } catch {} decDoneResolve?.(); });
|
|
43
|
+
}
|
|
44
|
+
const write = (c) => new Promise((res) => {
|
|
45
|
+
if (dec) {
|
|
46
|
+
if (!dec.write(c)) dec.once("drain", () => res());
|
|
47
|
+
else res();
|
|
48
|
+
} else {
|
|
49
|
+
onData(c);
|
|
50
|
+
res();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const finish = () => {
|
|
54
|
+
if (dec) { dec.end(); return decDone; }
|
|
55
|
+
close();
|
|
56
|
+
return Promise.resolve();
|
|
57
|
+
};
|
|
58
|
+
const destroy = () => { if (dec) { try { dec.destroy(); } catch {} } };
|
|
59
|
+
return { dec, write, finish, destroy };
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// lib/proxy/errors.js — 通道层结构化错误(纯函数,零依赖)
|
|
2
|
+
//
|
|
3
|
+
// 所有代理相关错误统一带机器可读 `code` 前缀(E*),方便上层(探测、
|
|
4
|
+
// agent 工具)分类展示;取消与超时对齐 fetch 语义(AbortError)。
|
|
5
|
+
|
|
6
|
+
/** 构造带 code 的错误。 */
|
|
7
|
+
export function proxyError(code, message, cause) {
|
|
8
|
+
const e = new Error(message);
|
|
9
|
+
e.code = code;
|
|
10
|
+
if (cause !== undefined) e.cause = cause;
|
|
11
|
+
return e;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 对齐 fetch 的取消错误(AbortError)。 */
|
|
15
|
+
export function abortError() {
|
|
16
|
+
const e = new Error("The operation was aborted.");
|
|
17
|
+
e.name = "AbortError";
|
|
18
|
+
e.code = "ABORT_ERR";
|
|
19
|
+
return e;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 单请求超时(ms):配置了 timeout 用之,否则默认 60000。 */
|
|
23
|
+
export function timeoutFor(proxy) {
|
|
24
|
+
const t = Number(proxy && proxy.timeout);
|
|
25
|
+
return Number.isFinite(t) && t > 0 ? t : 60000;
|
|
26
|
+
}
|