@having5548/dsh-downloader 0.0.0-stage → 0.5.3
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/CHANGELOG.md +401 -0
- package/LICENSE +29 -0
- package/README.en.md +386 -0
- package/README.md +390 -2
- package/cordis.patch.yml +9 -0
- package/lib/client.js +862 -0
- package/lib/core/ai-domains.js +130 -0
- package/lib/core/data/cn-cidrs.json.gz +0 -0
- package/lib/core/data/cn-cidrs6.json.gz +0 -0
- package/lib/core/data/cn-domains.json.gz +0 -0
- package/lib/core/profiles.js +372 -0
- package/lib/core/proxy-server.js +348 -0
- package/lib/core/rules.js +242 -0
- package/lib/core/session-guard.js +208 -0
- package/lib/core/share-links.js +371 -0
- package/lib/core/shell-proxy.js +143 -0
- package/lib/core/subscription.js +126 -0
- package/lib/core/transports.js +697 -0
- package/lib/core/upstream.js +1044 -0
- package/lib/core/vmess.js +298 -0
- package/lib/core/ws.js +128 -0
- package/lib/download/auth.js +110 -0
- package/lib/download/fetch-file.js +363 -0
- package/lib/download/http.js +299 -0
- package/lib/download/net-probe.js +126 -0
- package/lib/download/route.js +103 -0
- package/lib/download/segmented.js +279 -0
- package/lib/download/util.js +103 -0
- package/lib/index.js +1199 -0
- package/lib/native/connector.exe +0 -0
- package/native/README.md +59 -0
- package/native/dial.go +17 -0
- package/native/frame.go +112 -0
- package/native/go.mod +19 -0
- package/native/go.sum +32 -0
- package/native/hysteria2.go +178 -0
- package/native/main.go +79 -0
- package/native/reality.go +188 -0
- package/native/salamander.go +84 -0
- package/native/vless.go +455 -0
- package/package.json +87 -4
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session guard: keep the harness's own model traffic off the proxy.
|
|
3
|
+
*
|
|
4
|
+
* The downloader never touches the LLM connection, but DSH's *launch-time*
|
|
5
|
+
* proxy policy does: `$DSH_HOME/.env` may set `HTTP_PROXY` / `HTTPS_PROXY`, and
|
|
6
|
+
* when the proxy app is closed every request that goes through it fails — the
|
|
7
|
+
* model connection breaks and the session dies.
|
|
8
|
+
*
|
|
9
|
+
* The fix is the same one that makes the downloader's own routing safe: list the
|
|
10
|
+
* domestic AI platforms in `NO_PROXY`, so they are resolved and reached
|
|
11
|
+
* directly no matter what the proxy is doing.
|
|
12
|
+
*
|
|
13
|
+
* This module only ever edits the `NO_PROXY` entry of `$DSH_HOME/.env`:
|
|
14
|
+
* - it never adds or changes `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`;
|
|
15
|
+
* - it backs the file up before the first write;
|
|
16
|
+
* - it merges rather than replaces, so hand-written entries survive;
|
|
17
|
+
* - nothing runs unless a caller explicitly asks for `apply`.
|
|
18
|
+
* @module dsh-downloader/session-guard
|
|
19
|
+
*/
|
|
20
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
22
|
+
|
|
23
|
+
/** Loopback entries every bypass list should carry, for consumers beyond DSH. */
|
|
24
|
+
export const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1", "[::1]"];
|
|
25
|
+
|
|
26
|
+
/** Proxy variable names that mean "a global proxy is configured". */
|
|
27
|
+
const PROXY_LINE = /^\s*(https?_proxy|all_proxy)\s*=\s*(.+?)\s*$/i;
|
|
28
|
+
/** The one line this module owns. */
|
|
29
|
+
const NO_PROXY_LINE = /^\s*no_proxy\s*=/i;
|
|
30
|
+
|
|
31
|
+
/** Split a bypass list on commas and whitespace, dropping blanks. */
|
|
32
|
+
export function splitEntries(value) {
|
|
33
|
+
if (typeof value !== "string") return [];
|
|
34
|
+
return value
|
|
35
|
+
.split(/[,\s]+/)
|
|
36
|
+
.map((entry) => entry.trim())
|
|
37
|
+
.filter((entry) => entry.length > 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the current NO_PROXY value out of an `.env` body, or "". */
|
|
41
|
+
export function readNoProxy(content) {
|
|
42
|
+
for (const line of String(content ?? "").split(/\r?\n/)) {
|
|
43
|
+
if (NO_PROXY_LINE.test(line)) return line.slice(line.indexOf("=") + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Domains from `domains` that the existing bypass list does not already cover. */
|
|
49
|
+
export function missingDomains(existing, domains) {
|
|
50
|
+
const keys = new Set(splitEntries(existing).map((entry) => entry.toLowerCase()));
|
|
51
|
+
if (keys.has("*")) return [];
|
|
52
|
+
return domains.filter((domain) => {
|
|
53
|
+
const lower = domain.toLowerCase();
|
|
54
|
+
return !keys.has(lower) && !keys.has(`.${lower}`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Merge the loopback entries and the missing domains into an existing bypass list,
|
|
60
|
+
* preserving the caller's entries and order. A list of `*` bypasses everything and
|
|
61
|
+
* is returned unchanged.
|
|
62
|
+
*/
|
|
63
|
+
export function composeNoProxy(existing, domains) {
|
|
64
|
+
const entries = splitEntries(existing);
|
|
65
|
+
if (entries.includes("*")) return "*";
|
|
66
|
+
const seen = new Set(entries.map((entry) => entry.toLowerCase()));
|
|
67
|
+
const out = [...entries];
|
|
68
|
+
for (const domain of domains) {
|
|
69
|
+
// Both spellings: DSH matches a bare suffix against subdomains, while older
|
|
70
|
+
// curl/git/npm want the leading-dot form.
|
|
71
|
+
for (const entry of [domain, `.${domain}`]) {
|
|
72
|
+
const key = entry.toLowerCase();
|
|
73
|
+
if (seen.has(key)) continue;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
out.push(entry);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const entry of LOOPBACK_NO_PROXY) {
|
|
79
|
+
const key = entry.toLowerCase();
|
|
80
|
+
if (seen.has(key)) continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
out.push(entry);
|
|
83
|
+
}
|
|
84
|
+
return out.join(",");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Replace the NO_PROXY line, or append one. Other lines are untouched. */
|
|
88
|
+
export function upsertNoProxy(content, value) {
|
|
89
|
+
const text = String(content ?? "");
|
|
90
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
91
|
+
const hadTrailingNewline = text.endsWith("\n");
|
|
92
|
+
const lines = text.length === 0 ? [] : text.split(/\r?\n/);
|
|
93
|
+
if (hadTrailingNewline && lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
94
|
+
|
|
95
|
+
const next = `NO_PROXY=${value}`;
|
|
96
|
+
let replaced = false;
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (!NO_PROXY_LINE.test(line)) {
|
|
100
|
+
out.push(line);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Keep the first occurrence (rewritten) and drop later duplicates.
|
|
104
|
+
if (!replaced) {
|
|
105
|
+
out.push(next);
|
|
106
|
+
replaced = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!replaced) out.push(next);
|
|
110
|
+
return `${out.join(eol)}${eol}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The `NO_PROXY=` line the user should end up with. */
|
|
114
|
+
export function buildNoProxyLine(existing, domains) {
|
|
115
|
+
return `NO_PROXY=${composeNoProxy(existing, missingDomains(existing, domains))}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The `$DSH_HOME/.env` path. */
|
|
119
|
+
export function envFilePath(home) {
|
|
120
|
+
const root = typeof home === "string" && home.length > 0 ? home : process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? ".", ".dsh");
|
|
121
|
+
return join(root, ".env");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Read and describe the guard state without changing anything. */
|
|
125
|
+
export function inspectGuard({ envPath, domains }) {
|
|
126
|
+
const result = {
|
|
127
|
+
env_path: envPath,
|
|
128
|
+
exists: false,
|
|
129
|
+
proxy_vars: [],
|
|
130
|
+
no_proxy_set: false,
|
|
131
|
+
no_proxy_entries: 0,
|
|
132
|
+
covered: false,
|
|
133
|
+
missing: [...domains],
|
|
134
|
+
suggested_line: `NO_PROXY=${composeNoProxy("", domains)}`,
|
|
135
|
+
needs_apply: true
|
|
136
|
+
};
|
|
137
|
+
let content = "";
|
|
138
|
+
if (existsSync(envPath)) {
|
|
139
|
+
result.exists = true;
|
|
140
|
+
try {
|
|
141
|
+
content = readFileSync(envPath, "utf8");
|
|
142
|
+
} catch (error) {
|
|
143
|
+
result.read_error = error instanceof Error ? error.message : String(error);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const line of content.split(/\r?\n/)) {
|
|
148
|
+
const match = PROXY_LINE.exec(line);
|
|
149
|
+
if (match !== null) result.proxy_vars.push(match[1].toUpperCase());
|
|
150
|
+
}
|
|
151
|
+
const current = readNoProxy(content);
|
|
152
|
+
result.no_proxy_set = current.length > 0;
|
|
153
|
+
result.no_proxy_entries = splitEntries(current).length;
|
|
154
|
+
result.missing = missingDomains(current, domains);
|
|
155
|
+
result.covered = result.missing.length === 0;
|
|
156
|
+
result.suggested_line = buildNoProxyLine(current, domains);
|
|
157
|
+
result.needs_apply = !result.covered;
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Newest backup next to `envPath`, or null. */
|
|
162
|
+
export function latestBackup(envPath) {
|
|
163
|
+
const dir = dirname(envPath);
|
|
164
|
+
if (!existsSync(dir)) return null;
|
|
165
|
+
const prefix = `${basename(envPath)}.bak-`;
|
|
166
|
+
const candidates = readdirSync(dir)
|
|
167
|
+
.filter((name) => name.startsWith(prefix))
|
|
168
|
+
.map((name) => join(dir, name))
|
|
169
|
+
.sort();
|
|
170
|
+
return candidates.length === 0 ? null : candidates[candidates.length - 1];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Merge the AI-platform domains into `$DSH_HOME/.env`'s NO_PROXY entry.
|
|
175
|
+
* Writes nothing when the file is already sufficient.
|
|
176
|
+
* @returns `{ changed, envPath, backupPath, noProxy, added, previous }`.
|
|
177
|
+
*/
|
|
178
|
+
export function applyGuard({ envPath, domains, stamp }) {
|
|
179
|
+
const before = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
180
|
+
const previous = readNoProxy(before);
|
|
181
|
+
const added = missingDomains(previous, domains);
|
|
182
|
+
if (added.length === 0) {
|
|
183
|
+
return { changed: false, envPath, backupPath: null, noProxy: previous, added: [], previous };
|
|
184
|
+
}
|
|
185
|
+
const noProxy = composeNoProxy(previous, added);
|
|
186
|
+
const after = upsertNoProxy(before, noProxy);
|
|
187
|
+
if (after === before) {
|
|
188
|
+
return { changed: false, envPath, backupPath: null, noProxy: previous, added: [], previous };
|
|
189
|
+
}
|
|
190
|
+
let backupPath = null;
|
|
191
|
+
if (existsSync(envPath)) {
|
|
192
|
+
const suffix = stamp ?? new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
|
193
|
+
backupPath = `${envPath}.bak-${suffix}`;
|
|
194
|
+
copyFileSync(envPath, backupPath);
|
|
195
|
+
} else {
|
|
196
|
+
mkdirSync(dirname(envPath), { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
writeFileSync(envPath, after, "utf8");
|
|
199
|
+
return { changed: true, envPath, backupPath, noProxy, added, previous };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Restore the newest backup over `$DSH_HOME/.env`. */
|
|
203
|
+
export function restoreGuard({ envPath }) {
|
|
204
|
+
const backupPath = latestBackup(envPath);
|
|
205
|
+
if (backupPath === null) throw new Error(`${envPath} 旁边找不到可还原的备份`);
|
|
206
|
+
copyFileSync(backupPath, envPath);
|
|
207
|
+
return { restored: true, envPath, backupPath };
|
|
208
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分享链接(share link)订阅解析。
|
|
3
|
+
*
|
|
4
|
+
* 市面上很大一部分机场返回的不是 Clash YAML,而是**一行一个节点 URI**:
|
|
5
|
+
* base64 裹一层,解开是 `hysteria2://…` / `ss://…` / `trojan://…` / `vless://…`。
|
|
6
|
+
* 本模块把它转成与 Clash YAML `proxies:` 完全一致的节点对象,这样下游
|
|
7
|
+
* (规则引擎、`connectThrough`)一行都不用改。
|
|
8
|
+
*
|
|
9
|
+
* 解析结果里保留原始 URI 的语义字段;**不认识的参数不猜**,宁可少写。
|
|
10
|
+
* @module dsh-downloader/share-links
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** base64 解码,容忍 urlsafe 与缺失的 padding。 */
|
|
14
|
+
function decodeBase64(text) {
|
|
15
|
+
const normalized = String(text).replace(/-/g, "+").replace(/_/g, "/").replace(/\s+/g, "");
|
|
16
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
17
|
+
return Buffer.from(padded, "base64").toString("utf8");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 是否是 base64(而不是明文)。 */
|
|
21
|
+
function looksBase64(text) {
|
|
22
|
+
const compact = String(text).replace(/\s+/g, "");
|
|
23
|
+
if (compact.length === 0 || compact.length % 4 === 1) return false;
|
|
24
|
+
return /^[A-Za-z0-9+/=_-]+$/.test(compact) && !/:/.test(compact.slice(0, 32));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `#名称` → 名称(解码失败就用原文)。 */
|
|
28
|
+
function fragmentName(hash) {
|
|
29
|
+
if (typeof hash !== "string" || hash.length === 0) return "";
|
|
30
|
+
const text = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
31
|
+
if (text.length === 0) return "";
|
|
32
|
+
try {
|
|
33
|
+
return decodeURIComponent(text).trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return text.trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 布尔开关:`1` / `true` / 存在。 */
|
|
40
|
+
function truthyParam(value) {
|
|
41
|
+
if (value === null || value === undefined) return false;
|
|
42
|
+
const text = String(value).toLowerCase();
|
|
43
|
+
return text === "1" || text === "true" || text === "yes";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 把一个 URI 拆成各段。 */
|
|
47
|
+
function dissect(uri) {
|
|
48
|
+
const hashAt = uri.indexOf("#");
|
|
49
|
+
const hash = hashAt === -1 ? "" : uri.slice(hashAt);
|
|
50
|
+
let rest = hashAt === -1 ? uri : uri.slice(0, hashAt);
|
|
51
|
+
const schemeEnd = rest.indexOf("://");
|
|
52
|
+
const scheme = rest.slice(0, schemeEnd).toLowerCase();
|
|
53
|
+
rest = rest.slice(schemeEnd + 3);
|
|
54
|
+
const queryAt = rest.indexOf("?");
|
|
55
|
+
const query = queryAt === -1 ? "" : rest.slice(queryAt + 1);
|
|
56
|
+
if (queryAt !== -1) rest = rest.slice(0, queryAt);
|
|
57
|
+
const slashAt = rest.indexOf("/");
|
|
58
|
+
const authority = slashAt === -1 ? rest : rest.slice(0, slashAt);
|
|
59
|
+
return { scheme, authority, query, hash, params: new URLSearchParams(query) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `user:pass@host:port` → 拆开;没有 `@` 时整个都是 host:port。 */
|
|
63
|
+
function splitAuthority(authority) {
|
|
64
|
+
const at = authority.lastIndexOf("@");
|
|
65
|
+
if (at === -1) return { userinfo: "", host: authority };
|
|
66
|
+
return { userinfo: authority.slice(0, at), host: authority.slice(at + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `host:port` → `{ host, port }`,支持 `[v6]:port`。 */
|
|
70
|
+
function splitHostPort(value) {
|
|
71
|
+
const text = String(value);
|
|
72
|
+
if (text.startsWith("[")) {
|
|
73
|
+
const end = text.indexOf("]");
|
|
74
|
+
const host = text.slice(1, end);
|
|
75
|
+
const port = text.slice(end + 2);
|
|
76
|
+
return { host, port: Number.parseInt(port, 10) };
|
|
77
|
+
}
|
|
78
|
+
const colon = text.lastIndexOf(":");
|
|
79
|
+
if (colon === -1) return { host: text, port: Number.NaN };
|
|
80
|
+
return { host: text.slice(0, colon), port: Number.parseInt(text.slice(colon + 1), 10) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 解析 `user:pass`(各段会做 URL 解码)。 */
|
|
84
|
+
function splitUserinfo(userinfo) {
|
|
85
|
+
if (userinfo.length === 0) return { username: "", password: "" };
|
|
86
|
+
const colon = userinfo.indexOf(":");
|
|
87
|
+
const decode = (value) => {
|
|
88
|
+
try {
|
|
89
|
+
return decodeURIComponent(value);
|
|
90
|
+
} catch {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
if (colon === -1) return { username: decode(userinfo), password: "" };
|
|
95
|
+
return { username: decode(userinfo.slice(0, colon)), password: decode(userinfo.slice(colon + 1)) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 传输层相关参数(ws / grpc / h2)→ Clash 字段。 */
|
|
99
|
+
function networkFields(params, scheme) {
|
|
100
|
+
const network = (params.get("type") ?? "tcp").toLowerCase();
|
|
101
|
+
const fields = { network };
|
|
102
|
+
if (network === "ws") {
|
|
103
|
+
const path = params.get("path") ?? "/";
|
|
104
|
+
const host = params.get("host") ?? "";
|
|
105
|
+
fields["ws-opts"] = host.length > 0 ? { path, headers: { Host: host } } : { path };
|
|
106
|
+
}
|
|
107
|
+
if (network === "grpc") fields["grpc-opts"] = { "grpc-service-name": params.get("serviceName") ?? "" };
|
|
108
|
+
if (scheme === "http" && network === "h2") fields["h2-opts"] = { path: params.get("path") ?? "/", host: [params.get("host") ?? ""] };
|
|
109
|
+
return fields;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SKIP = Symbol("skip");
|
|
113
|
+
|
|
114
|
+
/** `ss://` —— 三种写法都要认。 */
|
|
115
|
+
function parseSs(uri, name) {
|
|
116
|
+
const { authority, query, params } = dissect(uri);
|
|
117
|
+
const decodedAuthority = looksBase64(authority) && !authority.includes(":") ? decodeBase64(authority) : authority;
|
|
118
|
+
const { userinfo, host } = splitAuthority(decodedAuthority);
|
|
119
|
+
let cipher = "";
|
|
120
|
+
let password = "";
|
|
121
|
+
if (userinfo.length > 0 && !userinfo.includes(":")) {
|
|
122
|
+
// ss://base64(method:password)@host:port
|
|
123
|
+
const inner = looksBase64(userinfo) || !userinfo.includes(":") ? decodeBase64(userinfo) : userinfo;
|
|
124
|
+
const colon = inner.indexOf(":");
|
|
125
|
+
cipher = colon === -1 ? inner : inner.slice(0, colon);
|
|
126
|
+
password = colon === -1 ? "" : inner.slice(colon + 1);
|
|
127
|
+
} else {
|
|
128
|
+
const pair = splitUserinfo(userinfo);
|
|
129
|
+
cipher = pair.username;
|
|
130
|
+
password = pair.password;
|
|
131
|
+
}
|
|
132
|
+
const target = splitHostPort(host);
|
|
133
|
+
if (!target.host) return SKIP;
|
|
134
|
+
const node = { name, type: "ss", server: target.host, port: target.port, cipher, password };
|
|
135
|
+
if (query.length > 0 && params.get("plugin")) {
|
|
136
|
+
// 带插件的 ss 需要额外实现,交回调用方记为「不支持」。
|
|
137
|
+
node.__unsupported = `ss 插件 ${params.get("plugin")}`;
|
|
138
|
+
}
|
|
139
|
+
return node;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `trojan://` */
|
|
143
|
+
function parseTrojan(uri, name) {
|
|
144
|
+
const { authority, params } = dissect(uri);
|
|
145
|
+
const { userinfo, host } = splitAuthority(authority);
|
|
146
|
+
const target = splitHostPort(host);
|
|
147
|
+
if (!target.host) return SKIP;
|
|
148
|
+
const node = {
|
|
149
|
+
...networkFields(params, "trojan"),
|
|
150
|
+
name,
|
|
151
|
+
type: "trojan",
|
|
152
|
+
server: target.host,
|
|
153
|
+
port: target.port,
|
|
154
|
+
password: splitUserinfo(userinfo).username
|
|
155
|
+
};
|
|
156
|
+
const sni = params.get("sni") ?? params.get("peer");
|
|
157
|
+
if (sni !== null) node.sni = sni;
|
|
158
|
+
if (truthyParam(params.get("allowInsecure")) || truthyParam(params.get("insecure"))) node["skip-cert-verify"] = true;
|
|
159
|
+
if (params.get("alpn")) node.alpn = params.get("alpn").split(",");
|
|
160
|
+
return node;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `vmess://base64(json)` */
|
|
164
|
+
function parseVmess(uri, name) {
|
|
165
|
+
const body = uri.slice(uri.indexOf("://") + 3);
|
|
166
|
+
const json = decodeBase64(body.split("#")[0]);
|
|
167
|
+
let spec;
|
|
168
|
+
try {
|
|
169
|
+
spec = JSON.parse(json);
|
|
170
|
+
} catch {
|
|
171
|
+
return SKIP;
|
|
172
|
+
}
|
|
173
|
+
if (spec === null || typeof spec !== "object" || spec.add === undefined) return SKIP;
|
|
174
|
+
const node = {
|
|
175
|
+
name: name.length > 0 ? name : String(spec.ps ?? `${spec.add}:${spec.port}`),
|
|
176
|
+
type: "vmess",
|
|
177
|
+
server: String(spec.add),
|
|
178
|
+
port: Number.parseInt(String(spec.port), 10),
|
|
179
|
+
uuid: String(spec.id ?? ""),
|
|
180
|
+
alterId: Number.parseInt(String(spec.aid ?? 0), 10) || 0,
|
|
181
|
+
cipher: String(spec.scy ?? "auto")
|
|
182
|
+
};
|
|
183
|
+
if (String(spec.tls ?? "").toLowerCase() === "tls") node.tls = true;
|
|
184
|
+
const sni = spec.sni ?? spec.host;
|
|
185
|
+
if (sni) node.servername = String(sni);
|
|
186
|
+
const network = String(spec.net ?? "tcp").toLowerCase();
|
|
187
|
+
node.network = network;
|
|
188
|
+
if (network === "ws") {
|
|
189
|
+
const headers = spec.host ? { Host: String(spec.host) } : {};
|
|
190
|
+
node["ws-opts"] = { path: String(spec.path ?? "/"), headers };
|
|
191
|
+
}
|
|
192
|
+
if (network === "grpc") node["grpc-opts"] = { "grpc-service-name": String(spec.path ?? "") };
|
|
193
|
+
if (spec.fp) node["client-fingerprint"] = String(spec.fp);
|
|
194
|
+
return node;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `vless://` */
|
|
198
|
+
function parseVless(uri, name) {
|
|
199
|
+
const { authority, params } = dissect(uri);
|
|
200
|
+
const { userinfo, host } = splitAuthority(authority);
|
|
201
|
+
const target = splitHostPort(host);
|
|
202
|
+
if (!target.host) return SKIP;
|
|
203
|
+
const security = (params.get("security") ?? "none").toLowerCase();
|
|
204
|
+
const node = {
|
|
205
|
+
...networkFields(params, "vless"),
|
|
206
|
+
name,
|
|
207
|
+
type: "vless",
|
|
208
|
+
server: target.host,
|
|
209
|
+
port: target.port,
|
|
210
|
+
uuid: splitUserinfo(userinfo).username
|
|
211
|
+
};
|
|
212
|
+
if (security === "tls" || security === "reality" || security === "xtls") node.tls = true;
|
|
213
|
+
const sni = params.get("sni") ?? params.get("peer") ?? params.get("host");
|
|
214
|
+
if (sni) node.servername = sni;
|
|
215
|
+
if (params.get("flow")) node.flow = params.get("flow");
|
|
216
|
+
if (params.get("fp")) node["client-fingerprint"] = params.get("fp");
|
|
217
|
+
if (params.get("alpn")) node.alpn = params.get("alpn").split(",");
|
|
218
|
+
if (truthyParam(params.get("allowInsecure")) || truthyParam(params.get("insecure"))) node["skip-cert-verify"] = true;
|
|
219
|
+
if (security === "reality") {
|
|
220
|
+
const publicKey = params.get("pbk") ?? "";
|
|
221
|
+
if (publicKey.length === 0) return SKIP;
|
|
222
|
+
node["reality-opts"] = { "public-key": publicKey, "short-id": params.get("sid") ?? "" };
|
|
223
|
+
}
|
|
224
|
+
return node;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `hysteria2://` / `hy2://` */
|
|
228
|
+
function parseHysteria2(uri, name) {
|
|
229
|
+
const { authority, params } = dissect(uri);
|
|
230
|
+
const { userinfo, host } = splitAuthority(authority);
|
|
231
|
+
const target = splitHostPort(host);
|
|
232
|
+
if (!target.host) return SKIP;
|
|
233
|
+
const node = {
|
|
234
|
+
name,
|
|
235
|
+
type: "hysteria2",
|
|
236
|
+
server: target.host,
|
|
237
|
+
port: target.port,
|
|
238
|
+
password: splitUserinfo(userinfo).username
|
|
239
|
+
};
|
|
240
|
+
const sni = params.get("sni") ?? params.get("peer");
|
|
241
|
+
if (sni) node.sni = sni;
|
|
242
|
+
if (truthyParam(params.get("insecure")) || truthyParam(params.get("allowInsecure"))) node["skip-cert-verify"] = true;
|
|
243
|
+
if (params.get("obfs")) node.obfs = params.get("obfs");
|
|
244
|
+
if (params.get("obfs-password")) node["obfs-password"] = params.get("obfs-password");
|
|
245
|
+
if (params.get("alpn")) node.alpn = params.get("alpn").split(",");
|
|
246
|
+
if (params.get("mport") ?? params.get("ports")) node.ports = params.get("mport") ?? params.get("ports");
|
|
247
|
+
return node;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `socks://` / `socks5://` */
|
|
251
|
+
function parseSocks(uri, name) {
|
|
252
|
+
const { authority, params } = dissect(uri);
|
|
253
|
+
const { userinfo, host } = splitAuthority(authority);
|
|
254
|
+
const target = splitHostPort(host);
|
|
255
|
+
if (!target.host) return SKIP;
|
|
256
|
+
const pair = splitUserinfo(userinfo);
|
|
257
|
+
const node = { name, type: "socks5", server: target.host, port: target.port };
|
|
258
|
+
if (pair.username.length > 0) node.username = pair.username;
|
|
259
|
+
if (pair.password.length > 0) node.password = pair.password;
|
|
260
|
+
if (truthyParam(params.get("tls"))) node.tls = true;
|
|
261
|
+
return node;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `http://` / `https://` 作为节点(不是订阅地址)时使用。 */
|
|
265
|
+
function parseHttpNode(uri, name, scheme) {
|
|
266
|
+
const { authority, params } = dissect(uri);
|
|
267
|
+
const { userinfo, host } = splitAuthority(authority);
|
|
268
|
+
const target = splitHostPort(host);
|
|
269
|
+
if (!target.host) return SKIP;
|
|
270
|
+
const pair = splitUserinfo(userinfo);
|
|
271
|
+
const node = { name, type: scheme === "https" ? "http" : "http", server: target.host, port: target.port };
|
|
272
|
+
if (scheme === "https") node.tls = true;
|
|
273
|
+
if (pair.username.length > 0) node.username = pair.username;
|
|
274
|
+
if (pair.password.length > 0) node.password = pair.password;
|
|
275
|
+
if (truthyParam(params.get("allowInsecure"))) node["skip-cert-verify"] = true;
|
|
276
|
+
return node;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 解析一行分享链接。
|
|
281
|
+
* @returns 节点对象;无法处理时返回 `null`。
|
|
282
|
+
*/
|
|
283
|
+
export function parseShareLink(line) {
|
|
284
|
+
const text = String(line ?? "").trim();
|
|
285
|
+
const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(text);
|
|
286
|
+
if (schemeMatch === null) return null;
|
|
287
|
+
const scheme = schemeMatch[1].toLowerCase();
|
|
288
|
+
const { hash } = dissect(text);
|
|
289
|
+
const rawName = fragmentName(hash);
|
|
290
|
+
|
|
291
|
+
let node = null;
|
|
292
|
+
switch (scheme) {
|
|
293
|
+
case "ss":
|
|
294
|
+
node = parseSs(text, rawName);
|
|
295
|
+
break;
|
|
296
|
+
case "trojan":
|
|
297
|
+
node = parseTrojan(text, rawName);
|
|
298
|
+
break;
|
|
299
|
+
case "vmess":
|
|
300
|
+
node = parseVmess(text, rawName);
|
|
301
|
+
break;
|
|
302
|
+
case "vless":
|
|
303
|
+
node = parseVless(text, rawName);
|
|
304
|
+
break;
|
|
305
|
+
case "hysteria2":
|
|
306
|
+
case "hy2":
|
|
307
|
+
node = parseHysteria2(text, rawName);
|
|
308
|
+
break;
|
|
309
|
+
case "socks":
|
|
310
|
+
case "socks5":
|
|
311
|
+
node = parseSocks(text, rawName);
|
|
312
|
+
break;
|
|
313
|
+
case "http":
|
|
314
|
+
case "https":
|
|
315
|
+
node = parseHttpNode(text, rawName, scheme);
|
|
316
|
+
break;
|
|
317
|
+
default:
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
if (node === SKIP || node === null) return null;
|
|
321
|
+
if (!node.name || node.name.length === 0) node.name = `${node.server}:${node.port}`;
|
|
322
|
+
if (!Number.isFinite(node.port) || node.port <= 0 || node.port > 65535) return null;
|
|
323
|
+
return node;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 解析整份分享链接列表。
|
|
328
|
+
*
|
|
329
|
+
* 调用方通常先做一次 base64 解包:本函数**也会**自己做一次,
|
|
330
|
+
* 这样明文列表与 base64 列表都能直接吃。
|
|
331
|
+
* @returns `{ proxies, names, skipped }`;一行都没解析出来时返回 null。
|
|
332
|
+
*/
|
|
333
|
+
export function parseShareLinkList(raw) {
|
|
334
|
+
let text = String(raw ?? "");
|
|
335
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
336
|
+
// 整份是 base64(且不是 URI 列表)时先解开。
|
|
337
|
+
const first = lines[0] ?? "";
|
|
338
|
+
if (lines.length <= 2 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(first) && looksBase64(first)) {
|
|
339
|
+
try {
|
|
340
|
+
text = decodeBase64(first);
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
text = raw;
|
|
346
|
+
}
|
|
347
|
+
const candidates = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
348
|
+
|
|
349
|
+
const proxies = [];
|
|
350
|
+
const skipped = [];
|
|
351
|
+
for (const line of candidates) {
|
|
352
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(line)) {
|
|
353
|
+
skipped.push({ line: line.slice(0, 40), reason: "不是分享链接" });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const node = parseShareLink(line);
|
|
357
|
+
if (node === null) {
|
|
358
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(line)?.[1] ?? "?";
|
|
359
|
+
skipped.push({ line: `${scheme}://…`, reason: `暂不支持的协议或参数:${scheme}` });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (typeof node.__unsupported === "string") {
|
|
363
|
+
skipped.push({ line: `${node.type}://…`, reason: node.__unsupported });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
delete node.__unsupported;
|
|
367
|
+
proxies.push(node);
|
|
368
|
+
}
|
|
369
|
+
if (proxies.length === 0) return null;
|
|
370
|
+
return { proxies, names: proxies.map((node) => node.name), skipped };
|
|
371
|
+
}
|