pac-proxy-cli 1.0.4 → 1.1.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.
@@ -10,12 +10,21 @@ const DEFAULT_CONFIG = {
10
10
  proxy: {
11
11
  enabled: false,
12
12
  mode: 'pac',
13
- upstream: '',
13
+ upstream: 'socks5://127.0.0.1:1080',
14
14
  httpPort: 5175,
15
15
  httpsPort: 5176,
16
16
  applySystemProxy: true,
17
17
  },
18
18
  pacRules: [],
19
+ sslocal: {
20
+ enabled: false,
21
+ server: '',
22
+ serverPort: 8388,
23
+ localPort: 1080,
24
+ password: '',
25
+ method: 'aes-256-gcm',
26
+ timeout: 300,
27
+ },
19
28
  };
20
29
 
21
30
  function getStoreDir() {
@@ -51,6 +60,7 @@ export function loadConfig() {
51
60
  mode,
52
61
  proxy: { ...DEFAULT_CONFIG.proxy, ...(data.proxy || {}) },
53
62
  pacRules: Array.isArray(data.pacRules) ? data.pacRules : DEFAULT_CONFIG.pacRules,
63
+ sslocal: { ...DEFAULT_CONFIG.sslocal, ...(data.sslocal || {}) },
54
64
  };
55
65
  } catch {
56
66
  return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
@@ -92,7 +102,14 @@ export function getPacRules() {
92
102
  }
93
103
 
94
104
  export function setPacRules(rules) {
95
- const list = Array.isArray(rules) ? rules : (rules?.rules ?? []);
105
+ const raw = Array.isArray(rules) ? rules : (rules?.rules ?? []);
106
+ const seen = new Set();
107
+ const list = raw.filter((r) => {
108
+ const key = (r.pattern || '').trim();
109
+ if (!key || seen.has(key)) return false;
110
+ seen.add(key);
111
+ return true;
112
+ });
96
113
  const config = loadConfig();
97
114
  if (config.mode === 'remote') {
98
115
  saveRemoteConfig({ pacRules: list });
@@ -108,6 +125,9 @@ export function getProxyConfig() {
108
125
  const proxy = { ...DEFAULT_CONFIG.proxy, ...config.proxy };
109
126
  proxy.enabled = proxy.enabled === true || proxy.enabled === 'true';
110
127
  proxy.applySystemProxy = proxy.applySystemProxy !== false && proxy.applySystemProxy !== 'false';
128
+ if (!proxy.upstream || !proxy.upstream.trim()) {
129
+ proxy.upstream = DEFAULT_CONFIG.proxy.upstream;
130
+ }
111
131
  return proxy;
112
132
  }
113
133
 
@@ -120,3 +140,20 @@ export function setProxyConfig(proxy) {
120
140
  saveConfig(config);
121
141
  return config.proxy;
122
142
  }
143
+
144
+ export function getSslocalConfig() {
145
+ const config = loadConfig();
146
+ return { ...DEFAULT_CONFIG.sslocal, ...(config.sslocal || {}) };
147
+ }
148
+
149
+ export function setSslocalConfig(sslocal) {
150
+ const config = loadConfig();
151
+ config.sslocal = { ...DEFAULT_CONFIG.sslocal, ...sslocal };
152
+ config.sslocal.enabled = config.sslocal.enabled === true || config.sslocal.enabled === 'true';
153
+ saveConfig(config);
154
+ return config.sslocal;
155
+ }
156
+
157
+ export function getSslocalConfigPath() {
158
+ return path.join(getStoreDir(), 'sslocal-config.json');
159
+ }
package/lib/server.js CHANGED
@@ -5,7 +5,7 @@ import dotenv from 'dotenv';
5
5
  import express from 'express';
6
6
  import cors from 'cors';
7
7
  import { createServer } from 'http';
8
- import { loadConfig, saveConfig, getPacRules, setPacRules, getProxyConfig, setProxyConfig } from './local-store.js';
8
+ import { loadConfig, saveConfig, getPacRules, setPacRules, getProxyConfig, setProxyConfig, getSslocalConfig, setSslocalConfig } from './local-store.js';
9
9
  import { getRecords as getTrafficRecords } from './traffic-log.js';
10
10
  import { getCaptures } from './capture-log.js';
11
11
  import { getPort } from 'get-port-please';
@@ -13,6 +13,7 @@ import { startProxyServer, stopProxyServer, getProxyStatus } from './proxy-serve
13
13
  import { startMitmProxyServer, stopMitmProxyServer, getMitmProxyStatus, getCaCertPath, hasCaCert } from './mitm-proxy-server.js';
14
14
  import { setSystemProxy, clearSystemProxy } from './system-proxy.js';
15
15
  import { generatePacJs } from './pac-file.js';
16
+ import { startSslocal, stopSslocal, getSslocalStatus } from './sslocal-manager.js';
16
17
 
17
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
19
  const envDir = path.resolve(__dirname, '..');
@@ -25,6 +26,19 @@ let controlPanelPort = null;
25
26
 
26
27
  async function applyProxyService(proxyOverride) {
27
28
  const proxy = proxyOverride ?? getProxyConfig();
29
+ const sslocalCfg = getSslocalConfig();
30
+
31
+ // 若 sslocal 启用,先确保进程运行,并覆盖 upstream
32
+ if (sslocalCfg.enabled && sslocalCfg.server) {
33
+ try {
34
+ const status = getSslocalStatus();
35
+ if (!status.running) await startSslocal(sslocalCfg);
36
+ proxy.upstream = `socks5://127.0.0.1:${sslocalCfg.localPort || 1080}`;
37
+ } catch (e) {
38
+ console.error('sslocal 启动失败:', e.message);
39
+ }
40
+ }
41
+
28
42
  const isMitm = (proxy.mode || '') === 'mitm';
29
43
  const prevStatus = isMitm ? getMitmProxyStatus() : getProxyStatus();
30
44
  const wasProxyRunning = prevStatus.running === true;
@@ -226,9 +240,56 @@ export async function startServer(port) {
226
240
  res.sendFile(path.join(distPath, 'index.html'));
227
241
  });
228
242
 
243
+ // ---------- sslocal API ----------
244
+ app.get('/api/local/sslocal', (req, res) => {
245
+ try { res.json(getSslocalConfig()); }
246
+ catch (e) { res.status(500).json({ error: String(e.message) }); }
247
+ });
248
+
249
+ app.put('/api/local/sslocal', async (req, res) => {
250
+ try {
251
+ const cfg = setSslocalConfig(req.body);
252
+ // 若已启用且进程未运行则启动;若禁用则停止
253
+ if (cfg.enabled && cfg.server) {
254
+ const status = getSslocalStatus();
255
+ if (!status.running) await startSslocal(cfg);
256
+ } else if (!cfg.enabled) {
257
+ await stopSslocal();
258
+ }
259
+ res.json(cfg);
260
+ } catch (e) { res.status(500).json({ error: String(e.message) }); }
261
+ });
262
+
263
+ app.get('/api/local/sslocal-status', (req, res) => {
264
+ try { res.json(getSslocalStatus()); }
265
+ catch (e) { res.status(500).json({ running: false, pid: null, error: String(e.message), logs: [] }); }
266
+ });
267
+
268
+ app.post('/api/local/sslocal/start', async (req, res) => {
269
+ try {
270
+ const cfg = getSslocalConfig();
271
+ if (!cfg.server) return res.status(400).json({ error: '请先配置服务器地址' });
272
+ await startSslocal(cfg);
273
+ res.json(getSslocalStatus());
274
+ } catch (e) { res.status(500).json({ error: String(e.message) }); }
275
+ });
276
+
277
+ app.post('/api/local/sslocal/stop', async (req, res) => {
278
+ try {
279
+ await stopSslocal();
280
+ res.json({ ok: true });
281
+ } catch (e) { res.status(500).json({ error: String(e.message) }); }
282
+ });
283
+
229
284
  const actualPort = await getPort({ port, portRange: [port, port + 100] });
230
285
  const server = createServer(app);
231
286
 
287
+ // 进程退出时清理 sslocal 子进程
288
+ const cleanup = () => { stopSslocal().catch(() => {}); };
289
+ process.once('exit', cleanup);
290
+ process.once('SIGINT', () => { cleanup(); process.exit(0); });
291
+ process.once('SIGTERM', () => { cleanup(); process.exit(0); });
292
+
232
293
  server.listen(actualPort, '127.0.0.1', async () => {
233
294
  controlPanelPort = actualPort;
234
295
  console.log(`控制台已启动: http://127.0.0.1:${actualPort}`);
@@ -0,0 +1,146 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import net from 'net';
4
+ import path from 'path';
5
+ import { createRequire } from 'module';
6
+ import { getSslocalConfigPath } from './local-store.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ const LOG_MAX = 200;
11
+
12
+ const PKG_MAP = {
13
+ 'win32-x64': 'pac-proxy-sslocal-win32-x64',
14
+ 'darwin-x64': 'pac-proxy-sslocal-darwin-x64',
15
+ 'linux-x64': 'pac-proxy-sslocal-linux-x64',
16
+ };
17
+
18
+ let _proc = null;
19
+ let _logs = [];
20
+ let _lastError = null;
21
+
22
+ function getBinaryPath() {
23
+ const key = `${process.platform}-${process.arch}`;
24
+ const pkgName = PKG_MAP[key];
25
+ if (pkgName) {
26
+ try {
27
+ const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`));
28
+ const bin = process.platform === 'win32' ? 'sslocal.exe' : 'sslocal';
29
+ const p = path.join(pkgDir, bin);
30
+ if (fs.existsSync(p)) return p;
31
+ } catch (_) {}
32
+ }
33
+ throw new Error(
34
+ `当前平台 ${process.platform}/${process.arch} 没有可用的 sslocal 二进制。\n` +
35
+ `请手动安装:npm install ${PKG_MAP[key] ?? '对应平台包'}`
36
+ );
37
+ }
38
+
39
+ function ensureExecutable(binPath) {
40
+ if (process.platform !== 'win32') {
41
+ try { fs.chmodSync(binPath, 0o755); } catch (_) {}
42
+ }
43
+ }
44
+
45
+ function pushLog(line) {
46
+ _logs.push(line);
47
+ if (_logs.length > LOG_MAX) _logs.shift();
48
+ }
49
+
50
+ function waitForPort(port, timeoutMs = 4000) {
51
+ return new Promise((resolve) => {
52
+ const deadline = Date.now() + timeoutMs;
53
+ function attempt() {
54
+ const sock = new net.Socket();
55
+ sock.setTimeout(300);
56
+ sock.connect(port, '127.0.0.1', () => {
57
+ sock.destroy();
58
+ resolve(true);
59
+ });
60
+ sock.on('error', () => {
61
+ sock.destroy();
62
+ if (Date.now() < deadline) setTimeout(attempt, 200);
63
+ else resolve(false);
64
+ });
65
+ sock.on('timeout', () => {
66
+ sock.destroy();
67
+ if (Date.now() < deadline) setTimeout(attempt, 200);
68
+ else resolve(false);
69
+ });
70
+ }
71
+ attempt();
72
+ });
73
+ }
74
+
75
+ export async function startSslocal(cfg) {
76
+ if (_proc) await stopSslocal();
77
+
78
+ const binPath = getBinaryPath();
79
+ if (!fs.existsSync(binPath)) {
80
+ throw new Error(`sslocal 二进制不存在: ${binPath}(当前平台: ${process.platform})`);
81
+ }
82
+ ensureExecutable(binPath);
83
+
84
+ // 写入临时 config 文件
85
+ const configPath = getSslocalConfigPath();
86
+ const sslocalCfg = {
87
+ server: cfg.server,
88
+ server_port: Number(cfg.serverPort) || 8388,
89
+ local_address: '127.0.0.1',
90
+ local_port: Number(cfg.localPort) || 1080,
91
+ password: cfg.password,
92
+ method: cfg.method || 'aes-256-gcm',
93
+ timeout: Number(cfg.timeout) || 300,
94
+ };
95
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
96
+ fs.writeFileSync(configPath, JSON.stringify(sslocalCfg, null, 2), 'utf-8');
97
+
98
+ _logs = [];
99
+ _lastError = null;
100
+
101
+ _proc = spawn(binPath, ['-c', configPath], {
102
+ detached: false,
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ });
105
+
106
+ _proc.stdout.on('data', (d) => {
107
+ String(d).split('\n').filter(Boolean).forEach(pushLog);
108
+ });
109
+ _proc.stderr.on('data', (d) => {
110
+ String(d).split('\n').filter(Boolean).forEach(pushLog);
111
+ });
112
+ _proc.on('exit', (code, signal) => {
113
+ _lastError = code !== 0 ? `进程退出 code=${code} signal=${signal}` : null;
114
+ _proc = null;
115
+ });
116
+ _proc.on('error', (e) => {
117
+ _lastError = e.message;
118
+ _proc = null;
119
+ });
120
+
121
+ const localPort = Number(cfg.localPort) || 1080;
122
+ const ready = await waitForPort(localPort);
123
+ if (!ready) {
124
+ const recentLogs = _logs.slice(-10).join('\n');
125
+ throw new Error(`sslocal 启动超时,端口 ${localPort} 未就绪。\n${recentLogs}`);
126
+ }
127
+ }
128
+
129
+ export async function stopSslocal() {
130
+ if (!_proc) return;
131
+ try {
132
+ _proc.kill('SIGTERM');
133
+ await new Promise((r) => setTimeout(r, 300));
134
+ if (_proc) _proc.kill('SIGKILL');
135
+ } catch (_) {}
136
+ _proc = null;
137
+ }
138
+
139
+ export function getSslocalStatus() {
140
+ return {
141
+ running: _proc !== null && !_proc.killed,
142
+ pid: _proc?.pid ?? null,
143
+ error: _lastError,
144
+ logs: [..._logs],
145
+ };
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pac-proxy-cli",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "node pac proxy and web control panel",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -16,7 +16,12 @@
16
16
  "scripts": {
17
17
  "dev": "node lib/index.js serve --port 5174",
18
18
  "build": "vite build",
19
- "prepublishOnly": "pnpm run build"
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "optionalDependencies": {
22
+ "pac-proxy-sslocal-win32-x64": "workspace:*",
23
+ "pac-proxy-sslocal-darwin-x64": "workspace:*",
24
+ "pac-proxy-sslocal-linux-x64": "workspace:*"
20
25
  },
21
26
  "dependencies": {
22
27
  "commander": "^12.0.0",