whistle.easy-proxy 1.0.0
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/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/easy-proxy.js +14 -0
- package/index.js +125 -0
- package/lib/cert-check.js +211 -0
- package/lib/cli/bypass-domains.js +187 -0
- package/lib/cli/commands/accel.js +184 -0
- package/lib/cli/commands/cert.js +35 -0
- package/lib/cli/commands/config-cmd.js +50 -0
- package/lib/cli/commands/health.js +53 -0
- package/lib/cli/commands/host.js +79 -0
- package/lib/cli/commands/index.js +31 -0
- package/lib/cli/commands/intercept.js +222 -0
- package/lib/cli/commands/network.js +32 -0
- package/lib/cli/commands/proxy.js +279 -0
- package/lib/cli/commands/rule.js +352 -0
- package/lib/cli/commands/start.js +98 -0
- package/lib/cli/commands/status.js +50 -0
- package/lib/cli/helpers.js +137 -0
- package/lib/cli/proxy-config.js +41 -0
- package/lib/cli/rule-api.js +230 -0
- package/lib/cli/rule-editor.js +362 -0
- package/lib/cli/rules-file.js +121 -0
- package/lib/cli/upstream-editor.js +218 -0
- package/lib/cli/upstream-proxy.js +74 -0
- package/lib/config.js +204 -0
- package/lib/debug.js +17 -0
- package/lib/network-utils.js +56 -0
- package/lib/plugin/default-hosts.js +48 -0
- package/lib/plugin/default-rules.js +668 -0
- package/lib/plugin/resolver.js +228 -0
- package/lib/plugin/rule-actions.js +344 -0
- package/lib/plugin/rule-engine.js +232 -0
- package/lib/plugin/rule-generator.js +100 -0
- package/lib/plugin/sni-agent.js +54 -0
- package/lib/plugin/ui.js +430 -0
- package/lib/proxy-check.js +67 -0
- package/package.json +64 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 拦截规则匹配引擎
|
|
3
|
+
*
|
|
4
|
+
* 负责 host + path 模式匹配,返回合并后的动作集合。
|
|
5
|
+
* 支持: 精确匹配、通配符 (*.example.com / *example.com)、正则表达式。
|
|
6
|
+
* 支持正则命名捕获组,用于 proxy_backup 模板变量替换。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---- URL 工具 ----
|
|
10
|
+
|
|
11
|
+
function extractHost(fullUrl) {
|
|
12
|
+
if (!fullUrl) return null;
|
|
13
|
+
try {
|
|
14
|
+
let u = fullUrl;
|
|
15
|
+
if (!/^https?:\/\//i.test(u)) u = `http://${u.replace(/^\/\//, '')}`;
|
|
16
|
+
const url = new URL(u);
|
|
17
|
+
return url.hostname;
|
|
18
|
+
} catch (_e) {
|
|
19
|
+
const m = /(?:https?:\/\/)?([^/:?]+)/i.exec(fullUrl);
|
|
20
|
+
return m ? m[1] : null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractPath(fullUrl) {
|
|
25
|
+
if (!fullUrl) return '/';
|
|
26
|
+
try {
|
|
27
|
+
let u = fullUrl;
|
|
28
|
+
if (!/^https?:\/\//i.test(u)) u = `http://${u.replace(/^\/\//, '')}`;
|
|
29
|
+
const url = new URL(u);
|
|
30
|
+
return url.pathname + url.search;
|
|
31
|
+
} catch (_e) {
|
|
32
|
+
const m = /https?:\/\/[^/]+(\/[^\s]*)/i.exec(fullUrl);
|
|
33
|
+
return m ? m[1] : '/';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- Host 匹配 ----
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 判断 hostPattern 是否为正则表达式。
|
|
41
|
+
* 以 ^ 开头、或包含正则元字符 ([, ], (, ), {, }, |, +, ?, <, =) 视为正则。
|
|
42
|
+
*/
|
|
43
|
+
function isRegexPattern(pattern) {
|
|
44
|
+
if (!pattern) return false;
|
|
45
|
+
// 以 ^ 开头一定是正则
|
|
46
|
+
if (pattern[0] === '^') return true;
|
|
47
|
+
// 包含典型正则元字符(但不是简单的 * 通配符)
|
|
48
|
+
if (/[\\[\](){}|+?<=>!]/.test(pattern)) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 匹配 host 模式。返回 { matched: bool, groups: object|null }。
|
|
54
|
+
* groups 包含正则命名捕获组(用于 proxy_backup 模板替换)。
|
|
55
|
+
*/
|
|
56
|
+
function matchHost(hostPattern, host) {
|
|
57
|
+
if (!hostPattern || !host) return { matched: false, groups: null };
|
|
58
|
+
|
|
59
|
+
// 正则匹配
|
|
60
|
+
if (isRegexPattern(hostPattern)) {
|
|
61
|
+
try {
|
|
62
|
+
const re = new RegExp(hostPattern);
|
|
63
|
+
const m = re.exec(host);
|
|
64
|
+
if (m) {
|
|
65
|
+
return { matched: true, groups: m.groups || null };
|
|
66
|
+
}
|
|
67
|
+
return { matched: false, groups: null };
|
|
68
|
+
} catch (_e) {
|
|
69
|
+
return { matched: false, groups: null };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 精确匹配
|
|
74
|
+
if (hostPattern === host) {
|
|
75
|
+
return { matched: true, groups: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 通配符: *.example.com → 匹配 sub.example.com
|
|
79
|
+
if (hostPattern.startsWith('*.')) {
|
|
80
|
+
const suffix = hostPattern.slice(1); // .example.com
|
|
81
|
+
return { matched: host.endsWith(suffix), groups: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 通配符: *example.com → 匹配 example.com / sub.example.com /anythingexample.com
|
|
85
|
+
if (hostPattern.startsWith('*')) {
|
|
86
|
+
const rest = hostPattern.slice(1);
|
|
87
|
+
return { matched: host.endsWith(rest) || host === rest, groups: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// clients*.google.com → 通配符在中间
|
|
91
|
+
if (hostPattern.indexOf('*') >= 0) {
|
|
92
|
+
// 转为正则: 将 * 替换为 .*, 转义其他特殊字符
|
|
93
|
+
const reStr = `^${hostPattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`;
|
|
94
|
+
try {
|
|
95
|
+
const re = new RegExp(reStr);
|
|
96
|
+
return { matched: re.test(host), groups: null };
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
return { matched: false, groups: null };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { matched: false, groups: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Path 匹配 ----
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 匹配 path 模式。返回 bool。
|
|
109
|
+
* "*" 匹配所有路径;其他视为正则。
|
|
110
|
+
*/
|
|
111
|
+
function matchPath(pathPattern, path) {
|
|
112
|
+
if (!pathPattern) return false;
|
|
113
|
+
|
|
114
|
+
// * 匹配所有
|
|
115
|
+
if (pathPattern === '*') return true;
|
|
116
|
+
|
|
117
|
+
// 正则匹配
|
|
118
|
+
try {
|
|
119
|
+
const re = new RegExp(pathPattern);
|
|
120
|
+
return re.test(path);
|
|
121
|
+
} catch (_e) {
|
|
122
|
+
// 正则编译失败,尝试精确匹配
|
|
123
|
+
return path === pathPattern;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- 核心匹配函数 ----
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 对给定 host 和 path,在规则集中查找所有匹配的规则。
|
|
131
|
+
* 合并所有匹配的 path pattern 的动作(后匹配的覆盖先匹配的)。
|
|
132
|
+
*
|
|
133
|
+
* @param {string} host - 请求主机名
|
|
134
|
+
* @param {string} path - 请求路径 (含 query string)
|
|
135
|
+
* @param {object} rules - 拦截规则 { hostPattern: { pathPattern: { actions } } }
|
|
136
|
+
* @returns {object|null} { actions: {...}, groups: {...}|null, matches: [...] } 或 null
|
|
137
|
+
*/
|
|
138
|
+
function matchRules(host, path, rules) {
|
|
139
|
+
if (!rules || typeof rules !== 'object') return null;
|
|
140
|
+
|
|
141
|
+
const mergedActions = {};
|
|
142
|
+
const matchedList = [];
|
|
143
|
+
let captureGroups = null;
|
|
144
|
+
|
|
145
|
+
for (const hostPattern of Object.keys(rules)) {
|
|
146
|
+
const hostResult = matchHost(hostPattern, host);
|
|
147
|
+
if (!hostResult.matched) continue;
|
|
148
|
+
|
|
149
|
+
const pathRules = rules[hostPattern];
|
|
150
|
+
if (!pathRules || typeof pathRules !== 'object') continue;
|
|
151
|
+
|
|
152
|
+
// 记录正则捕获组(取最后一个匹配的,通常只有一个正则 host pattern 匹配)
|
|
153
|
+
if (hostResult.groups) {
|
|
154
|
+
captureGroups = hostResult.groups;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const pathPattern of Object.keys(pathRules)) {
|
|
158
|
+
if (!matchPath(pathPattern, path)) continue;
|
|
159
|
+
|
|
160
|
+
const actions = pathRules[pathPattern];
|
|
161
|
+
if (!actions || typeof actions !== 'object') continue;
|
|
162
|
+
|
|
163
|
+
matchedList.push({
|
|
164
|
+
hostPattern,
|
|
165
|
+
pathPattern,
|
|
166
|
+
actions: { ...actions },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// 合并动作(后匹配覆盖先匹配,但只合并非 undefined 的字段)
|
|
170
|
+
for (const key of Object.keys(actions)) {
|
|
171
|
+
if (actions[key] !== undefined) {
|
|
172
|
+
mergedActions[key] = actions[key];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 过滤掉纯文档字段,得到实际动作
|
|
179
|
+
const docFields = ['desc', 'remark', 'test'];
|
|
180
|
+
const realActions = {};
|
|
181
|
+
for (const key of Object.keys(mergedActions)) {
|
|
182
|
+
if (docFields.indexOf(key) < 0) {
|
|
183
|
+
realActions[key] = mergedActions[key];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (Object.keys(realActions).length === 0) return null;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
actions: realActions,
|
|
191
|
+
groups: captureGroups,
|
|
192
|
+
matches: matchedList,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- 模板变量替换 ----
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 将 ${var} 模板变量用捕获组的值替换。
|
|
200
|
+
* 例如: "${pre}.gvt1.com" + groups: { pre: "rr1" } → "rr1.gvt1.com"
|
|
201
|
+
*/
|
|
202
|
+
function substituteTemplate(str, groups) {
|
|
203
|
+
if (!str || !groups) return str;
|
|
204
|
+
return str.replace(/\$\{(\w+)\}/g, (match, name) => {
|
|
205
|
+
return groups[name] !== undefined ? groups[name] : match;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- 提取所有域名模式 ----
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 从规则中提取所有唯一的 host 模式,用于生成 whistle 路由规则。
|
|
213
|
+
*/
|
|
214
|
+
function extractHostPatterns(rules) {
|
|
215
|
+
const set = new Set();
|
|
216
|
+
if (!rules || typeof rules !== 'object') return [];
|
|
217
|
+
for (const hostPattern of Object.keys(rules)) {
|
|
218
|
+
set.add(hostPattern);
|
|
219
|
+
}
|
|
220
|
+
return Array.from(set);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
extractHost,
|
|
225
|
+
extractPath,
|
|
226
|
+
isRegexPattern,
|
|
227
|
+
matchHost,
|
|
228
|
+
matchPath,
|
|
229
|
+
matchRules,
|
|
230
|
+
substituteTemplate,
|
|
231
|
+
extractHostPatterns,
|
|
232
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从拦截规则生成 whistle rules.txt 内容。
|
|
3
|
+
*
|
|
4
|
+
* 将每个 host 模式转为 whistle 规则格式:
|
|
5
|
+
* - 精确域名: example.com easy-proxy://
|
|
6
|
+
* - 通配符: *.example.com easy-proxy:// / *example.com easy-proxy://
|
|
7
|
+
* - 中间通配: client*.google.com → /^client.*\.google\.com$/ easy-proxy://
|
|
8
|
+
* - 正则: /^pattern$/ easy-proxy://
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function isRegexPattern(pattern) {
|
|
12
|
+
if (!pattern) return false;
|
|
13
|
+
if (pattern[0] === '^') return true;
|
|
14
|
+
if (/[\\[\](){}|+?<=>!]/.test(pattern)) return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasMiddleWildcard(pattern) {
|
|
19
|
+
// * 不在开头且不是 *. 形式
|
|
20
|
+
if (!pattern || pattern[0] === '*') return false;
|
|
21
|
+
return pattern.indexOf('*') >= 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function wildcardToRegex(pattern) {
|
|
25
|
+
// 转为 /^...$/ 正则: 转义特殊字符, * → .*
|
|
26
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
27
|
+
return `/^${escaped}$/`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hostPatternToRule(hostPattern) {
|
|
31
|
+
if (isRegexPattern(hostPattern)) {
|
|
32
|
+
return `/${hostPattern}/ easy-proxy://`;
|
|
33
|
+
}
|
|
34
|
+
if (hasMiddleWildcard(hostPattern)) {
|
|
35
|
+
return `${wildcardToRegex(hostPattern)} easy-proxy://`;
|
|
36
|
+
}
|
|
37
|
+
return `${hostPattern} easy-proxy://`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 从拦截规则对象生成 rules.txt 文本。
|
|
42
|
+
*/
|
|
43
|
+
function generateRulesTxt(rules) {
|
|
44
|
+
const lines = [
|
|
45
|
+
'# EasyProxy — auto-loaded global rules',
|
|
46
|
+
'# Routes interception-rule domains to the easy-proxy plugin protocol,',
|
|
47
|
+
'# which triggers server hook for SNI rewrite / proxy / cache / headers / etc.',
|
|
48
|
+
'# No manual configuration needed. Remove lines to disable per-domain.',
|
|
49
|
+
'',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const categories = {
|
|
54
|
+
GitHub: [],
|
|
55
|
+
Google: [],
|
|
56
|
+
'Microsoft / Docker': [],
|
|
57
|
+
Pixiv: [],
|
|
58
|
+
YouTube: [],
|
|
59
|
+
Steam: [],
|
|
60
|
+
'CDN / 镜像': [],
|
|
61
|
+
其他: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function categorize(pattern) {
|
|
65
|
+
if (/github|githubusercontent|githubassets|gravatar/.test(pattern)) return 'GitHub';
|
|
66
|
+
if (
|
|
67
|
+
/google|googleapis|googleusercontent|gstatic|googlevideo|ggpht|ytimg|recaptcha/.test(pattern)
|
|
68
|
+
)
|
|
69
|
+
return 'Google';
|
|
70
|
+
if (/docker|microsoft|windows/.test(pattern)) return 'Microsoft / Docker';
|
|
71
|
+
if (/pixiv|pximg|nikke/.test(pattern)) return 'Pixiv';
|
|
72
|
+
if (/youtube|youtu\.be|nocookie/.test(pattern)) return 'YouTube';
|
|
73
|
+
if (/steam/.test(pattern)) return 'Steam';
|
|
74
|
+
if (
|
|
75
|
+
/jsdelivr|greasyfork|huggingface|vuejs|elastic|launchpad|openwrt|maven|npm|gvt|loli|ustclug|fastgit|aks\.moe|pai233|incept/.test(
|
|
76
|
+
pattern
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return 'CDN / 镜像';
|
|
80
|
+
return '其他';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const hostPattern of Object.keys(rules || {})) {
|
|
84
|
+
if (seen.has(hostPattern)) continue;
|
|
85
|
+
seen.add(hostPattern);
|
|
86
|
+
const cat = categorize(hostPattern);
|
|
87
|
+
categories[cat].push(hostPatternToRule(hostPattern));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const cat of Object.keys(categories)) {
|
|
91
|
+
if (categories[cat].length === 0) continue;
|
|
92
|
+
lines.push(`# ${cat}`);
|
|
93
|
+
lines.push.apply(lines, categories[cat]);
|
|
94
|
+
lines.push('');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return lines.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { generateRulesTxt, hostPatternToRule };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const https = require('node:https');
|
|
2
|
+
const { debug } = require('../debug');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom https.Agent for SNI-aware forwarding.
|
|
6
|
+
*
|
|
7
|
+
* Key technique: set `host` to real IP (direct connect, no DNS needed),
|
|
8
|
+
* `servername` to desired SNI value (can be rewritten to bypass GFW detection).
|
|
9
|
+
* whistle's req.request() natively supports this — no custom lookup needed.
|
|
10
|
+
*/
|
|
11
|
+
function createSniAgent(opts) {
|
|
12
|
+
opts = opts || {};
|
|
13
|
+
return new https.Agent({
|
|
14
|
+
keepAlive: opts.keepAlive !== false,
|
|
15
|
+
maxFreeSockets: 256,
|
|
16
|
+
rejectUnauthorized: opts.rejectUnauthorized === undefined ? true : opts.rejectUnauthorized,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Forward request via direct-connect IP + custom SNI in whistle server hook.
|
|
22
|
+
* req.request() makes whistle auto-write upstream response to client (preserves packet capture).
|
|
23
|
+
*
|
|
24
|
+
* @param {object} req whistle PluginServerRequest
|
|
25
|
+
* @param {object} params { domain, ip, port, servername, ca, rejectUnauthorized }
|
|
26
|
+
* @param {function} [onResponse] 可选,收到上游响应后、写入 client 前的回调,用于修改响应头
|
|
27
|
+
*/
|
|
28
|
+
function forward(req, params, onResponse) {
|
|
29
|
+
const { domain, ip, port, servername, ca, rejectUnauthorized } = params;
|
|
30
|
+
const agent = createSniAgent({ rejectUnauthorized });
|
|
31
|
+
|
|
32
|
+
const opts = {
|
|
33
|
+
host: ip,
|
|
34
|
+
port: port || 443,
|
|
35
|
+
servername: servername || domain,
|
|
36
|
+
agent,
|
|
37
|
+
isHttps: true,
|
|
38
|
+
};
|
|
39
|
+
if (ca) opts.ca = ca;
|
|
40
|
+
|
|
41
|
+
const ret = req.request(opts, (res) => {
|
|
42
|
+
if (typeof onResponse === 'function') {
|
|
43
|
+
try {
|
|
44
|
+
onResponse(res);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
debug('SNI onResponse callback error:', e.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (res && typeof res.resume === 'function') res.resume();
|
|
50
|
+
});
|
|
51
|
+
return ret;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { createSniAgent, forward };
|