@swimmingliu/autovpn 2.2.4 → 2.3.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.
@@ -7,16 +7,16 @@
7
7
  */
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { AccountStore } from '../pool/account-store.js';
10
+ import { AccountStore, retiredRetentionDays } from '../pool/account-store.js';
11
11
  import { BRANDS, brandLabel, normalizeBrand } from '../pool/brands.js';
12
12
  import { CSV_COLUMNS, RETIRED_STATUSES, parseAccountFile, stringifyCsv } from '../pool/csv.js';
13
13
  import { CliUsageError } from './errors.js';
14
14
  import { readOptionValue } from './global-options.js';
15
- export const ACCOUNTS_SUBCOMMANDS = ['import', 'list', 'stats', 'snapshot', 'disable', 'enable', 'expire', 'export', 'template', 'refresh-expiry'];
15
+ export const ACCOUNTS_SUBCOMMANDS = ['import', 'list', 'stats', 'snapshot', 'disable', 'enable', 'expire', 'export', 'template', 'refresh-expiry', 'purge'];
16
16
  /** Options that consume the next argv slot, so positionals can be found. */
17
17
  const VALUE_OPTIONS = new Set([
18
18
  '--project-root', '--format', '--output', '--brand', '--status', '--reason', '--date', '--file', '--limit', '--query', '--id',
19
- '--concurrency'
19
+ '--concurrency', '--days'
20
20
  ]);
21
21
  /**
22
22
  * Resolve the pipeline worker lazily.
@@ -94,6 +94,16 @@ export function validateAccountsCommand(argv) {
94
94
  throw new CliUsageError('accounts refresh-expiry --concurrency must be a positive integer');
95
95
  }
96
96
  }
97
+ if (subcommand === 'purge') {
98
+ const days = readOptionValue(argv, '--days');
99
+ // Whole days only. The cutoff is built from `days * 86_400_000` and rows
100
+ // are compared as strings, so a fraction would have to be rounded one way
101
+ // or the other anyway; refuse it rather than quietly giving a different
102
+ // window than the operator typed. 0 is legal and means "keep everything".
103
+ if (days !== undefined && !/^\d+$/.test(days)) {
104
+ throw new CliUsageError('accounts purge --days must be a non-negative whole number of days');
105
+ }
106
+ }
97
107
  if (['disable', 'enable', 'expire'].includes(subcommand)) {
98
108
  const id = readOptionValue(argv, '--id');
99
109
  if (id !== undefined) {
@@ -223,7 +233,7 @@ export async function runAccountsCommand(argv, context) {
223
233
  if (subcommand === 'refresh-expiry') {
224
234
  return await runRefreshExpiry(argv, context);
225
235
  }
226
- const dryRun = subcommand === 'import' && argv.includes('--dry-run');
236
+ const dryRun = (subcommand === 'import' || subcommand === 'purge') && argv.includes('--dry-run');
227
237
  // A preview must not write: the store's getters sweep expired rows, so even
228
238
  // opening it read-write would mutate the pool before showing the plan.
229
239
  const store = context.store ?? new AccountStore({ env: context.env, now: context.now, readOnly: dryRun });
@@ -240,6 +250,47 @@ export async function runAccountsCommand(argv, context) {
240
250
  context.io.writeStdout(jsonLine(payload));
241
251
  return 0;
242
252
  }
253
+ if (subcommand === 'purge') {
254
+ // An explicit, unconditional sweep: the operator asked for it now, so it
255
+ // ignores the once-per-window stamp that read paths use. `--dry-run`
256
+ // reports what would go without erasing anything, and opens the store
257
+ // read-only so not even the lapse sweep writes.
258
+ //
259
+ // The count has two parts because they are two different answers. Retired
260
+ // rows past the window go on this run. Unrecorded lapses are retired by
261
+ // the sweep first, and their own window starts at that moment - so they
262
+ // are reported as a separate number rather than promised as erased.
263
+ const daysRaw = readOptionValue(argv, '--days');
264
+ // Checked here as well as in `validateAccountsCommand`, because this
265
+ // function is also the entry point for callers that skip validation; a
266
+ // fractional value would otherwise be silently truncated to whole days.
267
+ if (daysRaw !== undefined && !/^\d+$/.test(daysRaw)) {
268
+ throw new CliUsageError('accounts purge --days must be a non-negative whole number of days');
269
+ }
270
+ const days = daysRaw === undefined ? retiredRetentionDays(context.env) : Number.parseInt(daysRaw, 10);
271
+ const dryRun = argv.includes('--dry-run');
272
+ if (dryRun) {
273
+ const counted = store.countRetired(days);
274
+ context.io.writeStdout(wantsHuman(argv)
275
+ ? `would purge ${counted.purgeable} retired account(s); retention ${days} day(s)` +
276
+ `${counted.pendingLapse ? `; ${counted.pendingLapse} lapsed account(s) will be retired now and counted next window` : ''}\n`
277
+ : jsonLine({ ok: true, retired_retention_days: days, erased: 0, would_erase: counted.purgeable, pending_lapse: counted.pendingLapse, dry_run: true }));
278
+ return 0;
279
+ }
280
+ // Retire first, so a row whose date lapsed before anyone read the pool is
281
+ // reported as expired rather than left invisible to both numbers.
282
+ const lapsed = store.expireDue();
283
+ const erased = store.sweepRetired(days, true);
284
+ // `--days 0` means "keep everything", so the report itself must not
285
+ // sweep with the default window on the way out.
286
+ const after = store.stats({ maintain: days > 0 });
287
+ const payload = { ok: true, retired_retention_days: days, erased, lapsed, dry_run: false, stats: after };
288
+ context.io.writeStdout(wantsHuman(argv)
289
+ ? `purged ${erased} retired account(s); retention ${days} day(s); ${after.total} row(s) remain` +
290
+ `${lapsed ? `; ${lapsed} expired account(s) newly retired` : ''}\n`
291
+ : jsonLine(payload));
292
+ return 0;
293
+ }
243
294
  if (subcommand === 'list') {
244
295
  const limitRaw = readOptionValue(argv, '--limit');
245
296
  let limit;
@@ -18,7 +18,7 @@ Usage:
18
18
 
19
19
  Commands:
20
20
  profile show|save|summary
21
- accounts import|list|stats|snapshot|disable|enable|expire|refresh-expiry|export|template
21
+ accounts import|list|stats|snapshot|disable|enable|expire|refresh-expiry|export|template|purge
22
22
  doctor
23
23
  artifacts latest|list|preview
24
24
  run
@@ -24,6 +24,34 @@ export function resolveAccountsDbPath(env = process.env) {
24
24
  return path.resolve(override);
25
25
  return path.join(resolveUserRuntimeRoot(env), 'accounts.db');
26
26
  }
27
+ /**
28
+ * How long a retired account's row, and its password, stay in the file.
29
+ *
30
+ * The default is a month: long enough that "where did that account go?" has an
31
+ * answer after a renewal cycle, short enough that a lapsed credential is not
32
+ * still sitting on disk a year later. `0` keeps retired rows forever, which is
33
+ * the behaviour before this existed.
34
+ */
35
+ export function retiredRetentionDays(env = process.env) {
36
+ const raw = String(env.AUTOVPN_ACCOUNT_RETIRED_RETENTION_DAYS ?? '').trim();
37
+ if (!raw)
38
+ return 30;
39
+ const parsed = Number.parseFloat(raw);
40
+ if (!Number.isFinite(parsed) || parsed < 0)
41
+ return 30;
42
+ return Math.trunc(parsed);
43
+ }
44
+ /**
45
+ * The cutoff row for a retention window, in the same UTC ISO spelling the
46
+ * ledger writes for `deleted_at`.
47
+ *
48
+ * Rows are compared as strings, so both sides must be truncated to whole
49
+ * seconds: a stamp of `2026-07-01T00:00:00Z` is shorter than a cutoff of
50
+ * `2026-07-01T00:00:00.000Z` and would read as "older than itself".
51
+ */
52
+ function retentionCutoff(now, days) {
53
+ return isoNow(new Date(now.getTime() - Math.trunc(days) * 86_400_000));
54
+ }
27
55
  /** Keep the first and last character; a 6-char account still reads as itself. */
28
56
  export function maskAccount(value) {
29
57
  const text = String(value ?? '');
@@ -69,6 +97,7 @@ CREATE TABLE IF NOT EXISTS accounts (
69
97
  UNIQUE (brand, account)
70
98
  );
71
99
  CREATE INDEX IF NOT EXISTS accounts_rotation ON accounts (brand, status, deleted_at, last_used_at);
100
+ CREATE INDEX IF NOT EXISTS accounts_retired ON accounts (deleted_at, status);
72
101
  CREATE TABLE IF NOT EXISTS pool_events (
73
102
  id INTEGER PRIMARY KEY AUTOINCREMENT,
74
103
  account_id INTEGER NOT NULL,
@@ -77,16 +106,22 @@ CREATE TABLE IF NOT EXISTS pool_events (
77
106
  detail TEXT NOT NULL DEFAULT ''
78
107
  );
79
108
  CREATE INDEX IF NOT EXISTS pool_events_account ON pool_events (account_id, id);
109
+ CREATE TABLE IF NOT EXISTS pool_meta (
110
+ key TEXT PRIMARY KEY,
111
+ value TEXT NOT NULL
112
+ );
80
113
  `;
81
114
  export class AccountStore {
82
115
  db;
83
116
  now;
84
117
  readOnly;
118
+ env;
85
119
  dbPath;
86
120
  constructor(options = {}) {
87
121
  this.dbPath = options.dbPath ?? resolveAccountsDbPath(options.env ?? process.env);
88
122
  this.now = options.now ?? (() => new Date());
89
123
  this.readOnly = options.readOnly === true;
124
+ this.env = options.env ?? process.env;
90
125
  const inMemory = this.dbPath === ':memory:';
91
126
  if (!inMemory) {
92
127
  // The pool holds plaintext credentials in the main file *and* in the WAL
@@ -129,6 +164,18 @@ export class AccountStore {
129
164
  .prepare('INSERT INTO pool_events (account_id, at, kind, detail) VALUES (?, ?, ?, ?)')
130
165
  .run(accountId, isoNow(this.now()), kind, detail);
131
166
  }
167
+ /**
168
+ * The housekeeping every read path owes the pool: lapse what is due, then
169
+ * erase what has been retired long enough.
170
+ *
171
+ * Both are cheap and both are idempotent, so a read is the natural trigger:
172
+ * there is no daemon to depend on, and whichever surface reads first does
173
+ * the work. `readOnly` connections skip both (see the constructor).
174
+ */
175
+ maintain() {
176
+ this.expireDue();
177
+ this.sweepRetired(retiredRetentionDays(this.env));
178
+ }
132
179
  /**
133
180
  * Retire accounts whose membership date has passed.
134
181
  *
@@ -154,6 +201,117 @@ export class AccountStore {
154
201
  }
155
202
  return due.length;
156
203
  }
204
+ /**
205
+ * Erase retired rows whose retirement is older than `days`.
206
+ *
207
+ * `expireDue` only flips a lapsed row to `expired` and soft-deletes it; the
208
+ * row and its password stay in the file forever, so a pool that has been
209
+ * rotated for a year carries every credential it ever held. This is the one
210
+ * place that removes them, and it removes the password with them.
211
+ *
212
+ * Only rows already retired by the sweep or by an operator qualify:
213
+ *
214
+ * - `expired` is erased, because its date is known and the pool already
215
+ * refuses to lease it. `invalid` is too: a rejected password is not coming
216
+ * back, and keeping the credential is the liability with no upside.
217
+ * - `disabled` is kept. It is an operator's deliberate pause, not a verdict,
218
+ * and re-enabling it is documented behaviour.
219
+ * - Rows with no `deleted_at` are never touched, whatever their status: that
220
+ * is the only column this sweep trusts to mean "already retired".
221
+ *
222
+ * `0` disables the sweep. The window is measured from the retirement stamp,
223
+ * so a row retired this morning survives a sweep run this afternoon however
224
+ * far in the past its membership date is.
225
+ */
226
+ purgeRetired(days) {
227
+ if (this.readOnly)
228
+ return 0;
229
+ if (!Number.isFinite(days) || days <= 0)
230
+ return 0;
231
+ const cutoff = retentionCutoff(this.now(), days);
232
+ const doomed = this.db
233
+ .prepare(`SELECT id FROM accounts
234
+ WHERE deleted_at IS NOT NULL AND deleted_at < ? AND status IN ('expired', 'invalid')`)
235
+ .all(cutoff);
236
+ if (doomed.length === 0)
237
+ return 0;
238
+ // One transaction: a half-purged pool would leave events pointing at rows
239
+ // that no longer exist, and the next sweep would have to reconcile both.
240
+ this.db.exec('BEGIN IMMEDIATE');
241
+ try {
242
+ const eraseEvents = this.db.prepare('DELETE FROM pool_events WHERE account_id = ?');
243
+ const eraseRow = this.db.prepare('DELETE FROM accounts WHERE id = ?');
244
+ for (const row of doomed) {
245
+ eraseEvents.run(row.id);
246
+ eraseRow.run(row.id);
247
+ }
248
+ this.db.exec('COMMIT');
249
+ }
250
+ catch (error) {
251
+ this.db.exec('ROLLBACK');
252
+ throw error;
253
+ }
254
+ return doomed.length;
255
+ }
256
+ /**
257
+ * Count what `purgeRetired` would erase, without erasing anything.
258
+ *
259
+ * `accounts purge --dry-run` must leave the file byte-identical, and every
260
+ * reader runs the retention sweep, so a preview cannot go through `stats()`.
261
+ *
262
+ * A lapse that no read has recorded yet is reported separately, not counted
263
+ * as purgeable: the sweep that retires it stamps `deleted_at` with the moment
264
+ * it runs, so that row's retention window starts then, not in the past.
265
+ */
266
+ countRetired(days) {
267
+ const pendingRow = this.db
268
+ .prepare(`SELECT COUNT(*) AS total FROM accounts
269
+ WHERE deleted_at IS NULL AND status = 'active' AND expires_at != '' AND expires_at < ?`)
270
+ .get(isoDay(this.now()));
271
+ const pendingLapse = Number(pendingRow?.total ?? 0);
272
+ if (!Number.isFinite(days) || days <= 0)
273
+ return { purgeable: 0, pendingLapse };
274
+ const cutoff = retentionCutoff(this.now(), days);
275
+ const row = this.db
276
+ .prepare(`SELECT COUNT(*) AS total FROM accounts
277
+ WHERE deleted_at IS NOT NULL AND deleted_at < ? AND status IN ('expired', 'invalid')`)
278
+ .get(cutoff);
279
+ return { purgeable: Number(row?.total ?? 0), pendingLapse };
280
+ }
281
+ /**
282
+ * Run `purgeRetired` at most once per retention window.
283
+ *
284
+ * Nothing here owns a long-lived process: `autovpn serve` starts and stops,
285
+ * the desktop app opens and closes, and a cron run is one CLI invocation. A
286
+ * timer in any one of them would clean the pool only while that surface
287
+ * happened to be open. Instead every store open runs the sweep, and a stamp
288
+ * in the database (`pool_meta`) makes the work a no-op until the window has
289
+ * passed. Whichever surface touches the pool first each day does the sweep;
290
+ * the rest see the stamp and skip it.
291
+ *
292
+ * Returns the number of erased rows, or 0 when the window had not elapsed.
293
+ */
294
+ sweepRetired(days, force = false) {
295
+ if (this.readOnly)
296
+ return 0;
297
+ if (!Number.isFinite(days) || days <= 0)
298
+ return 0;
299
+ const now = this.now();
300
+ if (!force) {
301
+ const last = this.db.prepare("SELECT value FROM pool_meta WHERE key = 'retired_purged_at'").get();
302
+ if (last?.value) {
303
+ const elapsed = now.getTime() - Date.parse(last.value);
304
+ if (Number.isFinite(elapsed) && elapsed >= 0 && elapsed < days * 86_400_000)
305
+ return 0;
306
+ }
307
+ }
308
+ // Stamp first: a sweep that throws midway must not retry on every open and
309
+ // hold the write lock each time. The next window will finish the job.
310
+ this.db
311
+ .prepare("INSERT INTO pool_meta (key, value) VALUES ('retired_purged_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
312
+ .run(isoNow(now));
313
+ return this.purgeRetired(days);
314
+ }
157
315
  /**
158
316
  * Insert or refresh rows from an import file.
159
317
  *
@@ -261,7 +419,7 @@ export class AccountStore {
261
419
  }
262
420
  /** Masked rows for the UI and `accounts list`. Never carries a password. */
263
421
  list(filter = {}) {
264
- this.expireDue();
422
+ this.maintain();
265
423
  const clauses = [];
266
424
  const params = [];
267
425
  if (!filter.includeDeleted)
@@ -288,8 +446,16 @@ export class AccountStore {
288
446
  .all(...params);
289
447
  return rows.map((row) => this.toPublic(row));
290
448
  }
291
- stats() {
292
- this.expireDue();
449
+ /**
450
+ * Totals for the UI and `accounts stats`.
451
+ *
452
+ * `maintain: false` is for `accounts purge --days 0`, which just promised to
453
+ * keep every retired row: the read that reports the result must not turn
454
+ * around and sweep with the default window.
455
+ */
456
+ stats({ maintain = true } = {}) {
457
+ if (maintain)
458
+ this.maintain();
293
459
  const rows = this.db
294
460
  .prepare('SELECT brand, status, deleted_at, expires_at FROM accounts')
295
461
  .all();
@@ -347,6 +513,10 @@ export class AccountStore {
347
513
  * never by a display path.
348
514
  */
349
515
  lease(brand) {
516
+ // Lapse-only, not `maintain()`: a lease is the hottest write path in the
517
+ // pool (once per login per account), and erasing retired rows there would
518
+ // take the write lock in the middle of a run for work that a plain read
519
+ // will do later. Expiring is cheap and decides whether this lease is legal.
350
520
  this.expireDue();
351
521
  const key = normalizeBrand(brand);
352
522
  if (!key)
@@ -367,6 +537,7 @@ export class AccountStore {
367
537
  }
368
538
  /** Every usable credential for a brand, for a run that wants to fan out. */
369
539
  leaseAll(brand) {
540
+ // Same reasoning as `lease`: expiry decides legality, the purge waits.
370
541
  this.expireDue();
371
542
  const key = normalizeBrand(brand);
372
543
  if (!key)