pac-proxy-cli 0.1.7 → 1.0.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.
- package/.env +1 -3
- package/README.md +71 -63
- package/bin/cli.js +3 -0
- package/dist/assets/index-BCu498Mr.css +1 -0
- package/dist/assets/index-BE710q9W.js +27 -0
- package/dist/index.html +16 -0
- package/lib/capture-log.js +138 -0
- package/lib/index.js +22 -0
- package/lib/local-store.js +122 -0
- package/lib/mitm-proxy-server.js +143 -0
- package/lib/pac-file.js +49 -0
- package/lib/pac-match.js +44 -0
- package/lib/proxy-server.js +247 -0
- package/lib/server.js +239 -0
- package/lib/socks-http.js +47 -0
- package/lib/system-proxy.js +264 -0
- package/lib/traffic-log.js +14 -0
- package/package.json +37 -15
- package/bin/proxy.js +0 -30
- package/public/index.html +0 -732
- package/src/capture/store.js +0 -40
- package/src/cli/panel.js +0 -23
- package/src/cli/start.js +0 -62
- package/src/pac/store.js +0 -186
- package/src/panel/server.js +0 -145
- package/src/proxy/server.js +0 -262
- package/src/sysproxy/index.js +0 -270
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 抓包记录存储:MITM 模式下的请求/响应详情,支持筛选与排序
|
|
3
|
+
*/
|
|
4
|
+
const MAX_RECORDS = 300;
|
|
5
|
+
const MAX_BODY_LENGTH = 512 * 1024; // 512KB 每侧截断
|
|
6
|
+
|
|
7
|
+
const records = [];
|
|
8
|
+
let idSeq = 0;
|
|
9
|
+
|
|
10
|
+
function nextId() {
|
|
11
|
+
idSeq += 1;
|
|
12
|
+
return `c${Date.now()}_${idSeq}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function headersToObject(headers) {
|
|
16
|
+
if (!headers) return {};
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
19
|
+
if (typeof v === 'string') out[k] = v;
|
|
20
|
+
else if (Array.isArray(v)) out[k] = v.join(', ');
|
|
21
|
+
else out[k] = String(v);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function truncateBody(buf, max = MAX_BODY_LENGTH) {
|
|
27
|
+
if (!buf || !buf.length) return '';
|
|
28
|
+
const str = Buffer.isBuffer(buf) ? buf.toString('utf-8', 0, max) : String(buf).slice(0, max);
|
|
29
|
+
const truncated = (Buffer.isBuffer(buf) ? buf.length : String(buf).length) > max;
|
|
30
|
+
return truncated ? str + '\n\n… (已截断)' : str;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从请求头推断资源类型
|
|
35
|
+
*/
|
|
36
|
+
function inferRequestType(reqHeaders) {
|
|
37
|
+
const accept = (reqHeaders && reqHeaders['accept']) || '';
|
|
38
|
+
const secFetchMode = (reqHeaders && reqHeaders['sec-fetch-mode']) || '';
|
|
39
|
+
const secFetchDest = (reqHeaders && reqHeaders['sec-fetch-dest']) || '';
|
|
40
|
+
if (secFetchDest === 'document' || (accept && accept.indexOf('text/html') !== -1 && secFetchMode === 'navigate')) return 'document';
|
|
41
|
+
if (secFetchDest === 'script' || (accept && accept.indexOf('application/javascript') !== -1)) return 'script';
|
|
42
|
+
if (secFetchDest === 'style') return 'stylesheet';
|
|
43
|
+
if (secFetchMode === 'cors' || (accept && accept.indexOf('application/json') !== -1)) return 'xhr';
|
|
44
|
+
if (secFetchDest === 'image') return 'image';
|
|
45
|
+
if (secFetchDest === 'font') return 'font';
|
|
46
|
+
if (secFetchDest === 'empty' && accept.indexOf('application/json') !== -1) return 'xhr';
|
|
47
|
+
return 'other';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 追加一条抓包记录(由 MITM 在 onResponseEnd 时调用)
|
|
52
|
+
* @param {object} opts - method, url, statusCode, requestHeaders, responseHeaders, requestBody (Buffer), responseBody (Buffer), startTime
|
|
53
|
+
*/
|
|
54
|
+
export function pushCapture(opts) {
|
|
55
|
+
const id = nextId();
|
|
56
|
+
const reqHeaders = headersToObject(opts.requestHeaders);
|
|
57
|
+
const resHeaders = headersToObject(opts.responseHeaders);
|
|
58
|
+
const requestBody = truncateBody(opts.requestBody);
|
|
59
|
+
const responseBody = truncateBody(opts.responseBody);
|
|
60
|
+
const type = opts.type || inferRequestType(opts.requestHeaders);
|
|
61
|
+
|
|
62
|
+
const record = {
|
|
63
|
+
id,
|
|
64
|
+
time: opts.startTime ?? Date.now(),
|
|
65
|
+
method: (opts.method || 'GET').toUpperCase(),
|
|
66
|
+
url: opts.url || '',
|
|
67
|
+
statusCode: opts.statusCode != null ? opts.statusCode : 0,
|
|
68
|
+
type,
|
|
69
|
+
requestHeaders: reqHeaders,
|
|
70
|
+
responseHeaders: resHeaders,
|
|
71
|
+
requestBody,
|
|
72
|
+
responseBody,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
records.unshift(record);
|
|
76
|
+
if (records.length > MAX_RECORDS) records.length = MAX_RECORDS;
|
|
77
|
+
return record;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 查询抓包记录
|
|
82
|
+
* @param {object} options - q(关键词), method, status, type, sort(time|method|status|url), order(asc|desc), limit, offset
|
|
83
|
+
* @returns {{ list: array, total: number }}
|
|
84
|
+
*/
|
|
85
|
+
export function getCaptures(options = {}) {
|
|
86
|
+
const {
|
|
87
|
+
q = '',
|
|
88
|
+
method = '',
|
|
89
|
+
status = '',
|
|
90
|
+
type = '',
|
|
91
|
+
sort = 'time',
|
|
92
|
+
order = 'desc',
|
|
93
|
+
limit = 100,
|
|
94
|
+
offset = 0,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
let list = [...records];
|
|
98
|
+
|
|
99
|
+
const qLower = String(q).trim().toLowerCase();
|
|
100
|
+
if (qLower) {
|
|
101
|
+
list = list.filter((r) => (r.url || '').toLowerCase().includes(qLower));
|
|
102
|
+
}
|
|
103
|
+
if (method) {
|
|
104
|
+
const m = String(method).toUpperCase();
|
|
105
|
+
list = list.filter((r) => r.method === m);
|
|
106
|
+
}
|
|
107
|
+
if (status !== '' && status !== undefined) {
|
|
108
|
+
const s = Number(status);
|
|
109
|
+
if (!Number.isNaN(s)) list = list.filter((r) => r.statusCode === s);
|
|
110
|
+
}
|
|
111
|
+
if (type) {
|
|
112
|
+
const t = String(type).toLowerCase();
|
|
113
|
+
list = list.filter((r) => (r.type || 'other') === t);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const total = list.length;
|
|
117
|
+
|
|
118
|
+
const sortKey = sort === 'time' ? 'time' : sort === 'method' ? 'method' : sort === 'status' ? 'statusCode' : 'url';
|
|
119
|
+
list.sort((a, b) => {
|
|
120
|
+
let va = a[sortKey];
|
|
121
|
+
let vb = b[sortKey];
|
|
122
|
+
if (sortKey === 'time') {
|
|
123
|
+
va = Number(va) || 0;
|
|
124
|
+
vb = Number(vb) || 0;
|
|
125
|
+
return order === 'asc' ? va - vb : vb - va;
|
|
126
|
+
}
|
|
127
|
+
va = String(va ?? '');
|
|
128
|
+
vb = String(vb ?? '');
|
|
129
|
+
const cmp = va.localeCompare(vb, undefined, { numeric: true });
|
|
130
|
+
return order === 'asc' ? cmp : -cmp;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const limitNum = Math.min(Math.max(1, Number(limit) || 100), 200);
|
|
134
|
+
const offsetNum = Math.max(0, Number(offset) || 0);
|
|
135
|
+
list = list.slice(offsetNum, offsetNum + limitNum);
|
|
136
|
+
|
|
137
|
+
return { list, total };
|
|
138
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { startServer } from './server.js';
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('pac-proxy')
|
|
10
|
+
.description('PAC/全局代理本地控制台')
|
|
11
|
+
.version('0.1.0');
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command('serve')
|
|
15
|
+
.description('启动本地 Web 服务,供控制台访问')
|
|
16
|
+
.option('-p, --port <number>', '端口号', '5174')
|
|
17
|
+
.action(async (opts) => {
|
|
18
|
+
const port = Number(opts.port) || 5174;
|
|
19
|
+
await startServer(port);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
program.parse();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
mode: 'local', // 'local' | 'remote',决定 getPacRules/setPacRules 读写 config 还是 remote_config.json
|
|
10
|
+
proxy: {
|
|
11
|
+
enabled: false,
|
|
12
|
+
mode: 'pac',
|
|
13
|
+
upstream: '',
|
|
14
|
+
httpPort: 5175,
|
|
15
|
+
httpsPort: 5176,
|
|
16
|
+
applySystemProxy: true,
|
|
17
|
+
},
|
|
18
|
+
pacRules: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function getStoreDir() {
|
|
22
|
+
return process.env.PAC_PROXY_HOME || path.join(os.homedir(), '.pac-proxy');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStorePath() {
|
|
26
|
+
return path.join(getStoreDir(), 'config.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getRemoteConfigPath() {
|
|
30
|
+
return path.join(getStoreDir(), 'remote_config.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureDir(dir) {
|
|
34
|
+
if (!fs.existsSync(dir)) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadConfig() {
|
|
40
|
+
const file = getStorePath();
|
|
41
|
+
if (!fs.existsSync(file)) {
|
|
42
|
+
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
46
|
+
const data = JSON.parse(raw);
|
|
47
|
+
const mode = data.mode === 'remote' ? 'remote' : 'local';
|
|
48
|
+
return {
|
|
49
|
+
...DEFAULT_CONFIG,
|
|
50
|
+
...data,
|
|
51
|
+
mode,
|
|
52
|
+
proxy: { ...DEFAULT_CONFIG.proxy, ...(data.proxy || {}) },
|
|
53
|
+
pacRules: Array.isArray(data.pacRules) ? data.pacRules : DEFAULT_CONFIG.pacRules,
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadRemoteConfig() {
|
|
61
|
+
const file = getRemoteConfigPath();
|
|
62
|
+
if (!fs.existsSync(file)) return { pacRules: [] };
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
65
|
+
const data = JSON.parse(raw);
|
|
66
|
+
return { pacRules: Array.isArray(data.pacRules) ? data.pacRules : [] };
|
|
67
|
+
} catch {
|
|
68
|
+
return { pacRules: [] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function saveRemoteConfig(data) {
|
|
73
|
+
const file = getRemoteConfigPath();
|
|
74
|
+
ensureDir(path.dirname(file));
|
|
75
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8');
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveConfig(config) {
|
|
80
|
+
const file = getStorePath();
|
|
81
|
+
ensureDir(path.dirname(file));
|
|
82
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2), 'utf-8');
|
|
83
|
+
return config;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getPacRules() {
|
|
87
|
+
const config = loadConfig();
|
|
88
|
+
if (config.mode === 'remote') {
|
|
89
|
+
return loadRemoteConfig().pacRules;
|
|
90
|
+
}
|
|
91
|
+
return config.pacRules || [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setPacRules(rules) {
|
|
95
|
+
const list = Array.isArray(rules) ? rules : (rules?.rules ?? []);
|
|
96
|
+
const config = loadConfig();
|
|
97
|
+
if (config.mode === 'remote') {
|
|
98
|
+
saveRemoteConfig({ pacRules: list });
|
|
99
|
+
return list;
|
|
100
|
+
}
|
|
101
|
+
config.pacRules = list;
|
|
102
|
+
saveConfig(config);
|
|
103
|
+
return config.pacRules;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getProxyConfig() {
|
|
107
|
+
const config = loadConfig();
|
|
108
|
+
const proxy = { ...DEFAULT_CONFIG.proxy, ...config.proxy };
|
|
109
|
+
proxy.enabled = proxy.enabled === true || proxy.enabled === 'true';
|
|
110
|
+
proxy.applySystemProxy = proxy.applySystemProxy !== false && proxy.applySystemProxy !== 'false';
|
|
111
|
+
return proxy;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function setProxyConfig(proxy) {
|
|
115
|
+
const config = loadConfig();
|
|
116
|
+
const merged = { ...DEFAULT_CONFIG.proxy, ...proxy };
|
|
117
|
+
merged.enabled = merged.enabled === true || merged.enabled === 'true';
|
|
118
|
+
merged.applySystemProxy = merged.applySystemProxy !== false && merged.applySystemProxy !== 'false';
|
|
119
|
+
config.proxy = merged;
|
|
120
|
+
saveConfig(config);
|
|
121
|
+
return config.proxy;
|
|
122
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 抓包代理(HTTPS MITM):所有流量经本地解密,CA 证书供用户安装信任;请求/响应写入 capture-log 供流量记录页展示
|
|
3
|
+
*/
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { pushCapture } from './capture-log.js';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const { Proxy } = require('http-mitm-proxy');
|
|
12
|
+
|
|
13
|
+
function getCertDir() {
|
|
14
|
+
const base = process.env.PAC_PROXY_HOME || path.join(os.homedir(), '.pac-proxy');
|
|
15
|
+
return path.join(base, 'mitm-certs');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** CA 证书路径(listen 成功后由库生成) */
|
|
19
|
+
export function getCaCertPath() {
|
|
20
|
+
return path.join(getCertDir(), 'certs', 'ca.pem');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** CA 证书是否存在 */
|
|
24
|
+
export function hasCaCert() {
|
|
25
|
+
try {
|
|
26
|
+
return fs.existsSync(getCaCertPath());
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let proxyInstance = null;
|
|
33
|
+
let listenPort = null;
|
|
34
|
+
|
|
35
|
+
export function startMitmProxyServer(port) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
stopMitmProxyServer().then(() => {
|
|
38
|
+
if (port == null || port <= 0) {
|
|
39
|
+
return resolve(null);
|
|
40
|
+
}
|
|
41
|
+
const certDir = getCertDir();
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return reject(e);
|
|
46
|
+
}
|
|
47
|
+
const proxy = new Proxy();
|
|
48
|
+
proxy.use(Proxy.gunzip);
|
|
49
|
+
|
|
50
|
+
proxy.onRequest((ctx, callback) => {
|
|
51
|
+
const startTime = Date.now();
|
|
52
|
+
const req = ctx.clientToProxyRequest;
|
|
53
|
+
const method = req.method || 'GET';
|
|
54
|
+
const host = (req.headers && req.headers.host) || '';
|
|
55
|
+
const url = (req.url || '').startsWith('/') ? `http://${host}${req.url}` : req.url;
|
|
56
|
+
ctx._capture = {
|
|
57
|
+
startTime,
|
|
58
|
+
method,
|
|
59
|
+
url,
|
|
60
|
+
requestHeaders: req.headers,
|
|
61
|
+
reqBodyChunks: [],
|
|
62
|
+
resBodyChunks: [],
|
|
63
|
+
statusCode: 0,
|
|
64
|
+
responseHeaders: {},
|
|
65
|
+
};
|
|
66
|
+
ctx.onResponseData((ctx, chunk, cb) => {
|
|
67
|
+
if (ctx._capture) ctx._capture.resBodyChunks.push(chunk);
|
|
68
|
+
cb(null, chunk);
|
|
69
|
+
});
|
|
70
|
+
ctx.onResponseEnd((ctx, cb) => {
|
|
71
|
+
const c = ctx._capture;
|
|
72
|
+
if (c) {
|
|
73
|
+
const requestBody = c.reqBodyChunks.length ? Buffer.concat(c.reqBodyChunks) : Buffer.alloc(0);
|
|
74
|
+
const responseBody = c.resBodyChunks.length ? Buffer.concat(c.resBodyChunks) : Buffer.alloc(0);
|
|
75
|
+
pushCapture({
|
|
76
|
+
method: c.method,
|
|
77
|
+
url: c.url,
|
|
78
|
+
statusCode: c.statusCode,
|
|
79
|
+
requestHeaders: c.requestHeaders,
|
|
80
|
+
responseHeaders: c.responseHeaders,
|
|
81
|
+
requestBody,
|
|
82
|
+
responseBody,
|
|
83
|
+
startTime: c.startTime,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
cb();
|
|
87
|
+
});
|
|
88
|
+
callback();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
proxy.onRequestData((ctx, chunk, callback) => {
|
|
92
|
+
if (ctx._capture) ctx._capture.reqBodyChunks.push(chunk);
|
|
93
|
+
callback(null, chunk);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
proxy.onResponse((ctx, callback) => {
|
|
97
|
+
if (ctx.serverToProxyResponse && ctx._capture) {
|
|
98
|
+
ctx._capture.statusCode = ctx.serverToProxyResponse.statusCode;
|
|
99
|
+
ctx._capture.responseHeaders = ctx.serverToProxyResponse.headers;
|
|
100
|
+
}
|
|
101
|
+
callback();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
proxyInstance = proxy;
|
|
105
|
+
proxy.listen(
|
|
106
|
+
{
|
|
107
|
+
port: Number(port),
|
|
108
|
+
host: '127.0.0.1',
|
|
109
|
+
sslCaDir: certDir,
|
|
110
|
+
},
|
|
111
|
+
(err) => {
|
|
112
|
+
if (err) {
|
|
113
|
+
proxyInstance = null;
|
|
114
|
+
return reject(err);
|
|
115
|
+
}
|
|
116
|
+
listenPort = proxyInstance.httpPort;
|
|
117
|
+
console.log(`抓包代理已启动: http://127.0.0.1:${listenPort}(HTTPS 需安装 CA 证书)`);
|
|
118
|
+
resolve(proxy);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function stopMitmProxyServer() {
|
|
126
|
+
if (!proxyInstance) return Promise.resolve();
|
|
127
|
+
const proxy = proxyInstance;
|
|
128
|
+
proxyInstance = null;
|
|
129
|
+
listenPort = null;
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
try {
|
|
132
|
+
proxy.close(() => resolve());
|
|
133
|
+
} catch {
|
|
134
|
+
resolve();
|
|
135
|
+
}
|
|
136
|
+
setTimeout(resolve, 2000);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getMitmProxyStatus() {
|
|
141
|
+
const running = !!(proxyInstance && listenPort);
|
|
142
|
+
return { running, port: running ? listenPort : null };
|
|
143
|
+
}
|
package/lib/pac-file.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生成 PAC 文件内容,供浏览器/系统“自动代理配置”使用
|
|
3
|
+
* 规则与 pac-match 一致:按优先级匹配,命中则 proxy/direct,未命中直连
|
|
4
|
+
*/
|
|
5
|
+
import { getPacRules, getProxyConfig } from './local-store.js';
|
|
6
|
+
|
|
7
|
+
function escapeForPac(str) {
|
|
8
|
+
return String(str)
|
|
9
|
+
.replace(/\\/g, '\\\\')
|
|
10
|
+
.replace(/"/g, '\\"')
|
|
11
|
+
.replace(/\r/g, '')
|
|
12
|
+
.replace(/\n/g, ' ');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 将一条规则的 pattern 转为 PAC 中 shExpMatch(url, pattern) 的表达式
|
|
17
|
+
*/
|
|
18
|
+
function patternToPacCondition(urlVar, pattern) {
|
|
19
|
+
if (!pattern || typeof pattern !== 'string') return 'false';
|
|
20
|
+
const escaped = escapeForPac(pattern);
|
|
21
|
+
return `shExpMatch(${urlVar}, "${escaped}")`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 生成 FindProxyForURL 函数体
|
|
26
|
+
*/
|
|
27
|
+
export function generatePacJs(proxyHost, proxyPort) {
|
|
28
|
+
const rules = getPacRules();
|
|
29
|
+
const config = getProxyConfig();
|
|
30
|
+
const port = Number(proxyPort) || Number(config?.httpPort) || 5175;
|
|
31
|
+
const host = (proxyHost || '127.0.0.1').trim();
|
|
32
|
+
const proxyStr = `PROXY ${host}:${port}`;
|
|
33
|
+
|
|
34
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
35
|
+
return `function FindProxyForURL(url, host) { return "DIRECT"; }`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sorted = [...rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
39
|
+
const lines = [];
|
|
40
|
+
for (const r of sorted) {
|
|
41
|
+
const condUrl = patternToPacCondition('url', r.pattern);
|
|
42
|
+
const condHost = patternToPacCondition('host', r.pattern);
|
|
43
|
+
const cond = `${condUrl} || ${condHost}`;
|
|
44
|
+
const ret = r.action === 'proxy' ? `return "${proxyStr}";` : 'return "DIRECT";';
|
|
45
|
+
lines.push(` if (${cond}) { ${ret} }`);
|
|
46
|
+
}
|
|
47
|
+
const body = lines.join('\n');
|
|
48
|
+
return `function FindProxyForURL(url, host) {\n${body}\n return "DIRECT";\n}`;
|
|
49
|
+
}
|
package/lib/pac-match.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAC 规则匹配:按优先级排序,命中则返回对应动作(proxy/direct)
|
|
3
|
+
* pattern 支持 * 通配符,匹配请求的 URL 或 host
|
|
4
|
+
*/
|
|
5
|
+
export function patternToRegex(pattern) {
|
|
6
|
+
if (!pattern || typeof pattern !== 'string') return null;
|
|
7
|
+
const escaped = pattern
|
|
8
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
9
|
+
.replace(/\*/g, '.*');
|
|
10
|
+
try {
|
|
11
|
+
return new RegExp(escaped, 'i');
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 判断 url 是否匹配 pattern(支持 * 通配)
|
|
19
|
+
*/
|
|
20
|
+
export function matchPattern(url, pattern) {
|
|
21
|
+
const re = patternToRegex(pattern);
|
|
22
|
+
if (!re) return false;
|
|
23
|
+
return re.test(url);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 根据 PAC 规则决定该 URL 走代理还是直连。
|
|
28
|
+
* PAC 模式下:匹配到规则则按规则执行(proxy/direct),未匹配到的一律直连。
|
|
29
|
+
* @param {string} url - 请求的完整 URL(如 http://example.com/path)或 CONNECT 时为 host:port
|
|
30
|
+
* @param {Array<{pattern:string, action:string, priority:number}>} rules
|
|
31
|
+
* @returns {'proxy'|'direct'}
|
|
32
|
+
*/
|
|
33
|
+
export function getPacAction(url, rules) {
|
|
34
|
+
if (!url || !Array.isArray(rules) || rules.length === 0) {
|
|
35
|
+
return 'direct';
|
|
36
|
+
}
|
|
37
|
+
const sorted = [...rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
38
|
+
for (const r of sorted) {
|
|
39
|
+
if (matchPattern(url, r.pattern)) {
|
|
40
|
+
return r.action === 'proxy' ? 'proxy' : 'direct';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return 'direct';
|
|
44
|
+
}
|