@wenbin_wb/dsh-bridge 2.8.4 → 2.8.6

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.
@@ -1,7 +1,5 @@
1
- // DSH Bridge - Cloudflared Manager
2
-
3
- import { spawn } from 'node:child_process';
4
- import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
1
+ import { spawn, execSync } from 'node:child_process';
2
+ import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
5
3
  import { chmod, stat, unlink, rename } from 'node:fs/promises';
6
4
  import { homedir, platform, arch } from 'node:os';
7
5
  import { join } from 'node:path';
@@ -73,6 +71,27 @@ async function downloadFile(url, dest, onProgress) {
73
71
  });
74
72
  }
75
73
 
74
+ function findSystemCloudflared() {
75
+ const isWin = platform() === 'win32';
76
+ const candidates = [];
77
+ if (isWin) {
78
+ candidates.push('cloudflared.exe', 'cloudflared', 'C:\\Program Files (x86)\\cloudflared\\cloudflared.exe', 'C:\\Program Files\\cloudflared\\cloudflared.exe');
79
+ } else {
80
+ candidates.push('cloudflared', '/opt/homebrew/bin/cloudflared', '/usr/local/bin/cloudflared', '/usr/bin/cloudflared', '/bin/cloudflared');
81
+ }
82
+
83
+ for (const bin of candidates) {
84
+ try {
85
+ if (bin.includes('/') || bin.includes('\\')) {
86
+ if (!existsSync(bin)) continue;
87
+ }
88
+ execSync(`"${bin}" --version`, { stdio: 'ignore', timeout: 3000 });
89
+ return bin;
90
+ } catch {}
91
+ }
92
+ return null;
93
+ }
94
+
76
95
  export class CloudflaredManager {
77
96
  constructor({ port, home, token, hostname, onStateChange, logger }) {
78
97
  this.port = port;
@@ -104,19 +123,47 @@ export class CloudflaredManager {
104
123
  }
105
124
 
106
125
  async _ensureBinary() {
126
+ // 1. 优先使用系统环境变量或 Homebrew / 包管理器已安装的全局二进制
127
+ const systemBin = findSystemCloudflared();
128
+ if (systemBin) {
129
+ this.binaryPath = systemBin;
130
+ this.logger?.info('优先使用系统全局 cloudflared: %s', systemBin);
131
+ return;
132
+ }
133
+
107
134
  const { url, name } = getCloudflaredInfo();
108
135
  const binDir = join(this.home, 'bin');
109
136
  const binPath = join(binDir, name);
110
137
  this.binaryPath = binPath;
111
138
 
139
+ // 2. 检查本地 ~/.dsh-bridge/bin/cloudflared 是否已存在且可用
112
140
  if (existsSync(binPath)) {
113
141
  try {
114
142
  const s = await stat(binPath);
115
143
  if (s.size > MIN_BINARY_SIZE) { // >5MB 才视为有效二进制
116
- this.logger?.info('cloudflared 已存在: %s', binPath);
117
- return;
144
+ // 检查是否为历史残留未解压的 gzip 压缩包 (0x1f 0x8b)
145
+ const fd = readFileSync(binPath);
146
+ const isGzip = fd.length >= 2 && fd[0] === 0x1f && fd[1] === 0x8b;
147
+ if (isGzip) {
148
+ this.logger?.warn('检测到历史残留的未解压 cloudflared.tgz 压缩包,正在清理重新准备...');
149
+ await unlink(binPath).catch(() => {});
150
+ } else {
151
+ // macOS / Linux 赋予可执行权限并清除 Gatekeeper 隔离属性
152
+ if (platform() !== 'win32') {
153
+ await chmod(binPath, 0o755).catch(() => {});
154
+ if (platform() === 'darwin') {
155
+ try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
156
+ }
157
+ }
158
+ // 执行一次 --version 验证是否能正常 spawn
159
+ execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
160
+ this.logger?.info('cloudflared 已存在且验证通过: %s', binPath);
161
+ return;
162
+ }
118
163
  }
119
- } catch {}
164
+ } catch (verifyErr) {
165
+ this.logger?.warn('现有 cloudflared 二进制验证失败 (%s),准备重新下载', verifyErr.message);
166
+ }
120
167
  // 损坏文件,删掉重下
121
168
  await unlink(binPath).catch(() => {});
122
169
  }
@@ -135,16 +182,32 @@ export class CloudflaredManager {
135
182
  this._setState('downloading', `下载 cloudflared: ${mb}/${totalMb} MB (${percent}%)`);
136
183
  });
137
184
 
138
- if (existsSync(binPath)) await unlink(binPath).catch(() => {});
139
- await rename(tempPath, binPath);
185
+ if (url.endsWith('.tgz') || url.endsWith('.tar.gz')) {
186
+ try {
187
+ execSync(`tar -xzf "${tempPath}" -C "${binDir}"`);
188
+ await unlink(tempPath).catch(() => {});
189
+ } catch (tarErr) {
190
+ this.logger?.error('解压 cloudflared 压缩包失败: %s', tarErr.message);
191
+ throw new Error(`解压 cloudflared 失败: ${tarErr.message}`);
192
+ }
193
+ } else {
194
+ if (existsSync(binPath)) await unlink(binPath).catch(() => {});
195
+ await rename(tempPath, binPath);
196
+ }
140
197
 
141
198
  if (platform() !== 'win32') {
142
- await chmod(binPath, 0o755);
199
+ await chmod(binPath, 0o755).catch(() => {});
200
+ if (platform() === 'darwin') {
201
+ try { execSync(`xattr -d com.apple.quarantine "${binPath}"`, { stdio: 'ignore' }); } catch {}
202
+ }
143
203
  }
144
- this.logger?.info('cloudflared 下载完成');
204
+
205
+ // 执行 --version 最终确认
206
+ execSync(`"${binPath}" --version`, { stdio: 'ignore', timeout: 3000 });
207
+ this.logger?.info('cloudflared 下载并准备完成');
145
208
  } catch (err) {
146
209
  await unlink(tempPath).catch(() => {});
147
- throw new Error(`下载失败: ${err.message}`);
210
+ throw new Error(`准备 cloudflared 失败: ${err.message}`);
148
211
  }
149
212
  }
150
213
 
@@ -205,6 +205,7 @@ export class FeishuGateway extends Service {
205
205
  // ---- 消息收发 ----
206
206
 
207
207
  async _handleMessageReceive(data) {
208
+ if (this._closing || this.status === 'offline') return
208
209
  if (!data?.message) return
209
210
  const { message, sender } = data
210
211
  const messageId = message.message_id
@@ -107,6 +107,7 @@ export class FeishuConversationNode extends ConversationBridge {
107
107
  }
108
108
 
109
109
  async _handleInbound(event) {
110
+ if (this.gateway?._closing || this.gateway?.status === 'offline') return
110
111
  const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
111
112
  this._lastPeer = { peerId, senderId, isGroup }
112
113
 
package/lib/index.js CHANGED
@@ -10,9 +10,10 @@ import { get as httpsGet } from 'node:https';
10
10
  import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
11
11
  import { join, dirname, basename, resolve, normalize } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
- import { readFileSync } from 'node:fs';
13
+ import { readFileSync, existsSync } from 'node:fs';
14
14
  import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
15
15
  import { spawn } from 'node:child_process';
16
+ import { createHash, createHmac } from 'node:crypto';
16
17
  import QRCode from 'qrcode';
17
18
  import { installBridgeRpc } from './bridge-rpc.js';
18
19
  import { CustomTunnelClient } from './tunnel-client.mjs';
@@ -42,23 +43,34 @@ function selectLanIPv4() {
42
43
  let best = null;
43
44
  let bestScore = -1;
44
45
 
46
+ const VIRTUAL_KEYWORDS = [
47
+ 'vethernet', 'wsl', 'hyper-v', 'virtual', 'vmware', 'vbox', 'docker',
48
+ 'tailscale', 'zerotier', 'tap', 'tun', 'utun', 'wireguard', 'loopback', 'bridge',
49
+ ];
50
+
45
51
  for (const [ifname, addrs] of Object.entries(interfaces)) {
46
52
  if (!addrs) continue;
53
+ const lower = ifname.toLowerCase();
54
+ const isVirtual = VIRTUAL_KEYWORDS.some((kw) => lower.includes(kw));
47
55
 
48
56
  for (const addr of addrs) {
49
57
  if (addr.family !== 'IPv4' || addr.internal) continue;
50
58
 
51
59
  let score = 0;
60
+ // 1. IP 网段优先(家庭/企业物理局域网最常用网段)
52
61
  if (addr.address.startsWith('192.168.')) score += 100;
53
62
  else if (addr.address.startsWith('10.')) score += 90;
54
- else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 90;
63
+ else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 70;
64
+ else score += 10;
55
65
 
56
- const lower = ifname.toLowerCase();
57
- if (!lower.includes('virtual')) score += 50;
58
- if (!lower.includes('vmware')) score += 50;
59
- if (!lower.includes('vbox')) score += 50;
60
- if (lower.includes('eth')) score += 20;
61
- else if (lower.includes('en')) score += 10;
66
+ // 2. 物理网卡与名称特征优先
67
+ if (isVirtual) {
68
+ score -= 200; // 虚拟网卡大幅降权
69
+ } else {
70
+ score += 100;
71
+ if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) score += 50;
72
+ else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) score += 40;
73
+ }
62
74
 
63
75
  if (score > bestScore) {
64
76
  bestScore = score;
@@ -161,6 +173,37 @@ function isCompressed(headers) {
161
173
  return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
162
174
  }
163
175
 
176
+ function encodeBase64Url(value) {
177
+ return Buffer.from(value).toString('base64url');
178
+ }
179
+
180
+ /**
181
+ * 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
182
+ */
183
+ function getDshLoopbackCookie(targetPort) {
184
+ try {
185
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
186
+ const credPath = join(dshHome, '.credentials.yaml');
187
+ if (!existsSync(credPath)) return '';
188
+ const content = readFileSync(credPath, 'utf8');
189
+ const match = content.match(/secret:\s*([A-Za-z0-9_-]+)/);
190
+ if (!match) return '';
191
+ const secret = Buffer.from(match[1], 'base64url');
192
+
193
+ const authority = `127.0.0.1:${targetPort}`;
194
+ const name = 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(authority).digest());
195
+ const issuedAt = Date.now() - 1000;
196
+ const expiresAt = issuedAt + 30 * 24 * 3600 * 1000;
197
+ const body = encodeBase64Url(Buffer.from(JSON.stringify({
198
+ version: 1, authority, issuedAt, expiresAt,
199
+ }), 'utf8'));
200
+ const sig = encodeBase64Url(createHmac('sha256', secret).update(body).digest());
201
+ return `${name}=v1.${body}.${sig}`;
202
+ } catch {
203
+ return '';
204
+ }
205
+ }
206
+
164
207
  /** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
165
208
  function loopbackHeaders(headers, targetPort) {
166
209
  const authority = `127.0.0.1:${targetPort}`;
@@ -168,6 +211,19 @@ function loopbackHeaders(headers, targetPort) {
168
211
  out['host'] = authority;
169
212
  if (out['origin']) out['origin'] = `http://${authority}`;
170
213
  if (out['Origin']) out['Origin'] = `http://${authority}`;
214
+
215
+ // 1. 注入 DSH 本地认证签名(若有)
216
+ const dshCookie = getDshLoopbackCookie(targetPort);
217
+ if (dshCookie) {
218
+ const existing = out['cookie'] || out['Cookie'] || '';
219
+ out['cookie'] = existing ? `${existing}; ${dshCookie}` : dshCookie;
220
+ delete out['Cookie'];
221
+ }
222
+
223
+ // 2. 禁用内部代理流量压缩,确保代理层拿到未压缩 HTML 以稳定注入 ownsHost 和 Polyfill
224
+ delete out['accept-encoding'];
225
+ delete out['Accept-Encoding'];
226
+
171
227
  return out;
172
228
  }
173
229
 
@@ -1254,7 +1310,7 @@ class BridgeService {
1254
1310
  */
1255
1311
  function apply(ctx, config = {}) {
1256
1312
  const logger = ctx.logger(name);
1257
- const dshPort = ctx.webServer.port;
1313
+ const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
1258
1314
 
1259
1315
  if (!dshPort) {
1260
1316
  logger.error('webServer port unavailable');