@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.
@@ -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
- if (timedOut || reachedCap) {
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 socketConnect(host, port, timeoutMs) {
157
+ function waitForHandshake(socket, timeoutMs) {
168
158
  return new Promise((resolve, reject) => {
169
- const socket = net.createConnection({ host, port });
170
- const timer = setTimeout(() => {
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
- const timer = setTimeout(() => {
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
- const finish = (bytes) => {
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
- resolve(bytes);
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
- finish(maxBytes);
235
+ settle(() => resolve(maxBytes));
235
236
  }
236
237
  };
237
- const onData = (chunk) => {
238
- if (!headersParsed) {
239
- buffered = Buffer.concat([buffered, chunk]);
240
- const split = splitHttpHeaders(buffered);
241
- if (!split) {
242
- return;
243
- }
244
- const status = parseHttpStatus(split.head);
245
- if (status < 200 || status >= 300) {
246
- cleanup();
247
- socket.destroy();
248
- reject(new Error(`unexpected status ${status}`));
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
- headersParsed = true;
252
- if (split.body.byteLength > 0) {
253
- countBody(split.body);
254
- }
255
- return;
268
+ countBody(chunk);
256
269
  }
257
- countBody(chunk);
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
- const timer = setTimeout(() => {
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 onError = (error) => {
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 onData = (chunk) => {
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 = splitHttpHeaders(buffered);
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
- socket.destroy();
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
- const proxyPort = Number(proxy.port || 80);
305
- const socket = await socketConnect(proxy.hostname, proxyPort, timeoutMs);
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
- if (target.protocol === 'http:') {
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
- `GET ${target.toString()} HTTP/1.1`,
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(socket, Math.max(1, maxBytes), timeoutMs);
384
+ return await readHttpBodyBytes(secureSocket, Math.max(1, maxBytes), timeoutMs);
317
385
  }
318
- if (target.protocol !== 'https:') {
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 mapWithConcurrency(links, config.concurrency, async (link) => {
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 mapWithConcurrency(links, config.concurrency, async (link) => {
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 mapWithConcurrency(candidateLinks, config.concurrency, async (link) => (testLink(link, config, { runtime_path: runtimePath })), (result, _index, completed) => {
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) ?? { brand: row.brand, total: 0, active: 0, disabled: 0, expired: 0, invalid: 0 };
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
- this.setStatus(id, 'expired', reason, 'expired');
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
- if (!this.hasId(id))
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
  }
@@ -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 ?? '');
@@ -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',