@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.
- 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/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
|
@@ -12,11 +12,29 @@ 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'];
|
|
15
|
+
export const ACCOUNTS_SUBCOMMANDS = ['import', 'list', 'stats', 'snapshot', 'disable', 'enable', 'expire', 'export', 'template', 'refresh-expiry'];
|
|
16
16
|
/** Options that consume the next argv slot, so positionals can be found. */
|
|
17
17
|
const VALUE_OPTIONS = new Set([
|
|
18
|
-
'--project-root', '--format', '--output', '--brand', '--status', '--reason', '--date', '--file', '--limit', '--query', '--id'
|
|
18
|
+
'--project-root', '--format', '--output', '--brand', '--status', '--reason', '--date', '--file', '--limit', '--query', '--id',
|
|
19
|
+
'--concurrency'
|
|
19
20
|
]);
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the pipeline worker lazily.
|
|
23
|
+
*
|
|
24
|
+
* The worker is owned by the pipeline module; a build that predates it (or a
|
|
25
|
+
* stripped install) must still let the rest of `accounts` work, so the command
|
|
26
|
+
* reports `refresh_expiry_unavailable` instead of failing at import time.
|
|
27
|
+
*/
|
|
28
|
+
async function loadRefreshAccountExpiry() {
|
|
29
|
+
try {
|
|
30
|
+
const module = await import('../pipeline/credential-source.js');
|
|
31
|
+
const worker = module.refreshAccountExpiry;
|
|
32
|
+
return typeof worker === 'function' ? worker : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
20
38
|
export function accountsPositionals(argv) {
|
|
21
39
|
const out = [];
|
|
22
40
|
for (let index = 1; index < argv.length; index += 1) {
|
|
@@ -63,6 +81,19 @@ export function validateAccountsCommand(argv) {
|
|
|
63
81
|
if (subcommand === 'import' && !positionals[0] && readOptionValue(argv, '--file') === undefined && !argv.includes('--stdin')) {
|
|
64
82
|
throw new CliUsageError('accounts import requires a file path or --stdin');
|
|
65
83
|
}
|
|
84
|
+
if (subcommand === 'refresh-expiry') {
|
|
85
|
+
for (const option of ['--limit', '--concurrency']) {
|
|
86
|
+
const value = readOptionValue(argv, option);
|
|
87
|
+
if (value === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
if (!/^\d+$/.test(value)) {
|
|
90
|
+
throw new CliUsageError(`accounts refresh-expiry ${option} must be a non-negative integer`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (Number(readOptionValue(argv, '--concurrency') ?? 3) < 1) {
|
|
94
|
+
throw new CliUsageError('accounts refresh-expiry --concurrency must be a positive integer');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
66
97
|
if (['disable', 'enable', 'expire'].includes(subcommand)) {
|
|
67
98
|
const id = readOptionValue(argv, '--id');
|
|
68
99
|
if (id !== undefined) {
|
|
@@ -82,24 +113,38 @@ function renderStatsHuman(stats) {
|
|
|
82
113
|
const lines = [
|
|
83
114
|
`account pool: ${stats.total} total, ${stats.active} active`,
|
|
84
115
|
` retired: ${stats.disabled} disabled, ${stats.expired} expired, ${stats.invalid} invalid`,
|
|
85
|
-
` expiring within 7 days: ${stats.expiring_soon}
|
|
116
|
+
` expiring within 7 days: ${stats.expiring_soon}`,
|
|
117
|
+
` expiry unknown: ${stats.expiry_unknown}`
|
|
86
118
|
];
|
|
87
119
|
for (const brand of stats.by_brand) {
|
|
88
120
|
lines.push(` ${brandLabel(brand.brand).padEnd(8)} total=${String(brand.total).padStart(3)} active=${String(brand.active).padStart(3)}` +
|
|
89
|
-
` disabled=${brand.disabled} expired=${brand.expired} invalid=${brand.invalid}`);
|
|
121
|
+
` disabled=${brand.disabled} expired=${brand.expired} invalid=${brand.invalid} unknown=${brand.expiry_unknown}`);
|
|
90
122
|
}
|
|
91
123
|
lines.push(` db: ${stats.db_path}`);
|
|
92
124
|
return `${lines.join('\n')}\n`;
|
|
93
125
|
}
|
|
94
|
-
function renderListHuman(rows) {
|
|
126
|
+
function renderListHuman(rows, hiddenRetired = 0) {
|
|
127
|
+
const footer = hiddenRetired > 0 ? `${hiddenRetired} retired row(s) hidden; use --all to show them\n` : '';
|
|
95
128
|
if (rows.length === 0)
|
|
96
|
-
return 'account pool: no matching accounts\n';
|
|
129
|
+
return footer || 'account pool: no matching accounts\n';
|
|
97
130
|
const lines = ['brand account status expires uses ok/fail note'];
|
|
98
131
|
for (const row of rows) {
|
|
99
132
|
lines.push(`${row.brand.padEnd(9)} ${row.account_masked.padEnd(14)} ${row.status.padEnd(9)} ` +
|
|
100
133
|
`${(row.expires_at || '-').padEnd(11)} ${String(row.use_count).padStart(4)} ` +
|
|
101
134
|
`${row.success_count}/${row.fail_count}`.padEnd(8) + ` ${row.status_reason || row.note}`);
|
|
102
135
|
}
|
|
136
|
+
// A sweep that retires an expired row hides it from the default view without
|
|
137
|
+
// a trace, which reads as "the pool lost accounts". Say how many are hidden
|
|
138
|
+
// and how to see them. English, like the rest of this table and the command's
|
|
139
|
+
// operator messages.
|
|
140
|
+
return `${lines.join('\n')}\n${footer}`;
|
|
141
|
+
}
|
|
142
|
+
function renderRefreshLine(event) {
|
|
143
|
+
return `${event.brand.padEnd(9)} ${event.account_masked.padEnd(14)} ${(event.expires_at || '-').padEnd(11)} ${event.status}${event.error ? ` ${event.error}` : ''}`;
|
|
144
|
+
}
|
|
145
|
+
function renderRefreshHuman(rows, result) {
|
|
146
|
+
const lines = rows.map(renderRefreshLine);
|
|
147
|
+
lines.push(`checked=${result.checked} updated=${result.updated} expired=${result.expired} failed=${result.failed}`);
|
|
103
148
|
return `${lines.join('\n')}\n`;
|
|
104
149
|
}
|
|
105
150
|
export function accountsTemplate() {
|
|
@@ -114,6 +159,58 @@ export function accountsTemplate() {
|
|
|
114
159
|
}));
|
|
115
160
|
return stringifyCsv(example);
|
|
116
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* `accounts refresh-expiry`: log in to pool accounts to learn their real
|
|
164
|
+
* membership date.
|
|
165
|
+
*
|
|
166
|
+
* The worker (brand-specific login, membership-date parsing, retries) lives in
|
|
167
|
+
* the pipeline; this function parses the flags, supplies the store and renders
|
|
168
|
+
* the result. Without the worker — an install whose dist predates it — the
|
|
169
|
+
* command refuses instead of pretending it checked anything.
|
|
170
|
+
*/
|
|
171
|
+
async function runRefreshExpiry(argv, context) {
|
|
172
|
+
const worker = context.refreshExpiry === null
|
|
173
|
+
? null
|
|
174
|
+
: context.refreshExpiry ?? await loadRefreshAccountExpiry();
|
|
175
|
+
if (!worker) {
|
|
176
|
+
context.io.writeStdout(jsonLine({ ok: false, error: 'refresh_expiry_unavailable' }));
|
|
177
|
+
context.io.writeStderr('accounts refresh-expiry: the installed build has no expiry refresh worker\n');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
const limit = Number(readOptionValue(argv, '--limit') ?? 0);
|
|
181
|
+
const concurrency = Number(readOptionValue(argv, '--concurrency') ?? 3);
|
|
182
|
+
const store = context.store ?? new AccountStore({ env: context.env, now: context.now });
|
|
183
|
+
const ownsStore = !context.store;
|
|
184
|
+
try {
|
|
185
|
+
let streamed = 0;
|
|
186
|
+
const result = await worker({
|
|
187
|
+
brand: readOptionValue(argv, '--brand'),
|
|
188
|
+
limit: Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 0,
|
|
189
|
+
concurrency: Number.isFinite(concurrency) ? Math.max(1, Math.trunc(concurrency)) : 3,
|
|
190
|
+
env: context.env,
|
|
191
|
+
store,
|
|
192
|
+
now: context.now,
|
|
193
|
+
// A refresh logs in to every account, which takes minutes per brand; the
|
|
194
|
+
// human view prints each outcome as it lands instead of going silent.
|
|
195
|
+
onProgress: wantsHuman(argv)
|
|
196
|
+
? (event) => {
|
|
197
|
+
context.io.writeStdout(`${renderRefreshLine(event)}\n`);
|
|
198
|
+
streamed += 1;
|
|
199
|
+
}
|
|
200
|
+
: undefined
|
|
201
|
+
});
|
|
202
|
+
// Any result the worker did not stream (or a worker version that never
|
|
203
|
+
// calls onProgress) is still printed once, so human and JSON output cover
|
|
204
|
+
// exactly the same accounts.
|
|
205
|
+
const remaining = result.results.slice(streamed);
|
|
206
|
+
context.io.writeStdout(wantsHuman(argv) ? renderRefreshHuman(remaining, result) : jsonLine(result));
|
|
207
|
+
return result.failed > 0 ? 1 : 0;
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (ownsStore)
|
|
211
|
+
store.close();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
117
214
|
export async function runAccountsCommand(argv, context) {
|
|
118
215
|
const subcommand = argv[1];
|
|
119
216
|
// The template is a static document: it must work before the pool exists and
|
|
@@ -122,6 +219,9 @@ export async function runAccountsCommand(argv, context) {
|
|
|
122
219
|
context.io.writeStdout(accountsTemplate());
|
|
123
220
|
return 0;
|
|
124
221
|
}
|
|
222
|
+
if (subcommand === 'refresh-expiry') {
|
|
223
|
+
return await runRefreshExpiry(argv, context);
|
|
224
|
+
}
|
|
125
225
|
const dryRun = subcommand === 'import' && argv.includes('--dry-run');
|
|
126
226
|
// A preview must not write: the store's getters sweep expired rows, so even
|
|
127
227
|
// opening it read-write would mutate the pool before showing the plan.
|
|
@@ -164,7 +264,23 @@ export async function runAccountsCommand(argv, context) {
|
|
|
164
264
|
query: readOptionValue(argv, '--query'),
|
|
165
265
|
limit
|
|
166
266
|
});
|
|
167
|
-
|
|
267
|
+
if (wantsHuman(argv)) {
|
|
268
|
+
// The default view hides every soft-deleted row, so an expiry retirement
|
|
269
|
+
// would look like an account that vanished. Count what the filter hid;
|
|
270
|
+
// `--all` and a retired-status filter already show those rows.
|
|
271
|
+
const hiddenRetired = includeDeleted
|
|
272
|
+
? 0
|
|
273
|
+
: store.list({
|
|
274
|
+
brand: readOptionValue(argv, '--brand'),
|
|
275
|
+
status: 'all',
|
|
276
|
+
includeDeleted: true,
|
|
277
|
+
query: readOptionValue(argv, '--query')
|
|
278
|
+
}).filter((row) => row.deleted).length;
|
|
279
|
+
context.io.writeStdout(renderListHuman(rows, hiddenRetired));
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
context.io.writeStdout(jsonLine({ ok: true, count: rows.length, accounts: rows }));
|
|
283
|
+
}
|
|
168
284
|
return 0;
|
|
169
285
|
}
|
|
170
286
|
if (subcommand === 'import') {
|
package/dist/cli/output.js
CHANGED
|
@@ -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|export|template
|
|
21
|
+
accounts import|list|stats|snapshot|disable|enable|expire|refresh-expiry|export|template
|
|
22
22
|
doctor
|
|
23
23
|
artifacts latest|list|preview
|
|
24
24
|
run
|
package/dist/config/profile.js
CHANGED
|
@@ -87,19 +87,21 @@ export function normalizeOfficialSources(sources) {
|
|
|
87
87
|
mode: 'credential',
|
|
88
88
|
official_site: String(config.official_site ?? '').trim() || brandSite(brand),
|
|
89
89
|
enabled: config.enabled !== false,
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// A round is now one cursor step over the fixed area list (one region by
|
|
91
|
+
// default), not a full sweep of every region. 200 / 50 preserves roughly
|
|
92
|
+
// the work the old 4 full sweeps did and matches the early-stop rule:
|
|
93
|
+
// 50 consecutive rounds with no genuinely new node.
|
|
94
|
+
max_iterations: wasCredential ? positiveInteger(config.max_iterations, 200) : 200,
|
|
95
|
+
plateau_limit: wasCredential ? positiveInteger(config.plateau_limit, 50) : 50,
|
|
96
|
+
areas_per_round: Math.min(64, positiveInteger(config.areas_per_round, 8)),
|
|
97
|
+
area_concurrency: Math.min(16, positiveInteger(config.area_concurrency, 4))
|
|
94
98
|
};
|
|
95
99
|
if (!wasCredential) {
|
|
96
100
|
// A legacy URL row's caps describe a different operation; start the
|
|
97
|
-
//
|
|
98
|
-
// 20 dry rounds meant 20 URL fetches; here each round is one request per
|
|
99
|
-
// route per platform, so the stored value would idle for minutes. A floor
|
|
101
|
+
// cursor flow at the documented scheduled-run default instead. A floor
|
|
100
102
|
// carried over from URL mode is the same mismatch and is dropped with it.
|
|
101
|
-
normalized.max_iterations =
|
|
102
|
-
normalized.plateau_limit =
|
|
103
|
+
normalized.max_iterations = 200;
|
|
104
|
+
normalized.plateau_limit = 50;
|
|
103
105
|
delete normalized.min_iterations;
|
|
104
106
|
}
|
|
105
107
|
for (const field of OBSOLETE_SOURCE_FIELDS)
|
|
@@ -111,6 +113,33 @@ export function normalizeOfficialSources(sources) {
|
|
|
111
113
|
function state(value) {
|
|
112
114
|
return String(value ?? '').trim() ? 'set' : 'missing';
|
|
113
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Crawl egress defaults, surfaced so the settings drawer can render them.
|
|
118
|
+
*
|
|
119
|
+
* `proxy` is the product default: the crawl goes through the upstream proxy and
|
|
120
|
+
* drops to direct only when the proxy is not listening. `auto` starts direct
|
|
121
|
+
* and pins the proxy on the first blocked response; `direct` never proxies.
|
|
122
|
+
* The pipeline reads the same values from the environment, which the
|
|
123
|
+
* orchestrator and the Electron backend env build from this section.
|
|
124
|
+
*/
|
|
125
|
+
export function normalizeNetworkSection(value) {
|
|
126
|
+
const raw = (value && typeof value === 'object' ? value : {});
|
|
127
|
+
const mode = String(raw.proxy_mode ?? 'proxy').trim().toLowerCase();
|
|
128
|
+
return {
|
|
129
|
+
...raw,
|
|
130
|
+
proxy_mode: ['proxy', 'direct', 'auto'].includes(mode) ? mode : 'proxy',
|
|
131
|
+
proxy_url: String(raw.proxy_url ?? 'http://127.0.0.1:7897').trim() || 'http://127.0.0.1:7897'
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Map the profile's [network] section onto the env the pipeline reads. */
|
|
135
|
+
export function networkEnv(section, base = {}) {
|
|
136
|
+
const network = normalizeNetworkSection(section);
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
VPN_AUTOMATION_PROXY_MODE: network.proxy_mode,
|
|
140
|
+
VPN_AUTOMATION_UPSTREAM_PROXY: network.proxy_url
|
|
141
|
+
};
|
|
142
|
+
}
|
|
114
143
|
function readProfile(profilePath) {
|
|
115
144
|
if (!fs.existsSync(profilePath)) {
|
|
116
145
|
return {};
|
|
@@ -178,6 +207,7 @@ export function profilePayload(projectRoot, env = process.env) {
|
|
|
178
207
|
return {
|
|
179
208
|
...raw,
|
|
180
209
|
sources: normalizeOfficialSources((raw.sources ?? {})),
|
|
210
|
+
network: normalizeNetworkSection(raw.network),
|
|
181
211
|
paths,
|
|
182
212
|
workspace: paths
|
|
183
213
|
};
|
|
@@ -189,6 +219,7 @@ export function saveProfilePayload(projectRoot, payload, env = process.env) {
|
|
|
189
219
|
delete persisted.paths;
|
|
190
220
|
delete persisted.workspace;
|
|
191
221
|
persisted.sources = normalizeOfficialSources((persisted.sources ?? {}));
|
|
222
|
+
persisted.network = normalizeNetworkSection(persisted.network);
|
|
192
223
|
fs.writeFileSync(profilePath, stringify(persisted), 'utf8');
|
|
193
224
|
return profilePayload(projectRoot, env);
|
|
194
225
|
}
|