@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.
@@ -0,0 +1,232 @@
1
+ import net from 'node:net';
2
+ import tls from 'node:tls';
3
+ /**
4
+ * Shared helpers for the hand-rolled HTTP transports in the pipeline.
5
+ *
6
+ * These transports talk to a local mihomo mixed port with raw sockets so the
7
+ * per-node measurement path never goes through the crawl proxy (see
8
+ * speedtest.ts / availability.ts). Everything here is deliberately small and
9
+ * side-effect free so it can be unit-tested without a live proxy.
10
+ */
11
+ /** Head sections larger than this are treated as a non-HTTP response. */
12
+ export const HTTP_HEAD_LIMIT_BYTES = 64 * 1024;
13
+ /** Line-oriented rendering of an HTTP response head, safe to put in an Error. */
14
+ export function httpStatusLine(head) {
15
+ const text = typeof head === 'string' ? head : head.toString('latin1');
16
+ const line = text.split('\r\n')[0] ?? '';
17
+ return line.length > 160 ? `${line.slice(0, 160)}...` : line;
18
+ }
19
+ /**
20
+ * Parse the status code out of an HTTP response head.
21
+ *
22
+ * Returns null instead of throwing: callers invoke this from socket 'data'
23
+ * listeners, where a throw would be an uncaught exception that takes down the
24
+ * whole process. A captive portal / block page body simply yields null and the
25
+ * caller fails that one link.
26
+ */
27
+ export function safeParseHttpStatus(head) {
28
+ const match = /^HTTP\/\d(?:\.\d)?\s+(\d+)(?:\s|$)/i.exec(httpStatusLine(head));
29
+ if (!match) {
30
+ return null;
31
+ }
32
+ const status = Number(match[1]);
33
+ return Number.isInteger(status) ? status : null;
34
+ }
35
+ /** Split a buffered HTTP response into its head and any already-received body bytes. */
36
+ export function splitHttpResponseHead(buffer) {
37
+ const marker = buffer.indexOf('\r\n\r\n');
38
+ if (marker < 0) {
39
+ return undefined;
40
+ }
41
+ return {
42
+ head: buffer.subarray(0, marker),
43
+ body: buffer.subarray(marker + 4)
44
+ };
45
+ }
46
+ function decodeUserInfo(value) {
47
+ try {
48
+ return decodeURIComponent(value);
49
+ }
50
+ catch {
51
+ return value;
52
+ }
53
+ }
54
+ /**
55
+ * Normalize a proxy URL for the raw-socket transports.
56
+ *
57
+ * The old call sites used `proxy.port || 80` regardless of scheme and dropped
58
+ * userinfo entirely. Default the port from the scheme, forward Basic
59
+ * credentials, and reject schemes this transport cannot speak.
60
+ */
61
+ export function parseProxyEndpoint(proxyUrl) {
62
+ let proxy;
63
+ try {
64
+ if (proxyUrl instanceof URL) {
65
+ proxy = proxyUrl;
66
+ }
67
+ else {
68
+ const raw = String(proxyUrl);
69
+ proxy = new URL(raw.includes('://') ? raw : `http://${raw}`);
70
+ }
71
+ }
72
+ catch (error) {
73
+ throw new Error(`invalid proxy URL: ${String(proxyUrl)}`, { cause: error });
74
+ }
75
+ const protocol = proxy.protocol.toLowerCase();
76
+ if (protocol !== 'http:' && protocol !== 'https:') {
77
+ throw new Error(`unsupported proxy protocol: ${proxy.protocol}`);
78
+ }
79
+ const port = Number(proxy.port || (protocol === 'https:' ? 443 : 80));
80
+ const user = decodeUserInfo(proxy.username);
81
+ const secret = decodeUserInfo(proxy.password);
82
+ const authorization = user || secret
83
+ ? `Basic ${Buffer.from(`${user}:${secret}`, 'utf8').toString('base64')}`
84
+ : '';
85
+ return { protocol, hostname: proxy.hostname, port, authorization };
86
+ }
87
+ function connectSocket(host, port, timeoutMs) {
88
+ return new Promise((resolve, reject) => {
89
+ const socket = net.createConnection({ host, port });
90
+ let settled = false;
91
+ const fail = (error) => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ socket.destroy();
97
+ reject(error);
98
+ };
99
+ const timer = setTimeout(() => {
100
+ fail(new Error(`proxy connection timed out after ${timeoutMs}ms`));
101
+ }, timeoutMs);
102
+ socket.once('connect', () => {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ socket.off('error', fail);
108
+ // Keep a permanent sink: the caller attaches its request-scoped listener
109
+ // a moment later, and an error in that gap must not be uncaught.
110
+ socket.on('error', () => { });
111
+ resolve(socket);
112
+ });
113
+ socket.once('error', fail);
114
+ });
115
+ }
116
+ function secureProxySocket(socket, hostname, timeoutMs) {
117
+ return new Promise((resolve, reject) => {
118
+ const secureSocket = tls.connect({
119
+ socket,
120
+ servername: net.isIP(hostname) ? undefined : hostname
121
+ });
122
+ let settled = false;
123
+ const cleanup = () => {
124
+ secureSocket.off('secureConnect', onSecureConnect);
125
+ secureSocket.off('error', onError);
126
+ };
127
+ const fail = (error) => {
128
+ if (settled)
129
+ return;
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ cleanup();
133
+ secureSocket.destroy();
134
+ socket.destroy();
135
+ reject(error);
136
+ };
137
+ const onSecureConnect = () => {
138
+ if (settled)
139
+ return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ cleanup();
143
+ resolve(secureSocket);
144
+ };
145
+ const onError = (error) => {
146
+ fail(error);
147
+ };
148
+ const timer = setTimeout(() => {
149
+ fail(new Error(`proxy TLS handshake timed out after ${timeoutMs}ms`));
150
+ }, timeoutMs);
151
+ secureSocket.once('secureConnect', onSecureConnect);
152
+ secureSocket.once('error', onError);
153
+ });
154
+ }
155
+ /**
156
+ * Open the transport socket to the proxy, performing TLS first for https:// proxies.
157
+ * The returned socket is owned by the caller and must be destroyed on every
158
+ * terminal branch of the request it serves.
159
+ */
160
+ export async function connectProxySocket(endpoint, timeoutMs) {
161
+ const socket = await connectSocket(endpoint.hostname, endpoint.port, timeoutMs);
162
+ if (endpoint.protocol === 'http:') {
163
+ return socket;
164
+ }
165
+ try {
166
+ return await secureProxySocket(socket, endpoint.hostname, timeoutMs);
167
+ }
168
+ catch (error) {
169
+ socket.destroy();
170
+ throw error;
171
+ }
172
+ }
173
+ /**
174
+ * Bounded byte ring used to retain a short diagnostic tail from a child
175
+ * process. Reading the pipe is mandatory (an unread 64 KB pipe stalls the
176
+ * child), but retaining all of it would grow without bound on a chatty runtime.
177
+ */
178
+ export function createBoundedLogRing(maxBytes = 8192) {
179
+ const limit = Math.max(1, Math.trunc(maxBytes));
180
+ let parts = [];
181
+ let total = 0;
182
+ const trimFront = (excess) => {
183
+ while (excess > 0 && parts.length > 0) {
184
+ const first = parts[0];
185
+ if (first.byteLength <= excess) {
186
+ parts.shift();
187
+ total -= first.byteLength;
188
+ excess -= first.byteLength;
189
+ }
190
+ else {
191
+ parts[0] = first.subarray(excess);
192
+ total -= excess;
193
+ excess = 0;
194
+ }
195
+ }
196
+ };
197
+ const compact = () => {
198
+ if (parts.length > 64) {
199
+ const merged = Buffer.concat(parts, total);
200
+ parts = [merged];
201
+ }
202
+ };
203
+ return {
204
+ append(chunk) {
205
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
206
+ if (buffer.byteLength === 0) {
207
+ return;
208
+ }
209
+ if (buffer.byteLength >= limit) {
210
+ parts = [buffer.subarray(buffer.byteLength - limit)];
211
+ total = limit;
212
+ return;
213
+ }
214
+ parts.push(buffer);
215
+ total += buffer.byteLength;
216
+ if (total > limit) {
217
+ trimFront(total - limit);
218
+ }
219
+ compact();
220
+ },
221
+ text() {
222
+ return Buffer.concat(parts, total).toString('utf8');
223
+ },
224
+ bytes() {
225
+ return total;
226
+ },
227
+ clear() {
228
+ parts = [];
229
+ total = 0;
230
+ }
231
+ };
232
+ }
@@ -2,7 +2,7 @@ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { parse } from '@iarna/toml';
5
- import { normalizeOfficialSources } from '../config/profile.js';
5
+ import { normalizeOfficialSources, networkEnv } from '../config/profile.js';
6
6
  import { mergeProjectEnv } from '../runtime/env.js';
7
7
  import { resolveArtifactsRoot, resolveProfilePath, resolveUserRuntimeRoot } from '../runtime/paths.js';
8
8
  import { redactText } from '../runtime/redaction.js';
@@ -167,8 +167,30 @@ function errorMessage(error) {
167
167
  }
168
168
  return redactText(String(error));
169
169
  }
170
- function defaultRuntimeStageEnv(env) {
171
- return { ...env };
170
+ function defaultRuntimeStageEnv(env, projectRoot) {
171
+ const merged = { ...env };
172
+ if (!projectRoot)
173
+ return merged;
174
+ // The profile's [network] section is the GUI's egress setting. Apply it as a
175
+ // default only: an operator who exported VPN_AUTOMATION_PROXY_MODE (or the
176
+ // legacy boolean) on the command line keeps that choice.
177
+ if (merged.VPN_AUTOMATION_PROXY_MODE !== undefined || merged.VPN_AUTOMATION_USE_UPSTREAM_PROXY !== undefined) {
178
+ return merged;
179
+ }
180
+ try {
181
+ const profilePath = resolveProfilePath(projectRoot, env);
182
+ if (fs.existsSync(profilePath)) {
183
+ const raw = asProfile(parse(fs.readFileSync(profilePath, 'utf8')));
184
+ const defaults = networkEnv(raw.network);
185
+ merged.VPN_AUTOMATION_PROXY_MODE = defaults.VPN_AUTOMATION_PROXY_MODE;
186
+ merged.VPN_AUTOMATION_UPSTREAM_PROXY = defaults.VPN_AUTOMATION_UPSTREAM_PROXY;
187
+ }
188
+ }
189
+ catch {
190
+ // A missing or malformed profile must not fail env construction; the
191
+ // pipeline's own resolver falls back to the proxy default.
192
+ }
193
+ return merged;
172
194
  }
173
195
  function createCountryLookup(context) {
174
196
  if (context.stages?.countryLookup)
@@ -406,7 +428,7 @@ async function writeWorkerArtifacts(artifactDir, profile, renderedSource, artifa
406
428
  export async function runNodePipeline(options, context = {}) {
407
429
  const projectRoot = path.resolve(options.projectRoot);
408
430
  const env = mergeProjectEnv(projectRoot, { ...process.env, ...(context.env ?? {}) });
409
- const runtimeStageEnv = defaultRuntimeStageEnv(env);
431
+ const runtimeStageEnv = defaultRuntimeStageEnv(env, projectRoot);
410
432
  const artifactDir = await uniqueArtifactDir(resolveArtifactsRoot(projectRoot, env), formatTimestamp((context.now ?? (() => new Date()))()));
411
433
  const runStore = RunStore.open(path.join(artifactDir, 'run.db'));
412
434
  try {
@@ -640,8 +662,12 @@ export async function runNodePipeline(options, context = {}) {
640
662
  if (sourceFailed) {
641
663
  emit('log', { message: `[extract] ${result.source_name} produced no links: ${sourceErrors.join('; ')}` });
642
664
  }
665
+ // Report rounds actually run, not the cap: an early-stopped 1000-round
666
+ // source must not read as 1000/1000. `rounds` is the per-platform figure,
667
+ // which shares the cap's units; fall back to the cap for adapters that
668
+ // do not report it.
643
669
  runStore.recordSourceProgress(sourceName, {
644
- processed: result.requested_iterations,
670
+ processed: Math.max(0, Math.min(result.rounds ?? result.requested_iterations, result.requested_iterations)),
645
671
  total: result.requested_iterations,
646
672
  status: sourceFailed ? 'failed' : 'success',
647
673
  ...(sourceFailed ? { error: sourceErrors.join('; ') } : {})
@@ -877,7 +903,7 @@ export async function retryNodePipelineStage(options, context = {}) {
877
903
  }
878
904
  const stage = normalizeRetryStage(options.stage);
879
905
  const env = mergeProjectEnv(projectRoot, { ...process.env, ...(context.env ?? {}) });
880
- const runtimeStageEnv = defaultRuntimeStageEnv(env);
906
+ const runtimeStageEnv = defaultRuntimeStageEnv(env, projectRoot);
881
907
  const profile = await readProfile(projectRoot, env);
882
908
  const retryArtifactDir = await uniqueArtifactDir(resolveArtifactsRoot(projectRoot, env), formatTimestamp((context.now ?? (() => new Date()))()));
883
909
  const retryContext = {
@@ -1303,7 +1329,7 @@ export async function resumeNodePipeline(options, context = {}) {
1303
1329
  const eventLog = options.eventLog ?? String(sessionPayload.event_log ?? path.join(sessionDir, 'events.jsonl'));
1304
1330
  const humanLog = options.humanLog ?? String(sessionPayload.human_log ?? path.join(sessionDir, 'human.log'));
1305
1331
  const env = mergeProjectEnv(projectRoot, { ...process.env, ...(context.env ?? {}) });
1306
- const runtimeStageEnv = defaultRuntimeStageEnv(env);
1332
+ const runtimeStageEnv = defaultRuntimeStageEnv(env, projectRoot);
1307
1333
  const profile = await readProfile(projectRoot, env);
1308
1334
  const report = await readJson(path.join(artifactDir, 'pipeline_report.json'), {});
1309
1335
  const summary = pipelineSummaryFromReport(artifactDir, report);
@@ -1452,7 +1478,7 @@ export async function resumeNodePipeline(options, context = {}) {
1452
1478
  emit('log', { message: `[extract] ${result.source_name} produced no links: ${resumeErrors.join('; ')}` });
1453
1479
  }
1454
1480
  runStore.recordSourceProgress(sourceName, {
1455
- processed: result.requested_iterations,
1481
+ processed: Math.max(0, Math.min(result.rounds ?? result.requested_iterations, result.requested_iterations)),
1456
1482
  total: result.requested_iterations,
1457
1483
  status: resumeFailed ? 'failed' : 'success',
1458
1484
  ...(resumeFailed ? { error: resumeErrors.join('; ') } : {})
@@ -3,6 +3,7 @@ import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import net from 'node:net';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { createBoundedLogRing } from './http-transport.js';
6
7
  export const PROXY_ENV_KEYS = [
7
8
  'HTTP_PROXY',
8
9
  'HTTPS_PROXY',
@@ -14,6 +15,7 @@ export const PROXY_ENV_KEYS = [
14
15
  'no_proxy'
15
16
  ];
16
17
  const MIHOMO_DELAY_TIMEOUT_MAX_MS = 30_000;
18
+ const MIHOMO_LOG_RING_BYTES = 8 * 1024;
17
19
  const reservedAutomaticPorts = new Set();
18
20
  function padBase64(encoded) {
19
21
  return encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
@@ -307,6 +309,14 @@ export async function openMihomoRuntime(link, options = {}) {
307
309
  let tempDir = '';
308
310
  let configPath = '';
309
311
  let child;
312
+ // stdout/stderr must be drained: an unread 64 KB pipe buffer blocks the child
313
+ // and the controller would never answer. Keep only a bounded tail.
314
+ const logRing = createBoundedLogRing(MIHOMO_LOG_RING_BYTES);
315
+ const recentLog = () => logRing.text();
316
+ const logTailSuffix = () => {
317
+ const tail = recentLog().trim();
318
+ return tail ? ` (runtime output: ${tail})` : '';
319
+ };
310
320
  try {
311
321
  const config = buildMihomoRuntimeConfig(payload, { mixedPort, controllerPort });
312
322
  tempDir = await mkdtemp(path.join(os.tmpdir(), 'autovpn-mihomo-'));
@@ -317,6 +327,19 @@ export async function openMihomoRuntime(link, options = {}) {
317
327
  stdio: ['ignore', 'pipe', 'pipe'],
318
328
  env: stripProxyEnv(options.env ?? process.env)
319
329
  });
330
+ const appendChunk = (chunk) => {
331
+ if (Buffer.isBuffer(chunk)) {
332
+ logRing.append(chunk);
333
+ }
334
+ else if (typeof chunk === 'string') {
335
+ logRing.append(chunk);
336
+ }
337
+ };
338
+ child.stdout?.on('data', appendChunk);
339
+ child.stderr?.on('data', appendChunk);
340
+ // A closed pipe (child already gone) must never surface as an uncaught error.
341
+ child.stdout?.on('error', () => { });
342
+ child.stderr?.on('error', () => { });
320
343
  }
321
344
  catch (error) {
322
345
  try {
@@ -334,6 +357,10 @@ export async function openMihomoRuntime(link, options = {}) {
334
357
  rejectStartupError = reject;
335
358
  });
336
359
  const onStartupError = (error) => {
360
+ const tail = logTailSuffix();
361
+ if (tail && !error.message.includes('runtime output:')) {
362
+ error.message = `${error.message}${tail}`;
363
+ }
337
364
  rejectStartupError?.(error);
338
365
  };
339
366
  child.once('error', onStartupError);
@@ -342,7 +369,7 @@ export async function openMihomoRuntime(link, options = {}) {
342
369
  rejectStartupExit = reject;
343
370
  });
344
371
  const onStartupExit = (code, signal) => {
345
- const error = new Error(`mihomo exited during startup with code ${code ?? 'unknown'}${signal ? ` signal ${signal}` : ''}`);
372
+ const error = new Error(`mihomo exited during startup with code ${code ?? 'unknown'}${signal ? ` signal ${signal}` : ''}${logTailSuffix()}`);
346
373
  error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
347
374
  rejectStartupExit?.(error);
348
375
  };
@@ -375,10 +402,15 @@ export async function openMihomoRuntime(link, options = {}) {
375
402
  child.off('error', onStartupError);
376
403
  child.off('exit', onStartupExit);
377
404
  child.on('error', () => { });
405
+ // Attach the runtime's own output so a silent controller is diagnosable.
406
+ const tail = logTailSuffix();
407
+ if (tail && error instanceof Error && !error.message.includes('runtime output:')) {
408
+ error.message = `${error.message}${tail}`;
409
+ }
378
410
  await close();
379
411
  if (error instanceof Error
380
412
  && error.name !== 'AbortError'
381
- && /^proxy port \d+ did not open in time$/.test(error.message)) {
413
+ && /^proxy port \d+ did not open in time/.test(error.message)) {
382
414
  error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
383
415
  }
384
416
  throw error;
@@ -392,6 +424,7 @@ export async function openMihomoRuntime(link, options = {}) {
392
424
  configPath,
393
425
  controllerUrl,
394
426
  proxyName,
427
+ recentLog,
395
428
  close
396
429
  };
397
430
  }