@swimmingliu/autovpn 1.8.0 → 2.0.2
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/artifacts/list.js +48 -6
- package/dist/cli/accounts-commands.js +304 -0
- package/dist/cli/commands/index.js +6 -0
- package/dist/cli/native-commands.js +10 -0
- package/dist/cli/output.js +1 -0
- package/dist/config/profile.js +119 -5
- package/dist/events/schema.js +2 -0
- package/dist/pipeline/apk-discovery.js +504 -0
- package/dist/pipeline/brand-cache.js +126 -0
- package/dist/pipeline/brand-protocol.js +210 -0
- package/dist/pipeline/credential-source.js +1406 -0
- package/dist/pipeline/extract.js +55 -2
- package/dist/pipeline/orchestrator.js +88 -9
- package/dist/pool/account-store.js +484 -0
- package/dist/pool/brands.js +110 -0
- package/dist/pool/csv.js +388 -0
- package/dist/server/http.js +25 -0
- package/dist/server/runtime.js +76 -2
- package/dist/server/web-adapter.js +18 -2
- package/dist/web/renderer/THIRD_PARTY_NOTICES.txt +345 -0
- package/dist/web/renderer/analytics.bundle.css +2 -0
- package/dist/web/renderer/analytics.bundle.js +66 -0
- package/dist/web/renderer/app.js +385 -80
- package/dist/web/renderer/appearance.js +76 -0
- package/dist/web/renderer/assets/vpn-auto-logo-v3.png +0 -0
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +38 -0
- package/dist/web/renderer/index.html +24 -2
- package/dist/web/renderer/state.js +0 -1
- package/dist/web/renderer/styles.css +584 -48
- package/dist/web/renderer/tables.js +208 -0
- package/dist/web/renderer/views.js +483 -255
- package/dist/web/renderer/workspace.css +381 -0
- package/package.json +4 -3
package/dist/pipeline/extract.js
CHANGED
|
@@ -42,7 +42,19 @@ function generateVmessLink(payload) {
|
|
|
42
42
|
const encoded = Buffer.from(json, 'utf8').toString('base64').replaceAll('+', '-').replaceAll('/', '_');
|
|
43
43
|
return `vmess://${encoded}`;
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Exact-string identity for a link.
|
|
47
|
+
*
|
|
48
|
+
* `linkFingerprint` collapses links that describe the same node, which is what
|
|
49
|
+
* the dedupe card counts. Raw link totals have to use the same `seen` identity
|
|
50
|
+
* the pipeline itself uses, or two platforms that label one node differently
|
|
51
|
+
* would make the live raw count disagree with the final result. A digest keeps
|
|
52
|
+
* the link out of the event stream.
|
|
53
|
+
*/
|
|
54
|
+
export function linkDigest(link) {
|
|
55
|
+
return crypto.createHash('sha256').update(String(link)).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
export function linkFingerprint(link) {
|
|
46
58
|
try {
|
|
47
59
|
const encoded = String(link).replace(/^vmess:\/\//, '');
|
|
48
60
|
const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
|
|
@@ -350,5 +362,46 @@ async function fetchSourceLinksInNode(input, options) {
|
|
|
350
362
|
};
|
|
351
363
|
}
|
|
352
364
|
export async function fetchSourceLinksWithBackend(input, options = {}) {
|
|
353
|
-
|
|
365
|
+
if (options.fetchSourceLinks) {
|
|
366
|
+
return options.fetchSourceLinks(input);
|
|
367
|
+
}
|
|
368
|
+
if (isCredentialSource(input.source)) {
|
|
369
|
+
const { fetchCredentialSourceLinks } = await import('./credential-source.js');
|
|
370
|
+
const result = await fetchCredentialSourceLinks(input, {
|
|
371
|
+
eventCallback: options.eventCallback,
|
|
372
|
+
linksCallback: options.linksCallback,
|
|
373
|
+
fingerprintStore: options.fingerprintStore,
|
|
374
|
+
memo: options.fingerprintMemo,
|
|
375
|
+
// The credential flow talks to the same hosts as the url flow, so it
|
|
376
|
+
// needs the same egress handling: an injected fetch for tests, the
|
|
377
|
+
// configured upstream proxy, and the curl TLS fallback.
|
|
378
|
+
fetchImpl: options.fetch,
|
|
379
|
+
env: options.env,
|
|
380
|
+
curlFetch: options.curlFetch
|
|
381
|
+
});
|
|
382
|
+
return {
|
|
383
|
+
source_name: result.source_name,
|
|
384
|
+
requested_iterations: result.requested_iterations,
|
|
385
|
+
successful_iterations: result.successful_iterations,
|
|
386
|
+
failed_iterations: result.failed_iterations,
|
|
387
|
+
links: result.links,
|
|
388
|
+
errors: result.diagnostics.errors
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return fetchSourceLinksInNode(input, options);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* A credential source is identified by its mode or by carrying an account
|
|
395
|
+
* instead of a request URL; this keeps older profiles working unchanged.
|
|
396
|
+
*/
|
|
397
|
+
export function isCredentialSource(source) {
|
|
398
|
+
if (source.mode === 'credential') {
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
if (source.mode === 'url') {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
const hasAccount = Boolean(String(source.account ?? '').trim());
|
|
405
|
+
const hasUrl = Boolean(String(source.url ?? '').trim());
|
|
406
|
+
return hasAccount && !hasUrl;
|
|
354
407
|
}
|
|
@@ -2,8 +2,9 @@ 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
6
|
import { mergeProjectEnv } from '../runtime/env.js';
|
|
6
|
-
import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
|
|
7
|
+
import { resolveArtifactsRoot, resolveProfilePath, resolveUserRuntimeRoot } from '../runtime/paths.js';
|
|
7
8
|
import { redactText } from '../runtime/redaction.js';
|
|
8
9
|
import { resolveWorkerTemplatePath } from '../runtime/templates.js';
|
|
9
10
|
import { fetchSourceLinksWithBackend } from './extract.js';
|
|
@@ -18,6 +19,8 @@ import { safeDeployment } from '../runtime/redaction.js';
|
|
|
18
19
|
import { RunStore, readLatestStageStatuses } from './run-store.js';
|
|
19
20
|
import { AsyncPermitPool, BoundedWorkerPool } from './streaming-coordinator.js';
|
|
20
21
|
import { createGeoIpLookup } from './geoip.js';
|
|
22
|
+
import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
|
|
23
|
+
import { createFingerprintMemo, credentialBrandKey } from './credential-source.js';
|
|
21
24
|
const STAGES = ['doctor', 'extract', 'dedupe', 'speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
|
|
22
25
|
const RETRYABLE_STAGES = ['speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
|
|
23
26
|
function formatTimestamp(date) {
|
|
@@ -45,12 +48,23 @@ function asProfile(payload) {
|
|
|
45
48
|
async function readProfile(projectRoot, env) {
|
|
46
49
|
const profilePath = resolveProfilePath(projectRoot, env);
|
|
47
50
|
const text = await readFile(profilePath, 'utf8');
|
|
48
|
-
|
|
51
|
+
const profile = asProfile(parse(text));
|
|
52
|
+
return { ...profile, sources: normalizeOfficialSources(profile.sources ?? {}) };
|
|
49
53
|
}
|
|
50
54
|
function enabledSources(profile) {
|
|
51
|
-
return Object.entries(profile.sources ?? {}).filter(([, source]) =>
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
return Object.entries(profile.sources ?? {}).filter(([, source]) => {
|
|
56
|
+
if (source.enabled === false) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Credential sources need only a location. The account is leased from the
|
|
60
|
+
// pool at extract time; an empty pool is a reported source failure, not a
|
|
61
|
+
// silently disabled source.
|
|
62
|
+
if (source.mode === 'credential') {
|
|
63
|
+
return [source.official_site, source.apk_url, source.apk_path]
|
|
64
|
+
.some((value) => String(value ?? '').trim().length > 0);
|
|
65
|
+
}
|
|
66
|
+
return Boolean(String(source.url ?? '').trim()) && Boolean(String(source.key ?? '').trim());
|
|
67
|
+
});
|
|
54
68
|
}
|
|
55
69
|
function linesText(lines) {
|
|
56
70
|
return lines.length > 0 ? `${lines.join('\n')}\n` : '';
|
|
@@ -136,6 +150,9 @@ function renderHumanEvent(event) {
|
|
|
136
150
|
if (event.type === 'run_failed') {
|
|
137
151
|
return `[run_failed] ${String(event.error ?? 'unknown error')}`;
|
|
138
152
|
}
|
|
153
|
+
if (event.type === 'extract_platform_started' || event.type === 'extract_platform_completed') {
|
|
154
|
+
return `[${event.type}] ${String(event.source_name ?? '')} platform=${String(event.platform ?? '')}`;
|
|
155
|
+
}
|
|
139
156
|
return JSON.stringify(event);
|
|
140
157
|
}
|
|
141
158
|
function eventLogLine(event) {
|
|
@@ -443,6 +460,24 @@ export async function runNodePipeline(options, context = {}) {
|
|
|
443
460
|
await setStage('doctor', 'success');
|
|
444
461
|
const profile = await readProfile(projectRoot, env);
|
|
445
462
|
const sourcesToRun = enabledSources(profile);
|
|
463
|
+
// Brand fingerprints are shared by every source of the same brand so one
|
|
464
|
+
// run downloads and inspects each APK at most once.
|
|
465
|
+
const fingerprintStore = new FingerprintStore({
|
|
466
|
+
cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
|
|
467
|
+
now: context.now ?? (() => new Date())
|
|
468
|
+
});
|
|
469
|
+
// Keyed by brand, not by profile entry name: two sources of one brand must
|
|
470
|
+
// share both the in-run memo and the on-disk fingerprint.
|
|
471
|
+
const brandKeyFor = (sourceName, source) => (source.mode === 'credential' || String(source.account ?? '').trim() ? credentialBrandKey(sourceName, source) : sourceName);
|
|
472
|
+
const fingerprintMemos = new Map();
|
|
473
|
+
const fingerprintMemoFor = (brand) => {
|
|
474
|
+
let memo = fingerprintMemos.get(brand);
|
|
475
|
+
if (!memo) {
|
|
476
|
+
memo = createFingerprintMemo(fingerprintStore, brand);
|
|
477
|
+
fingerprintMemos.set(brand, memo);
|
|
478
|
+
}
|
|
479
|
+
return memo;
|
|
480
|
+
};
|
|
446
481
|
const runtimePath = path.join(artifactDir, 'runtime');
|
|
447
482
|
const speedConfig = normalizeSpeedTestConfig(profile.speed_test);
|
|
448
483
|
// Preserve cross-stage streaming even for concurrency=1 while preventing
|
|
@@ -576,7 +611,9 @@ export async function runNodePipeline(options, context = {}) {
|
|
|
576
611
|
cwd: projectRoot,
|
|
577
612
|
env: runtimeStageEnv,
|
|
578
613
|
eventCallback: (type, payload) => emit(type, payload),
|
|
579
|
-
linksCallback: stream?.onLinks
|
|
614
|
+
linksCallback: stream?.onLinks,
|
|
615
|
+
fingerprintStore,
|
|
616
|
+
fingerprintMemo: fingerprintMemoFor(brandKeyFor(sourceName, source))
|
|
580
617
|
});
|
|
581
618
|
}
|
|
582
619
|
catch (error) {
|
|
@@ -594,10 +631,20 @@ export async function runNodePipeline(options, context = {}) {
|
|
|
594
631
|
successful_iterations: result.successful_iterations,
|
|
595
632
|
failed_iterations: result.failed_iterations
|
|
596
633
|
};
|
|
634
|
+
// A credential source reports its own failures (rejected login, APK not
|
|
635
|
+
// located) as zero links rather than an exception. Recording that as
|
|
636
|
+
// `success` hides the reason, so the source is marked failed and the
|
|
637
|
+
// text is emitted where an operator can read it.
|
|
638
|
+
const sourceErrors = (result.errors ?? []).filter((line) => String(line ?? '').trim());
|
|
639
|
+
const sourceFailed = result.links.length === 0 && sourceErrors.length > 0;
|
|
640
|
+
if (sourceFailed) {
|
|
641
|
+
emit('log', { message: `[extract] ${result.source_name} produced no links: ${sourceErrors.join('; ')}` });
|
|
642
|
+
}
|
|
597
643
|
runStore.recordSourceProgress(sourceName, {
|
|
598
644
|
processed: result.requested_iterations,
|
|
599
645
|
total: result.requested_iterations,
|
|
600
|
-
status: 'success'
|
|
646
|
+
status: sourceFailed ? 'failed' : 'success',
|
|
647
|
+
...(sourceFailed ? { error: sourceErrors.join('; ') } : {})
|
|
601
648
|
});
|
|
602
649
|
return result;
|
|
603
650
|
}));
|
|
@@ -1261,6 +1308,22 @@ export async function resumeNodePipeline(options, context = {}) {
|
|
|
1261
1308
|
const report = await readJson(path.join(artifactDir, 'pipeline_report.json'), {});
|
|
1262
1309
|
const summary = pipelineSummaryFromReport(artifactDir, report);
|
|
1263
1310
|
const runtimePath = path.join(artifactDir, 'runtime');
|
|
1311
|
+
// A resumed run must reuse the fingerprint cache too: re-extracting a
|
|
1312
|
+
// credential source would otherwise re-download and re-parse the full APK.
|
|
1313
|
+
const resumeFingerprintStore = new FingerprintStore({
|
|
1314
|
+
cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
|
|
1315
|
+
now: context.now ?? (() => new Date())
|
|
1316
|
+
});
|
|
1317
|
+
const resumeMemos = new Map();
|
|
1318
|
+
const resumeMemoFor = (brand) => {
|
|
1319
|
+
let memo = resumeMemos.get(brand);
|
|
1320
|
+
if (!memo) {
|
|
1321
|
+
memo = createFingerprintMemo(resumeFingerprintStore, brand);
|
|
1322
|
+
resumeMemos.set(brand, memo);
|
|
1323
|
+
}
|
|
1324
|
+
return memo;
|
|
1325
|
+
};
|
|
1326
|
+
const resumeBrandKeyFor = (sourceName, source) => (source.mode === 'credential' || String(source.account ?? '').trim() ? credentialBrandKey(sourceName, source) : sourceName);
|
|
1264
1327
|
const emit = (type, payload = {}) => {
|
|
1265
1328
|
const event = { type, ...payload };
|
|
1266
1329
|
appendTextFile(eventLog, eventLogLine(event));
|
|
@@ -1375,9 +1438,25 @@ export async function resumeNodePipeline(options, context = {}) {
|
|
|
1375
1438
|
try {
|
|
1376
1439
|
const result = context.stages?.extract
|
|
1377
1440
|
? await context.stages.extract({ source_name: sourceName, source }, { onLinks })
|
|
1378
|
-
: await fetchSourceLinksWithBackend({ source_name: sourceName, source }, {
|
|
1441
|
+
: await fetchSourceLinksWithBackend({ source_name: sourceName, source }, {
|
|
1442
|
+
cwd: projectRoot,
|
|
1443
|
+
env: runtimeStageEnv,
|
|
1444
|
+
linksCallback: onLinks,
|
|
1445
|
+
fingerprintStore: resumeFingerprintStore,
|
|
1446
|
+
fingerprintMemo: resumeMemoFor(resumeBrandKeyFor(sourceName, source))
|
|
1447
|
+
});
|
|
1379
1448
|
await onLinks(result.links.filter((link) => !streamed.has(link)));
|
|
1380
|
-
|
|
1449
|
+
const resumeErrors = (result.errors ?? []).filter((line) => String(line ?? '').trim());
|
|
1450
|
+
const resumeFailed = result.links.length === 0 && resumeErrors.length > 0;
|
|
1451
|
+
if (resumeFailed) {
|
|
1452
|
+
emit('log', { message: `[extract] ${result.source_name} produced no links: ${resumeErrors.join('; ')}` });
|
|
1453
|
+
}
|
|
1454
|
+
runStore.recordSourceProgress(sourceName, {
|
|
1455
|
+
processed: result.requested_iterations,
|
|
1456
|
+
total: result.requested_iterations,
|
|
1457
|
+
status: resumeFailed ? 'failed' : 'success',
|
|
1458
|
+
...(resumeFailed ? { error: resumeErrors.join('; ') } : {})
|
|
1459
|
+
});
|
|
1381
1460
|
summary.source_counts[sourceName] = { raw_links: result.links.length, successful_iterations: result.successful_iterations, failed_iterations: result.failed_iterations };
|
|
1382
1461
|
}
|
|
1383
1462
|
catch (error) {
|