@swimmingliu/autovpn 2.0.3 → 2.1.0
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/dist/cli/accounts-commands.js +123 -7
- package/dist/cli/output.js +1 -1
- package/dist/config/profile.js +40 -9
- package/dist/pipeline/availability.js +201 -150
- package/dist/pipeline/concurrency.js +68 -0
- package/dist/pipeline/credential-source.js +490 -99
- package/dist/pipeline/egress.js +372 -0
- package/dist/pipeline/extract.js +108 -69
- package/dist/pipeline/http-transport.js +232 -0
- package/dist/pipeline/orchestrator.js +34 -8
- package/dist/pipeline/proxy-runtime.js +35 -2
- package/dist/pipeline/speedtest.js +187 -144
- package/dist/pool/account-store.js +46 -4
- package/dist/server/http.js +11 -0
- package/dist/server/runtime.js +27 -1
- package/dist/server/web-adapter.js +12 -0
- package/dist/web/renderer/app.js +115 -10
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/styles.css +37 -2
- package/dist/web/renderer/tables.js +2 -2
- package/dist/web/renderer/views.js +156 -20
- package/dist/web/renderer/workspace.css +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One egress authority for the crawl stages (credential + url extraction).
|
|
3
|
+
*
|
|
4
|
+
* Before this module the two stages disagreed: each had its own env-only,
|
|
5
|
+
* default-off proxy switch, the credential flow built its CommContext without
|
|
6
|
+
* the egress object so its curl/TLS fallback was dead code, and neither stage
|
|
7
|
+
* noticed that the vendor had started answering with a block page.
|
|
8
|
+
*
|
|
9
|
+
* Policy, in the order the operator asked for it:
|
|
10
|
+
* - `proxy` (the default) crawls through the upstream proxy. When the proxy
|
|
11
|
+
* is not listening the run drops to direct rather than failing every
|
|
12
|
+
* request, and a later block on direct switches back to the proxy.
|
|
13
|
+
* - `auto` starts direct and pins the proxy for the rest of the run the
|
|
14
|
+
* first time a response looks like an IP block.
|
|
15
|
+
* - `direct` never uses a proxy. It is the explicit override.
|
|
16
|
+
*
|
|
17
|
+
* Per-node measurement (speedtest/availability) deliberately does NOT use this
|
|
18
|
+
* module: those transports must reach the node itself, not the crawl proxy.
|
|
19
|
+
*/
|
|
20
|
+
import net from 'node:net';
|
|
21
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
22
|
+
import { fetch as upstreamFetch, ProxyAgent } from 'undici';
|
|
23
|
+
export const DEFAULT_PROXY_URL = 'http://127.0.0.1:7897';
|
|
24
|
+
const DISABLED_VALUES = new Set(['', 'off', 'none', 'false', '0', 'no', 'direct']);
|
|
25
|
+
const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
26
|
+
function normalizeMode(value) {
|
|
27
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
28
|
+
if (text === 'proxy' || text === 'upstream')
|
|
29
|
+
return 'proxy';
|
|
30
|
+
if (text === 'auto' || text === 'fallback')
|
|
31
|
+
return 'auto';
|
|
32
|
+
if (text === 'direct' || text === 'off' || text === 'none')
|
|
33
|
+
return 'direct';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the crawl egress policy.
|
|
38
|
+
*
|
|
39
|
+
* `VPN_AUTOMATION_PROXY_MODE` is the current knob. The older
|
|
40
|
+
* `VPN_AUTOMATION_USE_UPSTREAM_PROXY` boolean still works: an explicit false
|
|
41
|
+
* means direct, an explicit true means proxy. The standard proxy variables are
|
|
42
|
+
* a URL fallback only, so a shell that exports HTTPS_PROXY for unrelated tools
|
|
43
|
+
* does not silently change the crawl's transport mode.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveEgressPolicy(env = process.env) {
|
|
46
|
+
const explicit = normalizeMode(env.VPN_AUTOMATION_PROXY_MODE);
|
|
47
|
+
const legacyRaw = String(env.VPN_AUTOMATION_USE_UPSTREAM_PROXY ?? '').trim().toLowerCase();
|
|
48
|
+
const legacy = legacyRaw
|
|
49
|
+
? (ENABLED_VALUES.has(legacyRaw) ? 'proxy' : (DISABLED_VALUES.has(legacyRaw) ? 'direct' : null))
|
|
50
|
+
: null;
|
|
51
|
+
const configured = String(env.VPN_AUTOMATION_UPSTREAM_PROXY ?? '').trim();
|
|
52
|
+
const standard = String(env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? '').trim();
|
|
53
|
+
const candidate = configured || standard;
|
|
54
|
+
// An UNSET candidate falls back to the default proxy; only an explicit
|
|
55
|
+
// disable token ('off', 'none', '0', ...) turns the proxy off. Treating ''
|
|
56
|
+
// as disabled would make the default mode direct.
|
|
57
|
+
const proxyUrl = candidate && DISABLED_VALUES.has(candidate.toLowerCase())
|
|
58
|
+
? ''
|
|
59
|
+
: (candidate || DEFAULT_PROXY_URL);
|
|
60
|
+
// An explicitly disabled proxy URL cannot serve any mode that needs one.
|
|
61
|
+
const mode = explicit ?? legacy ?? 'proxy';
|
|
62
|
+
if (!proxyUrl)
|
|
63
|
+
return { mode: 'direct', proxyUrl: '' };
|
|
64
|
+
return { mode, proxyUrl };
|
|
65
|
+
}
|
|
66
|
+
/** Status codes a vendor or its CDN uses when it stops serving this client. */
|
|
67
|
+
const BLOCK_STATUS = new Set([403, 407, 429, 451, 503]);
|
|
68
|
+
const BLOCK_BODY_PATTERN = /封禁|封鎖|封锁|禁止访问|禁止訪問|访问受限|訪問受限|您的IP|你的IP|IP\s*被|异常访问|異常訪問|请求过于频繁|請求過於頻繁|操作频繁|操作頻繁|too\s*many\s*requests|rate\s*limit|access\s*denied|forbidden|blocked|cf-browser-verification|just\s*a\s*moment|attention\s*required|captcha/i;
|
|
69
|
+
/** Classify a response or transport error for the switch decision. */
|
|
70
|
+
export function classifyEgressFailure(input) {
|
|
71
|
+
const status = Number(input.status ?? 0);
|
|
72
|
+
if (BLOCK_STATUS.has(status))
|
|
73
|
+
return 'blocked';
|
|
74
|
+
if (input.body && BLOCK_BODY_PATTERN.test(input.body.slice(0, 4096)))
|
|
75
|
+
return 'blocked';
|
|
76
|
+
if (input.error !== undefined) {
|
|
77
|
+
const text = errorText(input.error);
|
|
78
|
+
if (/econnreset|econnrefused|etimedout|enotfound|ehostunreach|enetunreach|socket\s*hang\s*up|fetch\s*failed|abort|timeout|und_err_/i.test(text)) {
|
|
79
|
+
return 'transient';
|
|
80
|
+
}
|
|
81
|
+
return 'fatal';
|
|
82
|
+
}
|
|
83
|
+
// A 2xx with no usable body is not a block on its own: the vendor answers an
|
|
84
|
+
// empty body for a blank `code` parameter too. The round-level empty sweep
|
|
85
|
+
// is what escalates that into a switch.
|
|
86
|
+
return status >= 400 ? 'transient' : 'fatal';
|
|
87
|
+
}
|
|
88
|
+
function errorText(error) {
|
|
89
|
+
if (error instanceof Error) {
|
|
90
|
+
const cause = error.cause;
|
|
91
|
+
return `${error.name}: ${error.message}${cause ? ` ${errorText(cause)}` : ''}`.toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
return String(error ?? '').toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* True when the failure is the proxy itself refusing the connection, not the
|
|
97
|
+
* target host. Only then may the run fall back to direct.
|
|
98
|
+
*/
|
|
99
|
+
export function isProxyUnreachable(error) {
|
|
100
|
+
return /econnrefused|enotfound|ehostunreach|enetunreach|proxy\s*(connection|connect|tunnel)|und_err_proxy|err_proxy/i.test(errorText(error));
|
|
101
|
+
}
|
|
102
|
+
function isTlsFailure(error) {
|
|
103
|
+
return /ssl|tls|certificate|handshake/i.test(errorText(error));
|
|
104
|
+
}
|
|
105
|
+
const defaultSpawn = nodeSpawn;
|
|
106
|
+
/**
|
|
107
|
+
* curl handles a proxy or an intercepted TLS chain that Node's fetch rejects.
|
|
108
|
+
* The URL travels through stdin so it never lands in a process argument list.
|
|
109
|
+
*/
|
|
110
|
+
export function defaultCurlFetch(url, proxyUrl, spawn, timeoutSeconds = 20) {
|
|
111
|
+
const run = spawn ?? defaultSpawn;
|
|
112
|
+
const args = [
|
|
113
|
+
'--fail', '--silent', '--show-error', '--location',
|
|
114
|
+
'--max-time', String(Math.max(1, Math.trunc(timeoutSeconds))),
|
|
115
|
+
'--connect-timeout', '10',
|
|
116
|
+
'--insecure', '--http1.1', '--config', '-'
|
|
117
|
+
];
|
|
118
|
+
if (proxyUrl)
|
|
119
|
+
args.push('--proxy', proxyUrl);
|
|
120
|
+
else
|
|
121
|
+
args.push('--noproxy', '*');
|
|
122
|
+
const child = run('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
123
|
+
let stdout = '';
|
|
124
|
+
let stderr = '';
|
|
125
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
|
126
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
127
|
+
const completion = new Promise((resolve, reject) => {
|
|
128
|
+
child.on('error', reject);
|
|
129
|
+
child.on('close', (code) => {
|
|
130
|
+
if (code === 0)
|
|
131
|
+
resolve(stdout);
|
|
132
|
+
else
|
|
133
|
+
reject(new Error((stderr || stdout || 'curl TLS fallback failed').trim()));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
child.stdin?.write(`url = ${JSON.stringify(url)}\n`);
|
|
137
|
+
child.stdin?.end();
|
|
138
|
+
return completion;
|
|
139
|
+
}
|
|
140
|
+
/** A TCP connect is enough to know whether a local proxy is listening. */
|
|
141
|
+
export function probeProxy(proxyUrl, timeoutMs = 1500) {
|
|
142
|
+
let target;
|
|
143
|
+
try {
|
|
144
|
+
target = new URL(proxyUrl);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return Promise.resolve(false);
|
|
148
|
+
}
|
|
149
|
+
const port = Number(target.port) || (target.protocol === 'https:' ? 443 : 80);
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
let settled = false;
|
|
152
|
+
const socket = net.connect({ host: target.hostname, port });
|
|
153
|
+
const finish = (reachable) => {
|
|
154
|
+
if (settled)
|
|
155
|
+
return;
|
|
156
|
+
settled = true;
|
|
157
|
+
socket.destroy();
|
|
158
|
+
resolve(reachable);
|
|
159
|
+
};
|
|
160
|
+
socket.setTimeout(timeoutMs);
|
|
161
|
+
socket.once('connect', () => finish(true));
|
|
162
|
+
socket.once('timeout', () => finish(false));
|
|
163
|
+
socket.once('error', () => finish(false));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const FAILED_TEXT = (url, transport, status = 0, blocked = false) => ({ text: '', url, ok: false, status, transport, blocked });
|
|
167
|
+
/**
|
|
168
|
+
* A run-scoped transport choice. One session per source: the switch is sticky
|
|
169
|
+
* so a blocked run does not oscillate between transports on every request.
|
|
170
|
+
*/
|
|
171
|
+
export class EgressSession {
|
|
172
|
+
policy;
|
|
173
|
+
switches = [];
|
|
174
|
+
/** HTTP attempts actually issued, including retries and fallbacks. */
|
|
175
|
+
attempts = 0;
|
|
176
|
+
current;
|
|
177
|
+
dispatcher = null;
|
|
178
|
+
fetchImpl;
|
|
179
|
+
curlFetch;
|
|
180
|
+
onSwitch;
|
|
181
|
+
proxyEverReachable;
|
|
182
|
+
/** A request has already been routed through the proxy this run. */
|
|
183
|
+
proxyAttempted = false;
|
|
184
|
+
closed = false;
|
|
185
|
+
constructor(policy, current, proxyReachable, options) {
|
|
186
|
+
this.policy = policy;
|
|
187
|
+
this.current = current;
|
|
188
|
+
this.proxyEverReachable = proxyReachable;
|
|
189
|
+
this.fetchImpl = options.fetchImpl;
|
|
190
|
+
this.curlFetch = options.curlFetch;
|
|
191
|
+
this.onSwitch = options.onSwitch;
|
|
192
|
+
}
|
|
193
|
+
static async open(env, options = {}) {
|
|
194
|
+
const policy = resolveEgressPolicy(env ?? {});
|
|
195
|
+
if (policy.mode === 'direct' || !policy.proxyUrl) {
|
|
196
|
+
return new EgressSession(policy, 'direct', false, options);
|
|
197
|
+
}
|
|
198
|
+
if (policy.mode === 'auto') {
|
|
199
|
+
// Direct first; the first blocked response pins the proxy.
|
|
200
|
+
return new EgressSession(policy, 'direct', false, options);
|
|
201
|
+
}
|
|
202
|
+
const probe = options.probe ?? ((url) => probeProxy(url));
|
|
203
|
+
// An injected fetch is a test seam: probing a real socket would make every
|
|
204
|
+
// unit test depend on whether a local proxy happens to be listening. The
|
|
205
|
+
// injected impl decides the bytes; `probe` can still be injected to drive
|
|
206
|
+
// the unreachable-proxy fallback.
|
|
207
|
+
let reachable = Boolean(options.fetchImpl) && !options.probe;
|
|
208
|
+
if (!reachable) {
|
|
209
|
+
try {
|
|
210
|
+
reachable = await probe(policy.proxyUrl);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
reachable = false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// When the proxy is not listening the run starts direct. `proxyReachable`
|
|
217
|
+
// stays false so the first blocked response may still try the proxy once
|
|
218
|
+
// (a local client often starts a moment later), and a failed proxy attempt
|
|
219
|
+
// is not retried after that.
|
|
220
|
+
return new EgressSession(policy, reachable ? 'proxy' : 'direct', reachable, options);
|
|
221
|
+
}
|
|
222
|
+
get transport() {
|
|
223
|
+
return this.current;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The transport-bound fetch, for the binary (APK) downloads that need
|
|
227
|
+
* `arrayBuffer` rather than text. It carries the same proxy dispatcher as
|
|
228
|
+
* `text()`, so a configured proxy serves the APK bytes too.
|
|
229
|
+
*/
|
|
230
|
+
rawFetch() {
|
|
231
|
+
return this.impl();
|
|
232
|
+
}
|
|
233
|
+
get proxyAvailable() {
|
|
234
|
+
return Boolean(this.policy.proxyUrl) && this.policy.mode !== 'direct';
|
|
235
|
+
}
|
|
236
|
+
get proxyReachable() {
|
|
237
|
+
return this.proxyEverReachable;
|
|
238
|
+
}
|
|
239
|
+
switchTo(next, reason) {
|
|
240
|
+
if (this.current === next)
|
|
241
|
+
return false;
|
|
242
|
+
// A proxy the probe found unreachable gets exactly one more chance (it may
|
|
243
|
+
// have just started listening). After that a blocked response does not
|
|
244
|
+
// bounce the run back onto a transport that already refused it.
|
|
245
|
+
if (next === 'proxy' && !this.proxyEverReachable && this.proxyAttempted)
|
|
246
|
+
return false;
|
|
247
|
+
const record = { from: this.current, to: next, reason };
|
|
248
|
+
this.current = next;
|
|
249
|
+
this.switches.push(record);
|
|
250
|
+
this.onSwitch?.(record);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Escalate a silent failure sweep. The vendor can answer an empty body to a
|
|
255
|
+
* banned IP instead of a 403, so a whole round with no answer is the signal.
|
|
256
|
+
*/
|
|
257
|
+
reportEmptySweep(reason = 'empty_responses') {
|
|
258
|
+
if (this.current !== 'direct' || !this.proxyAvailable)
|
|
259
|
+
return false;
|
|
260
|
+
return this.switchTo('proxy', reason);
|
|
261
|
+
}
|
|
262
|
+
impl() {
|
|
263
|
+
if (this.fetchImpl)
|
|
264
|
+
return this.fetchImpl;
|
|
265
|
+
if (this.current === 'proxy' && this.policy.proxyUrl) {
|
|
266
|
+
this.dispatcher ??= new ProxyAgent({ uri: this.policy.proxyUrl, proxyTunnel: false });
|
|
267
|
+
const dispatcher = this.dispatcher;
|
|
268
|
+
return (url, init) => upstreamFetch(url, { ...init, dispatcher });
|
|
269
|
+
}
|
|
270
|
+
return (url, init) => upstreamFetch(url, { ...init });
|
|
271
|
+
}
|
|
272
|
+
async attempt(url, timeoutMs, transport) {
|
|
273
|
+
if (transport === 'proxy')
|
|
274
|
+
this.proxyAttempted = true;
|
|
275
|
+
const controller = new AbortController();
|
|
276
|
+
const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
|
|
277
|
+
const fetchImpl = this.impl();
|
|
278
|
+
this.attempts += 1;
|
|
279
|
+
let response = null;
|
|
280
|
+
try {
|
|
281
|
+
response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' });
|
|
282
|
+
const status = Number(response.status ?? (response.ok === false ? 500 : 200));
|
|
283
|
+
const ok = response.ok !== false && status < 400;
|
|
284
|
+
if (!ok) {
|
|
285
|
+
// An abandoned body pins its keep-alive socket until GC; at thousands
|
|
286
|
+
// of route requests per run that exhausts the pool.
|
|
287
|
+
const body = await this.drain(response, 2048);
|
|
288
|
+
await this.cancel(response);
|
|
289
|
+
const blocked = classifyEgressFailure({ status, body }) === 'blocked';
|
|
290
|
+
if (blocked && transport === 'direct' && this.proxyAvailable) {
|
|
291
|
+
return { ...FAILED_TEXT(url, transport, status, true), retryWith: 'proxy', reason: `blocked_direct_${status}` };
|
|
292
|
+
}
|
|
293
|
+
return FAILED_TEXT(url, transport, status, blocked);
|
|
294
|
+
}
|
|
295
|
+
const text = await response.text();
|
|
296
|
+
const blocked = classifyEgressFailure({ status, body: text }) === 'blocked';
|
|
297
|
+
if (blocked && transport === 'direct' && this.proxyAvailable) {
|
|
298
|
+
return { ...FAILED_TEXT(url, transport, status, true), retryWith: 'proxy', reason: 'blocked_direct_body' };
|
|
299
|
+
}
|
|
300
|
+
return { text: blocked ? '' : text, url, ok: !blocked, status, transport, blocked };
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
await this.cancel(response);
|
|
304
|
+
if (transport === 'proxy' && isProxyUnreachable(error)) {
|
|
305
|
+
this.proxyEverReachable = false;
|
|
306
|
+
return { ...FAILED_TEXT(url, transport), retryWith: 'direct', reason: 'proxy_unreachable' };
|
|
307
|
+
}
|
|
308
|
+
// Same fallback the url mode has always applied: curl handles a proxy or
|
|
309
|
+
// a self-signed/intercepted TLS chain that Node's fetch rejects.
|
|
310
|
+
if (this.curlFetch && (isTlsFailure(error) || classifyEgressFailure({ error }) === 'transient')) {
|
|
311
|
+
this.attempts += 1;
|
|
312
|
+
try {
|
|
313
|
+
const text = await this.curlFetch(url, transport === 'proxy' ? this.policy.proxyUrl : '');
|
|
314
|
+
if (text.trim()) {
|
|
315
|
+
return { text, url, ok: true, status: 200, transport, blocked: false };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Fall through to the failure result below.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return FAILED_TEXT(url, transport);
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async drain(response, limit) {
|
|
329
|
+
if (!response)
|
|
330
|
+
return '';
|
|
331
|
+
try {
|
|
332
|
+
const text = await response.text();
|
|
333
|
+
return text.slice(0, limit);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return '';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async cancel(response) {
|
|
340
|
+
try {
|
|
341
|
+
await response?.body?.cancel?.();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// Already consumed or already closed.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Fetch a URL as text under the current transport, switching once if needed. */
|
|
348
|
+
async text(url, timeoutMs) {
|
|
349
|
+
if (this.closed)
|
|
350
|
+
throw new Error('egress session is closed');
|
|
351
|
+
const first = await this.attempt(url, timeoutMs, this.current);
|
|
352
|
+
if (!first.retryWith)
|
|
353
|
+
return first;
|
|
354
|
+
this.switchTo(first.retryWith, first.reason ?? 'retry');
|
|
355
|
+
const second = await this.attempt(url, timeoutMs, this.current);
|
|
356
|
+
const { retryWith: _ignored, reason: _reason, ...rest } = second;
|
|
357
|
+
return rest;
|
|
358
|
+
}
|
|
359
|
+
async close() {
|
|
360
|
+
this.closed = true;
|
|
361
|
+
const dispatcher = this.dispatcher;
|
|
362
|
+
this.dispatcher = null;
|
|
363
|
+
if (dispatcher) {
|
|
364
|
+
try {
|
|
365
|
+
await dispatcher.destroy();
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// Destroy is best-effort; a dead proxy must not fail the run.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
package/dist/pipeline/extract.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
3
|
+
import { EgressSession, resolveEgressPolicy, defaultCurlFetch as egressCurlFetch } from './egress.js';
|
|
3
4
|
function defaultRandomInt(start, end) {
|
|
4
5
|
return Math.floor(Math.random() * (end - start + 1)) + start;
|
|
5
6
|
}
|
|
@@ -135,11 +136,9 @@ function isEnabled(value) {
|
|
|
135
136
|
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
|
136
137
|
}
|
|
137
138
|
function resolveUpstreamProxyUrl(env) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const value = String(env.VPN_AUTOMATION_UPSTREAM_PROXY ?? 'http://127.0.0.1:7897').trim();
|
|
142
|
-
return ['', 'off', 'none', 'false', '0'].includes(value.toLowerCase()) ? '' : value;
|
|
139
|
+
// One resolver for both extract modes, so a run cannot half-use a proxy.
|
|
140
|
+
const policy = resolveEgressPolicy(env);
|
|
141
|
+
return policy.mode === 'direct' ? '' : policy.proxyUrl;
|
|
143
142
|
}
|
|
144
143
|
function isTlsFailure(error) {
|
|
145
144
|
const text = error instanceof Error
|
|
@@ -201,10 +200,29 @@ function defaultCurlFetch(url, proxyUrl, spawn = defaultSpawn) {
|
|
|
201
200
|
function emitExtractEvent(options, type, payload) {
|
|
202
201
|
options.eventCallback?.(type, payload);
|
|
203
202
|
}
|
|
204
|
-
async function fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy) {
|
|
203
|
+
async function fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy, session) {
|
|
204
|
+
// The shared session is the egress authority for both modes: it owns the
|
|
205
|
+
// proxy dispatcher, cancels abandoned bodies and switches direct -> proxy
|
|
206
|
+
// when the vendor starts answering with a block page.
|
|
207
|
+
if (session) {
|
|
208
|
+
const result = await session.text(url, 20_000);
|
|
209
|
+
const via = result.transport === 'proxy' ? 'upstream_proxy' : 'direct';
|
|
210
|
+
emitExtractEvent(options, 'extract_request_result', {
|
|
211
|
+
source_name: input.source_name,
|
|
212
|
+
iteration: attempt,
|
|
213
|
+
success: result.ok,
|
|
214
|
+
via,
|
|
215
|
+
...(result.ok ? {} : { error: result.blocked ? 'blocked' : `HTTP ${result.status}` })
|
|
216
|
+
});
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
throw new Error(result.blocked ? 'egress blocked' : `HTTP ${result.status}`);
|
|
219
|
+
}
|
|
220
|
+
return { text: result.text, via };
|
|
221
|
+
}
|
|
205
222
|
try {
|
|
206
223
|
const response = await fetchWithTimeout(fetchImpl, url, 20_000);
|
|
207
224
|
if (response.ok === false || Number(response.status ?? 200) >= 400) {
|
|
225
|
+
await response.body?.cancel?.();
|
|
208
226
|
throw new Error(`HTTP ${Number(response.status ?? 0)}`);
|
|
209
227
|
}
|
|
210
228
|
const text = await response.text();
|
|
@@ -270,88 +288,104 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
270
288
|
let failures = 0;
|
|
271
289
|
const startedAt = Date.now();
|
|
272
290
|
const upstreamProxy = resolveUpstreamProxyUrl(options.env ?? process.env);
|
|
291
|
+
const session = await EgressSession.open(options.env ?? process.env, {
|
|
292
|
+
fetchImpl: fetchImpl,
|
|
293
|
+
curlFetch: options.curlFetch ?? ((url, proxyUrl) => egressCurlFetch(url, proxyUrl, options.spawn)),
|
|
294
|
+
// The switch has to be visible in the run log for both extract modes; a
|
|
295
|
+
// credential source logs it from its own wrapper, a url source from here.
|
|
296
|
+
onSwitch: (switched) => emitExtractEvent(options, 'log', {
|
|
297
|
+
message: `[extract][${input.source_name}] egress ${switched.from} -> ${switched.to} (${switched.reason})`
|
|
298
|
+
})
|
|
299
|
+
});
|
|
273
300
|
emitExtractEvent(options, 'extract_source_started', {
|
|
274
301
|
source_name: input.source_name,
|
|
275
302
|
requested_iterations: maxIterations,
|
|
276
303
|
min_iterations: minIterations,
|
|
277
304
|
resume_from_iteration: startIteration
|
|
278
305
|
});
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
const url = buildRuntimeSourceUrl(source, iteration);
|
|
286
|
-
const response = await fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy);
|
|
287
|
-
let plaintext = '';
|
|
288
|
-
try {
|
|
289
|
-
plaintext = decryptPayload(response.text.trim(), source.key);
|
|
290
|
-
emitExtractEvent(options, 'extract_decrypt_result', {
|
|
291
|
-
source_name: input.source_name,
|
|
292
|
-
iteration: attempt,
|
|
293
|
-
success: true
|
|
294
|
-
});
|
|
306
|
+
try {
|
|
307
|
+
for (let iteration = startIteration - 1; iteration < maxIterations; iteration += 1) {
|
|
308
|
+
const attempt = iteration + 1;
|
|
309
|
+
if (maxRuntimeSeconds > 0 && attempt > minIterations && (Date.now() - startedAt) / 1000 >= maxRuntimeSeconds) {
|
|
310
|
+
break;
|
|
295
311
|
}
|
|
296
|
-
|
|
297
|
-
|
|
312
|
+
try {
|
|
313
|
+
const url = buildRuntimeSourceUrl(source, iteration);
|
|
314
|
+
const response = await fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy, session);
|
|
315
|
+
let plaintext = '';
|
|
316
|
+
try {
|
|
317
|
+
plaintext = decryptPayload(response.text.trim(), source.key);
|
|
318
|
+
emitExtractEvent(options, 'extract_decrypt_result', {
|
|
319
|
+
source_name: input.source_name,
|
|
320
|
+
iteration: attempt,
|
|
321
|
+
success: true
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (decryptError) {
|
|
325
|
+
emitExtractEvent(options, 'extract_decrypt_result', {
|
|
326
|
+
source_name: input.source_name,
|
|
327
|
+
iteration: attempt,
|
|
328
|
+
success: false,
|
|
329
|
+
error: decryptError instanceof Error ? `${decryptError.constructor.name}: ${decryptError.message}` : String(decryptError)
|
|
330
|
+
});
|
|
331
|
+
throw decryptError;
|
|
332
|
+
}
|
|
333
|
+
const extracted = extractLinksFromPlaintext(input.source_name, plaintext);
|
|
334
|
+
successes += 1;
|
|
335
|
+
failures = 0;
|
|
336
|
+
let newItems = 0;
|
|
337
|
+
const newLinks = [];
|
|
338
|
+
const newItemFingerprints = [];
|
|
339
|
+
for (const link of extracted) {
|
|
340
|
+
if (seen.has(link)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
seen.add(link);
|
|
344
|
+
links.push(link);
|
|
345
|
+
newLinks.push(link);
|
|
346
|
+
newItems += 1;
|
|
347
|
+
newItemFingerprints.push(linkFingerprint(link));
|
|
348
|
+
}
|
|
349
|
+
emitExtractEvent(options, 'extract_iteration', {
|
|
298
350
|
source_name: input.source_name,
|
|
299
351
|
iteration: attempt,
|
|
300
|
-
|
|
301
|
-
|
|
352
|
+
requested_iterations: maxIterations,
|
|
353
|
+
new_items: newItems,
|
|
354
|
+
extracted_links: extracted.length,
|
|
355
|
+
total_links: links.length,
|
|
356
|
+
deduped_links: links.length,
|
|
357
|
+
new_item_fingerprints: newItemFingerprints
|
|
302
358
|
});
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const newLinks = [];
|
|
310
|
-
const newItemFingerprints = [];
|
|
311
|
-
for (const link of extracted) {
|
|
312
|
-
if (seen.has(link)) {
|
|
313
|
-
continue;
|
|
359
|
+
if (newItems > 0) {
|
|
360
|
+
await options.linksCallback?.(newLinks);
|
|
361
|
+
}
|
|
362
|
+
plateau = newItems === 0 ? plateau + 1 : 0;
|
|
363
|
+
if (plateau >= plateauLimit && attempt >= minIterations) {
|
|
364
|
+
break;
|
|
314
365
|
}
|
|
315
|
-
seen.add(link);
|
|
316
|
-
links.push(link);
|
|
317
|
-
newLinks.push(link);
|
|
318
|
-
newItems += 1;
|
|
319
|
-
newItemFingerprints.push(linkFingerprint(link));
|
|
320
|
-
}
|
|
321
|
-
emitExtractEvent(options, 'extract_iteration', {
|
|
322
|
-
source_name: input.source_name,
|
|
323
|
-
iteration: attempt,
|
|
324
|
-
requested_iterations: maxIterations,
|
|
325
|
-
new_items: newItems,
|
|
326
|
-
extracted_links: extracted.length,
|
|
327
|
-
total_links: links.length,
|
|
328
|
-
deduped_links: links.length,
|
|
329
|
-
new_item_fingerprints: newItemFingerprints
|
|
330
|
-
});
|
|
331
|
-
if (newItems > 0) {
|
|
332
|
-
await options.linksCallback?.(newLinks);
|
|
333
|
-
}
|
|
334
|
-
plateau = newItems === 0 ? plateau + 1 : 0;
|
|
335
|
-
if (plateau >= plateauLimit && attempt >= minIterations) {
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
failures += 1;
|
|
341
|
-
if (failures >= failureLimit) {
|
|
342
|
-
break;
|
|
343
366
|
}
|
|
344
|
-
|
|
345
|
-
|
|
367
|
+
catch (error) {
|
|
368
|
+
failures += 1;
|
|
369
|
+
if (failures >= failureLimit) {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
if (attempt >= maxIterations) {
|
|
373
|
+
throw new Error(`Node extract request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
374
|
+
}
|
|
346
375
|
}
|
|
347
376
|
}
|
|
348
377
|
}
|
|
378
|
+
finally {
|
|
379
|
+
await session.close();
|
|
380
|
+
}
|
|
349
381
|
emitExtractEvent(options, 'extract_source_completed', {
|
|
350
382
|
source_name: input.source_name,
|
|
351
383
|
requested_iterations: maxIterations,
|
|
352
384
|
successful_iterations: successes,
|
|
353
385
|
failed_iterations: failures,
|
|
354
|
-
raw_links: links.length
|
|
386
|
+
raw_links: links.length,
|
|
387
|
+
egress_transport: session.transport,
|
|
388
|
+
egress_switches: session.switches.length
|
|
355
389
|
});
|
|
356
390
|
return {
|
|
357
391
|
source_name: input.source_name,
|
|
@@ -385,7 +419,12 @@ export async function fetchSourceLinksWithBackend(input, options = {}) {
|
|
|
385
419
|
successful_iterations: result.successful_iterations,
|
|
386
420
|
failed_iterations: result.failed_iterations,
|
|
387
421
|
links: result.links,
|
|
388
|
-
errors: result.diagnostics.errors
|
|
422
|
+
errors: result.diagnostics.errors,
|
|
423
|
+
// Without these the orchestrator could only report the requested cap,
|
|
424
|
+
// so an early-stopped 1000-round run still read as 1000/1000.
|
|
425
|
+
rounds: result.diagnostics.rounds,
|
|
426
|
+
rounds_total: result.diagnostics.rounds_total,
|
|
427
|
+
platforms: result.diagnostics.platforms
|
|
389
428
|
};
|
|
390
429
|
}
|
|
391
430
|
return fetchSourceLinksInNode(input, options);
|