@swimmingliu/autovpn 2.1.2 → 2.2.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.
@@ -57,7 +57,11 @@ const OFFICIAL_SOURCE_KEYS = new Set(['leiting', 'heidong', 'mifeng', 'xuanfeng'
57
57
  const OBSOLETE_SOURCE_FIELDS = [
58
58
  'url', 'key', 'account', 'password', 'platform',
59
59
  'use_random_area', 'area_min', 'area_max',
60
- 'resume_from_iteration', 'failure_limit'
60
+ 'resume_from_iteration', 'failure_limit',
61
+ // Internal: the extractor sets this when `backend_brand` redirects the data
62
+ // plane. It is derived state, so a hand-edited profile must not persist it —
63
+ // it would outlive the redirect that produced it.
64
+ 'brand_override'
61
65
  ];
62
66
  function positiveInteger(value, fallback) {
63
67
  const parsed = Number(value);
@@ -95,6 +99,18 @@ export function normalizeOfficialSources(sources) {
95
99
  areas_per_round: Math.min(64, positiveInteger(config.areas_per_round, 8)),
96
100
  area_concurrency: Math.min(16, positiveInteger(config.area_concurrency, 4))
97
101
  };
102
+ // Only a known vendor can serve as a data plane; an unknown name would
103
+ // silently fall back to the row's own brand and produce the very node
104
+ // shortage the field exists to fix.
105
+ if (config.backend_brand !== undefined) {
106
+ const backend = normalizeBrand(String(config.backend_brand).trim());
107
+ if (backend && backend !== brand) {
108
+ normalized.backend_brand = backend;
109
+ }
110
+ else {
111
+ delete normalized.backend_brand;
112
+ }
113
+ }
98
114
  if (!wasCredential) {
99
115
  // A legacy URL row's caps describe a different operation; start the
100
116
  // cursor flow at the documented scheduled-run default instead. A floor
@@ -9,6 +9,23 @@ import { BRANDS, brandFromSite, brandSite, normalizeBrand } from '../pool/brands
9
9
  import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
10
10
  import { resolveUserRuntimeRoot } from '../runtime/paths.js';
11
11
  import { redactText } from '../runtime/redaction.js';
12
+ /**
13
+ * How many pooled accounts one source may try before it reports failure.
14
+ *
15
+ * A redirected row leases from a foreign vendor's list, and only a minority of
16
+ * those accounts exist on the backend: measured against the deployed pool, 2 of
17
+ * 40 黑洞 accounts log in on the 雷霆 backend. Each rejected attempt retires that
18
+ * account and leases the next by least-recent use, so the width decides whether
19
+ * the run ever reaches a usable credential. A row without a redirect keeps the
20
+ * tighter default.
21
+ */
22
+ export function credentialAccountAttempts(source, brand) {
23
+ const backend = normalizeBrand(String(source.backend_brand ?? '').trim());
24
+ const redirected = Boolean(backend && brand && backend !== brand);
25
+ const fallback = redirected ? 24 : 3;
26
+ const parsed = Math.trunc(Number(source.account_retry_limit ?? fallback));
27
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback;
28
+ }
12
29
  /**
13
30
  * Each brand concatenates `getCommParams()` in its own order with its own
14
31
  * literal block. The layout can be overridden from the profile, and is picked
@@ -28,7 +45,9 @@ export function resolveBrandLayout(source) {
28
45
  return { afterPackage: explicit.startsWith('&') ? explicit : `&${explicit}` };
29
46
  }
30
47
  }
31
- const brand = String(source.brand ?? '').trim();
48
+ // A redirected data plane speaks the backend's parameter order, not the
49
+ // storefront's: the backend decides which flag block it recognises.
50
+ const brand = String(source.brand_override ?? source.brand ?? '').trim();
32
51
  if (brand.includes('旋风') || /xuanfeng/i.test(brand)) {
33
52
  return { vipFirst: true, viewport: true };
34
53
  }
@@ -47,7 +66,7 @@ export function resolveBrandLayout(source) {
47
66
  * downloader uses a Chinese product name, so both are mapped. The official
48
67
  * host is the last resort when a profile uses a custom source name.
49
68
  */
50
- const BRAND_APK_PREFIXES = {
69
+ export const BRAND_APK_PREFIXES = {
51
70
  雷霆: 'lt',
52
71
  旋风: 'xf',
53
72
  黑洞: 'hd',
@@ -490,6 +509,16 @@ function declaredSizeTooLarge(response) {
490
509
  const length = Number(raw);
491
510
  return Number.isFinite(length) && length > MAX_APK_BYTES;
492
511
  }
512
+ /**
513
+ * Test seam: `defaultLoadApk` with the brand name spelled out.
514
+ *
515
+ * The archive prefix is chosen from this name, and a redirected row must pass
516
+ * its backend's name rather than the row's. Exported so a test can prove which
517
+ * name reaches the loader without standing up a whole extraction.
518
+ */
519
+ export async function loadApkWithBrand(source, fetchImpl, timeoutMs, now, log, brandName, egress) {
520
+ return defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName, egress);
521
+ }
493
522
  async function defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName = '', egress) {
494
523
  if (source.apk_path) {
495
524
  try {
@@ -805,7 +834,9 @@ export async function fetchCredentialSourceLinks(input, options = {}) {
805
834
  }
806
835
  }
807
836
  async function fetchCredentialSourceLinksWithTransport(input, options) {
808
- const source = input.source;
837
+ // A copy: a redirected data plane rewrites the site and the layout marker,
838
+ // and the caller's config object must not carry that into later stages.
839
+ const source = { ...input.source };
809
840
  const platforms = resolveCredentialPlatforms(source);
810
841
  const policy = resolveRoundPolicy(source);
811
842
  const errors = [];
@@ -845,6 +876,29 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
845
876
  const brand = normalizeBrand(input.source_name)
846
877
  ?? brandFromSite(source.official_site)
847
878
  ?? normalizeBrand(credentialBrandKey(input.source_name, source));
879
+ // The account is always leased as the row's own brand; only the data plane
880
+ // can be redirected. See `backend_brand` for why 黑洞 needs this.
881
+ const backendBrand = normalizeBrand(String(source.backend_brand ?? '').trim()) ?? brand;
882
+ if (backendBrand && brand && backendBrand !== brand) {
883
+ const redirected = brandSite(backendBrand);
884
+ if (redirected) {
885
+ // A pinned `apk_path` / `apk_url` is the operator's explicit choice of
886
+ // build and stays as given: it may already be the backend's own APK, and
887
+ // discarding it turned a working pinned profile into "APK could not be
888
+ // located". Only the site moves.
889
+ if (!source.apk_path && !source.apk_url) {
890
+ source.official_site = redirected;
891
+ }
892
+ // The layout is picked from `brand` at every call site; carrying the row
893
+ // name through would keep sending the storefront's own flag block.
894
+ source.brand_override = backendBrand;
895
+ }
896
+ // Informational, not an error: the row is expected to work this way.
897
+ diagnostics.backend_brand = backendBrand;
898
+ event(options, 'log', {
899
+ message: `[extract][${input.source_name}] data plane ${brand} -> ${backendBrand}`
900
+ });
901
+ }
848
902
  let store = options.accountStore ?? null;
849
903
  let ownsStore = false;
850
904
  let leased = null;
@@ -879,13 +933,25 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
879
933
  });
880
934
  return { ...empty, failed_iterations: 1 };
881
935
  }
882
- const memo = options.memo ?? createFingerprintMemo(options.fingerprintStore ?? null, credentialBrandKey(input.source_name, source));
936
+ // Keyed by the data plane, not the storefront: a redirected row downloads and
937
+ // inspects the backend's APK, so it must share that brand's cached entry.
938
+ //
939
+ // A caller-supplied memo cannot be trusted here. The orchestrator builds one
940
+ // per row name before the redirect exists, so `options.memo` for a `heidong`
941
+ // row is seeded from 黑洞's entry while every read and write below goes to the
942
+ // 雷霆 one. That pairing sends the 黑洞 APK's package and seeds to the 雷霆
943
+ // backend on a cached run, and it hides the 雷霆 entry the next run should
944
+ // reuse. A redirect therefore always gets a memo of its own key; the shared
945
+ // one is used only when the two brands agree.
946
+ const memoBrandKey = source.brand_override ?? credentialBrandKey(input.source_name, source);
947
+ const memo = source.brand_override
948
+ ? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey)
949
+ : (options.memo ?? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey));
883
950
  let linkQueue = Promise.resolve();
884
951
  // A non-numeric retry limit (a hand-edited `account_retry_limit = "three"`)
885
952
  // must not turn the attempt loop into zero passes, which reported the source
886
953
  // as successful with no links and no error.
887
- const retryLimit = Math.trunc(Number(source.account_retry_limit ?? 3));
888
- const attempts = explicitAccount && explicitPassword ? 1 : (Number.isFinite(retryLimit) && retryLimit >= 1 ? retryLimit : 3);
954
+ const attempts = explicitAccount && explicitPassword ? 1 : credentialAccountAttempts(source, brand);
889
955
  let results = [];
890
956
  let callbackFailure = null;
891
957
  // The dashboard reads per-source totals from one `extract_iteration` stream.
@@ -1006,13 +1072,35 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
1006
1072
  if (lapsed) {
1007
1073
  store.markExpired(leased.id, 'membership expired');
1008
1074
  }
1009
- else if (level !== undefined && level < 2 && !linked) {
1075
+ else if (level !== undefined && level < 2 && !linked && !source.brand_override) {
1010
1076
  // A free-tier account (level 0/1) only ever produces placeholders.
1011
1077
  // Retire it, but only when it actually produced nothing: a paid
1012
1078
  // account misreported as level 1 must not lose its row.
1079
+ //
1080
+ // A redirected row is exempt: the level it reports is the *row*
1081
+ // brand's backend grading it, not the backend it now reads, so a
1082
+ // low level there says nothing about whether the account works.
1083
+ // Retiring on it would discard exactly the accounts the rotation
1084
+ // below is trying to find.
1013
1085
  store.markInvalid(leased.id, 'no paid membership');
1014
1086
  }
1015
1087
  }
1088
+ // A login is not proof the account is usable. On a backend that gates
1089
+ // routes by membership, a free-tier account logs in fine and then sees
1090
+ // every paid row disabled, so the vendor filter leaves nothing to ask.
1091
+ // Publishing that as a finished source was the bug; rotate instead.
1092
+ // The account itself is valid, so it is not retired — only skipped.
1093
+ const producedRoutes = results.some((result) => result.diagnostics.route_count > 0);
1094
+ const producedLinks = results.some((result) => result.links.length > 0);
1095
+ if (!producedRoutes && !producedLinks && store && leased && attempt < attempts && brand) {
1096
+ const next = store.lease(brand);
1097
+ if (next) {
1098
+ leased = next;
1099
+ account = next.account;
1100
+ password = next.password;
1101
+ continue;
1102
+ }
1103
+ }
1016
1104
  break;
1017
1105
  }
1018
1106
  const membershipExpired = results.length > 0
@@ -1203,9 +1291,15 @@ async function runCredentialPlatform(input, options) {
1203
1291
  // These only change when the vendor publishes a new build, so a cached entry
1204
1292
  // is used first and the APK is inspected only when the cached seeds all stop
1205
1293
  // answering. That keeps a normal run to a handful of HTTP calls.
1206
- const brandKey = credentialBrandKey(input.source_name, source);
1294
+ // Keyed by the data plane, not the storefront: a redirected row downloads and
1295
+ // inspects the backend's APK, so it must share that brand's cached entry.
1296
+ const brandKey = source.brand_override ?? credentialBrandKey(input.source_name, source);
1207
1297
  const store = options.fingerprintStore ?? null;
1208
- const memo = options.memo ?? createFingerprintMemo(store, brandKey);
1298
+ // Same rule as the outer memo: a caller-supplied memo is keyed by the row
1299
+ // name, so a redirected row must not read or write through it.
1300
+ const memo = source.brand_override
1301
+ ? createFingerprintMemo(store, brandKey)
1302
+ : (options.memo ?? createFingerprintMemo(store, brandKey));
1209
1303
  // A fingerprint past its trust window is still used when its seeds answer,
1210
1304
  // but it is re-verified against the APK rather than trusted indefinitely: a
1211
1305
  // vendor can rotate the signing certificate while the old seeds still serve
@@ -1228,7 +1322,13 @@ async function runCredentialPlatform(input, options) {
1228
1322
  try {
1229
1323
  apkBytes = options.loadApk
1230
1324
  ? await options.loadApk(source)
1231
- : await defaultLoadApk(source, fetchImpl, timeoutMs, now, log, input.source_name, egress);
1325
+ : await defaultLoadApk(source, fetchImpl, timeoutMs, now, log,
1326
+ // The archive prefix names the *backend's* build (`_lt`, not `_hd`):
1327
+ // a redirected row downloads from the backend's CDN, which publishes
1328
+ // only the backend's own archives. Passing the row name here made
1329
+ // every candidate `_hd<N>.apk` against a 雷霆 host, so all lookback
1330
+ // slots missed and the run failed with "APK could not be located".
1331
+ source.brand_override ?? input.source_name, egress);
1232
1332
  }
1233
1333
  catch (error) {
1234
1334
  errors.push(`apk: ${error instanceof Error ? error.message : String(error)}`);
@@ -1348,7 +1448,7 @@ async function runCredentialPlatform(input, options) {
1348
1448
  platform,
1349
1449
  timestampMs: ts,
1350
1450
  code: ''
1351
- }, resolveBrandLayout({ ...source, brand: input.source_name })), keys, timeoutMs, egress);
1451
+ }, resolveBrandLayout({ ...source, brand: source.brand_override ?? input.source_name })), keys, timeoutMs, egress);
1352
1452
  substage('domain_discovery', resolved ? 'success' : 'failed');
1353
1453
  return resolved;
1354
1454
  }
@@ -1359,7 +1459,7 @@ async function runCredentialPlatform(input, options) {
1359
1459
  identity,
1360
1460
  device,
1361
1461
  platform,
1362
- layout: resolveBrandLayout({ ...source, brand: input.source_name }),
1462
+ layout: resolveBrandLayout({ ...source, brand: source.brand_override ?? input.source_name }),
1363
1463
  token: '',
1364
1464
  // Before login the app's local KV has no `code`, so the request carries an
1365
1465
  // empty one; `&code=` only holds a value once the account has one.
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.1.2',
8
+ sidebarVersion: 'v.2.2.1',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {