@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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Protocol primitives the brand apps share.
3
+ *
4
+ * Every brand ships the same control plane, differing only in seed domains,
5
+ * package name, certificate and a handful of literal flags, so the crypto and
6
+ * request shapes below are common to all of them.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ /** Login requests sign the credentials with a fixed salt baked into every APK. */
10
+ export const LOGIN_SALT = '954d976c3c9fd5e5c63dab4016cc12da';
11
+ /**
12
+ * `getApiDomain()` picks a seed by the first character of MD5(deviceId).
13
+ * The index uses the character code, not its hexadecimal value, which is why
14
+ * a device whose digest starts with `b` lands on index 2 rather than 1.
15
+ */
16
+ export function apiDomainIndex(deviceId, seedCount) {
17
+ if (seedCount <= 0) {
18
+ return -1;
19
+ }
20
+ const digest = createMd5Hex(deviceId);
21
+ return digest.charCodeAt(0) % seedCount;
22
+ }
23
+ export function createMd5Hex(value) {
24
+ return createHash('md5').update(value, 'utf8').digest('hex');
25
+ }
26
+ /**
27
+ * `apps=` is `MD5(certSha1 + deviceId)`; the Java code upper-cases the
28
+ * concatenation before hashing, so the digest differs from a naive join.
29
+ */
30
+ export function appsFingerprint(certSha1, deviceId) {
31
+ return createMd5Hex(`${certSha1}${deviceId}`.toUpperCase());
32
+ }
33
+ export function selectApiDomain(seedDomains, deviceId) {
34
+ const index = apiDomainIndex(deviceId, seedDomains.length);
35
+ return index < 0 ? '' : seedDomains[index];
36
+ }
37
+ /**
38
+ * `getCommParams()` as a parameter list.
39
+ *
40
+ * The four brands share the same fields but not the same order: each one is a
41
+ * single `return` concatenation with a literal flag block in a different place,
42
+ * and the server does notice. 雷霆 carries the viewport block before `&isvip=`,
43
+ * 旋风 puts `&vip=` first and drops `isvip` entirely.
44
+ */
45
+ export function commonParams(input, layout = {}) {
46
+ const { identity, device, platform } = input;
47
+ const isVip = input.isVip === false ? 0 : 1;
48
+ const unlimited = input.unlimited === false ? 0 : 1;
49
+ const viewport = '&width=392.7&height=830.9&w=1178&h=2493';
50
+ const parts = [];
51
+ if (layout.vipFirst) {
52
+ parts.push(`&vip=${isVip}`);
53
+ }
54
+ parts.push(`&proto=${identity.protocol}`, `&platform=${platform}`, `&core=${identity.core_version}`, `&lite=${identity.lite_version}`, `&ver=${identity.version_name}`, `&deviceid=${device.device_id}`, `&unicode=${device.device_id}`, `&t=${input.timestampMs}`, `&code=${input.code ?? ''}`, `&recomm_code=${input.recommendCode ?? ''}`, `&f=${device.install_date}`, `&install=${device.install_date}`, `&token=${input.token ?? ''}`, `&package=${identity.package_name}`);
55
+ if (layout.afterPackage) {
56
+ parts.push(layout.afterPackage);
57
+ }
58
+ if (layout.viewport) {
59
+ parts.push(viewport);
60
+ }
61
+ if (!layout.vipFirst) {
62
+ parts.push(`&isvip=${isVip}`);
63
+ }
64
+ parts.push(`&unlimit=${unlimited}`);
65
+ if (layout.beforeApps) {
66
+ parts.push(layout.beforeApps);
67
+ }
68
+ parts.push(`&apps=${appsFingerprint(identity.cert_sha1, device.device_id)}`);
69
+ return parts.join('');
70
+ }
71
+ export function buildUrl(base, params) {
72
+ const separator = base.includes('?') ? '' : '?';
73
+ return `${base}${separator}${params}`;
74
+ }
75
+ /** `Uri.parse(url)` + `getPath() + "?" + getQuery()` — the domain is dropped. */
76
+ export function pathAndQuery(url) {
77
+ try {
78
+ const parsed = new URL(url);
79
+ return `${parsed.pathname}?${parsed.searchParams.toString()}`;
80
+ }
81
+ catch {
82
+ return url;
83
+ }
84
+ }
85
+ /**
86
+ * Flattens `/myapi/apinodelist` the way `LineActivity` renders it.
87
+ *
88
+ * `open == 2` shows only the children (the group is a collapsed header), so the
89
+ * clickable rows carry `data[].id`. For the other modes the group row itself is
90
+ * clickable too and carries the group's `cid`.
91
+ */
92
+ export function flattenRouteGroups(groups) {
93
+ const out = [];
94
+ for (const group of groups) {
95
+ const children = group.nodes ?? [];
96
+ if (group.open !== 2) {
97
+ out.push({
98
+ area: group.cid,
99
+ name: group.cname,
100
+ region: group.cname,
101
+ cid: group.cid,
102
+ enabled: true
103
+ });
104
+ }
105
+ for (const child of children) {
106
+ out.push({
107
+ area: child.id,
108
+ name: child.name,
109
+ region: group.cname,
110
+ cid: group.cid,
111
+ enabled: Number(child.enable) === 1
112
+ });
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * Route groups the pool must not spend rounds on.
119
+ *
120
+ * `/myapi/apinodelist` mixes three kinds of row: paid foreign regions, a free
121
+ * tier, and mainland/return-to-China lines. Only the first produces nodes worth
122
+ * publishing, and asking the other two costs a request per round forever.
123
+ * Matching is on the group name (`cname`) and the row name, because a free row
124
+ * can sit inside an otherwise paid group.
125
+ */
126
+ const FREE_ROUTE_PATTERN = /免费|免費|试用|試用|体验|體驗|free|trial|guest/i;
127
+ /**
128
+ * Mainland and return-to-China labels.
129
+ *
130
+ * 香港 / 台湾 / 澳门 are deliberately not matched: the apps list them as their own
131
+ * regions rather than as 中国, and they are the routes this pool exists to
132
+ * collect. `回国` is matched because it is the mainland-bound direction.
133
+ */
134
+ const CHINA_ROUTE_PATTERN = /中国|中國|国内|國內|大陆|大陸|内地|內地|回国|回國|china|mainland|\bCN\b|北京|上海|天津|重庆|廣州|广州|深圳|杭州|南京|成都|武汉|武漢|西安|苏州|蘇州|郑州|鄭州|长沙|長沙|青岛|青島|大连|大連|宁波|寧波|厦门|廈門|福州|济南|濟南|合肥|昆明|南昌|贵阳|貴陽|太原|石家庄|石家莊|哈尔滨|哈爾濱|长春|長春|沈阳|瀋陽|南宁|南寧|海口|兰州|蘭州|银川|銀川|西宁|西寧|乌鲁木齐|烏魯木齊|呼和浩特|拉萨|拉薩|广东|廣東|江苏|江蘇|浙江|山东|山東|河南|河北|湖南|湖北|四川|福建|安徽|辽宁|遼寧|陕西|陝西|江西|云南|雲南|贵州|貴州|山西|甘肃|甘肅|吉林|黑龙江|黑龍江|海南|青海|内蒙古|內蒙古|广西|廣西|宁夏|寧夏|新疆|西藏/i;
135
+ export function isFreeRoute(route) {
136
+ return FREE_ROUTE_PATTERN.test(`${route.region} ${route.name}`);
137
+ }
138
+ /**
139
+ * Hong Kong / Taiwan / Macau stay in the pool even when the vendor spells the
140
+ * region `中国香港` or `中国台湾`; the bare `中国` rule below would otherwise drop
141
+ * exactly the routes the filter promises to keep.
142
+ */
143
+ const GREATER_CHINA_EXEMPT_PATTERN = /香港|台湾|台灣|臺灣|澳门|澳門|hong\s*kong|taiwan|macau|macao/i;
144
+ export function isChinaRoute(route) {
145
+ const text = `${route.region} ${route.name}`;
146
+ if (GREATER_CHINA_EXEMPT_PATTERN.test(text) && !/回国|回國|mainland-bound/i.test(text)) {
147
+ return false;
148
+ }
149
+ return CHINA_ROUTE_PATTERN.test(text);
150
+ }
151
+ /**
152
+ * Reduce one or more route tables to the area ids worth looping over.
153
+ *
154
+ * Dedupe is by `area`, not by name: 旋风 serves two labelled tables that overlap,
155
+ * and the same id in both is one route, not two. The first occurrence wins so
156
+ * the region label stays the one the vendor listed first.
157
+ */
158
+ export function selectForeignRoutes(routes, options = {}) {
159
+ const result = {
160
+ routes: [],
161
+ dropped_free: 0,
162
+ dropped_china: 0,
163
+ dropped_disabled: 0,
164
+ dropped_duplicate: 0
165
+ };
166
+ const seen = new Set();
167
+ for (const route of routes) {
168
+ if (!Number.isFinite(route.area) || route.area < 0) {
169
+ result.dropped_duplicate += 1;
170
+ continue;
171
+ }
172
+ if (!options.includeDisabled && !route.enabled) {
173
+ result.dropped_disabled += 1;
174
+ continue;
175
+ }
176
+ if (!options.includeFree && isFreeRoute(route)) {
177
+ result.dropped_free += 1;
178
+ continue;
179
+ }
180
+ if (!options.includeChina && isChinaRoute(route)) {
181
+ result.dropped_china += 1;
182
+ continue;
183
+ }
184
+ if (seen.has(route.area)) {
185
+ result.dropped_duplicate += 1;
186
+ continue;
187
+ }
188
+ seen.add(route.area);
189
+ result.routes.push(route);
190
+ }
191
+ return result;
192
+ }
193
+ /** The placeholder the server returns when it will not issue a real node. */
194
+ export const PLACEHOLDER_ADDRESS = '1.1.1.1';
195
+ export function isPlaceholderNode(address) {
196
+ return String(address).trim() === PLACEHOLDER_ADDRESS;
197
+ }
198
+ /** `transformNodeId` — swaps each 4-character pair, matching the app's UUID form. */
199
+ export function transformNodeId(original) {
200
+ return String(original).split('-').map((part) => {
201
+ const chunks = [];
202
+ for (let index = 0; index < part.length; index += 4) {
203
+ const chunk = part.slice(index, index + 4);
204
+ if (chunk) {
205
+ chunks.push(`${chunk.slice(2)}${chunk.slice(0, 2)}`);
206
+ }
207
+ }
208
+ return chunks.join('');
209
+ }).join('-');
210
+ }