@swimmingliu/autovpn 2.0.3 → 2.1.1
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 +378 -0
- package/dist/pipeline/extract.js +108 -69
- package/dist/pipeline/http-transport.js +232 -0
- package/dist/pipeline/orchestrator.js +34 -8
- package/dist/pipeline/proxy-runtime.js +35 -2
- package/dist/pipeline/speedtest.js +187 -144
- package/dist/pool/account-store.js +46 -4
- package/dist/server/http.js +11 -0
- package/dist/server/runtime.js +27 -1
- package/dist/server/web-adapter.js +12 -0
- package/dist/web/renderer/app.js +115 -10
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/styles.css +37 -2
- package/dist/web/renderer/tables.js +2 -2
- package/dist/web/renderer/views.js +156 -20
- package/dist/web/renderer/workspace.css +5 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import net from 'node:net';
|
|
2
1
|
import tls from 'node:tls';
|
|
3
2
|
import { openMihomoRuntime as defaultOpenMihomoRuntime, probeMihomoProxyDelay as defaultProbeMihomoProxyDelay } from './proxy-runtime.js';
|
|
3
|
+
import { mapWithCompletion } from './concurrency.js';
|
|
4
|
+
import { HTTP_HEAD_LIMIT_BYTES, connectProxySocket, httpStatusLine, parseProxyEndpoint, safeParseHttpStatus, splitHttpResponseHead } from './http-transport.js';
|
|
4
5
|
import { retryTransientNetwork } from './network-retry.js';
|
|
5
6
|
const PROBE_MAX_ATTEMPTS = 2;
|
|
6
7
|
export function aggregateSpeedMeasurements(values) {
|
|
@@ -52,31 +53,10 @@ function emitEvent(callback, eventType, payload) {
|
|
|
52
53
|
callback(eventType, payload);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
async function mapWithConcurrency(items, concurrency, worker, onComplete) {
|
|
56
|
-
const limit = Math.max(1, Math.trunc(Number(concurrency) || 1));
|
|
57
|
-
const results = new Array(items.length);
|
|
58
|
-
let nextIndex = 0;
|
|
59
|
-
let completed = 0;
|
|
60
|
-
async function runWorker() {
|
|
61
|
-
while (nextIndex < items.length) {
|
|
62
|
-
const index = nextIndex;
|
|
63
|
-
nextIndex += 1;
|
|
64
|
-
const result = await worker(items[index], index);
|
|
65
|
-
results[index] = result;
|
|
66
|
-
completed += 1;
|
|
67
|
-
onComplete?.(result, index, completed);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const workers = await Promise.allSettled(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
|
|
71
|
-
const failed = workers.find((result) => result.status === 'rejected');
|
|
72
|
-
if (failed) {
|
|
73
|
-
throw failed.reason;
|
|
74
|
-
}
|
|
75
|
-
return results;
|
|
76
|
-
}
|
|
77
56
|
function defaultNow() {
|
|
78
57
|
return performance.now();
|
|
79
58
|
}
|
|
59
|
+
/** Shared-concurrency loop plus the progress hook speedtest emits per finished link. */
|
|
80
60
|
async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
81
61
|
const controller = new AbortController();
|
|
82
62
|
let internallyTimedOut = false;
|
|
@@ -87,6 +67,9 @@ async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
|
87
67
|
try {
|
|
88
68
|
const response = await fetchImpl(url, { signal: controller.signal });
|
|
89
69
|
if (internallyTimedOut) {
|
|
70
|
+
// The headers arrived after we gave up: release the body/socket too.
|
|
71
|
+
const cancellation = response.body?.cancel?.();
|
|
72
|
+
void Promise.resolve(cancellation).catch(() => { });
|
|
90
73
|
const error = new Error(`request timed out after ${timeoutMs}ms`);
|
|
91
74
|
error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
|
|
92
75
|
throw error;
|
|
@@ -126,6 +109,7 @@ async function readResponseBytes(response, maxBytes, timeoutMs) {
|
|
|
126
109
|
const reader = response.body.getReader();
|
|
127
110
|
let timedOut = false;
|
|
128
111
|
let reachedCap = false;
|
|
112
|
+
let failed = false;
|
|
129
113
|
try {
|
|
130
114
|
return await withBodyTimeout(async () => {
|
|
131
115
|
let total = 0;
|
|
@@ -143,11 +127,14 @@ async function readResponseBytes(response, maxBytes, timeoutMs) {
|
|
|
143
127
|
}, timeoutMs);
|
|
144
128
|
}
|
|
145
129
|
catch (error) {
|
|
130
|
+
failed = true;
|
|
146
131
|
timedOut = error instanceof Error && error.message.includes('response body timed out');
|
|
147
132
|
throw error;
|
|
148
133
|
}
|
|
149
134
|
finally {
|
|
150
|
-
|
|
135
|
+
// Any exit that did not drain the body naturally must release the
|
|
136
|
+
// keep-alive connection instead of leaving the reader locked.
|
|
137
|
+
if (failed || timedOut || reachedCap) {
|
|
151
138
|
await reader.cancel().catch(() => { });
|
|
152
139
|
}
|
|
153
140
|
reader.releaseLock();
|
|
@@ -158,104 +145,132 @@ async function readResponseBytes(response, maxBytes, timeoutMs) {
|
|
|
158
145
|
function requireOkResponse(response, allowedStatuses) {
|
|
159
146
|
const status = Number(response.status ?? 200);
|
|
160
147
|
if (response.ok === false || !allowedStatuses.has(status)) {
|
|
148
|
+
// Release the keep-alive socket for a body we are about to abandon.
|
|
149
|
+
const cancellation = response.body?.cancel?.();
|
|
150
|
+
void Promise.resolve(cancellation).catch(() => { });
|
|
161
151
|
throw new Error(`unexpected status ${status}`);
|
|
162
152
|
}
|
|
163
153
|
}
|
|
164
154
|
async function retryTransientProbe(operation) {
|
|
165
155
|
return retryTransientNetwork(operation, { maxAttempts: PROBE_MAX_ATTEMPTS, delayMs: 0 });
|
|
166
156
|
}
|
|
167
|
-
function
|
|
157
|
+
function waitForHandshake(socket, timeoutMs) {
|
|
168
158
|
return new Promise((resolve, reject) => {
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
socket.destroy();
|
|
172
|
-
reject(new Error(`proxy connection timed out after ${timeoutMs}ms`));
|
|
173
|
-
}, timeoutMs);
|
|
174
|
-
socket.once('connect', () => {
|
|
175
|
-
clearTimeout(timer);
|
|
176
|
-
resolve(socket);
|
|
177
|
-
});
|
|
178
|
-
socket.once('error', (error) => {
|
|
159
|
+
let settled = false;
|
|
160
|
+
const cleanup = () => {
|
|
179
161
|
clearTimeout(timer);
|
|
162
|
+
socket.off('secureConnect', onSecureConnect);
|
|
163
|
+
socket.off('error', onError);
|
|
164
|
+
};
|
|
165
|
+
const fail = (error) => {
|
|
166
|
+
if (settled) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
settled = true;
|
|
170
|
+
cleanup();
|
|
171
|
+
socket.destroy();
|
|
180
172
|
reject(error);
|
|
181
|
-
}
|
|
173
|
+
};
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
const error = new Error(`TLS handshake timed out after ${timeoutMs}ms`);
|
|
176
|
+
error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
|
|
177
|
+
fail(error);
|
|
178
|
+
}, timeoutMs);
|
|
179
|
+
function onSecureConnect() {
|
|
180
|
+
if (settled) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
settled = true;
|
|
184
|
+
cleanup();
|
|
185
|
+
resolve();
|
|
186
|
+
}
|
|
187
|
+
function onError(error) {
|
|
188
|
+
fail(error);
|
|
189
|
+
}
|
|
190
|
+
socket.once('secureConnect', onSecureConnect);
|
|
191
|
+
socket.once('error', onError);
|
|
182
192
|
});
|
|
183
193
|
}
|
|
184
|
-
function splitHttpHeaders(buffer) {
|
|
185
|
-
const marker = buffer.indexOf('\r\n\r\n');
|
|
186
|
-
if (marker < 0) {
|
|
187
|
-
return undefined;
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
head: buffer.subarray(0, marker).subarray(0),
|
|
191
|
-
body: buffer.subarray(marker + 4).subarray(0)
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
function parseHttpStatus(head) {
|
|
195
|
-
const firstLine = head.toString('latin1').split('\r\n')[0] ?? '';
|
|
196
|
-
const match = /^HTTP\/\d(?:\.\d)?\s+(\d+)/i.exec(firstLine);
|
|
197
|
-
if (!match) {
|
|
198
|
-
throw new Error(`invalid HTTP response: ${firstLine}`);
|
|
199
|
-
}
|
|
200
|
-
return Number(match[1]);
|
|
201
|
-
}
|
|
202
194
|
function readHttpBodyBytes(socket, maxBytes, timeoutMs) {
|
|
203
195
|
return new Promise((resolve, reject) => {
|
|
204
196
|
let buffered = Buffer.alloc(0);
|
|
205
197
|
let headersParsed = false;
|
|
206
198
|
let total = 0;
|
|
207
|
-
|
|
208
|
-
cleanup();
|
|
209
|
-
socket.destroy();
|
|
210
|
-
reject(new Error(`proxy download timed out after ${timeoutMs}ms`));
|
|
211
|
-
}, timeoutMs);
|
|
199
|
+
let settled = false;
|
|
212
200
|
const cleanup = () => {
|
|
213
201
|
clearTimeout(timer);
|
|
214
202
|
socket.off('data', onData);
|
|
215
203
|
socket.off('end', onEnd);
|
|
216
204
|
socket.off('error', onError);
|
|
217
205
|
};
|
|
218
|
-
|
|
206
|
+
// Single terminal point: stop listening, close the handle, then settle once.
|
|
207
|
+
const settle = (action) => {
|
|
208
|
+
if (settled) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
settled = true;
|
|
219
212
|
cleanup();
|
|
220
213
|
socket.destroy();
|
|
221
|
-
|
|
222
|
-
};
|
|
223
|
-
const onError = (error) => {
|
|
224
|
-
cleanup();
|
|
225
|
-
reject(error);
|
|
226
|
-
};
|
|
227
|
-
const onEnd = () => {
|
|
228
|
-
cleanup();
|
|
229
|
-
resolve(total);
|
|
214
|
+
action();
|
|
230
215
|
};
|
|
216
|
+
const fail = (error) => settle(() => reject(error));
|
|
217
|
+
const timer = setTimeout(() => fail(new Error(`proxy download timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
218
|
+
function onError(error) {
|
|
219
|
+
fail(error);
|
|
220
|
+
}
|
|
221
|
+
function onEnd() {
|
|
222
|
+
if (!headersParsed) {
|
|
223
|
+
// A captive portal / block page that closes before a parseable head:
|
|
224
|
+
// fail this link instead of reporting a zero-byte download.
|
|
225
|
+
fail(buffered.byteLength > 0
|
|
226
|
+
? new Error(`invalid HTTP response: ${httpStatusLine(buffered)}`)
|
|
227
|
+
: new Error('proxy response ended before HTTP headers were received'));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
settle(() => resolve(total));
|
|
231
|
+
}
|
|
231
232
|
const countBody = (chunk) => {
|
|
232
233
|
total += Math.min(chunk.byteLength, Math.max(maxBytes - total, 0));
|
|
233
234
|
if (total >= maxBytes) {
|
|
234
|
-
|
|
235
|
+
settle(() => resolve(maxBytes));
|
|
235
236
|
}
|
|
236
237
|
};
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
238
|
+
function onData(chunk) {
|
|
239
|
+
if (settled) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
if (!headersParsed) {
|
|
244
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
245
|
+
const split = splitHttpResponseHead(buffered);
|
|
246
|
+
if (!split) {
|
|
247
|
+
if (buffered.byteLength > HTTP_HEAD_LIMIT_BYTES) {
|
|
248
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(buffered)}`));
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const status = safeParseHttpStatus(split.head);
|
|
253
|
+
if (status === null) {
|
|
254
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(split.head)}`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (status < 200 || status >= 300) {
|
|
258
|
+
fail(new Error(`unexpected status ${status}`));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
headersParsed = true;
|
|
262
|
+
buffered = Buffer.alloc(0);
|
|
263
|
+
if (split.body.byteLength > 0) {
|
|
264
|
+
countBody(split.body);
|
|
265
|
+
}
|
|
249
266
|
return;
|
|
250
267
|
}
|
|
251
|
-
|
|
252
|
-
if (split.body.byteLength > 0) {
|
|
253
|
-
countBody(split.body);
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
268
|
+
countBody(chunk);
|
|
256
269
|
}
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
catch (error) {
|
|
271
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
259
274
|
socket.on('data', onData);
|
|
260
275
|
socket.once('end', onEnd);
|
|
261
276
|
socket.once('error', onError);
|
|
@@ -264,92 +279,116 @@ function readHttpBodyBytes(socket, maxBytes, timeoutMs) {
|
|
|
264
279
|
function readConnectResponse(socket, timeoutMs) {
|
|
265
280
|
return new Promise((resolve, reject) => {
|
|
266
281
|
let buffered = Buffer.alloc(0);
|
|
267
|
-
|
|
268
|
-
cleanup();
|
|
269
|
-
socket.destroy();
|
|
270
|
-
reject(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`));
|
|
271
|
-
}, timeoutMs);
|
|
282
|
+
let settled = false;
|
|
272
283
|
const cleanup = () => {
|
|
273
284
|
clearTimeout(timer);
|
|
274
285
|
socket.off('data', onData);
|
|
286
|
+
socket.off('end', onEnd);
|
|
275
287
|
socket.off('error', onError);
|
|
276
288
|
};
|
|
277
|
-
const
|
|
289
|
+
const fail = (error) => {
|
|
290
|
+
if (settled) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
settled = true;
|
|
278
294
|
cleanup();
|
|
295
|
+
socket.destroy();
|
|
279
296
|
reject(error);
|
|
280
297
|
};
|
|
281
|
-
const
|
|
298
|
+
const timer = setTimeout(() => fail(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
299
|
+
function onError(error) {
|
|
300
|
+
fail(error);
|
|
301
|
+
}
|
|
302
|
+
function onEnd() {
|
|
303
|
+
fail(new Error('proxy CONNECT response ended before HTTP headers were received'));
|
|
304
|
+
}
|
|
305
|
+
function onData(chunk) {
|
|
306
|
+
if (settled) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
282
309
|
buffered = Buffer.concat([buffered, chunk]);
|
|
283
|
-
const split =
|
|
310
|
+
const split = splitHttpResponseHead(buffered);
|
|
284
311
|
if (!split) {
|
|
312
|
+
if (buffered.byteLength > HTTP_HEAD_LIMIT_BYTES) {
|
|
313
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(buffered)}`));
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const status = safeParseHttpStatus(split.head);
|
|
318
|
+
if (status === null) {
|
|
319
|
+
fail(new Error(`invalid HTTP response: ${httpStatusLine(split.head)}`));
|
|
285
320
|
return;
|
|
286
321
|
}
|
|
287
|
-
const status = parseHttpStatus(split.head);
|
|
288
|
-
cleanup();
|
|
289
322
|
if (status < 200 || status >= 300) {
|
|
290
|
-
|
|
291
|
-
reject(new Error(`proxy CONNECT failed with status ${status}`));
|
|
323
|
+
fail(new Error(`proxy CONNECT failed with status ${status}`));
|
|
292
324
|
return;
|
|
293
325
|
}
|
|
326
|
+
settled = true;
|
|
327
|
+
cleanup();
|
|
294
328
|
resolve();
|
|
295
|
-
}
|
|
329
|
+
}
|
|
296
330
|
socket.on('data', onData);
|
|
331
|
+
socket.once('end', onEnd);
|
|
297
332
|
socket.once('error', onError);
|
|
298
333
|
});
|
|
299
334
|
}
|
|
300
335
|
export async function downloadUrlViaHttpProxy(url, proxyUrl, maxBytes, timeoutSeconds) {
|
|
301
336
|
const target = new URL(url);
|
|
302
|
-
const proxy = new URL(proxyUrl);
|
|
303
337
|
const timeoutMs = Math.max(1, timeoutSeconds) * 1000;
|
|
304
|
-
|
|
305
|
-
|
|
338
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
339
|
+
throw new Error(`unsupported speedtest URL protocol: ${target.protocol}`);
|
|
340
|
+
}
|
|
341
|
+
const endpoint = parseProxyEndpoint(proxyUrl);
|
|
342
|
+
const socket = await connectProxySocket(endpoint, timeoutMs);
|
|
306
343
|
const requestPath = `${target.pathname || '/'}${target.search}`;
|
|
307
344
|
const targetPort = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
|
|
308
|
-
|
|
345
|
+
const proxyAuthorization = endpoint.authorization ? [`Proxy-Authorization: ${endpoint.authorization}`] : [];
|
|
346
|
+
let secureSocket;
|
|
347
|
+
try {
|
|
348
|
+
if (target.protocol === 'http:') {
|
|
349
|
+
socket.write([
|
|
350
|
+
`GET ${target.toString()} HTTP/1.1`,
|
|
351
|
+
`Host: ${target.host}`,
|
|
352
|
+
...proxyAuthorization,
|
|
353
|
+
'Connection: close',
|
|
354
|
+
'',
|
|
355
|
+
''
|
|
356
|
+
].join('\r\n'));
|
|
357
|
+
return await readHttpBodyBytes(socket, Math.max(1, maxBytes), timeoutMs);
|
|
358
|
+
}
|
|
309
359
|
socket.write([
|
|
310
|
-
`
|
|
360
|
+
`CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
|
|
361
|
+
`Host: ${target.hostname}:${targetPort}`,
|
|
362
|
+
...proxyAuthorization,
|
|
363
|
+
'Connection: keep-alive',
|
|
364
|
+
'',
|
|
365
|
+
''
|
|
366
|
+
].join('\r\n'));
|
|
367
|
+
await readConnectResponse(socket, timeoutMs);
|
|
368
|
+
secureSocket = tls.connect({
|
|
369
|
+
socket,
|
|
370
|
+
servername: target.hostname,
|
|
371
|
+
rejectUnauthorized: false
|
|
372
|
+
});
|
|
373
|
+
// Destroying a TLSSocket after the request-scoped listeners are removed
|
|
374
|
+
// must not surface as an uncaught 'error'.
|
|
375
|
+
secureSocket.on('error', () => { });
|
|
376
|
+
await waitForHandshake(secureSocket, timeoutMs);
|
|
377
|
+
secureSocket.write([
|
|
378
|
+
`GET ${requestPath} HTTP/1.1`,
|
|
311
379
|
`Host: ${target.host}`,
|
|
312
380
|
'Connection: close',
|
|
313
381
|
'',
|
|
314
382
|
''
|
|
315
383
|
].join('\r\n'));
|
|
316
|
-
return await readHttpBodyBytes(
|
|
384
|
+
return await readHttpBodyBytes(secureSocket, Math.max(1, maxBytes), timeoutMs);
|
|
317
385
|
}
|
|
318
|
-
|
|
386
|
+
finally {
|
|
387
|
+
// Every terminal branch (success, non-2xx, parse failure, timeout, handshake
|
|
388
|
+
// failure) releases both handles. destroy() is idempotent.
|
|
389
|
+
secureSocket?.destroy();
|
|
319
390
|
socket.destroy();
|
|
320
|
-
throw new Error(`unsupported speedtest URL protocol: ${target.protocol}`);
|
|
321
391
|
}
|
|
322
|
-
socket.write([
|
|
323
|
-
`CONNECT ${target.hostname}:${targetPort} HTTP/1.1`,
|
|
324
|
-
`Host: ${target.hostname}:${targetPort}`,
|
|
325
|
-
'Connection: keep-alive',
|
|
326
|
-
'',
|
|
327
|
-
''
|
|
328
|
-
].join('\r\n'));
|
|
329
|
-
await readConnectResponse(socket, timeoutMs);
|
|
330
|
-
const secureSocket = tls.connect({
|
|
331
|
-
socket,
|
|
332
|
-
servername: target.hostname,
|
|
333
|
-
rejectUnauthorized: false
|
|
334
|
-
});
|
|
335
|
-
await new Promise((resolve, reject) => {
|
|
336
|
-
const timer = setTimeout(() => {
|
|
337
|
-
secureSocket.destroy();
|
|
338
|
-
const error = new Error(`TLS handshake timed out after ${timeoutMs}ms`);
|
|
339
|
-
error.code = 'AUTOVPN_INTERNAL_TIMEOUT';
|
|
340
|
-
reject(error);
|
|
341
|
-
}, timeoutMs);
|
|
342
|
-
secureSocket.once('secureConnect', () => { clearTimeout(timer); resolve(); });
|
|
343
|
-
secureSocket.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
344
|
-
});
|
|
345
|
-
secureSocket.write([
|
|
346
|
-
`GET ${requestPath} HTTP/1.1`,
|
|
347
|
-
`Host: ${target.host}`,
|
|
348
|
-
'Connection: close',
|
|
349
|
-
'',
|
|
350
|
-
''
|
|
351
|
-
].join('\r\n'));
|
|
352
|
-
return await readHttpBodyBytes(secureSocket, Math.max(1, maxBytes), timeoutMs);
|
|
353
392
|
}
|
|
354
393
|
async function probeLinksDirect(links, config, options, onComplete) {
|
|
355
394
|
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
@@ -358,13 +397,17 @@ async function probeLinksDirect(links, config, options, onComplete) {
|
|
|
358
397
|
}
|
|
359
398
|
const now = options.now ?? defaultNow;
|
|
360
399
|
const timeoutMs = Math.max(1, Number(config.timeout_seconds)) * 1000;
|
|
361
|
-
return
|
|
400
|
+
return mapWithCompletion(links, config.concurrency, async (link) => {
|
|
362
401
|
try {
|
|
363
402
|
return await retryTransientProbe(async () => {
|
|
364
403
|
const started = now();
|
|
365
404
|
const response = await fetchWithTimeout(fetchImpl, config.probe_url, timeoutMs);
|
|
366
405
|
const elapsed = Math.max(now() - started, 1);
|
|
367
406
|
requireOkResponse(response, new Set([200, 204]));
|
|
407
|
+
// A reachability probe never reads the body; cancel it so the
|
|
408
|
+
// keep-alive connection goes back to the pool instead of leaking.
|
|
409
|
+
const cancellation = response.body?.cancel?.();
|
|
410
|
+
void Promise.resolve(cancellation).catch(() => { });
|
|
368
411
|
return { link, reachable: true, latency_ms: Math.max(Math.round(elapsed), 1), error: '' };
|
|
369
412
|
});
|
|
370
413
|
}
|
|
@@ -376,7 +419,7 @@ async function probeLinksDirect(links, config, options, onComplete) {
|
|
|
376
419
|
async function probeLinksMihomo(links, config, runtimePath, options, onComplete) {
|
|
377
420
|
const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
|
|
378
421
|
const probeDelay = options.probeMihomoProxyDelay ?? defaultProbeMihomoProxyDelay;
|
|
379
|
-
return
|
|
422
|
+
return mapWithCompletion(links, config.concurrency, async (link) => {
|
|
380
423
|
try {
|
|
381
424
|
return await retryTransientProbe(async () => {
|
|
382
425
|
let runtime;
|
|
@@ -542,7 +585,7 @@ async function speedtestInNode(input, options) {
|
|
|
542
585
|
latency_ms: probe.latency_ms,
|
|
543
586
|
error: probe.error ?? ''
|
|
544
587
|
}));
|
|
545
|
-
const testedResults = await
|
|
588
|
+
const testedResults = await mapWithCompletion(candidateLinks, config.concurrency, async (link) => (testLink(link, config, { runtime_path: runtimePath })), (result, _index, completed) => {
|
|
546
589
|
if (result.reachable && result.latency_ms <= 0) {
|
|
547
590
|
result.latency_ms = probeByLink.get(result.link)?.latency_ms ?? 0;
|
|
548
591
|
}
|
|
@@ -302,7 +302,8 @@ export class AccountStore {
|
|
|
302
302
|
deleted: 0,
|
|
303
303
|
expiring_soon: 0,
|
|
304
304
|
by_brand: [],
|
|
305
|
-
db_path: this.dbPath
|
|
305
|
+
db_path: this.dbPath,
|
|
306
|
+
expiry_unknown: 0
|
|
306
307
|
};
|
|
307
308
|
const soon = isoDay(new Date(this.now().getTime() + 7 * 86_400_000));
|
|
308
309
|
const today = isoDay(this.now());
|
|
@@ -311,12 +312,27 @@ export class AccountStore {
|
|
|
311
312
|
stats[row.status] += 1;
|
|
312
313
|
if (row.deleted_at !== null)
|
|
313
314
|
stats.deleted += 1;
|
|
315
|
+
// Only live rows count: a retired row's unknown date is history, not a
|
|
316
|
+
// gap the operator can still close by refreshing the pool.
|
|
317
|
+
const expiryUnknown = row.deleted_at === null && row.expires_at === '';
|
|
318
|
+
if (expiryUnknown)
|
|
319
|
+
stats.expiry_unknown += 1;
|
|
314
320
|
if (row.status === 'active' && row.expires_at && row.expires_at >= today && row.expires_at <= soon) {
|
|
315
321
|
stats.expiring_soon += 1;
|
|
316
322
|
}
|
|
317
|
-
const bucket = brands.get(row.brand) ?? {
|
|
323
|
+
const bucket = brands.get(row.brand) ?? {
|
|
324
|
+
brand: row.brand,
|
|
325
|
+
total: 0,
|
|
326
|
+
active: 0,
|
|
327
|
+
disabled: 0,
|
|
328
|
+
expired: 0,
|
|
329
|
+
invalid: 0,
|
|
330
|
+
expiry_unknown: 0
|
|
331
|
+
};
|
|
318
332
|
bucket.total += 1;
|
|
319
333
|
bucket[row.status] += 1;
|
|
334
|
+
if (expiryUnknown)
|
|
335
|
+
bucket.expiry_unknown += 1;
|
|
320
336
|
brands.set(row.brand, bucket);
|
|
321
337
|
}
|
|
322
338
|
stats.by_brand = [...brands.values()].sort((left, right) => left.brand.localeCompare(right.brand));
|
|
@@ -393,8 +409,21 @@ export class AccountStore {
|
|
|
393
409
|
this.db.prepare('UPDATE accounts SET fail_count = fail_count + 1, updated_at = ? WHERE id = ?').run(stamp, id);
|
|
394
410
|
this.setStatus(id, 'invalid', reason, 'invalidated');
|
|
395
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Retire a row because its membership is gone.
|
|
414
|
+
*
|
|
415
|
+
* Same semantics as `expireDue`: status='expired', a soft delete, and a
|
|
416
|
+
* 'expired' event, so a row retired by the date sweep and one retired by the
|
|
417
|
+
* pipeline's login (a vendor that rejects a lapsed credential without
|
|
418
|
+
* stating a date) are indistinguishable in the UI. `setExpiryById` revives
|
|
419
|
+
* exactly this shape once a fresh date is learned.
|
|
420
|
+
*/
|
|
396
421
|
markExpired(id, reason = 'membership expired') {
|
|
397
|
-
|
|
422
|
+
// `setExpiryById` revives a lapse only when the reason is the canonical one,
|
|
423
|
+
// so a caller passing an empty string must not create a row no renewal can
|
|
424
|
+
// bring back.
|
|
425
|
+
const detail = String(reason ?? '').trim() || 'membership expired';
|
|
426
|
+
this.setStatus(id, 'expired', detail, 'expired');
|
|
398
427
|
}
|
|
399
428
|
disable(brand, account, reason = 'disabled by operator') {
|
|
400
429
|
const id = this.findId(brand, account);
|
|
@@ -449,9 +478,16 @@ export class AccountStore {
|
|
|
449
478
|
* a stored `2026/1/5` would sort above every ISO date and never retire the
|
|
450
479
|
* row. Returns null when the value is not a date, so the caller can refuse
|
|
451
480
|
* instead of writing something the sweep will skip forever.
|
|
481
|
+
*
|
|
482
|
+
* Renewing a row this function itself retired brings it back: a fresh date on
|
|
483
|
+
* or after today clears the 'membership expired' retirement, exactly as an
|
|
484
|
+
* import revives a row. Only that exact shape is revived — `invalid` rows and
|
|
485
|
+
* operator-disabled rows keep their retirement, because a new membership date
|
|
486
|
+
* says nothing about a rejected password or an explicit disable.
|
|
452
487
|
*/
|
|
453
488
|
setExpiryById(id, expiresAt) {
|
|
454
|
-
|
|
489
|
+
const row = this.db.prepare('SELECT status, status_reason FROM accounts WHERE id = ?').get(Math.trunc(id));
|
|
490
|
+
if (!row)
|
|
455
491
|
return false;
|
|
456
492
|
const normalized = normalizeExpiry(expiresAt);
|
|
457
493
|
if (normalized === null)
|
|
@@ -459,6 +495,12 @@ export class AccountStore {
|
|
|
459
495
|
const stamp = isoNow(this.now());
|
|
460
496
|
this.db.prepare('UPDATE accounts SET expires_at = ?, updated_at = ? WHERE id = ?').run(normalized, stamp, id);
|
|
461
497
|
this.event(id, 'expiry_set', normalized || '(cleared)');
|
|
498
|
+
if (normalized && normalized >= isoDay(this.now()) && row.status === 'expired' && row.status_reason === 'membership expired') {
|
|
499
|
+
this.db
|
|
500
|
+
.prepare(`UPDATE accounts SET status = 'active', status_reason = '', deleted_at = NULL, updated_at = ? WHERE id = ?`)
|
|
501
|
+
.run(stamp, Math.trunc(id));
|
|
502
|
+
this.event(id, 'revived', normalized);
|
|
503
|
+
}
|
|
462
504
|
this.expireDue();
|
|
463
505
|
return true;
|
|
464
506
|
}
|
package/dist/server/http.js
CHANGED
|
@@ -206,6 +206,17 @@ export async function createAutoVpnServer(options) {
|
|
|
206
206
|
writeJson(response, result.ok === false ? 400 : 200, result);
|
|
207
207
|
return;
|
|
208
208
|
}
|
|
209
|
+
if (request.method === 'POST' && url.pathname === '/api/accounts/expiry') {
|
|
210
|
+
const body = await readJsonBody(request);
|
|
211
|
+
const result = await options.runtime.setAccountExpiry?.({
|
|
212
|
+
id: Number(body.id ?? 0),
|
|
213
|
+
brand: String(body.brand ?? ''),
|
|
214
|
+
account: String(body.account ?? ''),
|
|
215
|
+
expires_at: body.expires_at === undefined ? undefined : String(body.expires_at)
|
|
216
|
+
}) ?? { ok: false, error: 'accounts_unavailable' };
|
|
217
|
+
writeJson(response, result.ok === false ? 400 : 200, result);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
209
220
|
if (request.method === 'POST' && url.pathname === '/api/qr') {
|
|
210
221
|
const body = await readJsonBody(request);
|
|
211
222
|
const text = String(body.text ?? '');
|
package/dist/server/runtime.js
CHANGED
|
@@ -8,7 +8,7 @@ import { followLog as defaultFollowLog } from '../jobs/logs.js';
|
|
|
8
8
|
import { latestJobId, loadJob } from '../jobs/read.js';
|
|
9
9
|
import { AccountStore } from '../pool/account-store.js';
|
|
10
10
|
import { normalizeBrand } from '../pool/brands.js';
|
|
11
|
-
import { parseAccountFile } from '../pool/csv.js';
|
|
11
|
+
import { parseAccountFile, normalizeExpiry } from '../pool/csv.js';
|
|
12
12
|
import { readRunProgress } from '../pipeline/run-store.js';
|
|
13
13
|
import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
|
|
14
14
|
const DEPLOY_SECRET_KEYS = new Set([
|
|
@@ -475,6 +475,32 @@ export function createServerRuntime(options) {
|
|
|
475
475
|
store.close();
|
|
476
476
|
}
|
|
477
477
|
},
|
|
478
|
+
async setAccountExpiry(input = {}) {
|
|
479
|
+
const id = Number(input.id);
|
|
480
|
+
const brand = normalizeBrand(input.brand);
|
|
481
|
+
const account = String(input.account ?? '').trim();
|
|
482
|
+
const byId = Number.isInteger(id) && id > 0;
|
|
483
|
+
if (!byId && (!brand || !account))
|
|
484
|
+
return { ok: false, error: 'id_or_brand_and_account_required' };
|
|
485
|
+
// An absent `expires_at` is a bad request, not "clear the date": the old
|
|
486
|
+
// CLI wrote '' on a missing flag and erased known expiries. Clearing is
|
|
487
|
+
// spelled as an explicit empty string.
|
|
488
|
+
if (input.expires_at === undefined)
|
|
489
|
+
return { ok: false, error: 'expires_at_required' };
|
|
490
|
+
const expiresAt = String(input.expires_at);
|
|
491
|
+
const store = new AccountStore({ env: options.env, now: options.now });
|
|
492
|
+
try {
|
|
493
|
+
const ok = byId ? store.setExpiryById(id, expiresAt) : store.setExpiry(brand, account, expiresAt);
|
|
494
|
+
if (ok === null)
|
|
495
|
+
return { ok: false, error: 'invalid_date', ...(byId ? { id } : { brand }) };
|
|
496
|
+
if (!ok)
|
|
497
|
+
return { ok: false, error: 'account_not_found' };
|
|
498
|
+
return { ok: true, ...(byId ? { id } : { brand }), expires_at: normalizeExpiry(expiresAt) };
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
store.close();
|
|
502
|
+
}
|
|
503
|
+
},
|
|
478
504
|
async startRun(runOptions = {}) {
|
|
479
505
|
if (runState === 'running' || runState === 'stopping') {
|
|
480
506
|
return { ok: false, error: 'run_already_active' };
|
|
@@ -172,6 +172,18 @@ export function renderWebAdapterScript(options = {}) {
|
|
|
172
172
|
method: 'POST',
|
|
173
173
|
body: JSON.stringify(payload || {})
|
|
174
174
|
}),
|
|
175
|
+
// The H5 build reaches the same expiry editor the Electron preload
|
|
176
|
+
// exposes; without this the browser UI hides the affordance entirely.
|
|
177
|
+
// Accepts both shapes the renderer may use: (id, date) like the preload
|
|
178
|
+
// bridge, or ({id, expires_at}).
|
|
179
|
+
setAccountExpiry: async (idOrPayload, date) => request('/api/accounts/expiry', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body: JSON.stringify(
|
|
182
|
+
idOrPayload && typeof idOrPayload === 'object'
|
|
183
|
+
? idOrPayload
|
|
184
|
+
: { id: idOrPayload, expires_at: date }
|
|
185
|
+
)
|
|
186
|
+
}),
|
|
175
187
|
accountsTemplate: async () => ({ ok: true, content: ${JSON.stringify(accountsTemplate())} }),
|
|
176
188
|
runPipeline: async (options = {}) => request('/api/runs', {
|
|
177
189
|
method: 'POST',
|