@swimmingliu/autovpn 1.4.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/README.md +102 -0
- package/bin/autovpn.mjs +5 -0
- package/dist/artifacts/list.js +99 -0
- package/dist/artifacts/preview.js +85 -0
- package/dist/backend/node-backend.js +194 -0
- package/dist/backend/python-backend.js +242 -0
- package/dist/backend/select-backend.js +12 -0
- package/dist/backend/types.js +1 -0
- package/dist/cli/commands/index.js +143 -0
- package/dist/cli/errors.js +7 -0
- package/dist/cli/global-options.js +29 -0
- package/dist/cli/main.js +231 -0
- package/dist/cli/native-commands.js +215 -0
- package/dist/cli/output.js +31 -0
- package/dist/config/profile.js +80 -0
- package/dist/doctor/checks.js +184 -0
- package/dist/events/schema.js +45 -0
- package/dist/jobs/commands.js +159 -0
- package/dist/jobs/logs.js +48 -0
- package/dist/jobs/process.js +75 -0
- package/dist/jobs/read.js +282 -0
- package/dist/jobs/store.js +115 -0
- package/dist/pipeline/availability.js +679 -0
- package/dist/pipeline/dedupe.js +104 -0
- package/dist/pipeline/deploy.js +1103 -0
- package/dist/pipeline/extract.js +259 -0
- package/dist/pipeline/obfuscate.js +203 -0
- package/dist/pipeline/orchestrator.js +1014 -0
- package/dist/pipeline/postprocess.js +214 -0
- package/dist/pipeline/proxy-runtime.js +228 -0
- package/dist/pipeline/render.js +91 -0
- package/dist/pipeline/speedtest.js +579 -0
- package/dist/runtime/env.js +35 -0
- package/dist/runtime/paths.js +66 -0
- package/dist/runtime/redaction.js +56 -0
- package/lib/cache.mjs +14 -0
- package/lib/errors.mjs +11 -0
- package/lib/install-python-cli.mjs +63 -0
- package/lib/runner.mjs +113 -0
- package/package.json +39 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import tls from 'node:tls';
|
|
6
|
+
import { mergeProjectEnv } from '../runtime/env.js';
|
|
7
|
+
import { openMihomoRuntime as defaultOpenMihomoRuntime } from './proxy-runtime.js';
|
|
8
|
+
const PROVIDER_TARGETS = [
|
|
9
|
+
{ name: 'gemini', url: 'https://gemini.google.com', allowed_hosts: ['gemini.google.com'], negative_phrases: [] },
|
|
10
|
+
{ name: 'chatgpt_ios', url: 'https://ios.chat.openai.com/', allowed_hosts: ['ios.chat.openai.com'], negative_phrases: [] },
|
|
11
|
+
{ name: 'chatgpt_web', url: 'https://api.openai.com/compliance/cookie_requirements', allowed_hosts: ['api.openai.com'], negative_phrases: [] },
|
|
12
|
+
{ name: 'claude', url: 'https://claude.ai/cdn-cgi/trace', allowed_hosts: ['claude.ai'], negative_phrases: [] }
|
|
13
|
+
];
|
|
14
|
+
const CHALLENGE_PHRASES = [
|
|
15
|
+
'just a moment',
|
|
16
|
+
'checking your browser',
|
|
17
|
+
'verify you are human',
|
|
18
|
+
'enable javascript and cookies'
|
|
19
|
+
];
|
|
20
|
+
const PYTHON_AVAILABILITY_HELPER = `
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from vpn_automation.config.models import AvailabilityTargetConfig, SpeedTestConfig
|
|
24
|
+
from vpn_automation.pipeline.availability import ProviderTarget, check_link_availability_batch
|
|
25
|
+
from vpn_automation.pipeline.speedtest import SpeedTestResult
|
|
26
|
+
|
|
27
|
+
payload = json.load(sys.stdin)
|
|
28
|
+
config = SpeedTestConfig(**payload["config"])
|
|
29
|
+
results = [SpeedTestResult(**item) for item in payload.get("results", [])]
|
|
30
|
+
raw_targets = payload.get("targets", None)
|
|
31
|
+
targets = None
|
|
32
|
+
if isinstance(raw_targets, list):
|
|
33
|
+
targets = tuple(
|
|
34
|
+
ProviderTarget(
|
|
35
|
+
name=str(item["name"]),
|
|
36
|
+
url=str(item["url"]),
|
|
37
|
+
allowed_hosts=tuple(item.get("allowed_hosts") or []),
|
|
38
|
+
negative_phrases=tuple(item.get("negative_phrases") or []),
|
|
39
|
+
)
|
|
40
|
+
for item in raw_targets
|
|
41
|
+
)
|
|
42
|
+
elif isinstance(raw_targets, dict):
|
|
43
|
+
targets = {name: AvailabilityTargetConfig(**value) for name, value in raw_targets.items()}
|
|
44
|
+
output = [
|
|
45
|
+
item.to_dict()
|
|
46
|
+
for item in check_link_availability_batch(
|
|
47
|
+
results,
|
|
48
|
+
config,
|
|
49
|
+
runtime_path=payload.get("runtime_path", ""),
|
|
50
|
+
targets=targets,
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
json.dump(output, sys.stdout, ensure_ascii=False)
|
|
54
|
+
sys.stdout.write("\\n")
|
|
55
|
+
`;
|
|
56
|
+
function providerResultWithDefaults(result) {
|
|
57
|
+
return {
|
|
58
|
+
provider: result.provider,
|
|
59
|
+
passed: result.passed,
|
|
60
|
+
reason: result.reason,
|
|
61
|
+
status_code: result.status_code ?? 0,
|
|
62
|
+
final_url: result.final_url ?? '',
|
|
63
|
+
matched_phrase: result.matched_phrase ?? ''
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function hostIsAllowed(hostname, allowedHosts) {
|
|
67
|
+
const host = hostname.toLowerCase();
|
|
68
|
+
return allowedHosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
|
69
|
+
}
|
|
70
|
+
function hostnameFor(url) {
|
|
71
|
+
try {
|
|
72
|
+
return new URL(url).hostname;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function normalizeProviderTargets(targets) {
|
|
79
|
+
if (targets == null) {
|
|
80
|
+
return PROVIDER_TARGETS.map((target) => ({ ...target, allowed_hosts: [...target.allowed_hosts], negative_phrases: [...target.negative_phrases] }));
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(targets)) {
|
|
83
|
+
return targets.map((target) => ({
|
|
84
|
+
name: String(target.name),
|
|
85
|
+
url: String(target.url),
|
|
86
|
+
allowed_hosts: (target.allowed_hosts ?? []).map((host) => String(host)),
|
|
87
|
+
negative_phrases: (target.negative_phrases ?? []).map((phrase) => String(phrase))
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
const normalized = [];
|
|
91
|
+
for (const [name, config] of Object.entries(targets)) {
|
|
92
|
+
if (config.enabled === false) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const url = String(config.url ?? '').trim();
|
|
96
|
+
if (!url) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
let allowedHosts = (config.allowed_hosts ?? [])
|
|
100
|
+
.map((host) => String(host).trim().toLowerCase())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
if (allowedHosts.length === 0) {
|
|
103
|
+
const host = hostnameFor(url).toLowerCase();
|
|
104
|
+
allowedHosts = host ? [host] : [];
|
|
105
|
+
}
|
|
106
|
+
normalized.push({
|
|
107
|
+
name: String(name),
|
|
108
|
+
url,
|
|
109
|
+
allowed_hosts: allowedHosts,
|
|
110
|
+
negative_phrases: []
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return normalized;
|
|
114
|
+
}
|
|
115
|
+
export function evaluateProviderResponse(target, response) {
|
|
116
|
+
const finalUrl = response.final_url;
|
|
117
|
+
const statusCode = Number(response.status_code || 0);
|
|
118
|
+
const host = hostnameFor(finalUrl);
|
|
119
|
+
if (!host || !hostIsAllowed(host, target.allowed_hosts)) {
|
|
120
|
+
return {
|
|
121
|
+
provider: target.name,
|
|
122
|
+
passed: false,
|
|
123
|
+
reason: 'unexpected_host',
|
|
124
|
+
status_code: statusCode,
|
|
125
|
+
final_url: finalUrl,
|
|
126
|
+
matched_phrase: ''
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (statusCode >= 400) {
|
|
130
|
+
return {
|
|
131
|
+
provider: target.name,
|
|
132
|
+
passed: false,
|
|
133
|
+
reason: 'http_error',
|
|
134
|
+
status_code: statusCode,
|
|
135
|
+
final_url: finalUrl,
|
|
136
|
+
matched_phrase: ''
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const content = `${response.title}\n${response.body}`.toLowerCase();
|
|
140
|
+
for (const phrase of CHALLENGE_PHRASES) {
|
|
141
|
+
if (content.includes(phrase)) {
|
|
142
|
+
return {
|
|
143
|
+
provider: target.name,
|
|
144
|
+
passed: false,
|
|
145
|
+
reason: 'challenge_page',
|
|
146
|
+
status_code: statusCode,
|
|
147
|
+
final_url: finalUrl,
|
|
148
|
+
matched_phrase: phrase
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const phrase of target.negative_phrases) {
|
|
153
|
+
if (content.includes(String(phrase).toLowerCase())) {
|
|
154
|
+
return {
|
|
155
|
+
provider: target.name,
|
|
156
|
+
passed: false,
|
|
157
|
+
reason: 'negative_phrase',
|
|
158
|
+
status_code: statusCode,
|
|
159
|
+
final_url: finalUrl,
|
|
160
|
+
matched_phrase: phrase
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
provider: target.name,
|
|
166
|
+
passed: true,
|
|
167
|
+
reason: 'ok',
|
|
168
|
+
status_code: statusCode,
|
|
169
|
+
final_url: finalUrl,
|
|
170
|
+
matched_phrase: ''
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function availabilityResultToDict(result) {
|
|
174
|
+
const providerResults = Object.fromEntries(Object.entries(result.provider_results).map(([name, provider]) => [name, providerResultWithDefaults(provider)]));
|
|
175
|
+
return {
|
|
176
|
+
link: result.speed_result.link,
|
|
177
|
+
reachable: result.speed_result.reachable,
|
|
178
|
+
average_download_mb_s: result.speed_result.average_download_mb_s,
|
|
179
|
+
latency_ms: result.speed_result.latency_ms,
|
|
180
|
+
all_passed: Object.values(providerResults).every((provider) => provider.passed),
|
|
181
|
+
provider_results: providerResults
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function buildRuntimeErrorResult(speedResult, reason, targets) {
|
|
185
|
+
return availabilityResultToDict({
|
|
186
|
+
speed_result: speedResult,
|
|
187
|
+
provider_results: Object.fromEntries(targets.map((target) => [target.name, {
|
|
188
|
+
provider: target.name,
|
|
189
|
+
passed: false,
|
|
190
|
+
reason: 'runtime_error',
|
|
191
|
+
final_url: target.url,
|
|
192
|
+
matched_phrase: reason,
|
|
193
|
+
status_code: 0
|
|
194
|
+
}]))
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function emitEvent(callback, eventType, payload) {
|
|
198
|
+
if (callback) {
|
|
199
|
+
callback(eventType, payload);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function numberOrDefault(value, fallback) {
|
|
203
|
+
const parsed = Number(value);
|
|
204
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
205
|
+
}
|
|
206
|
+
async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
209
|
+
try {
|
|
210
|
+
return await fetchImpl(url, { signal: controller.signal });
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function extractTitle(html) {
|
|
217
|
+
return /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? '';
|
|
218
|
+
}
|
|
219
|
+
async function checkLinkAvailabilityDirect(speedResult, config, options) {
|
|
220
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
221
|
+
if (!fetchImpl) {
|
|
222
|
+
throw new Error('Node availability backend requires fetch support');
|
|
223
|
+
}
|
|
224
|
+
const timeoutMs = Math.max(1, numberOrDefault(config.timeout_seconds, 20)) * 1000;
|
|
225
|
+
const providerResults = {};
|
|
226
|
+
for (const target of options.targets) {
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetchWithTimeout(fetchImpl, target.url, timeoutMs);
|
|
229
|
+
const body = await response.text();
|
|
230
|
+
providerResults[target.name] = evaluateProviderResponse(target, {
|
|
231
|
+
final_url: String(response.url ?? target.url),
|
|
232
|
+
status_code: Number(response.status ?? 200),
|
|
233
|
+
title: extractTitle(body),
|
|
234
|
+
body
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
providerResults[target.name] = {
|
|
239
|
+
provider: target.name,
|
|
240
|
+
passed: false,
|
|
241
|
+
reason: 'runtime_error',
|
|
242
|
+
status_code: 0,
|
|
243
|
+
final_url: target.url,
|
|
244
|
+
matched_phrase: error instanceof Error ? error.message : String(error)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
speed_result: speedResult,
|
|
250
|
+
provider_results: providerResults
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function socketConnect(host, port, timeoutMs) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const socket = net.createConnection({ host, port });
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
socket.destroy();
|
|
258
|
+
reject(new Error(`proxy connection timed out after ${timeoutMs}ms`));
|
|
259
|
+
}, timeoutMs);
|
|
260
|
+
socket.once('connect', () => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
resolve(socket);
|
|
263
|
+
});
|
|
264
|
+
socket.once('error', (error) => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
reject(error);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function splitHttpHeaders(buffer) {
|
|
271
|
+
const marker = buffer.indexOf('\r\n\r\n');
|
|
272
|
+
if (marker < 0) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
head: buffer.subarray(0, marker),
|
|
277
|
+
body: buffer.subarray(marker + 4)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function parseHttpStatus(head) {
|
|
281
|
+
const firstLine = head.toString('latin1').split('\r\n')[0] ?? '';
|
|
282
|
+
const match = /^HTTP\/\d(?:\.\d)?\s+(\d+)/i.exec(firstLine);
|
|
283
|
+
if (!match) {
|
|
284
|
+
throw new Error(`invalid HTTP response: ${firstLine}`);
|
|
285
|
+
}
|
|
286
|
+
return Number(match[1]);
|
|
287
|
+
}
|
|
288
|
+
function parseHttpHeaders(head) {
|
|
289
|
+
const headers = {};
|
|
290
|
+
for (const line of head.toString('latin1').split('\r\n').slice(1)) {
|
|
291
|
+
const separator = line.indexOf(':');
|
|
292
|
+
if (separator < 0) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const key = line.slice(0, separator).trim().toLowerCase();
|
|
296
|
+
const value = line.slice(separator + 1).trim();
|
|
297
|
+
const existing = headers[key];
|
|
298
|
+
if (Array.isArray(existing)) {
|
|
299
|
+
existing.push(value);
|
|
300
|
+
}
|
|
301
|
+
else if (existing) {
|
|
302
|
+
headers[key] = [existing, value];
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
headers[key] = value;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return headers;
|
|
309
|
+
}
|
|
310
|
+
function headerValue(headers, name) {
|
|
311
|
+
const value = headers[name.toLowerCase()];
|
|
312
|
+
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
|
|
313
|
+
}
|
|
314
|
+
function redirectTarget(current, response) {
|
|
315
|
+
if (response.status_code < 300 || response.status_code >= 400) {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
const location = headerValue(response.headers, 'location');
|
|
319
|
+
return location ? new URL(location, current) : undefined;
|
|
320
|
+
}
|
|
321
|
+
function fetchHttpUrlViaHttpProxy(target, proxy, timeoutMs) {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const request = http.request({
|
|
324
|
+
hostname: proxy.hostname,
|
|
325
|
+
port: Number(proxy.port || 80),
|
|
326
|
+
method: 'GET',
|
|
327
|
+
path: target.toString(),
|
|
328
|
+
headers: {
|
|
329
|
+
Host: target.host,
|
|
330
|
+
Connection: 'close'
|
|
331
|
+
},
|
|
332
|
+
agent: false,
|
|
333
|
+
timeout: timeoutMs
|
|
334
|
+
}, (response) => {
|
|
335
|
+
const chunks = [];
|
|
336
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
337
|
+
response.once('end', () => {
|
|
338
|
+
resolve({
|
|
339
|
+
final_url: target.toString(),
|
|
340
|
+
status_code: Number(response.statusCode ?? 0),
|
|
341
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
342
|
+
headers: response.headers
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
request.once('timeout', () => {
|
|
347
|
+
request.destroy(new Error(`proxy fetch timed out after ${timeoutMs}ms`));
|
|
348
|
+
});
|
|
349
|
+
request.once('error', reject);
|
|
350
|
+
request.end();
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function readHttpResponse(socket, timeoutMs) {
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
let buffered = Buffer.alloc(0);
|
|
356
|
+
let headersParsed = false;
|
|
357
|
+
let status = 0;
|
|
358
|
+
let headers = {};
|
|
359
|
+
const chunks = [];
|
|
360
|
+
const timer = setTimeout(() => {
|
|
361
|
+
cleanup();
|
|
362
|
+
socket.destroy();
|
|
363
|
+
reject(new Error(`proxy fetch timed out after ${timeoutMs}ms`));
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
const cleanup = () => {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
socket.off('data', onData);
|
|
368
|
+
socket.off('end', onEnd);
|
|
369
|
+
socket.off('error', onError);
|
|
370
|
+
};
|
|
371
|
+
const onError = (error) => {
|
|
372
|
+
cleanup();
|
|
373
|
+
reject(error);
|
|
374
|
+
};
|
|
375
|
+
const onEnd = () => {
|
|
376
|
+
cleanup();
|
|
377
|
+
if (!headersParsed) {
|
|
378
|
+
reject(new Error('proxy response ended before HTTP headers were received'));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
resolve({ status, headers, body: Buffer.concat(chunks).toString('utf8') });
|
|
382
|
+
};
|
|
383
|
+
const onData = (chunk) => {
|
|
384
|
+
if (!headersParsed) {
|
|
385
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
386
|
+
const split = splitHttpHeaders(buffered);
|
|
387
|
+
if (!split) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
status = parseHttpStatus(split.head);
|
|
391
|
+
headers = parseHttpHeaders(split.head);
|
|
392
|
+
headersParsed = true;
|
|
393
|
+
if (split.body.byteLength > 0) {
|
|
394
|
+
chunks.push(split.body);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
chunks.push(chunk);
|
|
399
|
+
};
|
|
400
|
+
socket.on('data', onData);
|
|
401
|
+
socket.once('end', onEnd);
|
|
402
|
+
socket.once('error', onError);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
function readConnectResponse(socket, timeoutMs) {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
let buffered = Buffer.alloc(0);
|
|
408
|
+
const timer = setTimeout(() => {
|
|
409
|
+
cleanup();
|
|
410
|
+
socket.destroy();
|
|
411
|
+
reject(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`));
|
|
412
|
+
}, timeoutMs);
|
|
413
|
+
const cleanup = () => {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
socket.off('data', onData);
|
|
416
|
+
socket.off('error', onError);
|
|
417
|
+
};
|
|
418
|
+
const onError = (error) => {
|
|
419
|
+
cleanup();
|
|
420
|
+
reject(error);
|
|
421
|
+
};
|
|
422
|
+
const onData = (chunk) => {
|
|
423
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
424
|
+
const split = splitHttpHeaders(buffered);
|
|
425
|
+
if (!split) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const status = parseHttpStatus(split.head);
|
|
429
|
+
cleanup();
|
|
430
|
+
if (status < 200 || status >= 300) {
|
|
431
|
+
socket.destroy();
|
|
432
|
+
reject(new Error(`proxy CONNECT failed with status ${status}`));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
resolve();
|
|
436
|
+
};
|
|
437
|
+
socket.on('data', onData);
|
|
438
|
+
socket.once('error', onError);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
function waitForSecureConnect(socket, timeoutMs) {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
const timer = setTimeout(() => {
|
|
444
|
+
cleanup();
|
|
445
|
+
socket.destroy();
|
|
446
|
+
reject(new Error(`proxy TLS handshake timed out after ${timeoutMs}ms`));
|
|
447
|
+
}, timeoutMs);
|
|
448
|
+
const cleanup = () => {
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
socket.off('secureConnect', onSecureConnect);
|
|
451
|
+
socket.off('error', onError);
|
|
452
|
+
};
|
|
453
|
+
const onSecureConnect = () => {
|
|
454
|
+
cleanup();
|
|
455
|
+
resolve();
|
|
456
|
+
};
|
|
457
|
+
const onError = (error) => {
|
|
458
|
+
cleanup();
|
|
459
|
+
reject(error);
|
|
460
|
+
};
|
|
461
|
+
socket.once('secureConnect', onSecureConnect);
|
|
462
|
+
socket.once('error', onError);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
async function fetchUrlViaHttpProxyTarget(target, proxy, timeoutMs, redirectsRemaining) {
|
|
466
|
+
if (target.protocol === 'http:') {
|
|
467
|
+
const response = await fetchHttpUrlViaHttpProxy(target, proxy, timeoutMs);
|
|
468
|
+
const next = redirectTarget(target, response);
|
|
469
|
+
if (next) {
|
|
470
|
+
if (redirectsRemaining <= 0) {
|
|
471
|
+
throw new Error(`too many redirects while fetching ${target.toString()}`);
|
|
472
|
+
}
|
|
473
|
+
return fetchUrlViaHttpProxyTarget(next, proxy, timeoutMs, redirectsRemaining - 1);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
final_url: response.final_url,
|
|
477
|
+
status_code: response.status_code,
|
|
478
|
+
body: response.body
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
if (target.protocol !== 'https:') {
|
|
482
|
+
throw new Error(`unsupported availability URL protocol: ${target.protocol}`);
|
|
483
|
+
}
|
|
484
|
+
const proxyPort = Number(proxy.port || 80);
|
|
485
|
+
const socket = await socketConnect(proxy.hostname, proxyPort, timeoutMs);
|
|
486
|
+
const targetPort = Number(target.port || 443);
|
|
487
|
+
socket.write([
|
|
488
|
+
`CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
|
|
489
|
+
`Host: ${target.hostname}:${targetPort}`,
|
|
490
|
+
'Connection: keep-alive',
|
|
491
|
+
'',
|
|
492
|
+
''
|
|
493
|
+
].join('\r\n'));
|
|
494
|
+
await readConnectResponse(socket, timeoutMs);
|
|
495
|
+
const secureSocket = tls.connect({
|
|
496
|
+
socket,
|
|
497
|
+
servername: target.hostname,
|
|
498
|
+
rejectUnauthorized: true
|
|
499
|
+
});
|
|
500
|
+
await waitForSecureConnect(secureSocket, timeoutMs);
|
|
501
|
+
secureSocket.write([
|
|
502
|
+
`GET ${target.pathname || '/'}${target.search} HTTP/1.1`,
|
|
503
|
+
`Host: ${target.host}`,
|
|
504
|
+
'Connection: close',
|
|
505
|
+
'',
|
|
506
|
+
''
|
|
507
|
+
].join('\r\n'));
|
|
508
|
+
const response = await readHttpResponse(secureSocket, timeoutMs);
|
|
509
|
+
const proxiedResponse = {
|
|
510
|
+
final_url: target.toString(),
|
|
511
|
+
status_code: response.status,
|
|
512
|
+
body: response.body,
|
|
513
|
+
headers: response.headers
|
|
514
|
+
};
|
|
515
|
+
const next = redirectTarget(target, proxiedResponse);
|
|
516
|
+
if (next) {
|
|
517
|
+
if (redirectsRemaining <= 0) {
|
|
518
|
+
throw new Error(`too many redirects while fetching ${target.toString()}`);
|
|
519
|
+
}
|
|
520
|
+
return fetchUrlViaHttpProxyTarget(next, proxy, timeoutMs, redirectsRemaining - 1);
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
final_url: target.toString(),
|
|
524
|
+
status_code: response.status,
|
|
525
|
+
body: response.body
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
export async function fetchUrlViaHttpProxy(url, proxyUrl, timeoutSeconds) {
|
|
529
|
+
const timeoutMs = Math.max(1, timeoutSeconds) * 1000;
|
|
530
|
+
return fetchUrlViaHttpProxyTarget(new URL(url), new URL(proxyUrl), timeoutMs, 5);
|
|
531
|
+
}
|
|
532
|
+
async function checkLinkAvailabilityMihomo(speedResult, config, options) {
|
|
533
|
+
const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
|
|
534
|
+
const fetchViaProxy = options.fetchUrlViaHttpProxy ?? fetchUrlViaHttpProxy;
|
|
535
|
+
let runtime;
|
|
536
|
+
try {
|
|
537
|
+
runtime = await openRuntime(speedResult.link, {
|
|
538
|
+
runtimePath: options.runtimePath,
|
|
539
|
+
startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
|
|
540
|
+
env: options.env
|
|
541
|
+
});
|
|
542
|
+
const providerResults = {};
|
|
543
|
+
const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
|
|
544
|
+
for (const target of options.targets) {
|
|
545
|
+
try {
|
|
546
|
+
const response = await fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds);
|
|
547
|
+
providerResults[target.name] = evaluateProviderResponse(target, {
|
|
548
|
+
final_url: response.final_url,
|
|
549
|
+
status_code: response.status_code,
|
|
550
|
+
title: extractTitle(response.body),
|
|
551
|
+
body: response.body
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
providerResults[target.name] = {
|
|
556
|
+
provider: target.name,
|
|
557
|
+
passed: false,
|
|
558
|
+
reason: 'runtime_error',
|
|
559
|
+
status_code: 0,
|
|
560
|
+
final_url: target.url,
|
|
561
|
+
matched_phrase: error instanceof Error ? error.message : String(error)
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
speed_result: speedResult,
|
|
567
|
+
provider_results: providerResults
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
await runtime?.close();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function checkBatchInNode(input, options) {
|
|
575
|
+
if (input.results.length === 0) {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
const targets = normalizeProviderTargets(input.targets);
|
|
579
|
+
const useMihomoRuntime = String((options.env ?? process.env).AUTOVPN_AVAILABILITY_RUNTIME ?? '').trim().toLowerCase() === 'mihomo';
|
|
580
|
+
const checkLinkAvailability = options.checkLinkAvailability ?? ((speedResult, config) => (useMihomoRuntime
|
|
581
|
+
? checkLinkAvailabilityMihomo(speedResult, config, {
|
|
582
|
+
...options,
|
|
583
|
+
targets,
|
|
584
|
+
runtimePath: input.runtime_path ?? ''
|
|
585
|
+
})
|
|
586
|
+
: checkLinkAvailabilityDirect(speedResult, config, { targets, fetch: options.fetch })));
|
|
587
|
+
const output = [];
|
|
588
|
+
for (let index = 0; index < input.results.length; index += 1) {
|
|
589
|
+
const speedResult = input.results[index];
|
|
590
|
+
let availability;
|
|
591
|
+
try {
|
|
592
|
+
availability = availabilityResultToDict(await checkLinkAvailability(speedResult, input.config, {
|
|
593
|
+
runtime_path: input.runtime_path ?? '',
|
|
594
|
+
targets
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
availability = buildRuntimeErrorResult(speedResult, error instanceof Error ? error.message : String(error), targets);
|
|
599
|
+
}
|
|
600
|
+
output.push(availability);
|
|
601
|
+
const completed = index + 1;
|
|
602
|
+
if (options.progressCallback) {
|
|
603
|
+
const statuses = Object.entries(availability.provider_results)
|
|
604
|
+
.map(([name, provider]) => `${name}=${provider.passed ? 'ok' : provider.reason}`)
|
|
605
|
+
.join(' ');
|
|
606
|
+
options.progressCallback(`[availability] ${completed}/${input.results.length} ${statuses}`);
|
|
607
|
+
}
|
|
608
|
+
emitEvent(options.eventCallback, 'availability_link_result', {
|
|
609
|
+
completed,
|
|
610
|
+
total: input.results.length,
|
|
611
|
+
link: availability.link,
|
|
612
|
+
all_passed: availability.all_passed,
|
|
613
|
+
provider_results: availability.provider_results
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return output;
|
|
617
|
+
}
|
|
618
|
+
export function selectPipelineStageBackend(stage, env = process.env) {
|
|
619
|
+
const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
|
|
620
|
+
const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
|
|
621
|
+
const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
|
|
622
|
+
const selected = stageOverride || pipelineOverride || 'node';
|
|
623
|
+
return selected === 'python' ? 'python' : 'node';
|
|
624
|
+
}
|
|
625
|
+
async function defaultResolvePythonCli(env) {
|
|
626
|
+
// @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
|
|
627
|
+
const runner = await import('../../lib/runner.mjs');
|
|
628
|
+
return runner.resolveOrInstallPythonCli({ env });
|
|
629
|
+
}
|
|
630
|
+
function pythonCommandFor(resolved) {
|
|
631
|
+
const command = resolved.command;
|
|
632
|
+
const name = path.basename(command).toLowerCase();
|
|
633
|
+
if (['autovpn', 'autovpn.exe'].includes(name)) {
|
|
634
|
+
const executable = process.platform === 'win32' ? 'python.exe' : 'python';
|
|
635
|
+
return path.join(path.dirname(command), executable);
|
|
636
|
+
}
|
|
637
|
+
return process.platform === 'win32' ? 'python.exe' : 'python3';
|
|
638
|
+
}
|
|
639
|
+
async function availabilityWithPython(input, options) {
|
|
640
|
+
const env = mergeProjectEnv(options.cwd ?? process.cwd(), options.env ?? process.env);
|
|
641
|
+
const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
|
|
642
|
+
const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', PYTHON_AVAILABILITY_HELPER], {
|
|
643
|
+
cwd: options.cwd ?? process.cwd(),
|
|
644
|
+
env,
|
|
645
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
646
|
+
});
|
|
647
|
+
let stdout = '';
|
|
648
|
+
let stderr = '';
|
|
649
|
+
child.stdout?.on('data', (chunk) => {
|
|
650
|
+
stdout += String(chunk);
|
|
651
|
+
});
|
|
652
|
+
child.stderr?.on('data', (chunk) => {
|
|
653
|
+
stderr += String(chunk);
|
|
654
|
+
});
|
|
655
|
+
const completion = new Promise((resolve, reject) => {
|
|
656
|
+
child.on('error', reject);
|
|
657
|
+
child.on('close', (code) => {
|
|
658
|
+
if (code !== 0) {
|
|
659
|
+
reject(new Error(`Python availability backend failed with exit code ${code}: ${stderr.trim()}`));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
resolve(JSON.parse(stdout));
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
reject(new Error(`Python availability backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
child.stdin?.write(JSON.stringify(input));
|
|
671
|
+
child.stdin?.end();
|
|
672
|
+
return completion;
|
|
673
|
+
}
|
|
674
|
+
export async function checkLinkAvailabilityBatchWithBackend(input, options = {}) {
|
|
675
|
+
if (selectPipelineStageBackend('availability', options.env ?? process.env) === 'python') {
|
|
676
|
+
return options.pythonAvailability ? options.pythonAvailability(input) : availabilityWithPython(input, options);
|
|
677
|
+
}
|
|
678
|
+
return checkBatchInNode(input, options);
|
|
679
|
+
}
|