@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
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import zlib from 'node:zlib';
|
|
3
|
+
const SEED_DOMAIN_PATTERN = /^(https?:\/\/www\.[a-z0-9]{3,14}\.(?:cc|info|com|net))(?::(\d+))?\/?$/;
|
|
4
|
+
const PACKAGE_PATTERN = /^[a-zA-Z][\w]*(?:\.[\w]+){2,}$/;
|
|
5
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
6
|
+
function readU16(buffer, offset) {
|
|
7
|
+
return offset + 2 <= buffer.length ? buffer.readUInt16LE(offset) : 0;
|
|
8
|
+
}
|
|
9
|
+
function readU32(buffer, offset) {
|
|
10
|
+
return offset + 4 <= buffer.length ? buffer.readUInt32LE(offset) : 0;
|
|
11
|
+
}
|
|
12
|
+
function decodeUleb128(buffer, start) {
|
|
13
|
+
let result = 0;
|
|
14
|
+
let shift = 0;
|
|
15
|
+
let offset = start;
|
|
16
|
+
while (offset < buffer.length) {
|
|
17
|
+
const byte = buffer[offset];
|
|
18
|
+
offset += 1;
|
|
19
|
+
result |= (byte & 0x7f) << shift;
|
|
20
|
+
if ((byte & 0x80) === 0) {
|
|
21
|
+
return { value: result >>> 0, next: offset };
|
|
22
|
+
}
|
|
23
|
+
shift += 7;
|
|
24
|
+
if (shift > 35) {
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { value: result >>> 0, next: offset };
|
|
29
|
+
}
|
|
30
|
+
/** Reads the dex `string_ids` table: every string literal the app carries. */
|
|
31
|
+
export function dexStrings(data) {
|
|
32
|
+
if (data.length < 0x70 || data.subarray(0, 4).toString('binary') !== 'dex\n') {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const stringIdsSize = readU32(data, 0x38);
|
|
36
|
+
const stringIdsOffset = readU32(data, 0x3c);
|
|
37
|
+
if (!stringIdsSize || stringIdsOffset + stringIdsSize * 4 > data.length) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let index = 0; index < stringIdsSize; index += 1) {
|
|
42
|
+
const dataOffset = readU32(data, stringIdsOffset + index * 4);
|
|
43
|
+
if (!dataOffset || dataOffset >= data.length) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const { next } = decodeUleb128(data, dataOffset);
|
|
47
|
+
const end = data.indexOf(0, next);
|
|
48
|
+
if (end < 0) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
out.push(data.subarray(next, end).toString('utf8'));
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** Reads selected zip entries by walking the central directory. */
|
|
56
|
+
export function readZipEntries(zip, wanted) {
|
|
57
|
+
const out = new Map();
|
|
58
|
+
const minEocd = 22;
|
|
59
|
+
let eocd = -1;
|
|
60
|
+
for (let offset = zip.length - minEocd; offset >= Math.max(0, zip.length - 0xffff - minEocd); offset -= 1) {
|
|
61
|
+
if (readU32(zip, offset) === 0x06054b50) {
|
|
62
|
+
eocd = offset;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (eocd < 0) {
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
const entryCount = readU16(zip, eocd + 10);
|
|
70
|
+
let centralOffset = readU32(zip, eocd + 16);
|
|
71
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
72
|
+
if (centralOffset + 46 > zip.length || readU32(zip, centralOffset) !== 0x02014b50) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
const method = readU16(zip, centralOffset + 10);
|
|
76
|
+
const compressedSize = readU32(zip, centralOffset + 20);
|
|
77
|
+
const uncompressedSize = readU32(zip, centralOffset + 24);
|
|
78
|
+
const nameLength = readU16(zip, centralOffset + 28);
|
|
79
|
+
const extraLength = readU16(zip, centralOffset + 30);
|
|
80
|
+
const commentLength = readU16(zip, centralOffset + 32);
|
|
81
|
+
const localOffset = readU32(zip, centralOffset + 42);
|
|
82
|
+
const name = zip.subarray(centralOffset + 46, centralOffset + 46 + nameLength).toString('utf8');
|
|
83
|
+
centralOffset += 46 + nameLength + extraLength + commentLength;
|
|
84
|
+
if (!wanted(name)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (localOffset + 30 > zip.length || readU32(zip, localOffset) !== 0x04034b50) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const dataStart = localOffset + 30 + readU16(zip, localOffset + 26) + readU16(zip, localOffset + 28);
|
|
91
|
+
const raw = zip.subarray(dataStart, dataStart + compressedSize);
|
|
92
|
+
if (raw.length !== compressedSize) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (method === 0) {
|
|
96
|
+
out.set(name, Buffer.from(raw));
|
|
97
|
+
}
|
|
98
|
+
else if (method === 8) {
|
|
99
|
+
try {
|
|
100
|
+
const inflated = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(uncompressedSize, 1) });
|
|
101
|
+
if (inflated.length === uncompressedSize) {
|
|
102
|
+
out.set(name, inflated);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// A corrupt entry must not abort discovery of the others.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** AndroidManifest.xml is binary AXML; the string pool holds package and versionName. */
|
|
113
|
+
export function axmlStrings(data) {
|
|
114
|
+
if (data.length < 8) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
if (readU16(data, 0) !== 0x0003) {
|
|
118
|
+
return [...data.toString('utf8').matchAll(/="([^"]*)"/g)].map((match) => match[1]);
|
|
119
|
+
}
|
|
120
|
+
const poolOffset = 8;
|
|
121
|
+
if (readU16(data, poolOffset) !== 0x0001) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const stringCount = readU32(data, poolOffset + 8);
|
|
125
|
+
const flags = readU32(data, poolOffset + 16);
|
|
126
|
+
const stringsStart = readU32(data, poolOffset + 20);
|
|
127
|
+
const isUtf8 = (flags & 0x100) !== 0;
|
|
128
|
+
const base = poolOffset + stringsStart;
|
|
129
|
+
// `stringCount` comes from an untrusted file: a corrupt or hostile manifest
|
|
130
|
+
// can declare 0xFFFFFFFF entries and spin the loop for billions of
|
|
131
|
+
// iterations, which is synchronous and cannot be interrupted. The offset
|
|
132
|
+
// table is the real bound, so both must fit inside the buffer.
|
|
133
|
+
const maxStrings = Math.max(0, Math.floor((data.length - (poolOffset + 28)) / 4));
|
|
134
|
+
const boundedCount = Math.min(Number.isFinite(stringCount) ? stringCount : 0, maxStrings);
|
|
135
|
+
const out = [];
|
|
136
|
+
for (let index = 0; index < boundedCount; index += 1) {
|
|
137
|
+
const relative = readU32(data, poolOffset + 28 + index * 4);
|
|
138
|
+
let cursor = base + relative;
|
|
139
|
+
if (cursor >= data.length) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
if (isUtf8) {
|
|
144
|
+
cursor = decodeUleb128(data, cursor).next;
|
|
145
|
+
const length = decodeUleb128(data, cursor);
|
|
146
|
+
cursor = length.next;
|
|
147
|
+
out.push(data.subarray(cursor, cursor + length.value).toString('utf8'));
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
let length = readU16(data, cursor);
|
|
151
|
+
cursor += 2;
|
|
152
|
+
if (length & 0x8000) {
|
|
153
|
+
length = ((length & 0x7fff) << 16) | readU16(data, cursor);
|
|
154
|
+
cursor += 2;
|
|
155
|
+
}
|
|
156
|
+
out.push(data.subarray(cursor, cursor + length * 2).toString('utf16le'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Ignore malformed entries; the remaining pool is still usable.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The manifest string pool lists every literal, so the package name cannot be
|
|
167
|
+
* "the first com.* string" — permission constants and bundled SDK namespaces
|
|
168
|
+
* come first. The reliable source is the dex, which carries the package name
|
|
169
|
+
* itself (the `<manifest package>` value) as a plain literal, next to the
|
|
170
|
+
* `…action.activity` style prefixes it generates.
|
|
171
|
+
*/
|
|
172
|
+
export function manifestIdentity(axml, dexLiterals = []) {
|
|
173
|
+
const strings = axmlStrings(axml);
|
|
174
|
+
const versionName = strings.find((value) => VERSION_PATTERN.test(value)) ?? '';
|
|
175
|
+
// `<manifest package="x">` also appears in the manifest pool; prefer a value
|
|
176
|
+
// the dex independently repeats and that no permission constant shadows.
|
|
177
|
+
const manifestCandidates = strings.filter((value) => (PACKAGE_PATTERN.test(value)
|
|
178
|
+
&& value.startsWith('com.')
|
|
179
|
+
&& !/^com\.android\./.test(value)));
|
|
180
|
+
const dexSet = new Set(dexLiterals);
|
|
181
|
+
const confirmed = manifestCandidates
|
|
182
|
+
.filter((value) => dexSet.has(value) && dexLiterals.some((literal) => literal.startsWith(`${value}.action.`)))
|
|
183
|
+
.sort((left, right) => right.length - left.length);
|
|
184
|
+
if (confirmed.length > 0) {
|
|
185
|
+
return { package_name: confirmed[0], version_name: versionName };
|
|
186
|
+
}
|
|
187
|
+
// Second best: any dex literal shaped `com.<vendor>.<app><digits>`.
|
|
188
|
+
const shaped = dexLiterals
|
|
189
|
+
.filter((value) => /^com\.[a-z]+\.[a-z]+\d{6,}$/i.test(value))
|
|
190
|
+
.sort((left, right) => right.length - left.length);
|
|
191
|
+
if (shaped.length > 0) {
|
|
192
|
+
return { package_name: shaped[0], version_name: versionName };
|
|
193
|
+
}
|
|
194
|
+
return { package_name: manifestCandidates[0] ?? '', version_name: versionName };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Every 16-character ASCII literal in the dex is a control-plane key candidate.
|
|
198
|
+
*
|
|
199
|
+
* The key is not stored in plaintext (it is reassembled at runtime), but the
|
|
200
|
+
* constant it is assembled from is in the string pool, so the response itself
|
|
201
|
+
* decides which candidate is right: only the correct key turns the ciphertext
|
|
202
|
+
* into JSON. Trying the pool keeps discovery working when a vendor rotates the
|
|
203
|
+
* key, instead of relying on a hardcoded constant.
|
|
204
|
+
*/
|
|
205
|
+
export function controlPlaneKeyCandidates(strings) {
|
|
206
|
+
const out = [];
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const value of strings) {
|
|
209
|
+
if (value.length !== 16 || seen.has(value)) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!/^[\x20-\x7e]{16}$/.test(value)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
seen.add(value);
|
|
216
|
+
out.push(value);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
export function certificateSha1(certificateDer) {
|
|
221
|
+
return crypto.createHash('sha1').update(certificateDer).digest('hex').toUpperCase();
|
|
222
|
+
}
|
|
223
|
+
/** Reads a DER TLV header, returning the value range and the total length. */
|
|
224
|
+
function derTlv(buffer, offset) {
|
|
225
|
+
if (offset + 2 > buffer.length) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const tag = buffer[offset];
|
|
229
|
+
let length = buffer[offset + 1];
|
|
230
|
+
let cursor = offset + 2;
|
|
231
|
+
if (length & 0x80) {
|
|
232
|
+
const lengthBytes = length & 0x7f;
|
|
233
|
+
if (lengthBytes === 0 || lengthBytes > 4 || cursor + lengthBytes > buffer.length) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
length = 0;
|
|
237
|
+
for (let index = 0; index < lengthBytes; index += 1) {
|
|
238
|
+
length = length * 256 + buffer[cursor + index];
|
|
239
|
+
}
|
|
240
|
+
cursor += lengthBytes;
|
|
241
|
+
}
|
|
242
|
+
const valueEnd = cursor + length;
|
|
243
|
+
if (valueEnd > buffer.length) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
return { tag, valueStart: cursor, valueEnd, next: valueEnd };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* True when the DER value is structurally an X.509 Certificate:
|
|
250
|
+
* Certificate ::= SEQUENCE { tbsCertificate SEQUENCE, signatureAlgorithm, signatureValue }
|
|
251
|
+
*/
|
|
252
|
+
function looksLikeX509(der) {
|
|
253
|
+
const outer = derTlv(der, 0);
|
|
254
|
+
if (!outer || outer.tag !== 0x30 || outer.valueEnd !== der.length) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const tbs = derTlv(der, outer.valueStart);
|
|
258
|
+
if (!tbs || tbs.tag !== 0x30) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
// tbsCertificate is itself a SEQUENCE of at least version/serial/validity.
|
|
262
|
+
const first = derTlv(der, tbs.valueStart);
|
|
263
|
+
if (!first || tbs.valueEnd <= tbs.valueStart) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
// After tbsCertificate there must be two more elements (algorithm, signature).
|
|
267
|
+
const algorithm = derTlv(der, tbs.next);
|
|
268
|
+
const signature = algorithm ? derTlv(der, algorithm.next) : null;
|
|
269
|
+
return Boolean(algorithm && signature);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Extracts the signer certificate from a PKCS#7 SignedData blob.
|
|
273
|
+
*
|
|
274
|
+
* The blob nests several SEQUENCEs (SignedData, its `certificates` set, then the
|
|
275
|
+
* certificate itself), so picking by size is unreliable: at RSA-3072/4096 the
|
|
276
|
+
* SubjectPublicKeyInfo nested inside the certificate is longer than a smaller
|
|
277
|
+
* certificate and wins a "shortest" rule. The certificate is instead identified
|
|
278
|
+
* structurally — a DER SEQUENCE that parses exactly as `Certificate` with a
|
|
279
|
+
* TBSCertificate, an algorithm and a signature, all consuming the whole value.
|
|
280
|
+
* `certificates [0] IMPLICIT` means the set holds the certificates directly, so
|
|
281
|
+
* the first structural match in document order is the signer.
|
|
282
|
+
*/
|
|
283
|
+
export function firstCertificateFromPkcs7(pkcs7) {
|
|
284
|
+
const candidates = [];
|
|
285
|
+
const walk = (buffer, depth) => {
|
|
286
|
+
if (depth > 8) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
for (let offset = 0; offset + 2 <= buffer.length; offset += 1) {
|
|
290
|
+
const tag = buffer[offset];
|
|
291
|
+
// Certificates are SEQUENCEs; skip everything else but keep scanning.
|
|
292
|
+
if (tag !== 0x30) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const header = derTlv(buffer, offset);
|
|
296
|
+
if (!header || header.valueEnd <= header.valueStart) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const candidate = buffer.subarray(offset, header.valueEnd);
|
|
300
|
+
if (candidate.length >= 0x80 && looksLikeX509(candidate)) {
|
|
301
|
+
candidates.push(Buffer.from(candidate));
|
|
302
|
+
}
|
|
303
|
+
walk(buffer.subarray(header.valueStart, header.valueEnd), depth + 1);
|
|
304
|
+
offset = header.valueEnd - 1;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
walk(pkcs7, 0);
|
|
308
|
+
return candidates[0] ?? null;
|
|
309
|
+
}
|
|
310
|
+
/** Hosts shipped as documentation samples, never a control-plane seed. */
|
|
311
|
+
const SAMPLE_HOSTS = new Set(['www.example.com', 'www.google.com', 'example.com', 'google.com']);
|
|
312
|
+
/**
|
|
313
|
+
* Seed domains are the `https://www.<label>.<tld>:20000` literals the dex
|
|
314
|
+
* carries; they are what `getApiDomain()` picks between and what
|
|
315
|
+
* `/api/eversion` answers on. The control-plane port is the filter that
|
|
316
|
+
* separates them from the sample hosts bundled in third-party libraries.
|
|
317
|
+
*/
|
|
318
|
+
export function seedDomains(strings) {
|
|
319
|
+
const candidates = [];
|
|
320
|
+
for (const value of strings) {
|
|
321
|
+
const match = SEED_DOMAIN_PATTERN.exec(value.trim());
|
|
322
|
+
if (!match || SAMPLE_HOSTS.has(match[1].replace(/^https?:\/\//, ''))) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const normalized = `${match[1]}:${match[2] ?? '20000'}`;
|
|
326
|
+
if (!candidates.includes(normalized)) {
|
|
327
|
+
candidates.push(normalized);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return candidates.filter((value) => value.endsWith(':20000'));
|
|
331
|
+
}
|
|
332
|
+
export function apkDiscoveryFromBytes(apk, apkUrl) {
|
|
333
|
+
const entries = readZipEntries(apk, (name) => (name === 'AndroidManifest.xml'
|
|
334
|
+
|| name.endsWith('.dex')
|
|
335
|
+
|| (name.startsWith('META-INF/') && /\.(RSA|DSA|EC)$/i.test(name))));
|
|
336
|
+
const manifest = entries.get('AndroidManifest.xml');
|
|
337
|
+
const strings = [];
|
|
338
|
+
for (const [name, data] of entries) {
|
|
339
|
+
if (name.endsWith('.dex')) {
|
|
340
|
+
// A spread here overflows the call stack: one dex routinely carries more
|
|
341
|
+
// literals than the argument limit allows.
|
|
342
|
+
for (const value of dexStrings(data)) {
|
|
343
|
+
strings.push(value);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const identity = manifest
|
|
348
|
+
? manifestIdentity(manifest, strings)
|
|
349
|
+
: { package_name: '', version_name: '' };
|
|
350
|
+
const joined = strings.join('\n');
|
|
351
|
+
let certSha1 = '';
|
|
352
|
+
for (const [name, data] of [...entries].sort(([left], [right]) => left.localeCompare(right))) {
|
|
353
|
+
if (!/\.(RSA|DSA|EC)$/i.test(name)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const certificate = firstCertificateFromPkcs7(data);
|
|
357
|
+
if (certificate) {
|
|
358
|
+
certSha1 = certificateSha1(certificate);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
apk_url: apkUrl,
|
|
364
|
+
key_candidates: controlPlaneKeyCandidates(strings),
|
|
365
|
+
package_name: identity.package_name,
|
|
366
|
+
version_name: identity.version_name,
|
|
367
|
+
cert_sha1: certSha1,
|
|
368
|
+
seed_domains: seedDomains(strings),
|
|
369
|
+
protocol: `v${/proto=v(\d+)/.exec(joined)?.[1] ?? '7'}`,
|
|
370
|
+
core_version: /&core=([\d.]+)/.exec(joined)?.[1] ?? '5.12.1',
|
|
371
|
+
lite_version: /lite=([\d.]+)/.exec(joined)?.[1] ?? '5.29.4'
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The download page loads a per-brand `cdn.js` that declares `cdnurl` (and often
|
|
376
|
+
* `cdnurl2`) plus `apk_interval`. That interval is the archive cadence in minutes.
|
|
377
|
+
*/
|
|
378
|
+
export function parseCdnConfig(source) {
|
|
379
|
+
const hosts = [];
|
|
380
|
+
for (const name of ['cdnurl', 'cdnurl2']) {
|
|
381
|
+
const match = new RegExp(`(?:var|let|const)\\s+${name}\\s*=\\s*["']([^"']*)["']`).exec(source);
|
|
382
|
+
const value = match ? match[1].trim().replace(/\/+$/, '') : '';
|
|
383
|
+
if (value && !hosts.includes(value)) {
|
|
384
|
+
hosts.push(value);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const intervalMatch = /(?:var|let|const)\s+apk_interval\s*=\s*(\d+)/.exec(source);
|
|
388
|
+
return {
|
|
389
|
+
download_hosts: hosts,
|
|
390
|
+
apk_interval_minutes: intervalMatch ? Number.parseInt(intervalMatch[1], 10) : 0
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The download page names its route chunks; Vite's dep map is the reliable list.
|
|
395
|
+
*
|
|
396
|
+
* Sites split their routes into lazy chunks (`TheAndriod`, `mf-pages-android`,
|
|
397
|
+
* …), so the Android downloader is not the page's own entry bundle. The dep map
|
|
398
|
+
* and the direct script tags together cover both shapes.
|
|
399
|
+
*/
|
|
400
|
+
export function androidChunkUrls(html, baseUrl) {
|
|
401
|
+
const urls = [];
|
|
402
|
+
const add = (reference) => {
|
|
403
|
+
try {
|
|
404
|
+
const absolute = new URL(reference, `${baseUrl.replace(/\/+$/, '')}/`).toString();
|
|
405
|
+
if (!urls.includes(absolute)) {
|
|
406
|
+
urls.push(absolute);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// Ignore references that cannot be resolved against the site.
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const depMap = /m\.f=\[([^\]]+)\]/.exec(html);
|
|
414
|
+
if (depMap) {
|
|
415
|
+
for (const match of depMap[1].matchAll(/"([^"]+\.js)"/g)) {
|
|
416
|
+
add(match[1]);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
for (const match of html.matchAll(/(?:src|href)="([^"]+\.js[^"]*)"/g)) {
|
|
420
|
+
add(match[1]);
|
|
421
|
+
}
|
|
422
|
+
return urls;
|
|
423
|
+
}
|
|
424
|
+
/** Sites whose `cdn.js` is served at the root and needs a cache-busting stamp. */
|
|
425
|
+
export function cdnConfigUrls(baseUrl, timestampMs) {
|
|
426
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
427
|
+
return [`${base}/cdn.js?t=${Math.floor(timestampMs / 1000)}`, `${base}/cdn.js`];
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Some sites inline the download formula in the Android chunk (fixed host and
|
|
431
|
+
* cadence), others load a separate `cdn.js`. Both shapes are recognised here.
|
|
432
|
+
*/
|
|
433
|
+
export function cdnReferencesFromChunk(source, chunkUrl) {
|
|
434
|
+
const references = [];
|
|
435
|
+
for (const match of source.matchAll(/["'`](\/assets\/[^"'`]*cdn[^"'`]*\.js[^"'`]*)["'`]/gi)) {
|
|
436
|
+
references.push({
|
|
437
|
+
url: new URL(match[1], chunkUrl).toString(),
|
|
438
|
+
apk_interval_minutes: 0
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
return references;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Fixed-host variant: the chunk hardcodes the download origin and computes the
|
|
445
|
+
* slot inline, e.g. `https://wpk.example.com/apks64/DATE_mfSLOT.apk`.
|
|
446
|
+
*/
|
|
447
|
+
export function inlineDownloadSpec(source) {
|
|
448
|
+
// The port is part of the origin: a brand publishing `http://cdn:8080/`
|
|
449
|
+
// would otherwise be reduced to a bare host that answers nothing.
|
|
450
|
+
const hostMatch = /(https?):\/\/([a-z0-9.-]+(?::\d+)?)\/apks64\//i.exec(source);
|
|
451
|
+
// The filename is assembled at runtime (`'…_mf'+index+'apk'`), so the prefix
|
|
452
|
+
// is read from the literal fragment rather than a complete `.apk` path.
|
|
453
|
+
const fileMatch = /apks64\/[^"'`+]*_([a-z]{2,6})["'`+]?\s*\+\s*index/i.exec(source)
|
|
454
|
+
?? /apks64\/[^"'`]*_([a-z]{2,6})\d*\.apk/i.exec(source);
|
|
455
|
+
if (!hostMatch || !fileMatch) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const intervalMatch = /getHours\(\)\s*\*\s*(\d+)\s*\+\s*Math\.floor\(\s*[^)]*getMinutes\(\)\s*\/\s*(\d+)\s*\)/.exec(source);
|
|
459
|
+
return {
|
|
460
|
+
host: `${hostMatch[1].toLowerCase()}://${hostMatch[2]}`,
|
|
461
|
+
prefix: fileMatch[1].toLowerCase(),
|
|
462
|
+
interval_minutes: intervalMatch ? Number.parseInt(intervalMatch[2], 10) : 0
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Time-sliced archive slot for one moment.
|
|
467
|
+
*
|
|
468
|
+
* The apps publish a fresh APK every `interval` minutes; the archive index is
|
|
469
|
+
* `hours * (60 / interval) + floor(minutes / interval)`.
|
|
470
|
+
*/
|
|
471
|
+
export function apkSlot(now, intervalMinutes) {
|
|
472
|
+
const interval = Math.max(1, intervalMinutes);
|
|
473
|
+
return now.getHours() * Math.floor(60 / interval) + Math.floor(now.getMinutes() / interval);
|
|
474
|
+
}
|
|
475
|
+
export function apkFileName(now, prefix, slot) {
|
|
476
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
477
|
+
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
478
|
+
return `${day}_${prefix}${slot}.apk`;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Candidate URLs for now and the preceding slots.
|
|
482
|
+
*
|
|
483
|
+
* The newest slot is frequently not published yet, so the walk goes backwards;
|
|
484
|
+
* it also wraps to the previous day because slot numbering restarts at midnight.
|
|
485
|
+
*/
|
|
486
|
+
export function candidateApkUrls(hosts, prefix, now, intervalMinutes, lookbackSlots = 8) {
|
|
487
|
+
const interval = Math.max(1, intervalMinutes);
|
|
488
|
+
const urls = [];
|
|
489
|
+
for (let step = 0; step < lookbackSlots; step += 1) {
|
|
490
|
+
const stamp = new Date(now.getTime() - step * interval * 60_000);
|
|
491
|
+
const name = apkFileName(stamp, prefix, apkSlot(stamp, interval));
|
|
492
|
+
for (const host of hosts) {
|
|
493
|
+
urls.push(`${host.replace(/\/+$/, '')}/apks64/${name}`);
|
|
494
|
+
}
|
|
495
|
+
if (stamp.getHours() === 0 && stamp.getMinutes() < interval) {
|
|
496
|
+
const previous = new Date(stamp.getTime() - 60 * 60_000);
|
|
497
|
+
const tail = apkFileName(previous, prefix, apkSlot(previous, interval));
|
|
498
|
+
for (const host of hosts) {
|
|
499
|
+
urls.push(`${host.replace(/\/+$/, '')}/apks64/${tail}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return urls;
|
|
504
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* File name for one brand's fingerprint.
|
|
6
|
+
*
|
|
7
|
+
* Brand keys are Chinese product names, which `[^\w.-]` would collapse to a
|
|
8
|
+
* single underscore; the code-point hex keeps them distinct and filesystem-safe.
|
|
9
|
+
*/
|
|
10
|
+
function cachePath(cacheDir, brand) {
|
|
11
|
+
const safe = [...String(brand)].map((char) => (/[\w.-]/.test(char) ? char : `u${char.codePointAt(0)?.toString(16)}`)).join('');
|
|
12
|
+
return path.join(cacheDir, `brand-${safe}.json`);
|
|
13
|
+
}
|
|
14
|
+
export /**
|
|
15
|
+
* A cache entry is only usable when every field the flow dereferences has the
|
|
16
|
+
* right shape. A parseable file with, say, `seed_domains: [12345]` would
|
|
17
|
+
* otherwise reach `String.prototype.replace` and crash the run before any
|
|
18
|
+
* network call — and keep crashing on every later run, since the cache is only
|
|
19
|
+
* invalidated when the seeds stop answering.
|
|
20
|
+
*/ function isUsableFingerprint(payload) {
|
|
21
|
+
if (!payload || typeof payload !== 'object') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const value = payload;
|
|
25
|
+
const seeds = value.seed_domains;
|
|
26
|
+
if (!Array.isArray(seeds) || seeds.length === 0) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (!seeds.every((seed) => typeof seed === 'string' && seed.trim().length > 0)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
for (const field of ['package_name', 'version_name', 'cert_sha1', 'apk_url']) {
|
|
33
|
+
if (typeof value[field] !== 'string') {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
export class FingerprintStore {
|
|
40
|
+
cacheDir;
|
|
41
|
+
now;
|
|
42
|
+
maxAgeMs;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.cacheDir = options.cacheDir;
|
|
45
|
+
this.now = options.now ?? (() => new Date());
|
|
46
|
+
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
47
|
+
}
|
|
48
|
+
read(brand) {
|
|
49
|
+
const file = cachePath(this.cacheDir, brand);
|
|
50
|
+
if (!fs.existsSync(file)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const payload = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
return isUsableFingerprint(payload) ? payload : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Returns a fingerprint when it is present and still within its trust window. */
|
|
62
|
+
/**
|
|
63
|
+
* Returns the fingerprint when it is still inside its trust window.
|
|
64
|
+
*
|
|
65
|
+
* A stale entry still carries usable seed domains, so callers fall back to
|
|
66
|
+
* `read()` and only re-inspect the APK when the seeds stop answering.
|
|
67
|
+
*/
|
|
68
|
+
readFresh(brand) {
|
|
69
|
+
const payload = this.read(brand);
|
|
70
|
+
if (!payload) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const verifiedAt = Date.parse(String(payload.verified_at ?? ''));
|
|
74
|
+
if (!Number.isFinite(verifiedAt)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return this.now().getTime() - verifiedAt <= this.maxAgeMs ? payload : null;
|
|
78
|
+
}
|
|
79
|
+
write(brand, discovery) {
|
|
80
|
+
const payload = {
|
|
81
|
+
...discovery,
|
|
82
|
+
brand,
|
|
83
|
+
verified_at: this.now().toISOString()
|
|
84
|
+
};
|
|
85
|
+
// The cache is an optimisation: a read-only or full directory, or a disk
|
|
86
|
+
// error, must not fail a run that already has a working fingerprint.
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
89
|
+
// Write to a sibling and rename, so a crash mid-write cannot leave a
|
|
90
|
+
// truncated file that later parses as a broken fingerprint.
|
|
91
|
+
const target = cachePath(this.cacheDir, brand);
|
|
92
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
93
|
+
fs.writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
94
|
+
fs.renameSync(temporary, target);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Deliberately ignored; see above.
|
|
98
|
+
}
|
|
99
|
+
return payload;
|
|
100
|
+
}
|
|
101
|
+
/** Records that a cached fingerprint still works, without re-reading the APK. */
|
|
102
|
+
touch(brand) {
|
|
103
|
+
const payload = this.read(brand);
|
|
104
|
+
if (!payload) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.write(brand, payload);
|
|
108
|
+
}
|
|
109
|
+
/** Drops a fingerprint so the next run re-inspects the APK. */
|
|
110
|
+
clear(brand) {
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(cachePath(this.cacheDir, brand), { force: true });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// The cache is an optimisation; failing to delete it must not fail a run.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The cache lives under the runtime root, beside the profile but as its own
|
|
121
|
+
* directory. Nothing clears it automatically: an entry is replaced when a run
|
|
122
|
+
* re-inspects the APK, and dropped when a cached fingerprint stops working.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveFingerprintCacheDir(runtimeRoot) {
|
|
125
|
+
return path.join(runtimeRoot, 'brand-fingerprints');
|
|
126
|
+
}
|