@wenbin_wb/dsh-bridge 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 +88 -0
- package/client/build.mjs +41 -0
- package/client/client.js +484 -0
- package/client/index.js +410 -0
- package/cordis.patch.yml +4 -0
- package/lib/bridge-rpc.js +113 -0
- package/lib/cloudflared-manager.mjs +236 -0
- package/lib/index.js +499 -0
- package/lib/tunnel-client.mjs +291 -0
- package/package.json +73 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// DSH Bridge - Cloudflared Manager
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { chmod, stat, unlink, rename } from 'node:fs/promises';
|
|
6
|
+
import { homedir, platform, arch } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { get as httpsGet } from 'node:https';
|
|
10
|
+
|
|
11
|
+
const CLOUDFLARED_VERSION = '2024.10.0';
|
|
12
|
+
const DOWNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 分钟
|
|
13
|
+
const MIN_BINARY_SIZE = 5 * 1024 * 1024; // 最小 5MB,防止下到 HTML 错误页
|
|
14
|
+
|
|
15
|
+
function getCloudflaredInfo() {
|
|
16
|
+
const os = platform();
|
|
17
|
+
const cpuArch = arch();
|
|
18
|
+
|
|
19
|
+
const platformMap = {
|
|
20
|
+
'win32-x64': { file: 'cloudflared-windows-amd64.exe', name: 'cloudflared.exe' },
|
|
21
|
+
'win32-arm64': { file: 'cloudflared-windows-arm64.exe', name: 'cloudflared.exe' },
|
|
22
|
+
'darwin-x64': { file: 'cloudflared-darwin-amd64.tgz', name: 'cloudflared' },
|
|
23
|
+
'darwin-arm64':{ file: 'cloudflared-darwin-arm64.tgz', name: 'cloudflared' },
|
|
24
|
+
'linux-x64': { file: 'cloudflared-linux-amd64', name: 'cloudflared' },
|
|
25
|
+
'linux-arm64': { file: 'cloudflared-linux-arm64', name: 'cloudflared' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const key = `${os}-${cpuArch}`;
|
|
29
|
+
const info = platformMap[key];
|
|
30
|
+
if (!info) throw new Error(`不支持的平台: ${os}-${cpuArch}`);
|
|
31
|
+
|
|
32
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${info.file}`;
|
|
33
|
+
return { url, name: info.name };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function downloadFile(url, dest, onProgress) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const timer = setTimeout(() => reject(new Error('下载超时(5分钟)')), DOWNLOAD_TIMEOUT);
|
|
39
|
+
|
|
40
|
+
function doGet(targetUrl, redirects = 0) {
|
|
41
|
+
if (redirects > 5) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
return reject(new Error('重定向次数过多'));
|
|
44
|
+
}
|
|
45
|
+
httpsGet(targetUrl, (res) => {
|
|
46
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
47
|
+
res.resume();
|
|
48
|
+
return doGet(res.headers.location, redirects + 1);
|
|
49
|
+
}
|
|
50
|
+
if (res.statusCode !== 200) {
|
|
51
|
+
res.resume();
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
return reject(new Error(`下载失败: HTTP ${res.statusCode}`));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const total = parseInt(res.headers['content-length'] ?? '0', 10);
|
|
57
|
+
let downloaded = 0;
|
|
58
|
+
res.on('data', (chunk) => {
|
|
59
|
+
downloaded += chunk.length;
|
|
60
|
+
if (onProgress && total > 0) {
|
|
61
|
+
onProgress(Math.round(downloaded / total * 100), downloaded, total);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const fileStream = createWriteStream(dest);
|
|
66
|
+
pipeline(res, fileStream)
|
|
67
|
+
.then(() => { clearTimeout(timer); resolve(); })
|
|
68
|
+
.catch((err) => { clearTimeout(timer); reject(err); });
|
|
69
|
+
}).on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
doGet(url);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class CloudflaredManager {
|
|
77
|
+
constructor({ port, home, onStateChange, logger }) {
|
|
78
|
+
this.port = port;
|
|
79
|
+
this.home = home || join(homedir(), '.dsh-bridge');
|
|
80
|
+
this.onStateChange = onStateChange;
|
|
81
|
+
this.logger = logger;
|
|
82
|
+
|
|
83
|
+
this.process = null;
|
|
84
|
+
this.url = null;
|
|
85
|
+
this.binaryPath = null;
|
|
86
|
+
this._stopped = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 异步启动,立即返回——调用方不需要 await
|
|
90
|
+
start() {
|
|
91
|
+
this._stopped = false;
|
|
92
|
+
this._run().catch((err) => {
|
|
93
|
+
this.logger?.error('cloudflared 启动失败: %s', err.message);
|
|
94
|
+
this._setState('error', err.message);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async _run() {
|
|
99
|
+
await this._ensureBinary();
|
|
100
|
+
if (this._stopped) return;
|
|
101
|
+
await this._startProcess();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async _ensureBinary() {
|
|
105
|
+
const { url, name } = getCloudflaredInfo();
|
|
106
|
+
const binDir = join(this.home, 'bin');
|
|
107
|
+
const binPath = join(binDir, name);
|
|
108
|
+
this.binaryPath = binPath;
|
|
109
|
+
|
|
110
|
+
if (existsSync(binPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const s = await stat(binPath);
|
|
113
|
+
if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
|
|
114
|
+
this.logger?.info('cloudflared 已存在: %s', binPath);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
} catch {}
|
|
118
|
+
// 损坏文件,删掉重下
|
|
119
|
+
await unlink(binPath).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this._setState('downloading', '正在下载 cloudflared (~30MB)...');
|
|
123
|
+
this.logger?.info('从 %s 下载 cloudflared', url);
|
|
124
|
+
|
|
125
|
+
mkdirSync(binDir, { recursive: true });
|
|
126
|
+
const tempPath = `${binPath}.tmp`;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await downloadFile(url, tempPath, (percent, downloaded, total) => {
|
|
130
|
+
if (this._stopped) return;
|
|
131
|
+
const mb = (downloaded / 1024 / 1024).toFixed(1);
|
|
132
|
+
const totalMb = (total / 1024 / 1024).toFixed(1);
|
|
133
|
+
this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (existsSync(binPath)) await unlink(binPath).catch(() => {});
|
|
137
|
+
await rename(tempPath, binPath);
|
|
138
|
+
|
|
139
|
+
if (platform() !== 'win32') {
|
|
140
|
+
await chmod(binPath, 0o755);
|
|
141
|
+
}
|
|
142
|
+
this.logger?.info('cloudflared 下载完成');
|
|
143
|
+
} catch (err) {
|
|
144
|
+
await unlink(tempPath).catch(() => {});
|
|
145
|
+
throw new Error(`下载失败: ${err.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_startProcess() {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
if (this._stopped) return reject(new Error('已取消'));
|
|
152
|
+
|
|
153
|
+
this._setState('connecting', '正在连接 Cloudflare...');
|
|
154
|
+
|
|
155
|
+
const args = ['tunnel', '--url', `http://127.0.0.1:${this.port}`];
|
|
156
|
+
this.logger?.info('启动 cloudflared: %s %s', this.binaryPath, args.join(' '));
|
|
157
|
+
|
|
158
|
+
this.process = spawn(this.binaryPath, args, {
|
|
159
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
let resolved = false;
|
|
163
|
+
|
|
164
|
+
const tryResolve = () => {
|
|
165
|
+
if (!resolved) {
|
|
166
|
+
resolved = true;
|
|
167
|
+
resolve();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// URL 同时在 stdout 和 stderr 里找(不同版本行为不同)
|
|
172
|
+
const parseUrl = (text) => {
|
|
173
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
174
|
+
if (match && !resolved) {
|
|
175
|
+
this.url = match[0];
|
|
176
|
+
this._setState('ready', '隧道已建立');
|
|
177
|
+
this.logger?.info('cloudflared 隧道就绪: %s', this.url);
|
|
178
|
+
tryResolve();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
this.process.stdout.on('data', (d) => parseUrl(d.toString()));
|
|
183
|
+
this.process.stderr.on('data', (d) => {
|
|
184
|
+
const text = d.toString();
|
|
185
|
+
this.logger?.debug('cloudflared: %s', text.trim());
|
|
186
|
+
parseUrl(text);
|
|
187
|
+
if (text.includes('Registered tunnel') && !resolved) {
|
|
188
|
+
this._setState('connecting', '隧道已注册,等待 URL...');
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
this.process.on('exit', (code) => {
|
|
193
|
+
this.process = null;
|
|
194
|
+
this.url = null;
|
|
195
|
+
if (!resolved) {
|
|
196
|
+
reject(new Error(`cloudflared 退出,code=${code}`));
|
|
197
|
+
} else {
|
|
198
|
+
this._setState('idle', '');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
this.process.on('error', (err) => {
|
|
203
|
+
if (!resolved) reject(err);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// 连接超时 90 秒
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
if (!resolved) {
|
|
209
|
+
this.stop();
|
|
210
|
+
reject(new Error('等待隧道 URL 超时(90秒)'));
|
|
211
|
+
}
|
|
212
|
+
}, 90000);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_setState(phase, detail) {
|
|
217
|
+
this.onStateChange?.({ phase, detail });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
stop() {
|
|
221
|
+
this._stopped = true;
|
|
222
|
+
if (this.process) {
|
|
223
|
+
this.logger?.info('停止 cloudflared...');
|
|
224
|
+
try {
|
|
225
|
+
if (platform() === 'win32') {
|
|
226
|
+
// Windows 不支持 SIGTERM,用 taskkill 强制终止
|
|
227
|
+
spawn('taskkill', ['/pid', String(this.process.pid), '/f', '/t'], { stdio: 'ignore' });
|
|
228
|
+
} else {
|
|
229
|
+
this.process.kill('SIGTERM');
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
this.process = null;
|
|
233
|
+
}
|
|
234
|
+
this.url = null;
|
|
235
|
+
}
|
|
236
|
+
}
|