@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,579 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import tls from 'node:tls';
|
|
5
|
+
import { mergeProjectEnv } from '../runtime/env.js';
|
|
6
|
+
import { openMihomoRuntime as defaultOpenMihomoRuntime, probeMihomoProxyDelay as defaultProbeMihomoProxyDelay } from './proxy-runtime.js';
|
|
7
|
+
const PYTHON_SPEEDTEST_HELPER = `
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from vpn_automation.config.models import SpeedTestConfig
|
|
11
|
+
from vpn_automation.pipeline.speedtest import speedtest_links
|
|
12
|
+
|
|
13
|
+
payload = json.load(sys.stdin)
|
|
14
|
+
output = [
|
|
15
|
+
item.__dict__
|
|
16
|
+
for item in speedtest_links(
|
|
17
|
+
payload.get("links", []),
|
|
18
|
+
SpeedTestConfig(**payload["config"]),
|
|
19
|
+
runtime_path=payload.get("runtime_path", ""),
|
|
20
|
+
)
|
|
21
|
+
]
|
|
22
|
+
json.dump(output, sys.stdout, ensure_ascii=False)
|
|
23
|
+
sys.stdout.write("\\n")
|
|
24
|
+
`;
|
|
25
|
+
export function aggregateSpeedMeasurements(values) {
|
|
26
|
+
if (values.length === 0) {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
return Number((values.reduce((total, value) => total + value, 0) / values.length).toFixed(3));
|
|
30
|
+
}
|
|
31
|
+
export function normalizeSpeedTestConfig(config) {
|
|
32
|
+
return {
|
|
33
|
+
min_download_mb_s: Number(config.min_download_mb_s),
|
|
34
|
+
timeout_seconds: Number(config.timeout_seconds),
|
|
35
|
+
concurrency: Number(config.concurrency),
|
|
36
|
+
urls: config.urls ?? [],
|
|
37
|
+
probe_url: config.probe_url ?? 'https://www.gstatic.com/generate_204',
|
|
38
|
+
max_download_bytes: Number(config.max_download_bytes ?? 5_000_000),
|
|
39
|
+
startup_wait_seconds: Number(config.startup_wait_seconds ?? 1.0),
|
|
40
|
+
max_download_candidates: Number(config.max_download_candidates ?? 50)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function selectSpeedtestCandidates(probes, limit) {
|
|
44
|
+
const reachable = probes
|
|
45
|
+
.filter((probe) => probe.reachable)
|
|
46
|
+
.sort((left, right) => {
|
|
47
|
+
const leftInvalidLatency = left.latency_ms <= 0 ? 1 : 0;
|
|
48
|
+
const rightInvalidLatency = right.latency_ms <= 0 ? 1 : 0;
|
|
49
|
+
if (leftInvalidLatency !== rightInvalidLatency) {
|
|
50
|
+
return leftInvalidLatency - rightInvalidLatency;
|
|
51
|
+
}
|
|
52
|
+
if (left.latency_ms !== right.latency_ms) {
|
|
53
|
+
return left.latency_ms - right.latency_ms;
|
|
54
|
+
}
|
|
55
|
+
if (left.link < right.link) {
|
|
56
|
+
return -1;
|
|
57
|
+
}
|
|
58
|
+
if (left.link > right.link) {
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
});
|
|
63
|
+
const links = reachable.map((probe) => probe.link);
|
|
64
|
+
if (limit <= 0) {
|
|
65
|
+
return links;
|
|
66
|
+
}
|
|
67
|
+
return links.slice(0, limit);
|
|
68
|
+
}
|
|
69
|
+
function emitEvent(callback, eventType, payload) {
|
|
70
|
+
if (callback) {
|
|
71
|
+
callback(eventType, payload);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function defaultNow() {
|
|
75
|
+
return performance.now();
|
|
76
|
+
}
|
|
77
|
+
async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
80
|
+
try {
|
|
81
|
+
return await fetchImpl(url, { signal: controller.signal });
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function readResponseBytes(response, maxBytes) {
|
|
88
|
+
if (response.body?.getReader) {
|
|
89
|
+
const reader = response.body.getReader();
|
|
90
|
+
let total = 0;
|
|
91
|
+
try {
|
|
92
|
+
while (total < maxBytes) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
total += Math.min(value.byteLength, maxBytes - total);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
reader.releaseLock();
|
|
102
|
+
}
|
|
103
|
+
return total;
|
|
104
|
+
}
|
|
105
|
+
if (response.arrayBuffer) {
|
|
106
|
+
return Math.min((await response.arrayBuffer()).byteLength, maxBytes);
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
function requireOkResponse(response, allowedStatuses) {
|
|
111
|
+
const status = Number(response.status ?? 200);
|
|
112
|
+
if (response.ok === false || !allowedStatuses.has(status)) {
|
|
113
|
+
throw new Error(`unexpected status ${status}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function socketConnect(host, port, timeoutMs) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const socket = net.createConnection({ host, port });
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
socket.destroy();
|
|
121
|
+
reject(new Error(`proxy connection timed out after ${timeoutMs}ms`));
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
socket.once('connect', () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
resolve(socket);
|
|
126
|
+
});
|
|
127
|
+
socket.once('error', (error) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
reject(error);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function splitHttpHeaders(buffer) {
|
|
134
|
+
const marker = buffer.indexOf('\r\n\r\n');
|
|
135
|
+
if (marker < 0) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
head: buffer.subarray(0, marker).subarray(0),
|
|
140
|
+
body: buffer.subarray(marker + 4).subarray(0)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function parseHttpStatus(head) {
|
|
144
|
+
const firstLine = head.toString('latin1').split('\r\n')[0] ?? '';
|
|
145
|
+
const match = /^HTTP\/\d(?:\.\d)?\s+(\d+)/i.exec(firstLine);
|
|
146
|
+
if (!match) {
|
|
147
|
+
throw new Error(`invalid HTTP response: ${firstLine}`);
|
|
148
|
+
}
|
|
149
|
+
return Number(match[1]);
|
|
150
|
+
}
|
|
151
|
+
function readHttpBodyBytes(socket, maxBytes, timeoutMs) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let buffered = Buffer.alloc(0);
|
|
154
|
+
let headersParsed = false;
|
|
155
|
+
let total = 0;
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
cleanup();
|
|
158
|
+
socket.destroy();
|
|
159
|
+
reject(new Error(`proxy download timed out after ${timeoutMs}ms`));
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
const cleanup = () => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
socket.off('data', onData);
|
|
164
|
+
socket.off('end', onEnd);
|
|
165
|
+
socket.off('error', onError);
|
|
166
|
+
};
|
|
167
|
+
const finish = (bytes) => {
|
|
168
|
+
cleanup();
|
|
169
|
+
socket.destroy();
|
|
170
|
+
resolve(bytes);
|
|
171
|
+
};
|
|
172
|
+
const onError = (error) => {
|
|
173
|
+
cleanup();
|
|
174
|
+
reject(error);
|
|
175
|
+
};
|
|
176
|
+
const onEnd = () => {
|
|
177
|
+
cleanup();
|
|
178
|
+
resolve(total);
|
|
179
|
+
};
|
|
180
|
+
const countBody = (chunk) => {
|
|
181
|
+
total += Math.min(chunk.byteLength, Math.max(maxBytes - total, 0));
|
|
182
|
+
if (total >= maxBytes) {
|
|
183
|
+
finish(maxBytes);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const onData = (chunk) => {
|
|
187
|
+
if (!headersParsed) {
|
|
188
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
189
|
+
const split = splitHttpHeaders(buffered);
|
|
190
|
+
if (!split) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const status = parseHttpStatus(split.head);
|
|
194
|
+
if (status < 200 || status >= 300) {
|
|
195
|
+
cleanup();
|
|
196
|
+
socket.destroy();
|
|
197
|
+
reject(new Error(`unexpected status ${status}`));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
headersParsed = true;
|
|
201
|
+
if (split.body.byteLength > 0) {
|
|
202
|
+
countBody(split.body);
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
countBody(chunk);
|
|
207
|
+
};
|
|
208
|
+
socket.on('data', onData);
|
|
209
|
+
socket.once('end', onEnd);
|
|
210
|
+
socket.once('error', onError);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function readConnectResponse(socket, timeoutMs) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
let buffered = Buffer.alloc(0);
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
cleanup();
|
|
218
|
+
socket.destroy();
|
|
219
|
+
reject(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`));
|
|
220
|
+
}, timeoutMs);
|
|
221
|
+
const cleanup = () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
socket.off('data', onData);
|
|
224
|
+
socket.off('error', onError);
|
|
225
|
+
};
|
|
226
|
+
const onError = (error) => {
|
|
227
|
+
cleanup();
|
|
228
|
+
reject(error);
|
|
229
|
+
};
|
|
230
|
+
const onData = (chunk) => {
|
|
231
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
232
|
+
const split = splitHttpHeaders(buffered);
|
|
233
|
+
if (!split) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const status = parseHttpStatus(split.head);
|
|
237
|
+
cleanup();
|
|
238
|
+
if (status < 200 || status >= 300) {
|
|
239
|
+
socket.destroy();
|
|
240
|
+
reject(new Error(`proxy CONNECT failed with status ${status}`));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
resolve();
|
|
244
|
+
};
|
|
245
|
+
socket.on('data', onData);
|
|
246
|
+
socket.once('error', onError);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
export async function downloadUrlViaHttpProxy(url, proxyUrl, maxBytes, timeoutSeconds) {
|
|
250
|
+
const target = new URL(url);
|
|
251
|
+
const proxy = new URL(proxyUrl);
|
|
252
|
+
const timeoutMs = Math.max(1, timeoutSeconds) * 1000;
|
|
253
|
+
const proxyPort = Number(proxy.port || 80);
|
|
254
|
+
const socket = await socketConnect(proxy.hostname, proxyPort, timeoutMs);
|
|
255
|
+
const requestPath = `${target.pathname || '/'}${target.search}`;
|
|
256
|
+
const targetPort = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
|
|
257
|
+
if (target.protocol === 'http:') {
|
|
258
|
+
socket.write([
|
|
259
|
+
`GET ${target.toString()} HTTP/1.1`,
|
|
260
|
+
`Host: ${target.host}`,
|
|
261
|
+
'Connection: close',
|
|
262
|
+
'',
|
|
263
|
+
''
|
|
264
|
+
].join('\r\n'));
|
|
265
|
+
return await readHttpBodyBytes(socket, Math.max(1, maxBytes), timeoutMs);
|
|
266
|
+
}
|
|
267
|
+
if (target.protocol !== 'https:') {
|
|
268
|
+
socket.destroy();
|
|
269
|
+
throw new Error(`unsupported speedtest URL protocol: ${target.protocol}`);
|
|
270
|
+
}
|
|
271
|
+
socket.write([
|
|
272
|
+
`CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
|
|
273
|
+
`Host: ${target.hostname}:${targetPort}`,
|
|
274
|
+
'Connection: keep-alive',
|
|
275
|
+
'',
|
|
276
|
+
''
|
|
277
|
+
].join('\r\n'));
|
|
278
|
+
await readConnectResponse(socket, timeoutMs);
|
|
279
|
+
const secureSocket = tls.connect({
|
|
280
|
+
socket,
|
|
281
|
+
servername: target.hostname,
|
|
282
|
+
rejectUnauthorized: false
|
|
283
|
+
});
|
|
284
|
+
await new Promise((resolve, reject) => {
|
|
285
|
+
secureSocket.once('secureConnect', resolve);
|
|
286
|
+
secureSocket.once('error', reject);
|
|
287
|
+
});
|
|
288
|
+
secureSocket.write([
|
|
289
|
+
`GET ${requestPath} HTTP/1.1`,
|
|
290
|
+
`Host: ${target.host}`,
|
|
291
|
+
'Connection: close',
|
|
292
|
+
'',
|
|
293
|
+
''
|
|
294
|
+
].join('\r\n'));
|
|
295
|
+
return await readHttpBodyBytes(secureSocket, Math.max(1, maxBytes), timeoutMs);
|
|
296
|
+
}
|
|
297
|
+
async function probeLinksDirect(links, config, options) {
|
|
298
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
299
|
+
if (!fetchImpl) {
|
|
300
|
+
throw new Error('Node speedtest backend requires fetch support');
|
|
301
|
+
}
|
|
302
|
+
const now = options.now ?? defaultNow;
|
|
303
|
+
const timeoutMs = Math.max(1, Number(config.timeout_seconds)) * 1000;
|
|
304
|
+
const results = [];
|
|
305
|
+
for (const link of links) {
|
|
306
|
+
const started = now();
|
|
307
|
+
try {
|
|
308
|
+
const response = await fetchWithTimeout(fetchImpl, config.probe_url, timeoutMs);
|
|
309
|
+
const elapsed = Math.max(now() - started, 1);
|
|
310
|
+
requireOkResponse(response, new Set([200, 204]));
|
|
311
|
+
results.push({ link, reachable: true, latency_ms: Math.max(Math.round(elapsed), 1), error: '' });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
results.push({ link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return results;
|
|
318
|
+
}
|
|
319
|
+
async function probeLinksMihomo(links, config, runtimePath, options) {
|
|
320
|
+
const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
|
|
321
|
+
const probeDelay = options.probeMihomoProxyDelay ?? defaultProbeMihomoProxyDelay;
|
|
322
|
+
const results = [];
|
|
323
|
+
for (const link of links) {
|
|
324
|
+
let runtime;
|
|
325
|
+
try {
|
|
326
|
+
runtime = await openRuntime(link, {
|
|
327
|
+
runtimePath,
|
|
328
|
+
startupWaitSeconds: config.startup_wait_seconds,
|
|
329
|
+
env: options.env
|
|
330
|
+
});
|
|
331
|
+
const latencyMs = await probeDelay(runtime.controllerUrl, runtime.proxyName, config.probe_url, config.timeout_seconds);
|
|
332
|
+
results.push({ link, reachable: true, latency_ms: latencyMs, error: '' });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
results.push({ link, reachable: false, latency_ms: 0, error: error instanceof Error ? error.message : String(error) });
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await runtime?.close();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return results;
|
|
342
|
+
}
|
|
343
|
+
async function testLinkDirect(link, config, options) {
|
|
344
|
+
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
345
|
+
if (!fetchImpl) {
|
|
346
|
+
throw new Error('Node speedtest backend requires fetch support');
|
|
347
|
+
}
|
|
348
|
+
const now = options.now ?? defaultNow;
|
|
349
|
+
const timeoutMs = Math.max(1, Number(config.timeout_seconds)) * 1000;
|
|
350
|
+
const speedValues = [];
|
|
351
|
+
const failures = [];
|
|
352
|
+
for (const url of config.urls) {
|
|
353
|
+
const started = now();
|
|
354
|
+
try {
|
|
355
|
+
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs);
|
|
356
|
+
requireOkResponse(response, new Set([200]));
|
|
357
|
+
const total = await readResponseBytes(response, Math.max(1, Number(config.max_download_bytes)));
|
|
358
|
+
const elapsedSeconds = Math.max((now() - started) / 1000, 0.001);
|
|
359
|
+
speedValues.push(total / elapsedSeconds / 1024 / 1024);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (speedValues.length === 0) {
|
|
366
|
+
return {
|
|
367
|
+
link,
|
|
368
|
+
reachable: false,
|
|
369
|
+
average_download_mb_s: 0,
|
|
370
|
+
latency_ms: 0,
|
|
371
|
+
error: failures.join('; ') || 'all speed test urls failed'
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
link,
|
|
376
|
+
reachable: true,
|
|
377
|
+
average_download_mb_s: aggregateSpeedMeasurements(speedValues),
|
|
378
|
+
latency_ms: 0,
|
|
379
|
+
error: failures.join('; ')
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
async function testLinkMihomo(link, config, runtimePath, options) {
|
|
383
|
+
const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
|
|
384
|
+
const downloadViaProxy = options.downloadUrlViaHttpProxy ?? downloadUrlViaHttpProxy;
|
|
385
|
+
let runtime;
|
|
386
|
+
try {
|
|
387
|
+
runtime = await openRuntime(link, {
|
|
388
|
+
runtimePath,
|
|
389
|
+
startupWaitSeconds: config.startup_wait_seconds,
|
|
390
|
+
env: options.env
|
|
391
|
+
});
|
|
392
|
+
const now = options.now ?? defaultNow;
|
|
393
|
+
const speedValues = [];
|
|
394
|
+
const failures = [];
|
|
395
|
+
for (const url of config.urls) {
|
|
396
|
+
const started = now();
|
|
397
|
+
try {
|
|
398
|
+
const total = await downloadViaProxy(url, runtime.proxies.http, Math.max(1, Number(config.max_download_bytes)), config.timeout_seconds);
|
|
399
|
+
const elapsedSeconds = Math.max((now() - started) / 1000, 0.001);
|
|
400
|
+
speedValues.push(total / elapsedSeconds / 1024 / 1024);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (speedValues.length === 0) {
|
|
407
|
+
return {
|
|
408
|
+
link,
|
|
409
|
+
reachable: false,
|
|
410
|
+
average_download_mb_s: 0,
|
|
411
|
+
latency_ms: 0,
|
|
412
|
+
error: failures.join('; ') || 'all speed test urls failed'
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
link,
|
|
417
|
+
reachable: true,
|
|
418
|
+
average_download_mb_s: aggregateSpeedMeasurements(speedValues),
|
|
419
|
+
latency_ms: 0,
|
|
420
|
+
error: failures.join('; ')
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
return {
|
|
425
|
+
link,
|
|
426
|
+
reachable: false,
|
|
427
|
+
average_download_mb_s: 0,
|
|
428
|
+
latency_ms: 0,
|
|
429
|
+
error: error instanceof Error ? error.message : String(error)
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
await runtime?.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function speedtestInNode(input, options) {
|
|
437
|
+
if (input.links.length === 0) {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
const config = normalizeSpeedTestConfig(input.config);
|
|
441
|
+
const runtimePath = input.runtime_path ?? '';
|
|
442
|
+
const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
|
|
443
|
+
const useMihomoRuntime = requestedRuntime === 'mihomo';
|
|
444
|
+
const probeLinks = options.probeLinks ?? ((links) => (useMihomoRuntime
|
|
445
|
+
? probeLinksMihomo(links, config, runtimePath, options)
|
|
446
|
+
: probeLinksDirect(links, config, options)));
|
|
447
|
+
const testLink = options.testLink ?? ((link) => (useMihomoRuntime
|
|
448
|
+
? testLinkMihomo(link, config, runtimePath, options)
|
|
449
|
+
: testLinkDirect(link, config, options)));
|
|
450
|
+
const runtimeCore = useMihomoRuntime || options.probeLinks || options.testLink ? 'mihomo' : 'direct';
|
|
451
|
+
options.progressCallback?.(`[speedtest] runtime_core=${runtimeCore} probe_url=${config.probe_url}`);
|
|
452
|
+
emitEvent(options.eventCallback, 'speedtest_runtime', {
|
|
453
|
+
runtime_core: runtimeCore,
|
|
454
|
+
probe_url: config.probe_url,
|
|
455
|
+
urls: [...config.urls]
|
|
456
|
+
});
|
|
457
|
+
const probes = await probeLinks(input.links, config, { runtime_path: runtimePath });
|
|
458
|
+
const candidateLinks = selectSpeedtestCandidates(probes, config.max_download_candidates);
|
|
459
|
+
const probeByLink = new Map(probes.map((probe) => [probe.link, probe]));
|
|
460
|
+
const candidateSet = new Set(candidateLinks);
|
|
461
|
+
const reachableCount = probes.filter((probe) => probe.reachable).length;
|
|
462
|
+
options.progressCallback?.(`[speedtest] selected ${candidateLinks.length}/${reachableCount} reachable links for full download test`);
|
|
463
|
+
emitEvent(options.eventCallback, 'speedtest_selected', {
|
|
464
|
+
total_links: input.links.length,
|
|
465
|
+
reachable_count: reachableCount,
|
|
466
|
+
candidate_count: candidateLinks.length
|
|
467
|
+
});
|
|
468
|
+
const results = probes
|
|
469
|
+
.filter((probe) => !probe.reachable)
|
|
470
|
+
.map((probe) => ({
|
|
471
|
+
link: probe.link,
|
|
472
|
+
reachable: false,
|
|
473
|
+
average_download_mb_s: 0,
|
|
474
|
+
latency_ms: probe.latency_ms,
|
|
475
|
+
error: probe.error ?? ''
|
|
476
|
+
}));
|
|
477
|
+
for (let index = 0; index < candidateLinks.length; index += 1) {
|
|
478
|
+
const result = await testLink(candidateLinks[index], config, { runtime_path: runtimePath });
|
|
479
|
+
if (result.reachable && result.latency_ms <= 0) {
|
|
480
|
+
result.latency_ms = probeByLink.get(result.link)?.latency_ms ?? 0;
|
|
481
|
+
}
|
|
482
|
+
results.push(result);
|
|
483
|
+
const completed = index + 1;
|
|
484
|
+
options.progressCallback?.(`[speedtest] ${completed}/${candidateSet.size} reachable=${result.reachable} speed=${result.average_download_mb_s}MB/s`);
|
|
485
|
+
emitEvent(options.eventCallback, 'speedtest_result', {
|
|
486
|
+
completed,
|
|
487
|
+
total: candidateSet.size,
|
|
488
|
+
link: result.link,
|
|
489
|
+
reachable: result.reachable,
|
|
490
|
+
average_download_mb_s: result.average_download_mb_s,
|
|
491
|
+
latency_ms: result.latency_ms,
|
|
492
|
+
passed_threshold: result.reachable && result.average_download_mb_s >= config.min_download_mb_s,
|
|
493
|
+
error: result.error ?? ''
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return results;
|
|
497
|
+
}
|
|
498
|
+
export function selectPipelineStageBackend(stage, env = process.env) {
|
|
499
|
+
const stageKey = `AUTOVPN_STAGE_BACKEND_${stage.toUpperCase()}`;
|
|
500
|
+
const stageOverride = String(env[stageKey] ?? '').trim().toLowerCase();
|
|
501
|
+
const pipelineOverride = String(env.AUTOVPN_PIPELINE_BACKEND ?? '').trim().toLowerCase();
|
|
502
|
+
const selected = stageOverride || pipelineOverride || 'node';
|
|
503
|
+
return selected === 'python' ? 'python' : 'node';
|
|
504
|
+
}
|
|
505
|
+
async function defaultResolvePythonCli(env) {
|
|
506
|
+
// @ts-expect-error Phase 1 runner remains plain ESM JavaScript.
|
|
507
|
+
const runner = await import('../../lib/runner.mjs');
|
|
508
|
+
return runner.resolveOrInstallPythonCli({ env });
|
|
509
|
+
}
|
|
510
|
+
function pythonCommandFor(resolved) {
|
|
511
|
+
const command = resolved.command;
|
|
512
|
+
const name = path.basename(command).toLowerCase();
|
|
513
|
+
if (['autovpn', 'autovpn.exe'].includes(name)) {
|
|
514
|
+
const executable = process.platform === 'win32' ? 'python.exe' : 'python';
|
|
515
|
+
return path.join(path.dirname(command), executable);
|
|
516
|
+
}
|
|
517
|
+
return process.platform === 'win32' ? 'python.exe' : 'python3';
|
|
518
|
+
}
|
|
519
|
+
async function speedtestWithPython(input, options) {
|
|
520
|
+
const env = mergeProjectEnv(options.cwd ?? process.cwd(), options.env ?? process.env);
|
|
521
|
+
const resolved = options.resolvePythonCli ? await options.resolvePythonCli() : await defaultResolvePythonCli(env);
|
|
522
|
+
const child = (options.spawn ?? defaultSpawn)(pythonCommandFor(resolved), ['-c', PYTHON_SPEEDTEST_HELPER], {
|
|
523
|
+
cwd: options.cwd ?? process.cwd(),
|
|
524
|
+
env,
|
|
525
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
526
|
+
});
|
|
527
|
+
let stdout = '';
|
|
528
|
+
let stderr = '';
|
|
529
|
+
child.stdout?.on('data', (chunk) => {
|
|
530
|
+
stdout += String(chunk);
|
|
531
|
+
});
|
|
532
|
+
child.stderr?.on('data', (chunk) => {
|
|
533
|
+
stderr += String(chunk);
|
|
534
|
+
});
|
|
535
|
+
const completion = new Promise((resolve, reject) => {
|
|
536
|
+
child.on('error', reject);
|
|
537
|
+
child.on('close', (code) => {
|
|
538
|
+
if (code !== 0) {
|
|
539
|
+
reject(new Error(`Python speedtest backend failed with exit code ${code}: ${stderr.trim()}`));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
resolve(JSON.parse(stdout));
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
reject(new Error(`Python speedtest backend returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
child.stdin?.write(JSON.stringify(input));
|
|
551
|
+
child.stdin?.end();
|
|
552
|
+
return completion;
|
|
553
|
+
}
|
|
554
|
+
export async function speedtestLinksWithBackend(input, options = {}) {
|
|
555
|
+
if (selectPipelineStageBackend('speedtest', options.env ?? process.env) === 'python') {
|
|
556
|
+
return options.pythonSpeedtest ? options.pythonSpeedtest(input) : speedtestWithPython(input, options);
|
|
557
|
+
}
|
|
558
|
+
return speedtestInNode(input, options);
|
|
559
|
+
}
|
|
560
|
+
export async function probeSpeedtestLinksInNode(input, options = {}) {
|
|
561
|
+
const config = normalizeSpeedTestConfig(input.config);
|
|
562
|
+
const runtimePath = input.runtime_path ?? '';
|
|
563
|
+
const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
|
|
564
|
+
const useMihomoRuntime = requestedRuntime === 'mihomo';
|
|
565
|
+
const probeLinks = options.probeLinks ?? ((links) => (useMihomoRuntime
|
|
566
|
+
? probeLinksMihomo(links, config, runtimePath, options)
|
|
567
|
+
: probeLinksDirect(links, config, options)));
|
|
568
|
+
return probeLinks(input.links, config, { runtime_path: runtimePath });
|
|
569
|
+
}
|
|
570
|
+
export async function testSpeedtestLinkInNode(input, options = {}) {
|
|
571
|
+
const config = normalizeSpeedTestConfig(input.config);
|
|
572
|
+
const runtimePath = input.runtime_path ?? '';
|
|
573
|
+
const requestedRuntime = String((options.env ?? process.env).AUTOVPN_SPEEDTEST_RUNTIME ?? '').trim().toLowerCase();
|
|
574
|
+
const useMihomoRuntime = requestedRuntime === 'mihomo';
|
|
575
|
+
const testLink = options.testLink ?? ((link) => (useMihomoRuntime
|
|
576
|
+
? testLinkMihomo(link, config, runtimePath, options)
|
|
577
|
+
: testLinkDirect(link, config, options)));
|
|
578
|
+
return testLink(input.link, config, { runtime_path: runtimePath });
|
|
579
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function parseDotEnv(text) {
|
|
4
|
+
const output = {};
|
|
5
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
6
|
+
const line = rawLine.trim();
|
|
7
|
+
if (!line || line.startsWith('#')) {
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
const equalsIndex = line.indexOf('=');
|
|
11
|
+
if (equalsIndex <= 0) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const key = line.slice(0, equalsIndex).trim();
|
|
15
|
+
let value = line.slice(equalsIndex + 1).trim();
|
|
16
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
17
|
+
value = value.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
output[key] = value;
|
|
20
|
+
}
|
|
21
|
+
return output;
|
|
22
|
+
}
|
|
23
|
+
export function loadProjectDotEnv(projectRoot) {
|
|
24
|
+
const envPath = path.join(projectRoot, '.env');
|
|
25
|
+
if (!fs.existsSync(envPath)) {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
return parseDotEnv(fs.readFileSync(envPath, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
export function mergeProjectEnv(projectRoot, env = process.env) {
|
|
31
|
+
return {
|
|
32
|
+
...loadProjectDotEnv(projectRoot),
|
|
33
|
+
...env
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function resolveRuntimeRoot(candidate) {
|
|
4
|
+
const absolute = path.resolve(candidate || process.cwd());
|
|
5
|
+
const resolved = fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
|
|
6
|
+
let current = fs.existsSync(resolved) && fs.statSync(resolved).isFile() ? path.dirname(resolved) : resolved;
|
|
7
|
+
while (true) {
|
|
8
|
+
if (fs.existsSync(path.join(current, 'pyproject.toml'))) {
|
|
9
|
+
return current;
|
|
10
|
+
}
|
|
11
|
+
const parent = path.dirname(current);
|
|
12
|
+
if (parent === current) {
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
current = parent;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function resolveProjectRoot(argv, cwd = process.cwd()) {
|
|
19
|
+
const value = readOptionValue(argv, '--project-root');
|
|
20
|
+
return resolveRuntimeRoot(value ? path.resolve(cwd, value) : cwd);
|
|
21
|
+
}
|
|
22
|
+
export function resolveProfilePath(projectRoot, env = process.env) {
|
|
23
|
+
const override = String(env.VPN_AUTOMATION_PROFILE_PATH ?? '').trim();
|
|
24
|
+
if (override) {
|
|
25
|
+
return path.resolve(override);
|
|
26
|
+
}
|
|
27
|
+
return path.join(resolveUserRuntimeRoot(env), 'profile.toml');
|
|
28
|
+
}
|
|
29
|
+
export function resolveArtifactsRoot(projectRoot, env = process.env) {
|
|
30
|
+
const override = String(env.VPN_AUTOMATION_ARTIFACTS_ROOT ?? '').trim();
|
|
31
|
+
if (override) {
|
|
32
|
+
return path.resolve(override);
|
|
33
|
+
}
|
|
34
|
+
return path.join(resolveUserRuntimeRoot(env), 'artifacts');
|
|
35
|
+
}
|
|
36
|
+
export function resolveUserRuntimeRoot(env = process.env) {
|
|
37
|
+
const override = String(env.VPN_AUTOMATION_RUNTIME_ROOT ?? '').trim();
|
|
38
|
+
if (override) {
|
|
39
|
+
return path.resolve(expandHomePath(override));
|
|
40
|
+
}
|
|
41
|
+
return path.join(osHomeDir(), '.auto-vpn');
|
|
42
|
+
}
|
|
43
|
+
function expandHomePath(value) {
|
|
44
|
+
if (value === '~') {
|
|
45
|
+
return osHomeDir();
|
|
46
|
+
}
|
|
47
|
+
if (value.startsWith(`~${path.sep}`)) {
|
|
48
|
+
return path.join(osHomeDir(), value.slice(2));
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function osHomeDir() {
|
|
53
|
+
return process.env.HOME || process.env.USERPROFILE || '';
|
|
54
|
+
}
|
|
55
|
+
export function readOptionValue(argv, optionName) {
|
|
56
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
57
|
+
const value = argv[index];
|
|
58
|
+
if (value === optionName) {
|
|
59
|
+
return argv[index + 1];
|
|
60
|
+
}
|
|
61
|
+
if (value.startsWith(`${optionName}=`)) {
|
|
62
|
+
return value.slice(optionName.length + 1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|