@swimmingliu/autovpn 2.0.2 → 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/run-store.js +39 -0
- 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 +42 -2
- package/dist/server/web-adapter.js +12 -0
- package/dist/web/renderer/app.js +143 -13
- 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,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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -76,6 +76,45 @@ export function readLatestStageStatuses(dbPath) {
|
|
|
76
76
|
catch { /* malformed or locked databases are ignored */ }
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
// Reports are written at stage boundaries. A running UI needs current totals,
|
|
80
|
+
// and opening RunStore would migrate/write the database during observation.
|
|
81
|
+
export function readRunProgress(dbPath) {
|
|
82
|
+
if (!fs.existsSync(dbPath))
|
|
83
|
+
return undefined;
|
|
84
|
+
let db;
|
|
85
|
+
try {
|
|
86
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
87
|
+
db.exec('PRAGMA busy_timeout=5000; BEGIN');
|
|
88
|
+
const runId = db.prepare('SELECT run_id FROM runs ORDER BY run_id DESC LIMIT 1').get()?.run_id;
|
|
89
|
+
if (typeof runId !== 'number' && typeof runId !== 'bigint')
|
|
90
|
+
return undefined;
|
|
91
|
+
const raw = Number(db.prepare('SELECT COUNT(*) AS n FROM raw_observations WHERE run_id=?').get(runId)?.n ?? 0);
|
|
92
|
+
const deduped = Number(db.prepare('SELECT COUNT(*) AS n FROM pipeline_nodes WHERE run_id=?').get(runId)?.n ?? 0);
|
|
93
|
+
const speed = Number(db.prepare("SELECT COUNT(*) AS n FROM speed_results WHERE run_id=? AND status='speed_passed'").get(runId)?.n ?? 0);
|
|
94
|
+
const availability = Number(db.prepare("SELECT COUNT(*) AS n FROM availability_results WHERE run_id=? AND status='availability_passed'").get(runId)?.n ?? 0);
|
|
95
|
+
const sources = {};
|
|
96
|
+
const completeOwnership = Number(db.prepare('SELECT COUNT(*) AS n FROM pipeline_nodes WHERE run_id=? AND first_source IS NULL').get(runId)?.n ?? 0) === 0;
|
|
97
|
+
for (const row of db.prepare('SELECT source,COUNT(*) AS n FROM raw_observations WHERE run_id=? GROUP BY source ORDER BY MIN(observation_id)').all(runId)) {
|
|
98
|
+
if (typeof row.source === 'string')
|
|
99
|
+
sources[row.source] = { raw_links: Number(row.n), ...(completeOwnership ? { deduped_links: 0 } : {}) };
|
|
100
|
+
}
|
|
101
|
+
if (completeOwnership) {
|
|
102
|
+
for (const row of db.prepare('SELECT first_source AS source,COUNT(*) AS n FROM pipeline_nodes WHERE run_id=? AND first_source IS NOT NULL GROUP BY first_source').all(runId)) {
|
|
103
|
+
if (typeof row.source === 'string')
|
|
104
|
+
sources[row.source] = { raw_links: sources[row.source]?.raw_links ?? 0, deduped_links: Number(row.n) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
db.exec('COMMIT');
|
|
108
|
+
return { counts: { raw_links: raw, deduped_links: deduped, speedtest_links: speed, availability_links: availability }, source_counts: sources };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Legacy or incomplete databases retain the existing report/event path.
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
db?.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
79
118
|
export class RunStore {
|
|
80
119
|
db;
|
|
81
120
|
statements = new Map();
|