@yezack/dsh-remote-mobile 1.9.2 → 1.10.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.
@@ -1,22 +1,52 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { SessionStore } from './token.js';
3
3
  /**
4
- * 判断请求是否来自本机回环
4
+ * 判断请求是否来自本机回环。
5
+ *
6
+ * 不能只看 socket:同机反向代理同样从 127.0.0.1 连入,但 Host/Forwarded 头
7
+ * 指向外部域名。若仍按回环无条件放行,会把所有反代外部用户当成本机。
8
+ *
9
+ * 规则:
10
+ * - 受信网关(底座补丁校验 Secret 后打标)始终按回环处理;
11
+ * - 物理回环 + 本地 Host(localhost、127.x、::1)且无 Forwarded 头 → 本机;
12
+ * - 物理回环 + 外部 Host 或 X-Forwarded-* / X-Real-IP → 按外部/反代处理,走正常鉴权。
5
13
  */
6
14
  export declare function isLoopbackRequest(req: IncomingMessage): boolean;
15
+ /**
16
+ * 提取主机名(去除端口与 IPv6 方括号)
17
+ */
18
+ export declare function extractBareHostname(hostWithPort?: string): string;
19
+ /**
20
+ * 提取端口号字符串
21
+ */
22
+ export declare function extractPortString(hostWithPort?: string): string;
23
+ /**
24
+ * 获取外部请求的有效协议(兼容 Nginx / Caddy / Tailscale Serve 等反代的 X-Forwarded-Proto)。
25
+ *
26
+ * 注意:该值用于同源判断与重定向,不用于认证。反向代理应正确设置 X-Forwarded-Proto;
27
+ * 直接 HTTP 访问没有该头时按 http 处理。
28
+ */
29
+ export declare function getRequestProtocol(req: IncomingMessage): 'http' | 'https';
30
+ /**
31
+ * 判断两个 Authority (host:port) 是否同源或属于本地回环等价。
32
+ *
33
+ * 当同时提供 Origin/请求的协议时,先严格比较协议,杜绝 http↔https 跨协议 CSRF;
34
+ * 并仅按对应协议的默认端口(http:80 / https:443)做反向代理兼容。
35
+ * 未提供协议时只做精确 authority 比较,不做默认端口放宽。
36
+ */
37
+ export declare function isSameOriginOrLocalEquivalent(originAuthority: string, requestHost: string, originProtocol?: string, requestProtocol?: string): boolean;
38
+ /**
39
+ * 判断请求 Host 是否属于受信任主机(本机回环、本机网卡 IP、Tailscale IP 或静态 trustedHosts)。
40
+ *
41
+ * 该函数只校验主机名,不强制校验端口:反向代理、端口映射、TLS 终结场景的
42
+ * 外部端口通常不是网关实际 boundPort,端口不应作为 DNS-Rebinding 的信任边界。
43
+ */
44
+ export declare function isTrustedHost(hostHeader: string | undefined, store: SessionStore, extraHosts?: string[]): boolean;
7
45
  /**
8
46
  * 判断请求是否携带浏览器跨站信号(用于回环 CSRF / drive-by 防御)。
9
47
  *
10
48
  * Origin 与 Sec-Fetch-Site 由浏览器强制填写,页面脚本无法伪造或隐藏;
11
49
  * 恶意网页驱使浏览器向本机回环发起的写请求必然携带这些信号。
12
- * 不带这些头的客户端(curl / 本机脚本 / 旧内核 WebView)不受影响,保持向后兼容。
13
- *
14
- * 说明(有意取舍,非缺陷):
15
- * - 仅比较 host:port,不比较 scheme:插件主要运行于 HTTP 局域网/回环场景,
16
- * 同 host:port 下出现 HTTPS 恶意来源的现实可能性极低;
17
- * - 无 Origin 时仅将 Sec-Fetch-Site: cross-site 判为跨站,same-site 不拦截:
18
- * 回环场景无域名层级概念,子域场景不适用;收紧为「非 same-origin 即拦截」
19
- * 会误伤不发送这些头的旧内核 WebView,与向后兼容目标冲突。
20
50
  */
21
51
  export declare function hasCrossSiteSignal(req: IncomingMessage): boolean;
22
52
  /**
package/lib/auth/gate.js CHANGED
@@ -1,28 +1,205 @@
1
- import { isTailscaleIp, isLanIp, getClientIp } from './tailscale.js';
1
+ import { isTailscaleIp, isLanIp, getClientIp, getAllNetworkIps } from './tailscale.js';
2
2
  import { resolveRedirectPath } from './prefix.js';
3
3
  /**
4
- * 判断请求是否来自本机回环
4
+ * 判断请求是否来自本机回环。
5
+ *
6
+ * 不能只看 socket:同机反向代理同样从 127.0.0.1 连入,但 Host/Forwarded 头
7
+ * 指向外部域名。若仍按回环无条件放行,会把所有反代外部用户当成本机。
8
+ *
9
+ * 规则:
10
+ * - 受信网关(底座补丁校验 Secret 后打标)始终按回环处理;
11
+ * - 物理回环 + 本地 Host(localhost、127.x、::1)且无 Forwarded 头 → 本机;
12
+ * - 物理回环 + 外部 Host 或 X-Forwarded-* / X-Real-IP → 按外部/反代处理,走正常鉴权。
5
13
  */
6
14
  export function isLoopbackRequest(req) {
7
15
  const ip = getClientIp(req);
8
- return ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
16
+ const socketIsLoopback = ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
17
+ if (!socketIsLoopback)
18
+ return false;
19
+ // 底座补丁已用网关内部 Secret 验证该请求来自受信 Gateway
20
+ if (req.__dsh_rm_trusted_gateway__)
21
+ return true;
22
+ const host = extractBareHostname(req.headers.host);
23
+ if (host && !isLocalVariant(host))
24
+ return false;
25
+ // 注意:插件自身会为 loopback 请求注入 x-forwarded-for / x-real-ip /
26
+ // x-forwarded-host(值均为 127.0.0.1),不能把“存在 Forwarded 头”直接
27
+ // 等同于“同机反向代理”,否则会误伤 3080 根路径的 connection 鉴权。
28
+ // 只有转发值指向外部主机/外部 IP,或出现 X-Forwarded-Proto 时,才判为代理。
29
+ const forwardedFor = firstHeaderValue(req.headers['x-forwarded-for']);
30
+ if (forwardedFor && !isLoopbackIpValue(forwardedFor))
31
+ return false;
32
+ const realIp = firstHeaderValue(req.headers['x-real-ip']);
33
+ if (realIp && !isLoopbackIpValue(realIp))
34
+ return false;
35
+ const forwardedHost = firstHeaderValue(req.headers['x-forwarded-host']);
36
+ if (forwardedHost) {
37
+ const bareForwardedHost = extractBareHostname(forwardedHost);
38
+ if (!bareForwardedHost || !isLocalVariant(bareForwardedHost))
39
+ return false;
40
+ }
41
+ // X-Forwarded-Proto 由反向代理/TLS 终结器写入;插件自身不会为 loopback 注入
42
+ if (firstHeaderValue(req.headers['x-forwarded-proto']))
43
+ return false;
44
+ return true;
45
+ }
46
+ /** 取请求头首个值(兼容 Node 合并的数组形式) */
47
+ function firstHeaderValue(value) {
48
+ if (Array.isArray(value))
49
+ return (value[0] || '').trim();
50
+ return typeof value === 'string' ? value.trim() : '';
51
+ }
52
+ /** 判断一个转发头 IP 是否仍是本机回环地址 */
53
+ function isLoopbackIpValue(value) {
54
+ const first = value.split(',')[0].trim();
55
+ if (!first)
56
+ return false;
57
+ const clean = first
58
+ .replace(/^\[/, '')
59
+ .replace(/\]$/, '')
60
+ .replace(/^::ffff:/i, '')
61
+ .toLowerCase();
62
+ return isLocalVariant(clean);
9
63
  }
10
64
  const STATIC_ASSET_EXT_REGEX = /\.(js|mjs|cjs|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot|otf|map|json|txt|wasm)$/i;
11
- /** 插件私有变更类 API 前缀(非 GET 请求需做跨站信号校验) */
12
- const MUTATION_API_PREFIX = '/api/remote-mobile/';
65
+ /**
66
+ * 提取主机名(去除端口与 IPv6 方括号)
67
+ */
68
+ export function extractBareHostname(hostWithPort = '') {
69
+ if (!hostWithPort)
70
+ return '';
71
+ let host = hostWithPort.trim();
72
+ if (host.startsWith('[')) {
73
+ host = host.slice(1).split(']')[0];
74
+ }
75
+ else {
76
+ host = host.split(':')[0];
77
+ }
78
+ // 去除通配符 *. 与末尾 FQDN 点
79
+ return host.replace(/^\*\./, '').replace(/\.+$/, '').toLowerCase();
80
+ }
81
+ /**
82
+ * 提取端口号字符串
83
+ */
84
+ export function extractPortString(hostWithPort = '') {
85
+ if (!hostWithPort)
86
+ return '';
87
+ const trimmed = hostWithPort.trim();
88
+ if (trimmed.startsWith('[')) {
89
+ const after = trimmed.split(']')[1];
90
+ return after && after.startsWith(':') ? after.slice(1) : '';
91
+ }
92
+ return trimmed.includes(':') ? trimmed.split(':')[1] : '';
93
+ }
94
+ /**
95
+ * 判断主机是否属于本地回环变体 (localhost, 127.*, ::1 等)
96
+ */
97
+ function isLocalVariant(hostname = '') {
98
+ if (!hostname)
99
+ return false;
100
+ return (hostname === 'localhost' ||
101
+ hostname === '::1' ||
102
+ hostname === '0:0:0:0:0:0:0:1' ||
103
+ hostname === '127.0.0.1' ||
104
+ /^127\./.test(hostname));
105
+ }
106
+ /**
107
+ * 获取外部请求的有效协议(兼容 Nginx / Caddy / Tailscale Serve 等反代的 X-Forwarded-Proto)。
108
+ *
109
+ * 注意:该值用于同源判断与重定向,不用于认证。反向代理应正确设置 X-Forwarded-Proto;
110
+ * 直接 HTTP 访问没有该头时按 http 处理。
111
+ */
112
+ export function getRequestProtocol(req) {
113
+ const raw = req?.headers?.['x-forwarded-proto'];
114
+ const value = Array.isArray(raw) ? raw[0] : raw;
115
+ if (typeof value === 'string' && value.split(',')[0].trim().toLowerCase() === 'https') {
116
+ return 'https';
117
+ }
118
+ if (req?.socket?.encrypted)
119
+ return 'https';
120
+ return 'http';
121
+ }
122
+ /**
123
+ * 判断两个 Authority (host:port) 是否同源或属于本地回环等价。
124
+ *
125
+ * 当同时提供 Origin/请求的协议时,先严格比较协议,杜绝 http↔https 跨协议 CSRF;
126
+ * 并仅按对应协议的默认端口(http:80 / https:443)做反向代理兼容。
127
+ * 未提供协议时只做精确 authority 比较,不做默认端口放宽。
128
+ */
129
+ export function isSameOriginOrLocalEquivalent(originAuthority, requestHost, originProtocol, requestProtocol) {
130
+ if (!originAuthority || !requestHost)
131
+ return false;
132
+ const originScheme = String(originProtocol || '').replace(/:$/, '').toLowerCase();
133
+ const requestScheme = String(requestProtocol || '').replace(/:$/, '').toLowerCase();
134
+ if (originScheme && requestScheme && originScheme !== requestScheme)
135
+ return false;
136
+ if (originAuthority.toLowerCase() === requestHost.toLowerCase())
137
+ return true;
138
+ const origHost = extractBareHostname(originAuthority);
139
+ const reqHost = extractBareHostname(requestHost);
140
+ const origPort = extractPortString(originAuthority);
141
+ const reqPort = extractPortString(requestHost);
142
+ const defaultPort = originScheme === 'https' ? '443' : originScheme === 'http' ? '80' : '';
143
+ const isDefaultPort = (port) => Boolean(defaultPort) && port === defaultPort;
144
+ // 外部主机:裸主机名相同,且端口相同或一方省略、另一方为该 Origin 协议的默认端口
145
+ if (origHost && origHost === reqHost) {
146
+ if (origPort === reqPort)
147
+ return true;
148
+ if (isDefaultPort(origPort) && reqPort === '')
149
+ return true;
150
+ if (origPort === '' && isDefaultPort(reqPort))
151
+ return true;
152
+ }
153
+ // 本地变体等价判断:host 均属本地变体,且端口相同或为该协议的默认端口
154
+ if (isLocalVariant(origHost) && isLocalVariant(reqHost)) {
155
+ if (origPort === reqPort)
156
+ return true;
157
+ if (isDefaultPort(origPort) && reqPort === '')
158
+ return true;
159
+ if (origPort === '' && isDefaultPort(reqPort))
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ /**
165
+ * 判断请求 Host 是否属于受信任主机(本机回环、本机网卡 IP、Tailscale IP 或静态 trustedHosts)。
166
+ *
167
+ * 该函数只校验主机名,不强制校验端口:反向代理、端口映射、TLS 终结场景的
168
+ * 外部端口通常不是网关实际 boundPort,端口不应作为 DNS-Rebinding 的信任边界。
169
+ */
170
+ export function isTrustedHost(hostHeader, store, extraHosts = []) {
171
+ const host = extractBareHostname(hostHeader);
172
+ if (!host)
173
+ return false;
174
+ // 通配地址永远不能被当作受信任 Host
175
+ if (host === '0.0.0.0' || host === '::')
176
+ return false;
177
+ if (isLocalVariant(host))
178
+ return true;
179
+ try {
180
+ const networkIps = getAllNetworkIps().map((i) => i.ip.toLowerCase());
181
+ if (networkIps.includes(host))
182
+ return true;
183
+ }
184
+ catch { }
185
+ try {
186
+ const statics = (store.getOptions().trustedHosts || [])
187
+ .map((h) => extractBareHostname(String(h)))
188
+ .filter((h) => Boolean(h) && h !== '0.0.0.0' && h !== '::');
189
+ if (statics.includes(host))
190
+ return true;
191
+ }
192
+ catch { }
193
+ const extras = extraHosts
194
+ .map((h) => extractBareHostname(String(h)))
195
+ .filter((h) => Boolean(h) && h !== '0.0.0.0' && h !== '::');
196
+ return extras.includes(host);
197
+ }
13
198
  /**
14
199
  * 判断请求是否携带浏览器跨站信号(用于回环 CSRF / drive-by 防御)。
15
200
  *
16
201
  * Origin 与 Sec-Fetch-Site 由浏览器强制填写,页面脚本无法伪造或隐藏;
17
202
  * 恶意网页驱使浏览器向本机回环发起的写请求必然携带这些信号。
18
- * 不带这些头的客户端(curl / 本机脚本 / 旧内核 WebView)不受影响,保持向后兼容。
19
- *
20
- * 说明(有意取舍,非缺陷):
21
- * - 仅比较 host:port,不比较 scheme:插件主要运行于 HTTP 局域网/回环场景,
22
- * 同 host:port 下出现 HTTPS 恶意来源的现实可能性极低;
23
- * - 无 Origin 时仅将 Sec-Fetch-Site: cross-site 判为跨站,same-site 不拦截:
24
- * 回环场景无域名层级概念,子域场景不适用;收紧为「非 same-origin 即拦截」
25
- * 会误伤不发送这些头的旧内核 WebView,与向后兼容目标冲突。
26
203
  */
27
204
  export function hasCrossSiteSignal(req) {
28
205
  const origin = req.headers.origin;
@@ -30,8 +207,9 @@ export function hasCrossSiteSignal(req) {
30
207
  if (origin === 'null')
31
208
  return true; // 沙箱 iframe 等场景的不透明 Origin,一律视为跨站
32
209
  try {
33
- const originHost = new URL(origin).host;
34
- return originHost !== '' && originHost !== (req.headers.host || '');
210
+ const originUrl = new URL(origin);
211
+ const reqHost = req.headers.host || '';
212
+ return !isSameOriginOrLocalEquivalent(originUrl.host, reqHost, originUrl.protocol, getRequestProtocol(req));
35
213
  }
36
214
  catch {
37
215
  return true; // 无法解析的 Origin 视为不可信
@@ -54,7 +232,7 @@ export function isPublicPath(url = '') {
54
232
  return true;
55
233
  if (pathname.startsWith('/assets/'))
56
234
  return true;
57
- if (pathname === '/favicon.ico')
235
+ if (pathname === '/favicon.ico' || pathname === '/manifest.webmanifest' || pathname === '/manifest.json')
58
236
  return true;
59
237
  return false;
60
238
  }
@@ -63,55 +241,75 @@ export function isPublicPath(url = '') {
63
241
  */
64
242
  export function createGlobalAuthGate(store) {
65
243
  return function authGateMiddleware(req, res, next) {
66
- const pass = () => {
244
+ const pass = (reason) => {
245
+ ;
246
+ req.__dsh_rm_gate_result = reason;
67
247
  if (typeof next === 'function')
68
248
  next();
69
249
  return true;
70
250
  };
71
- // 0. 变更类插件 API 的跨站写请求防御(先于回环放行,且运行在上下文虚拟化改写之前,
72
- // 保证 Origin / Sec-Fetch-Site 为浏览器原始值):本机回环虽免认证,但恶意网页驱使
73
- // 浏览器发起的跨站写请求(drive-by)必然携带跨站信号,在此直接拒绝。
74
- const pathname = (req.url || '').split('?')[0];
75
- if (pathname.startsWith(MUTATION_API_PREFIX) &&
76
- req.method !== 'GET' &&
77
- req.method !== 'HEAD' &&
78
- hasCrossSiteSignal(req)) {
251
+ // 0. 全局非 GET/HEAD 写请求的跨站防御(在上下文虚拟化改写之前运行,
252
+ // 保证 Origin / Sec-Fetch-Site 为浏览器原始值):
253
+ // 不论回环还是外部,恶意跨站写请求(Local Drive-by / CSWSH)直接 403 阻断。
254
+ if (req.method !== 'GET' && req.method !== 'HEAD' && hasCrossSiteSignal(req)) {
79
255
  res.writeHead(403, {
80
256
  'Content-Type': 'application/json; charset=utf-8',
81
257
  'Cache-Control': 'no-cache, no-store, must-revalidate',
82
258
  });
83
259
  res.end(JSON.stringify({
84
260
  error: 'Forbidden',
85
- message: '已拦截来自其他网站的跨站写请求。',
261
+ message: '已拦截来自其他网站的跨站写请求 (Cross-Site Request Blocked)。',
86
262
  }));
87
263
  return false;
88
264
  }
89
265
  // 1. 本机 127.0.0.1 回环无条件放行(保证本机 PC 始终可用)
90
266
  if (isLoopbackRequest(req)) {
91
- return pass();
267
+ return pass('loopback');
268
+ }
269
+ // 1.5 非回环请求必须先通过 Host 白名单(DNS-Rebinding / 恶意 Host 防御)。
270
+ // GatewayServer 在转发前已经用自己的 Host 白名单校验过,并打上 __dsh_rm_host_allowed__,
271
+ // 这里避免对同一请求重复校验(尤其是 gatewayHost 为自定义域名时)。
272
+ if (req.headers.host &&
273
+ !req.__dsh_rm_host_allowed__ &&
274
+ !isTrustedHost(req.headers.host, store)) {
275
+ res.writeHead(403, {
276
+ 'Content-Type': 'application/json; charset=utf-8',
277
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
278
+ });
279
+ res.end(JSON.stringify({
280
+ error: 'Forbidden',
281
+ message: '未受信任的 Host。若使用 Tailscale MagicDNS 或自定义域名,请在 trustedHosts 中声明。',
282
+ }));
283
+ return false;
92
284
  }
93
285
  // 2. 公开登录页面与认证接口放行
94
286
  if (isPublicPath(req.url)) {
95
- return pass();
287
+ return pass('public');
96
288
  }
97
289
  const clientIp = getClientIp(req);
98
290
  const options = store.getOptions();
291
+ // 反向代理同样可能位于 LAN/Tailscale 网段;若携带 Forwarded 头,说明是代理而非
292
+ // 真实客户端,不能再按 allowLan/allowTailscale 免密放行,否则外部用户可借代理绕过认证。
293
+ const hasForwardedHeaders = Boolean(req.headers['x-forwarded-for'] ||
294
+ req.headers['x-forwarded-host'] ||
295
+ req.headers['x-forwarded-proto'] ||
296
+ req.headers['x-real-ip']);
99
297
  // 3. 如果开启了 Tailscale 私网免密直连,且客户端来源是 Tailscale 虚拟网段,自动归因并放行
100
- if (options.allowTailscale && isTailscaleIp(clientIp)) {
298
+ if (!hasForwardedHeaders && options.allowTailscale && isTailscaleIp(clientIp)) {
101
299
  const ua = req.headers['user-agent'] || '';
102
300
  store.registerBypassDevice(clientIp, ua, 'Tailscale');
103
- return pass();
301
+ return pass('bypass-tailscale');
104
302
  }
105
303
  // 4. 如果开启了局域网 LAN 免密直连,且客户端来源是局域网私有网段 (10.x / 172.16-31.x / 192.168.x),自动归因并放行
106
- if (options.allowLan && isLanIp(clientIp)) {
304
+ if (!hasForwardedHeaders && options.allowLan && isLanIp(clientIp)) {
107
305
  const ua = req.headers['user-agent'] || '';
108
306
  store.registerBypassDevice(clientIp, ua, 'LAN');
109
- return pass();
307
+ return pass('bypass-lan');
110
308
  }
111
309
  // 5. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
112
310
  const token = store.extractTokenFromRequest(req);
113
311
  if (token && store.validateToken(token, clientIp)) {
114
- return pass();
312
+ return pass('token');
115
313
  }
116
314
  // 5. 未授权访问拦截处理:
117
315
  // 只有主文档请求(GET 根路径或请求头明确包含 text/html)才 302 重定向到 /auth 登录页,子资源与 API 请求一律返回 401
@@ -8,7 +8,16 @@ export declare function isTailscaleIp(rawIp?: string): boolean;
8
8
  */
9
9
  export declare function isLanIp(rawIp?: string): boolean;
10
10
  /**
11
- * 提取请求的客户端真实 IP(基于底层 Socket 连接,防止外部伪造 X-Forwarded-For,且兼容虚拟化场景)
11
+ * 获取底层物理连接的真实 Socket IP(严格基于底座物理连接,绝不受任何请求头篡改,仅去除 IPv6 映射前缀)
12
+ * 专用于底座与网关内部的物理信任边界判定(如 isLoopbackSocket)
13
+ */
14
+ export declare function getPhysicalSocketIp(req: {
15
+ socket?: {
16
+ remoteAddress?: string;
17
+ };
18
+ }): string;
19
+ /**
20
+ * 提取请求的物理 Socket 来源 IP(兼容历史接口,保持物理连接语义)
12
21
  */
13
22
  export declare function getClientIp(req: {
14
23
  socket?: {
@@ -26,7 +35,20 @@ export declare function getLocalTailscaleIp(): string | null;
26
35
  */
27
36
  export declare function getLocalLanIp(): string | null;
28
37
  /**
29
- * 获取本机所有可用的网络 IP
38
+ * 设置模拟网络网卡 IP(仅供单测模拟切换 Wi-Fi / 新网卡场景)
39
+ */
40
+ export declare function setMockNetworkIps(mockList: {
41
+ name: string;
42
+ ip: string;
43
+ isTailscale: boolean;
44
+ isLan: boolean;
45
+ }[] | null): void;
46
+ /**
47
+ * 清除网络网卡 IP 缓存
48
+ */
49
+ export declare function clearNetworkIpCache(): void;
50
+ /**
51
+ * 获取本机所有可用的网络 IP(带 2 秒短缓存,兼顾动态切换 Wi-Fi 响应与高频访问性能)
30
52
  */
31
53
  export declare function getAllNetworkIps(): {
32
54
  name: string;
@@ -42,11 +42,19 @@ export function isLanIp(rawIp) {
42
42
  return false;
43
43
  }
44
44
  /**
45
- * 提取请求的客户端真实 IP(基于底层 Socket 连接,防止外部伪造 X-Forwarded-For,且兼容虚拟化场景)
45
+ * 获取底层物理连接的真实 Socket IP(严格基于底座物理连接,绝不受任何请求头篡改,仅去除 IPv6 映射前缀)
46
+ * 专用于底座与网关内部的物理信任边界判定(如 isLoopbackSocket)
47
+ */
48
+ export function getPhysicalSocketIp(req) {
49
+ const raw = req?.socket?.remoteAddress;
50
+ return raw ? raw.replace(/^::ffff:/, '').trim() : '127.0.0.1';
51
+ }
52
+ /**
53
+ * 提取请求的物理 Socket 来源 IP(兼容历史接口,保持物理连接语义)
46
54
  */
47
55
  export function getClientIp(req) {
48
56
  const realAddress = req.socket?.__dsh_real_remote_address__ || req.socket?.remoteAddress;
49
- return realAddress?.replace(/^::ffff:/, '') || '127.0.0.1';
57
+ return realAddress?.replace(/^::ffff:/, '').trim() || '127.0.0.1';
50
58
  }
51
59
  /**
52
60
  * 获取当前本机的 Tailscale IP
@@ -103,10 +111,35 @@ export function getLocalLanIp() {
103
111
  catch { }
104
112
  return null;
105
113
  }
114
+ let cachedNetworkIps = null;
115
+ let cacheExpiresAt = 0;
116
+ let mockNetworkIps = null;
117
+ /**
118
+ * 设置模拟网络网卡 IP(仅供单测模拟切换 Wi-Fi / 新网卡场景)
119
+ */
120
+ export function setMockNetworkIps(mockList) {
121
+ mockNetworkIps = mockList;
122
+ cachedNetworkIps = null;
123
+ cacheExpiresAt = 0;
124
+ }
106
125
  /**
107
- * 获取本机所有可用的网络 IP
126
+ * 清除网络网卡 IP 缓存
127
+ */
128
+ export function clearNetworkIpCache() {
129
+ cachedNetworkIps = null;
130
+ cacheExpiresAt = 0;
131
+ }
132
+ /**
133
+ * 获取本机所有可用的网络 IP(带 2 秒短缓存,兼顾动态切换 Wi-Fi 响应与高频访问性能)
108
134
  */
109
135
  export function getAllNetworkIps() {
136
+ if (mockNetworkIps) {
137
+ return [...mockNetworkIps];
138
+ }
139
+ const now = Date.now();
140
+ if (cachedNetworkIps && now < cacheExpiresAt) {
141
+ return cachedNetworkIps;
142
+ }
110
143
  const list = [];
111
144
  try {
112
145
  const interfaces = networkInterfaces();
@@ -124,5 +157,7 @@ export function getAllNetworkIps() {
124
157
  }
125
158
  }
126
159
  catch { }
160
+ cachedNetworkIps = list;
161
+ cacheExpiresAt = now + 2000;
127
162
  return list;
128
163
  }
@@ -106,6 +106,10 @@ export interface SessionStoreOptions {
106
106
  maxFailedAttempts?: number;
107
107
  lockDurationMs?: number;
108
108
  maxVisitsPerMinute?: number;
109
+ gatewayPort?: number;
110
+ gatewayHost?: string;
111
+ mode?: 'auto' | 'gateway' | 'direct';
112
+ trustedHosts?: string[];
109
113
  }
110
114
  /**
111
115
  * 将 User-Agent 字符串解析为人类友好的设备名称
@@ -120,8 +124,26 @@ export declare class SessionStore extends EventEmitter {
120
124
  private debounceTimer;
121
125
  readonly persistPath: string;
122
126
  readonly settingsFilePath: string | null;
127
+ /** 网关运行时状态契约 */
128
+ private gatewayStatus;
123
129
  constructor(options?: SessionStoreOptions);
124
130
  getOptions(): Required<SessionStoreOptions>;
131
+ /** 设置网关当前生效的运行时参数与状态契约 */
132
+ setActiveGatewayInfo(mode: 'gateway' | 'direct', activePort: number, corePort: number, running?: boolean, error?: string | null): void;
133
+ /** 获取网关完整运行状态对象 (供 /api/remote-mobile/status 消费) */
134
+ getGatewayStatus(): {
135
+ mode: 'gateway' | 'direct';
136
+ running: boolean;
137
+ port: number;
138
+ corePort: number;
139
+ error: string | null;
140
+ };
141
+ /** 获取当前实际对外提供访问的模式 (gateway | direct) */
142
+ getActiveMode(): 'gateway' | 'direct';
143
+ /** 获取当前实际对外提供访问的端口 (3081 或 3080) */
144
+ getActivePort(): number;
145
+ /** 获取底层 DSH 本地核心端口 (通常为 3080) */
146
+ getCorePort(): number;
125
147
  hasSecret(): boolean;
126
148
  setSettingsMutator(mutator: (patch: Partial<SessionStoreOptions>) => void): void;
127
149
  updateOptions(newOpts: Partial<SessionStoreOptions>, syncToSettings?: boolean): void;
package/lib/auth/token.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes, randomInt } from 'node:crypto';
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
- import { homedir } from 'node:os';
4
+ import { homedir, tmpdir } from 'node:os';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { hashSecret, verifySecretHash } from './crypto.js';
7
7
  import { isTailscaleIp, isLanIp } from './tailscale.js';
@@ -188,6 +188,15 @@ export function readPluginSettingsJson(filePath = SETTINGS_FILE) {
188
188
  result.lockDurationMs = data.lockDurationMs;
189
189
  if (typeof data.maxVisitsPerMinute === 'number' && data.maxVisitsPerMinute > 0)
190
190
  result.maxVisitsPerMinute = data.maxVisitsPerMinute;
191
+ if (typeof data.gatewayPort === 'number' && data.gatewayPort > 0)
192
+ result.gatewayPort = data.gatewayPort;
193
+ if (typeof data.gatewayHost === 'string' && data.gatewayHost)
194
+ result.gatewayHost = data.gatewayHost;
195
+ if (data.mode === 'auto' || data.mode === 'gateway' || data.mode === 'direct')
196
+ result.mode = data.mode;
197
+ if (Array.isArray(data.trustedHosts)) {
198
+ result.trustedHosts = data.trustedHosts.filter((h) => typeof h === 'string' && h.trim());
199
+ }
191
200
  return result;
192
201
  }
193
202
  catch {
@@ -211,6 +220,10 @@ export function writePluginSettingsJson(options, filePath = SETTINGS_FILE) {
211
220
  maxFailedAttempts: options.maxFailedAttempts || 5,
212
221
  lockDurationMs: options.lockDurationMs || 900000,
213
222
  maxVisitsPerMinute: options.maxVisitsPerMinute || 60,
223
+ gatewayPort: options.gatewayPort || 3081,
224
+ gatewayHost: options.gatewayHost || '0.0.0.0',
225
+ mode: options.mode || 'auto',
226
+ trustedHosts: Array.isArray(options.trustedHosts) ? options.trustedHosts : [],
214
227
  };
215
228
  writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
216
229
  renameSync(tmpPath, filePath);
@@ -277,6 +290,10 @@ export function resolveInitialStoreOptions(explicit = {}, own = null, legacy = {
277
290
  maxFailedAttempts: merged.maxFailedAttempts ?? 5,
278
291
  lockDurationMs: merged.lockDurationMs ?? 15 * 60 * 1000,
279
292
  maxVisitsPerMinute: merged.maxVisitsPerMinute ?? 60,
293
+ gatewayPort: merged.gatewayPort ?? 3081,
294
+ gatewayHost: merged.gatewayHost ?? '0.0.0.0',
295
+ mode: merged.mode ?? 'auto',
296
+ trustedHosts: Array.isArray(merged.trustedHosts) ? merged.trustedHosts : [],
280
297
  };
281
298
  }
282
299
  /**
@@ -334,6 +351,14 @@ export class SessionStore extends EventEmitter {
334
351
  debounceTimer = null;
335
352
  persistPath;
336
353
  settingsFilePath;
354
+ /** 网关运行时状态契约 */
355
+ gatewayStatus = {
356
+ mode: 'direct',
357
+ running: true,
358
+ port: 3080,
359
+ corePort: 3080,
360
+ error: null,
361
+ };
337
362
  constructor(options = {}) {
338
363
  super();
339
364
  this.setMaxListeners(100);
@@ -348,7 +373,12 @@ export class SessionStore extends EventEmitter {
348
373
  const ownOpts = isTestEnv ? {} : (hasOwnSettings ? readPluginSettingsJson(SETTINGS_FILE) : null);
349
374
  const legacyOpts = isTestEnv || hasOwnSettings ? {} : readLegacyDshSettings();
350
375
  this.options = resolveInitialStoreOptions(options, ownOpts, legacyOpts);
351
- this.persistPath = this.options.devicesFile;
376
+ // 测试隔离必须同时覆盖 devices.json,而不仅是 settings.json。
377
+ // 否则测试里的 store.verify() 会持续写入用户真实的 ~/.dsh/remote-mobile/devices.json。
378
+ const testPersistPath = isTestEnv && (!options.devicesFile || options.devicesFile === DEFAULT_PERSIST_FILE)
379
+ ? join(tmpdir(), `dsh-rm-test-devices-${process.pid}-${randomBytes(6).toString('hex')}.json`)
380
+ : this.options.devicesFile;
381
+ this.persistPath = testPersistPath;
352
382
  // 首次启动迁移:自有配置不存在但旧 settings.yaml / settings.yaml.imported 中仍有配置时,
353
383
  // 落盘一次到自有 settings.json,把旧配置(如长期密码哈希、限频)救回。
354
384
  if (!isTestEnv && !hasOwnSettings && Object.keys(legacyOpts).length > 0) {
@@ -360,6 +390,32 @@ export class SessionStore extends EventEmitter {
360
390
  getOptions() {
361
391
  return { ...this.options };
362
392
  }
393
+ /** 设置网关当前生效的运行时参数与状态契约 */
394
+ setActiveGatewayInfo(mode, activePort, corePort, running = true, error = null) {
395
+ this.gatewayStatus = {
396
+ mode,
397
+ running,
398
+ port: activePort,
399
+ corePort,
400
+ error,
401
+ };
402
+ }
403
+ /** 获取网关完整运行状态对象 (供 /api/remote-mobile/status 消费) */
404
+ getGatewayStatus() {
405
+ return { ...this.gatewayStatus };
406
+ }
407
+ /** 获取当前实际对外提供访问的模式 (gateway | direct) */
408
+ getActiveMode() {
409
+ return this.gatewayStatus.mode;
410
+ }
411
+ /** 获取当前实际对外提供访问的端口 (3081 或 3080) */
412
+ getActivePort() {
413
+ return this.gatewayStatus.port;
414
+ }
415
+ /** 获取底层 DSH 本地核心端口 (通常为 3080) */
416
+ getCorePort() {
417
+ return this.gatewayStatus.corePort;
418
+ }
363
419
  hasSecret() {
364
420
  return Boolean(this.options.secretHash || this.options.secret);
365
421
  }