@wenbin_wb/dsh-bridge 2.8.5 → 2.8.7

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.
@@ -12,6 +12,7 @@ export const BRIDGE_ENDPOINTS = {
12
12
  saveCloudflaredConfig: 'saveCloudflaredConfig',
13
13
  setTunnelAutoStart: 'setTunnelAutoStart',
14
14
  saveCustomTunnelConfig: 'saveCustomTunnelConfig',
15
+ setLanIp: 'setLanIp',
15
16
  checkVersion: 'checkVersion',
16
17
  upgradePlugin: 'upgradePlugin',
17
18
  restartDsh: 'restartDsh',
package/lib/bridge-rpc.js CHANGED
@@ -167,6 +167,15 @@ export function installBridgeRpc(ctx, { service, authManager, wechat, platformMa
167
167
  return ok(status);
168
168
  }
169
169
 
170
+ if (endpoint === BRIDGE_ENDPOINTS.setLanIp) {
171
+ const adminErr = checkAdminAuth(authManager, payload);
172
+ if (adminErr) return adminErr;
173
+
174
+ const { ip } = payload;
175
+ const status = await service.setLanIp({ ip });
176
+ return ok(status);
177
+ }
178
+
170
179
  if (endpoint === BRIDGE_ENDPOINTS.startCustomTunnel) {
171
180
  const adminErr = checkAdminAuth(authManager, payload);
172
181
  if (adminErr) return adminErr;
@@ -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
 
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';
@@ -34,40 +35,67 @@ const inject = ['connection', 'webServer', 'sessions', 'agents', 'approval', 'wo
34
35
  const PACKAGE_JSON = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
35
36
  const VERSION = PACKAGE_JSON.version ?? '0.0.0';
36
37
 
38
+ const VIRTUAL_KEYWORDS = [
39
+ 'vethernet', 'wsl', 'hyper-v', 'virtual', 'vmware', 'vbox', 'docker',
40
+ 'tailscale', 'zerotier', 'tap', 'tun', 'utun', 'wireguard', 'loopback', 'bridge',
41
+ ];
42
+
37
43
  /**
38
- * 选择最佳局域网 IP
44
+ * 列出所有可用的局域网 IPv4 网卡与 IP 地址(按推荐优先级排序)
39
45
  */
40
- function selectLanIPv4() {
46
+ function listAllLanIPv4() {
41
47
  const interfaces = networkInterfaces();
42
- let best = null;
43
- let bestScore = -1;
48
+ const list = [];
44
49
 
45
50
  for (const [ifname, addrs] of Object.entries(interfaces)) {
46
51
  if (!addrs) continue;
52
+ const lower = ifname.toLowerCase();
53
+ const isVirtual = VIRTUAL_KEYWORDS.some((kw) => lower.includes(kw));
47
54
 
48
55
  for (const addr of addrs) {
49
56
  if (addr.family !== 'IPv4' || addr.internal) continue;
50
57
 
51
58
  let score = 0;
59
+ // 1. IP 网段优先(家庭/企业物理局域网最常用网段)
52
60
  if (addr.address.startsWith('192.168.')) score += 100;
53
61
  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;
55
-
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;
62
-
63
- if (score > bestScore) {
64
- bestScore = score;
65
- best = addr.address;
62
+ else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 70;
63
+ else score += 10;
64
+
65
+ // 2. 物理网卡与名称特征优先
66
+ if (isVirtual) {
67
+ score -= 200; // 虚拟网卡大幅降权
68
+ } else {
69
+ score += 100;
70
+ if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) score += 50;
71
+ else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) score += 40;
66
72
  }
73
+
74
+ let label = ifname;
75
+ if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) label += ' (Wi-Fi 无线网卡)';
76
+ else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) label += ' (有线网卡)';
77
+ else if (isVirtual) label += ' (虚拟网卡 / WSL / 虚拟机)';
78
+
79
+ list.push({
80
+ name: ifname,
81
+ label,
82
+ address: addr.address,
83
+ netmask: addr.netmask,
84
+ isVirtual,
85
+ score,
86
+ });
67
87
  }
68
88
  }
69
89
 
70
- return best;
90
+ return list.sort((a, b) => b.score - a.score);
91
+ }
92
+
93
+ /**
94
+ * 选择最佳默认局域网 IP
95
+ */
96
+ function selectLanIPv4() {
97
+ const list = listAllLanIPv4();
98
+ return list[0]?.address || null;
71
99
  }
72
100
 
73
101
  /**
@@ -161,6 +189,37 @@ function isCompressed(headers) {
161
189
  return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
162
190
  }
163
191
 
192
+ function encodeBase64Url(value) {
193
+ return Buffer.from(value).toString('base64url');
194
+ }
195
+
196
+ /**
197
+ * 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
198
+ */
199
+ function getDshLoopbackCookie(targetPort) {
200
+ try {
201
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
202
+ const credPath = join(dshHome, '.credentials.yaml');
203
+ if (!existsSync(credPath)) return '';
204
+ const content = readFileSync(credPath, 'utf8');
205
+ const match = content.match(/secret:\s*([A-Za-z0-9_-]+)/);
206
+ if (!match) return '';
207
+ const secret = Buffer.from(match[1], 'base64url');
208
+
209
+ const authority = `127.0.0.1:${targetPort}`;
210
+ const name = 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(authority).digest());
211
+ const issuedAt = Date.now() - 1000;
212
+ const expiresAt = issuedAt + 30 * 24 * 3600 * 1000;
213
+ const body = encodeBase64Url(Buffer.from(JSON.stringify({
214
+ version: 1, authority, issuedAt, expiresAt,
215
+ }), 'utf8'));
216
+ const sig = encodeBase64Url(createHmac('sha256', secret).update(body).digest());
217
+ return `${name}=v1.${body}.${sig}`;
218
+ } catch {
219
+ return '';
220
+ }
221
+ }
222
+
164
223
  /** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
165
224
  function loopbackHeaders(headers, targetPort) {
166
225
  const authority = `127.0.0.1:${targetPort}`;
@@ -168,6 +227,19 @@ function loopbackHeaders(headers, targetPort) {
168
227
  out['host'] = authority;
169
228
  if (out['origin']) out['origin'] = `http://${authority}`;
170
229
  if (out['Origin']) out['Origin'] = `http://${authority}`;
230
+
231
+ // 1. 注入 DSH 本地认证签名(若有)
232
+ const dshCookie = getDshLoopbackCookie(targetPort);
233
+ if (dshCookie) {
234
+ const existing = out['cookie'] || out['Cookie'] || '';
235
+ out['cookie'] = existing ? `${existing}; ${dshCookie}` : dshCookie;
236
+ delete out['Cookie'];
237
+ }
238
+
239
+ // 2. 禁用内部代理流量压缩,确保代理层拿到未压缩 HTML 以稳定注入 ownsHost 和 Polyfill
240
+ delete out['accept-encoding'];
241
+ delete out['Accept-Encoding'];
242
+
171
243
  return out;
172
244
  }
173
245
 
@@ -453,12 +525,13 @@ class ProxyServer {
453
525
  * Bridge Service
454
526
  */
455
527
  class BridgeService {
456
- constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, authManager, onPersist, logger }) {
528
+ constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, lanConfig, authManager, onPersist, logger }) {
457
529
  this.dshPort = dshPort;
458
530
  this.proxyPort = proxyPort;
459
531
  this.home = home;
460
532
  this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
461
533
  this.customTunnelConfig = customTunnelConfig ?? null;
534
+ this.selectedLanIp = lanConfig?.selectedIp ?? null;
462
535
  this.authManager = authManager ?? null;
463
536
  this.onPersist = onPersist ?? null;
464
537
  this.logger = logger;
@@ -473,6 +546,14 @@ class BridgeService {
473
546
  this.cloudflaredState = { phase: 'idle', detail: '' };
474
547
  }
475
548
 
549
+ async setLanIp({ ip } = {}) {
550
+ const trimmed = ip ? String(ip).trim() : null;
551
+ this.selectedLanIp = trimmed || null;
552
+ await this.onPersist?.({ lan: { selectedIp: this.selectedLanIp } });
553
+ this.logger?.info('局域网选定 IP 更新为: %s', this.selectedLanIp || '自动推荐');
554
+ return this.getStatus();
555
+ }
556
+
476
557
  async startProxy() {
477
558
  if (this.proxy) return this.proxy;
478
559
 
@@ -488,7 +569,9 @@ class BridgeService {
488
569
  }
489
570
 
490
571
  async getStatus({ adminAuthValid = false } = {}) {
491
- const lanIp = selectLanIPv4();
572
+ const allInterfaces = listAllLanIPv4();
573
+ const isSelectedValid = Boolean(this.selectedLanIp && allInterfaces.some(i => i.address === this.selectedLanIp));
574
+ const lanIp = isSelectedValid ? this.selectedLanIp : selectLanIPv4();
492
575
  const token = adminAuthValid ? this.authManager?.secretToken : null;
493
576
  const isAuthEnabled = Boolean(this.authManager?.enabled && this.authManager?.mode !== 'password_only' && token);
494
577
 
@@ -529,6 +612,8 @@ class BridgeService {
529
612
 
530
613
  lan: {
531
614
  ip: lanIp,
615
+ selectedIp: this.selectedLanIp || '',
616
+ interfaces: allInterfaces,
532
617
  url: lanUrl,
533
618
  rawUrl: baseLanUrl,
534
619
  qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
@@ -742,10 +827,40 @@ class BridgeService {
742
827
  const pkgSpec = `@wenbin_wb/dsh-bridge@${targetVersion}`;
743
828
  const isWin = process.platform === 'win32';
744
829
 
830
+ // 自动构建包含 Homebrew / NVM / Node 兄弟目录的全量 PATH 环境变量
831
+ const nodeDir = dirname(process.execPath);
832
+ const home = homedir();
833
+ const extraPaths = isWin ? [
834
+ nodeDir,
835
+ ] : [
836
+ nodeDir,
837
+ '/opt/homebrew/bin',
838
+ '/opt/homebrew/sbin',
839
+ '/usr/local/bin',
840
+ '/usr/bin',
841
+ '/bin',
842
+ join(home, '.nvm/current/bin'),
843
+ join(home, '.fnm/current/bin'),
844
+ join(home, '.local/bin'),
845
+ join(home, '.cargo/bin'),
846
+ ];
847
+
848
+ const separator = isWin ? ';' : ':';
849
+ const existingPath = process.env.PATH || process.env.Path || '';
850
+ const augmentedEnv = {
851
+ ...process.env,
852
+ PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
853
+ };
854
+ if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
855
+
856
+ // 寻找与当前 node 配对的 npm/npx 绝对路径
857
+ const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
858
+ const siblingNpx = join(nodeDir, isWin ? 'npx.cmd' : 'npx');
859
+
745
860
  const tasks = [
746
861
  { cmd: 'dsh', args: ['plugin', '--profile', 'web', 'add', pkgSpec] },
747
- { cmd: 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
748
- { cmd: 'npm', args: ['install', pkgSpec] },
862
+ { cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', 'web', 'add', pkgSpec] },
863
+ { cmd: existsSync(siblingNpm) ? siblingNpm : 'npm', args: ['install', pkgSpec] },
749
864
  ];
750
865
 
751
866
  let lastError = null;
@@ -757,7 +872,8 @@ class BridgeService {
757
872
  try {
758
873
  cp = spawn(task.cmd, task.args, {
759
874
  windowsHide: true,
760
- shell: isWin ? true : false,
875
+ shell: true,
876
+ env: augmentedEnv,
761
877
  timeout: 120000,
762
878
  });
763
879
  } catch (spawnErr) {
@@ -1254,7 +1370,7 @@ class BridgeService {
1254
1370
  */
1255
1371
  function apply(ctx, config = {}) {
1256
1372
  const logger = ctx.logger(name);
1257
- const dshPort = ctx.webServer.port;
1373
+ const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
1258
1374
 
1259
1375
  if (!dshPort) {
1260
1376
  logger.error('webServer port unavailable');
@@ -1344,6 +1460,7 @@ function apply(ctx, config = {}) {
1344
1460
  home: config.home,
1345
1461
  customTunnelConfig: config.customTunnel ?? null,
1346
1462
  cloudflaredConfig: config.cloudflared ?? null,
1463
+ lanConfig: config.lan ?? null,
1347
1464
  authManager,
1348
1465
  onPersist: async (patch) => {
1349
1466
  const stored = await loadConfig();
@@ -1353,8 +1470,12 @@ function apply(ctx, config = {}) {
1353
1470
  logger,
1354
1471
  });
1355
1472
 
1356
- // 启动时读取已保存的公网隧道配置并按需自动拉起
1473
+ // 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
1357
1474
  loadConfig().then(async (stored) => {
1475
+ if (stored?.lan?.selectedIp) {
1476
+ service.selectedLanIp = stored.lan.selectedIp;
1477
+ logger.info('dsh-bridge: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);
1478
+ }
1358
1479
  if (stored?.cloudflared) {
1359
1480
  service.cloudflaredConfig = stored.cloudflared;
1360
1481
  logger.info('dsh-bridge: loaded saved cloudflared config (autoStart=%s, tokenConfigured=%s)', Boolean(service.cloudflaredConfig.autoStart), Boolean(service.cloudflaredConfig.token));
@@ -1680,4 +1801,4 @@ function apply(ctx, config = {}) {
1680
1801
  }, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
1681
1802
  }
1682
1803
 
1683
- export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4 };
1804
+ export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4, listAllLanIPv4 };