@qpjoy/electron-tunnel 0.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.
- package/README.md +53 -0
- package/dist/admin/AdminServer.d.ts +10 -0
- package/dist/admin/AdminServer.js +362 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +92 -0
- package/dist/config/renderRuntimeConfig.d.ts +3 -0
- package/dist/config/renderRuntimeConfig.js +151 -0
- package/dist/createElectronTunnel.d.ts +21 -0
- package/dist/createElectronTunnel.js +49 -0
- package/dist/db/TunnelDatabase.d.ts +23 -0
- package/dist/db/TunnelDatabase.js +296 -0
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +70 -0
- package/dist/defaults.d.ts +18 -0
- package/dist/defaults.js +68 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +25 -0
- package/dist/ipc/registerTunnelIpc.d.ts +7 -0
- package/dist/ipc/registerTunnelIpc.js +70 -0
- package/dist/mihomo/MihomoApi.d.ts +11 -0
- package/dist/mihomo/MihomoApi.js +56 -0
- package/dist/mihomo/MihomoManager.d.ts +75 -0
- package/dist/mihomo/MihomoManager.js +635 -0
- package/dist/security.d.ts +3 -0
- package/dist/security.js +24 -0
- package/dist/system/electronProxy.d.ts +4 -0
- package/dist/system/electronProxy.js +30 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +2 -0
- package/package.json +52 -0
- package/resources/engine/darwin-arm64/mihomo.gz +0 -0
- package/resources/engine/darwin-x64/mihomo.gz +0 -0
- package/resources/engine/linux-arm64/mihomo.gz +0 -0
- package/resources/engine/linux-x64/mihomo.gz +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# QPJoy Electron Tunnel
|
|
2
|
+
|
|
3
|
+
Reusable tunnel runtime for Electron apps on macOS and Linux.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @qpjoy/electron-tunnel
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { app, ipcMain, session } from 'electron';
|
|
11
|
+
import { createElectronTunnel } from '@qpjoy/electron-tunnel';
|
|
12
|
+
|
|
13
|
+
const tunnel = createElectronTunnel({ app, ipcMain, session: session.defaultSession }, {
|
|
14
|
+
adminPort: 23456,
|
|
15
|
+
controllerPort: 23457,
|
|
16
|
+
mixedPort: 23458,
|
|
17
|
+
dnsPort: 23459
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.whenReady().then(async () => {
|
|
21
|
+
await tunnel.applyProxy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
app.on('before-quit', () => {
|
|
25
|
+
tunnel.close();
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package also installs a CLI:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm exec qpjoy-tunnel snippet
|
|
33
|
+
pnpm exec qpjoy-tunnel init --out src-electron/qpjoy-tunnel.ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For `electron-builder`, package the bundled engine resources:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
extraResources: [
|
|
40
|
+
{
|
|
41
|
+
from: 'node_modules/@qpjoy/electron-tunnel/resources/engine',
|
|
42
|
+
to: 'qpjoy-tunnel-engine',
|
|
43
|
+
filter: ['**/*']
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Default admin backend:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
http://127.0.0.1:23456
|
|
52
|
+
admin/admin
|
|
53
|
+
```
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AdminServer = void 0;
|
|
4
|
+
const http_1 = require("http");
|
|
5
|
+
const url_1 = require("url");
|
|
6
|
+
const security_1 = require("../security");
|
|
7
|
+
const defaults_1 = require("../defaults");
|
|
8
|
+
const sessions = new Set();
|
|
9
|
+
function sendJson(res, status, data) {
|
|
10
|
+
const body = JSON.stringify(data);
|
|
11
|
+
res.writeHead(status, {
|
|
12
|
+
'content-type': 'application/json; charset=utf-8',
|
|
13
|
+
'cache-control': 'no-store'
|
|
14
|
+
});
|
|
15
|
+
res.end(body);
|
|
16
|
+
}
|
|
17
|
+
function sendText(res, status, data, contentType = 'text/plain; charset=utf-8') {
|
|
18
|
+
res.writeHead(status, {
|
|
19
|
+
'content-type': contentType,
|
|
20
|
+
'cache-control': 'no-store'
|
|
21
|
+
});
|
|
22
|
+
res.end(data);
|
|
23
|
+
}
|
|
24
|
+
function adminHtml() {
|
|
25
|
+
const presets = Object.keys(defaults_1.DOMAIN_PRESETS)
|
|
26
|
+
.map((id) => `<button data-preset="${id}">${id}</button>`)
|
|
27
|
+
.join('');
|
|
28
|
+
return `<!doctype html>
|
|
29
|
+
<html lang="zh-CN">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="utf-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
33
|
+
<title>QPJoy Tunnel Admin</title>
|
|
34
|
+
<style>
|
|
35
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:#f4f6f8;color:#121417}
|
|
36
|
+
header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:#fff;border-bottom:1px solid #dde1e6}
|
|
37
|
+
main{max-width:1160px;margin:0 auto;padding:24px;display:grid;gap:18px}
|
|
38
|
+
section{background:#fff;border:1px solid #dde1e6;border-radius:8px;padding:18px}
|
|
39
|
+
input,select,button{height:36px;border:1px solid #c9d1d9;border-radius:6px;padding:0 10px;font-size:14px}
|
|
40
|
+
button{background:#1264d8;color:#fff;border-color:#1264d8;cursor:pointer}
|
|
41
|
+
button.secondary{background:#fff;color:#202833}
|
|
42
|
+
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
|
43
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
|
|
44
|
+
.muted{color:#6b7280}
|
|
45
|
+
.card{border:1px solid #e3e7ec;border-radius:8px;padding:14px}
|
|
46
|
+
pre{white-space:pre-wrap;max-height:280px;overflow:auto;background:#101418;color:#dbeafe;border-radius:8px;padding:14px}
|
|
47
|
+
#login{max-width:360px;margin:12vh auto}
|
|
48
|
+
#app{display:none}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<section id="login">
|
|
53
|
+
<h2>QPJoy Tunnel</h2>
|
|
54
|
+
<div class="row"><input id="user" placeholder="admin" value="admin"><input id="pass" type="password" placeholder="password" value="admin"><button id="loginBtn">登录</button></div>
|
|
55
|
+
</section>
|
|
56
|
+
<div id="app">
|
|
57
|
+
<header><strong>QPJoy Tunnel Admin</strong><span id="status" class="muted"></span></header>
|
|
58
|
+
<main>
|
|
59
|
+
<section>
|
|
60
|
+
<h3>模式</h3>
|
|
61
|
+
<div class="row">
|
|
62
|
+
<select id="mode">
|
|
63
|
+
<option value="system-tun">虚拟网卡</option>
|
|
64
|
+
<option value="app-global">全局模式</option>
|
|
65
|
+
<option value="app-rule">App 模式</option>
|
|
66
|
+
</select>
|
|
67
|
+
<button id="saveMode">切换</button>
|
|
68
|
+
<button id="installTun" class="secondary">安装 TUN</button>
|
|
69
|
+
<button id="uninstallTun" class="secondary">卸载 TUN</button>
|
|
70
|
+
<button id="start">启动</button>
|
|
71
|
+
<button id="stop" class="secondary">停止</button>
|
|
72
|
+
</div>
|
|
73
|
+
</section>
|
|
74
|
+
<section>
|
|
75
|
+
<h3>高级设置</h3>
|
|
76
|
+
<div class="row">
|
|
77
|
+
<input id="corePath" placeholder="自动使用内置隧道引擎" style="flex:1;min-width:320px">
|
|
78
|
+
<button id="saveCorePath">保存引擎路径</button>
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
<section>
|
|
82
|
+
<h3>本地端口</h3>
|
|
83
|
+
<div class="row">
|
|
84
|
+
<input id="mixedPort" placeholder="23458">
|
|
85
|
+
<input id="dnsPort" placeholder="23459">
|
|
86
|
+
<button id="savePorts">保存端口</button>
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|
|
89
|
+
<section>
|
|
90
|
+
<h3>订阅</h3>
|
|
91
|
+
<div class="row">
|
|
92
|
+
<input id="subName" placeholder="名称">
|
|
93
|
+
<input id="subUrl" placeholder="订阅文件链接" style="flex:1;min-width:320px">
|
|
94
|
+
<input id="subUser" placeholder="用户">
|
|
95
|
+
<input id="subPass" placeholder="密码" type="password">
|
|
96
|
+
<button id="addSub">新建</button>
|
|
97
|
+
<button id="updateSub" class="secondary">更新当前</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div id="subs" class="grid"></div>
|
|
100
|
+
</section>
|
|
101
|
+
<section>
|
|
102
|
+
<h3>白名单 / 黑名单</h3>
|
|
103
|
+
<div class="row">
|
|
104
|
+
${presets}
|
|
105
|
+
<input id="ruleDomain" placeholder="example.com">
|
|
106
|
+
<select id="ruleKind"><option value="allow">白名单</option><option value="block">黑名单</option></select>
|
|
107
|
+
<button id="addRule">添加</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div id="rules" class="grid"></div>
|
|
110
|
+
</section>
|
|
111
|
+
<section>
|
|
112
|
+
<h3>日志</h3>
|
|
113
|
+
<pre id="events"></pre>
|
|
114
|
+
</section>
|
|
115
|
+
</main>
|
|
116
|
+
</div>
|
|
117
|
+
<script>
|
|
118
|
+
let token = '';
|
|
119
|
+
async function api(path, options = {}) {
|
|
120
|
+
const res = await fetch(path, {
|
|
121
|
+
...options,
|
|
122
|
+
headers: { 'content-type': 'application/json', authorization: token ? 'Bearer ' + token : '', ...(options.headers || {}) }
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok) throw new Error(await res.text());
|
|
125
|
+
return res.json();
|
|
126
|
+
}
|
|
127
|
+
async function refresh() {
|
|
128
|
+
const data = await api('/api/snapshot');
|
|
129
|
+
document.querySelector('#status').textContent = data.status.running ? '运行中' : '已停止';
|
|
130
|
+
document.querySelector('#mode').value = data.status.mode;
|
|
131
|
+
document.querySelector('#corePath').value = data.status.corePath || '';
|
|
132
|
+
document.querySelector('#mixedPort').value = data.status.ports.mixed;
|
|
133
|
+
document.querySelector('#dnsPort').value = data.status.ports.dns;
|
|
134
|
+
document.querySelector('#subs').innerHTML = data.subscriptions.map(s => '<div class="card"><strong>'+s.name+'</strong><p class="muted">'+s.url+'</p><p>'+(s.active?'当前':'')+' '+(s.lastUpdatedAt||'未更新')+'</p><button data-active="'+s.id+'">启用</button> <button class="secondary" data-refresh="'+s.id+'">刷新</button> <button class="secondary" data-sub-remove="'+s.id+'">删除</button></div>').join('');
|
|
135
|
+
document.querySelector('#rules').innerHTML = data.rules.map(r => '<div class="card"><strong>'+r.kind+'</strong> '+r.domain+'<p class="muted">'+r.source+'</p><button class="secondary" data-rule-remove="'+r.id+'">删除</button></div>').join('');
|
|
136
|
+
document.querySelector('#events').textContent = data.events.map(e => '['+e.level+'] '+e.createdAt+' '+e.message).join('\\n');
|
|
137
|
+
}
|
|
138
|
+
document.querySelector('#loginBtn').onclick = async () => {
|
|
139
|
+
const result = await api('/api/login', { method:'POST', body: JSON.stringify({ username:user.value, password:pass.value }) });
|
|
140
|
+
token = result.token;
|
|
141
|
+
login.style.display = 'none';
|
|
142
|
+
app.style.display = 'block';
|
|
143
|
+
refresh();
|
|
144
|
+
};
|
|
145
|
+
document.querySelector('#saveMode').onclick = async () => { await api('/api/mode', { method:'POST', body: JSON.stringify({ mode: mode.value }) }); refresh(); };
|
|
146
|
+
document.querySelector('#saveCorePath').onclick = async () => { await api('/api/core/path', { method:'POST', body: JSON.stringify({ corePath: corePath.value }) }); refresh(); };
|
|
147
|
+
document.querySelector('#savePorts').onclick = async () => { await api('/api/ports', { method:'POST', body: JSON.stringify({ mixed: Number(mixedPort.value), dns: Number(dnsPort.value) }) }); refresh(); };
|
|
148
|
+
document.querySelector('#installTun').onclick = async () => { await api('/api/tun/install', { method:'POST' }); refresh(); };
|
|
149
|
+
document.querySelector('#uninstallTun').onclick = async () => { await api('/api/tun/uninstall', { method:'POST' }); refresh(); };
|
|
150
|
+
document.querySelector('#start').onclick = async () => { await api('/api/core/start', { method:'POST' }); refresh(); };
|
|
151
|
+
document.querySelector('#stop').onclick = async () => { await api('/api/core/stop', { method:'POST' }); refresh(); };
|
|
152
|
+
document.querySelector('#updateSub').onclick = async () => { await api('/api/subscriptions/active/update', { method:'POST' }); refresh(); };
|
|
153
|
+
document.querySelector('#addSub').onclick = async () => { await api('/api/subscriptions', { method:'POST', body: JSON.stringify({ name: subName.value, url: subUrl.value, username: subUser.value, password: subPass.value }) }); refresh(); };
|
|
154
|
+
document.querySelector('#addRule').onclick = async () => { await api('/api/rules', { method:'POST', body: JSON.stringify({ kind: ruleKind.value, domain: ruleDomain.value }) }); refresh(); };
|
|
155
|
+
document.body.onclick = async (event) => {
|
|
156
|
+
const target = event.target;
|
|
157
|
+
if (!(target instanceof HTMLElement)) return;
|
|
158
|
+
if (target.dataset.preset) await api('/api/presets/'+target.dataset.preset, { method:'POST' });
|
|
159
|
+
if (target.dataset.active) await api('/api/subscriptions/'+target.dataset.active+'/active', { method:'POST' });
|
|
160
|
+
if (target.dataset.refresh) await api('/api/subscriptions/'+target.dataset.refresh+'/update', { method:'POST' });
|
|
161
|
+
if (target.dataset.subRemove) await api('/api/subscriptions/'+target.dataset.subRemove, { method:'DELETE' });
|
|
162
|
+
if (target.dataset.ruleRemove) await api('/api/rules/'+target.dataset.ruleRemove, { method:'DELETE' });
|
|
163
|
+
if (target.dataset.preset || target.dataset.active || target.dataset.refresh || target.dataset.subRemove || target.dataset.ruleRemove) refresh();
|
|
164
|
+
};
|
|
165
|
+
</script>
|
|
166
|
+
</body>
|
|
167
|
+
</html>`;
|
|
168
|
+
}
|
|
169
|
+
async function readBody(req) {
|
|
170
|
+
const chunks = [];
|
|
171
|
+
for await (const chunk of req) {
|
|
172
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
173
|
+
}
|
|
174
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
175
|
+
if (!raw) {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
return JSON.parse(raw);
|
|
179
|
+
}
|
|
180
|
+
function isAuthed(req) {
|
|
181
|
+
const header = req.headers.authorization ?? '';
|
|
182
|
+
if (!header.startsWith('Bearer ')) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return sessions.has(header.slice('Bearer '.length));
|
|
186
|
+
}
|
|
187
|
+
class AdminServer {
|
|
188
|
+
manager;
|
|
189
|
+
server = null;
|
|
190
|
+
constructor(manager) {
|
|
191
|
+
this.manager = manager;
|
|
192
|
+
}
|
|
193
|
+
start() {
|
|
194
|
+
if (this.server) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const settings = this.manager.db.getSettings();
|
|
198
|
+
this.server = (0, http_1.createServer)(async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
await this.handle(req, res);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
this.server.listen(settings.ports.admin, '127.0.0.1');
|
|
207
|
+
}
|
|
208
|
+
stop() {
|
|
209
|
+
this.server?.close();
|
|
210
|
+
this.server = null;
|
|
211
|
+
}
|
|
212
|
+
async handle(req, res) {
|
|
213
|
+
const method = req.method ?? 'GET';
|
|
214
|
+
const pathname = (0, url_1.parse)(req.url ?? '/', true).pathname ?? '/';
|
|
215
|
+
if (method === 'GET' && pathname === '/') {
|
|
216
|
+
sendText(res, 200, adminHtml(), 'text/html; charset=utf-8');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const body = await readBody(req);
|
|
220
|
+
if (method === 'POST' && pathname === '/api/login') {
|
|
221
|
+
const { username, password } = body;
|
|
222
|
+
const settings = this.manager.db.getSettings();
|
|
223
|
+
if (username === settings.adminUser && password && (0, security_1.verifyPassword)(password, settings.adminPasswordHash)) {
|
|
224
|
+
const token = (0, security_1.createSessionToken)();
|
|
225
|
+
sessions.add(token);
|
|
226
|
+
sendJson(res, 200, { token });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
sendJson(res, 401, { error: 'invalid credentials' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!isAuthed(req)) {
|
|
233
|
+
sendJson(res, 401, { error: 'unauthorized' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const route = this.route(method, pathname);
|
|
237
|
+
if (!route) {
|
|
238
|
+
sendJson(res, 404, { error: 'not found' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await route(req, res, body);
|
|
242
|
+
}
|
|
243
|
+
route(method, pathname) {
|
|
244
|
+
if (method === 'GET' && pathname === '/api/snapshot') {
|
|
245
|
+
return async (_req, res) => sendJson(res, 200, await this.manager.snapshot());
|
|
246
|
+
}
|
|
247
|
+
if (method === 'POST' && pathname === '/api/mode') {
|
|
248
|
+
return async (_req, res, body) => {
|
|
249
|
+
const { mode } = body;
|
|
250
|
+
const changedMode = this.manager.setMode(mode);
|
|
251
|
+
if (changedMode) {
|
|
252
|
+
await this.manager.applyRuntimeConfigChange();
|
|
253
|
+
}
|
|
254
|
+
sendJson(res, 200, this.manager.status());
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (method === 'POST' && pathname === '/api/ports') {
|
|
258
|
+
return async (_req, res, body) => {
|
|
259
|
+
const { mixed, dns } = body;
|
|
260
|
+
await this.manager.setLocalPorts({ mixed, dns });
|
|
261
|
+
sendJson(res, 200, this.manager.status());
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (method === 'POST' && pathname === '/api/tun/install') {
|
|
265
|
+
return async (_req, res) => {
|
|
266
|
+
this.manager.installTunFeature();
|
|
267
|
+
await this.manager.applyRuntimeConfigChange();
|
|
268
|
+
sendJson(res, 200, this.manager.status());
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (method === 'POST' && pathname === '/api/tun/uninstall') {
|
|
272
|
+
return async (_req, res) => {
|
|
273
|
+
this.manager.uninstallTunFeature();
|
|
274
|
+
await this.manager.applyRuntimeConfigChange();
|
|
275
|
+
sendJson(res, 200, this.manager.status());
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (method === 'POST' && pathname === '/api/core/start') {
|
|
279
|
+
return async (_req, res) => {
|
|
280
|
+
await this.manager.start();
|
|
281
|
+
sendJson(res, 200, this.manager.status());
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (method === 'POST' && pathname === '/api/core/stop') {
|
|
285
|
+
return async (_req, res) => {
|
|
286
|
+
await this.manager.stop();
|
|
287
|
+
sendJson(res, 200, this.manager.status());
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (method === 'POST' && pathname === '/api/core/path') {
|
|
291
|
+
return (_req, res, body) => {
|
|
292
|
+
const { corePath } = body;
|
|
293
|
+
this.manager.setCorePath(corePath);
|
|
294
|
+
sendJson(res, 200, this.manager.status());
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (method === 'POST' && pathname === '/api/subscriptions') {
|
|
298
|
+
return async (_req, res, body) => {
|
|
299
|
+
sendJson(res, 200, await this.manager.createSubscription(body));
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (method === 'POST' && pathname === '/api/subscriptions/active/update') {
|
|
303
|
+
return async (_req, res) => {
|
|
304
|
+
const subscription = await this.manager.updateActiveSubscription();
|
|
305
|
+
await this.manager.applyRuntimeConfigChange();
|
|
306
|
+
sendJson(res, 200, subscription);
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const activeMatch = pathname.match(/^\/api\/subscriptions\/(\d+)\/active$/);
|
|
310
|
+
if (method === 'POST' && activeMatch) {
|
|
311
|
+
return async (_req, res) => {
|
|
312
|
+
const subscription = this.manager.setActiveSubscription(Number(activeMatch[1]));
|
|
313
|
+
await this.manager.applyRuntimeConfigChange();
|
|
314
|
+
sendJson(res, 200, subscription);
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const updateMatch = pathname.match(/^\/api\/subscriptions\/(\d+)\/update$/);
|
|
318
|
+
if (method === 'POST' && updateMatch) {
|
|
319
|
+
return async (_req, res) => {
|
|
320
|
+
const subscription = await this.manager.updateSubscription(Number(updateMatch[1]));
|
|
321
|
+
if (subscription.active) {
|
|
322
|
+
await this.manager.applyRuntimeConfigChange();
|
|
323
|
+
}
|
|
324
|
+
sendJson(res, 200, subscription);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const deleteSubscriptionMatch = pathname.match(/^\/api\/subscriptions\/(\d+)$/);
|
|
328
|
+
if (method === 'DELETE' && deleteSubscriptionMatch) {
|
|
329
|
+
return async (_req, res) => {
|
|
330
|
+
this.manager.deleteSubscription(Number(deleteSubscriptionMatch[1]));
|
|
331
|
+
await this.manager.applyRuntimeConfigChange();
|
|
332
|
+
sendJson(res, 200, { ok: true });
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (method === 'POST' && pathname === '/api/rules') {
|
|
336
|
+
return async (_req, res, body) => {
|
|
337
|
+
const { kind, domain } = body;
|
|
338
|
+
const rule = this.manager.addDomainRule(kind, domain);
|
|
339
|
+
await this.manager.applyRuntimeConfigChange();
|
|
340
|
+
sendJson(res, 200, rule);
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const ruleDeleteMatch = pathname.match(/^\/api\/rules\/(\d+)$/);
|
|
344
|
+
if (method === 'DELETE' && ruleDeleteMatch) {
|
|
345
|
+
return async (_req, res) => {
|
|
346
|
+
this.manager.removeDomainRule(Number(ruleDeleteMatch[1]));
|
|
347
|
+
await this.manager.applyRuntimeConfigChange();
|
|
348
|
+
sendJson(res, 200, { ok: true });
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const presetMatch = pathname.match(/^\/api\/presets\/([a-z]+)$/);
|
|
352
|
+
if (method === 'POST' && presetMatch) {
|
|
353
|
+
return async (_req, res) => {
|
|
354
|
+
const rules = this.manager.addPreset(presetMatch[1]);
|
|
355
|
+
await this.manager.applyRuntimeConfigChange();
|
|
356
|
+
sendJson(res, 200, rules);
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
exports.AdminServer = AdminServer;
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const command = process.argv[2] ?? 'help';
|
|
7
|
+
function help() {
|
|
8
|
+
process.stdout.write(`QPJoy Electron Tunnel CLI
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
qpjoy-tunnel help
|
|
12
|
+
qpjoy-tunnel snippet
|
|
13
|
+
qpjoy-tunnel init [--out <path>]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
snippet Print the minimal Electron main-process integration.
|
|
17
|
+
init Create a small integration module in the current project.
|
|
18
|
+
`);
|
|
19
|
+
}
|
|
20
|
+
function snippet() {
|
|
21
|
+
return `import { app, ipcMain, session } from 'electron'
|
|
22
|
+
import { createElectronTunnel } from '@qpjoy/electron-tunnel'
|
|
23
|
+
|
|
24
|
+
const tunnel = createElectronTunnel(
|
|
25
|
+
{ app, ipcMain, session: session.defaultSession },
|
|
26
|
+
{
|
|
27
|
+
adminPort: 23456,
|
|
28
|
+
controllerPort: 23457,
|
|
29
|
+
mixedPort: 23458,
|
|
30
|
+
dnsPort: 23459
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app.whenReady().then(async () => {
|
|
35
|
+
await tunnel.applyProxy()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
app.on('before-quit', () => {
|
|
39
|
+
tunnel.close()
|
|
40
|
+
})
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
function argValue(name) {
|
|
44
|
+
const index = process.argv.indexOf(name);
|
|
45
|
+
if (index < 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return process.argv[index + 1] ?? null;
|
|
49
|
+
}
|
|
50
|
+
function init() {
|
|
51
|
+
const outPath = (0, path_1.resolve)(process.cwd(), argValue('--out') ?? 'src-electron/qpjoy-tunnel.ts');
|
|
52
|
+
if ((0, fs_1.existsSync)(outPath)) {
|
|
53
|
+
throw new Error(`File already exists: ${outPath}`);
|
|
54
|
+
}
|
|
55
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(outPath), { recursive: true });
|
|
56
|
+
(0, fs_1.writeFileSync)(outPath, snippet(), 'utf8');
|
|
57
|
+
process.stdout.write(`Created ${outPath}
|
|
58
|
+
|
|
59
|
+
Next steps:
|
|
60
|
+
1. Import this module from your Electron main process.
|
|
61
|
+
2. Add the QPJoy tunnel engine resources to your Electron package config.
|
|
62
|
+
|
|
63
|
+
electron-builder example:
|
|
64
|
+
extraResources: [
|
|
65
|
+
{
|
|
66
|
+
from: 'node_modules/@qpjoy/electron-tunnel/resources/engine',
|
|
67
|
+
to: 'qpjoy-tunnel-engine',
|
|
68
|
+
filter: ['**/*']
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
75
|
+
help();
|
|
76
|
+
}
|
|
77
|
+
else if (command === 'snippet') {
|
|
78
|
+
process.stdout.write(snippet());
|
|
79
|
+
}
|
|
80
|
+
else if (command === 'init') {
|
|
81
|
+
init();
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
process.stderr.write(`Unknown command: ${command}\n`);
|
|
85
|
+
help();
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderRuntimeConfig = renderRuntimeConfig;
|
|
4
|
+
exports.proxyPolicyNames = proxyPolicyNames;
|
|
5
|
+
const yaml_1 = require("yaml");
|
|
6
|
+
const defaults_1 = require("../defaults");
|
|
7
|
+
const PRIVATE_DIRECT_RULES = [
|
|
8
|
+
'DOMAIN-SUFFIX,local,DIRECT',
|
|
9
|
+
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
|
|
10
|
+
'IP-CIDR,10.0.0.0/8,DIRECT,no-resolve',
|
|
11
|
+
'IP-CIDR,172.16.0.0/12,DIRECT,no-resolve',
|
|
12
|
+
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
|
|
13
|
+
'IP-CIDR,169.254.0.0/16,DIRECT,no-resolve',
|
|
14
|
+
'IP-CIDR6,::1/128,DIRECT,no-resolve',
|
|
15
|
+
'IP-CIDR6,fc00::/7,DIRECT,no-resolve',
|
|
16
|
+
'IP-CIDR6,fe80::/10,DIRECT,no-resolve'
|
|
17
|
+
];
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function stringArray(value) {
|
|
22
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
23
|
+
}
|
|
24
|
+
function findProxyPolicyName(config) {
|
|
25
|
+
const groups = Array.isArray(config['proxy-groups']) ? config['proxy-groups'] : [];
|
|
26
|
+
for (const group of groups) {
|
|
27
|
+
if (isRecord(group) && typeof group.name === 'string') {
|
|
28
|
+
return group.name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const proxies = Array.isArray(config.proxies) ? config.proxies : [];
|
|
32
|
+
for (const proxy of proxies) {
|
|
33
|
+
if (isRecord(proxy) && typeof proxy.name === 'string') {
|
|
34
|
+
return proxy.name;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return 'PROXY';
|
|
38
|
+
}
|
|
39
|
+
function ensureProxyGroup(config, proxyPolicyName) {
|
|
40
|
+
if (proxyPolicyName !== 'PROXY') {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const groups = Array.isArray(config['proxy-groups']) ? config['proxy-groups'] : [];
|
|
44
|
+
if (groups.some((group) => isRecord(group) && group.name === 'PROXY')) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const proxies = Array.isArray(config.proxies) ? config.proxies : [];
|
|
48
|
+
const proxyNames = proxies
|
|
49
|
+
.filter(isRecord)
|
|
50
|
+
.map((proxy) => proxy.name)
|
|
51
|
+
.filter((name) => typeof name === 'string');
|
|
52
|
+
if (proxyNames.length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
config['proxy-groups'] = [
|
|
56
|
+
{
|
|
57
|
+
name: 'PROXY',
|
|
58
|
+
type: 'select',
|
|
59
|
+
proxies: proxyNames
|
|
60
|
+
},
|
|
61
|
+
...groups
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
function dnsOverlay(dnsPort) {
|
|
65
|
+
return {
|
|
66
|
+
enable: true,
|
|
67
|
+
listen: `0.0.0.0:${dnsPort}`,
|
|
68
|
+
ipv6: false,
|
|
69
|
+
'use-hosts': true,
|
|
70
|
+
'use-system-hosts': true,
|
|
71
|
+
'cache-algorithm': 'arc',
|
|
72
|
+
'enhanced-mode': 'fake-ip',
|
|
73
|
+
'fake-ip-range': '198.18.0.1/16',
|
|
74
|
+
'default-nameserver': ['223.5.5.5', '119.29.29.29', '1.1.1.1'],
|
|
75
|
+
nameserver: ['https://dns.alidns.com/dns-query', 'https://doh.pub/dns-query'],
|
|
76
|
+
fallback: ['tls://1.1.1.1', 'tls://8.8.8.8'],
|
|
77
|
+
'fallback-filter': {
|
|
78
|
+
geoip: true,
|
|
79
|
+
'geoip-code': 'CN',
|
|
80
|
+
geosite: ['gfw']
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function tunOverlay(enable) {
|
|
85
|
+
return {
|
|
86
|
+
enable,
|
|
87
|
+
stack: 'system',
|
|
88
|
+
'auto-route': true,
|
|
89
|
+
'auto-redirect': true,
|
|
90
|
+
'auto-detect-interface': true,
|
|
91
|
+
'strict-route': true,
|
|
92
|
+
'dns-hijack': ['any:53', 'tcp://any:53']
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function domainRule(rule, target) {
|
|
96
|
+
return `DOMAIN-SUFFIX,${rule.domain},${target}`;
|
|
97
|
+
}
|
|
98
|
+
function buildRules(mode, proxyPolicyName, rules) {
|
|
99
|
+
const enabled = rules.filter((rule) => rule.enabled);
|
|
100
|
+
const blockRules = enabled.filter((rule) => rule.kind === 'block').map((rule) => domainRule(rule, 'REJECT'));
|
|
101
|
+
const allowRules = enabled.filter((rule) => rule.kind === 'allow').map((rule) => domainRule(rule, proxyPolicyName));
|
|
102
|
+
if (mode === 'system-tun' || mode === 'app-global') {
|
|
103
|
+
return [
|
|
104
|
+
...PRIVATE_DIRECT_RULES,
|
|
105
|
+
...blockRules,
|
|
106
|
+
`MATCH,${proxyPolicyName}`
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
const appModeTail = allowRules.length > 0 ? 'MATCH,REJECT' : `MATCH,${proxyPolicyName}`;
|
|
110
|
+
return [
|
|
111
|
+
...PRIVATE_DIRECT_RULES,
|
|
112
|
+
...blockRules,
|
|
113
|
+
...allowRules,
|
|
114
|
+
'GEOSITE,CN,DIRECT',
|
|
115
|
+
'GEOIP,CN,DIRECT',
|
|
116
|
+
appModeTail
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
function renderRuntimeConfig(input) {
|
|
120
|
+
const parsed = (0, yaml_1.parse)(input.baseYaml);
|
|
121
|
+
const config = isRecord(parsed) ? parsed : {};
|
|
122
|
+
const proxyPolicyName = findProxyPolicyName(config);
|
|
123
|
+
ensureProxyGroup(config, proxyPolicyName);
|
|
124
|
+
config['mixed-port'] = input.settings.ports.mixed;
|
|
125
|
+
config['allow-lan'] = false;
|
|
126
|
+
config.mode = 'rule';
|
|
127
|
+
config['log-level'] = 'info';
|
|
128
|
+
config['external-controller'] = `127.0.0.1:${input.settings.ports.controller}`;
|
|
129
|
+
config.secret = input.settings.controllerSecret;
|
|
130
|
+
config['geodata-mode'] = true;
|
|
131
|
+
config['geo-auto-update'] = true;
|
|
132
|
+
config['geo-update-interval'] = 24;
|
|
133
|
+
config['geox-url'] = defaults_1.GEOX_URL;
|
|
134
|
+
config.dns = {
|
|
135
|
+
...(isRecord(config.dns) ? config.dns : {}),
|
|
136
|
+
...dnsOverlay(input.settings.ports.dns)
|
|
137
|
+
};
|
|
138
|
+
config.tun = tunOverlay(input.settings.mode === 'system-tun' && input.settings.tunInstalled);
|
|
139
|
+
config.rules = buildRules(input.settings.mode, proxyPolicyName, input.rules);
|
|
140
|
+
return {
|
|
141
|
+
yaml: (0, yaml_1.stringify)(config, { lineWidth: 0 }),
|
|
142
|
+
proxyPolicyName
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function proxyPolicyNames(configYaml) {
|
|
146
|
+
const parsed = (0, yaml_1.parse)(configYaml);
|
|
147
|
+
if (!isRecord(parsed)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
return stringArray(parsed['proxy-groups']?.[0]?.proxies);
|
|
151
|
+
}
|