dsh-remote-mobile 1.4.3 → 1.5.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/CHANGELOG.md +9 -0
- package/lib/auth/token.js +2 -2
- package/lib/bridge/compat.d.ts +19 -0
- package/lib/bridge/compat.js +105 -0
- package/lib/client.js +2 -2
- package/lib/index.js +97 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-09-04
|
|
9
|
+
|
|
10
|
+
### Compatibility (适配 dsh@0.1.2-rc.1 官方 Token 鉴权)
|
|
11
|
+
- **适配:透明接管官方底座临时 Token 鉴权,消除双重鉴权冗余**。DSH 0.1.2-rc.1 官方底座引入了进程级临时启动 Token(`?token=xxxx`)与全链路 RPC/WebSocket 401 阻断;装有本插件的用户在进程重启后,手机已配对的直连书签会遭遇官方 401 报错。现通过对底座 `connection` 服务(`authorizeIndex`、`requestRejection`、`browserAuth.isAuthenticated`)实施切面代理:凡通过本插件门禁(本机回环、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求直接放行,无需手动复制官方临时 Token,手机书签直连与局域网免密恢复丝滑体验;同时兼容保留官方原生合法 token 的换取逻辑。
|
|
12
|
+
- **优化:终端就绪输出专属卡片**。服务启动后在控制台打印美观的边框横幅,明确标注「官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token」,按类型列出本机回环、局域网、Tailscale 私网等可用直连地址,并附带设置页配置指引与项目文档链接。
|
|
13
|
+
- **优化:URL Token 提取解耦**。`extractTokenFromRequest` 优化参数提取,支持 `rm_token` 与 `auth_token`,避免官方 `token=` 干扰插件内部会话逻辑。
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
8
17
|
## [1.4.3] - 2026-09-03
|
|
9
18
|
|
|
10
19
|
### Fixed (缺陷修复)
|
package/lib/auth/token.js
CHANGED
|
@@ -657,10 +657,10 @@ export class SessionStore extends EventEmitter {
|
|
|
657
657
|
const authHeader = req.headers.authorization || '';
|
|
658
658
|
if (authHeader.startsWith('Bearer '))
|
|
659
659
|
return authHeader.slice(7).trim();
|
|
660
|
-
if (req.url && req.url.includes('
|
|
660
|
+
if (req.url && (req.url.includes('rm_token=') || req.url.includes('auth_token='))) {
|
|
661
661
|
try {
|
|
662
662
|
const u = new URL(req.url, 'http://localhost');
|
|
663
|
-
const t = u.searchParams.get('
|
|
663
|
+
const t = u.searchParams.get('rm_token') || u.searchParams.get('auth_token');
|
|
664
664
|
if (t)
|
|
665
665
|
return t;
|
|
666
666
|
}
|
package/lib/bridge/compat.d.ts
CHANGED
|
@@ -118,3 +118,22 @@ export declare const PLUGIN_MODULE_NAME = "dsh-remote-mobile";
|
|
|
118
118
|
* @param store - 会话管理器实例
|
|
119
119
|
*/
|
|
120
120
|
export declare function registerRemoteWebUiPairingBridge(ctx: any, store: SessionStore): void;
|
|
121
|
+
/**
|
|
122
|
+
* 判定一个请求是否已获得 dsh-remote-mobile 门禁授权(包括回环、免密直连与已认证会话)
|
|
123
|
+
*/
|
|
124
|
+
export declare function isRequestAuthorizedByGate(req: any, store: SessionStore): boolean;
|
|
125
|
+
/**
|
|
126
|
+
* 透明接管 DSH 官方底座的 Token 鉴权服务 (connection.browserAuth & connection.requestRejection)
|
|
127
|
+
*
|
|
128
|
+
* 【设计职责】
|
|
129
|
+
* 官方新版在底座硬编码了临时进程 Token(dsh web: http://.../?token=xxxx)以及基于签名 Cookie 的
|
|
130
|
+
* 全链路 RPC/WebSocket 401 阻断。对于已安装本插件的用户而言,两层鉴权导致体验严重割裂且使手机书签失效。
|
|
131
|
+
* 本函数对底层 connection 服务实施透明短路代理:
|
|
132
|
+
* 1. 凡通过本插件门禁(本机、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求,底座鉴权全链路放行;
|
|
133
|
+
* 2. 彻底消除进程重启后移动端遭遇官方 401 的痛点,对外提供唯一定制化移动端门禁体系;
|
|
134
|
+
* 3. 未授权外部请求保持官方与插件原本拦截逻辑,且保留官方原生合法 token 的交换能力。
|
|
135
|
+
*
|
|
136
|
+
* @param connection - DSH 底座提供的 connection 服务实例
|
|
137
|
+
* @param store - 会话与配置管理器实例
|
|
138
|
+
*/
|
|
139
|
+
export declare function takeOverConnectionAuth(connection: any, store: SessionStore): void;
|
package/lib/bridge/compat.js
CHANGED
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* - 判定基于 Cordis 公开运行时结构(服务占用 + fiber 运行态),不针对特定插件;
|
|
31
31
|
* - 保证与其他远程接入类插件共存时必定可以正常启动(不再触发整树回滚致命错误)。
|
|
32
32
|
*/
|
|
33
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
33
34
|
import { getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
|
|
35
|
+
import { isLoopbackRequest } from '../auth/gate.js';
|
|
34
36
|
import { isMobileUserAgent, applyDataMobileAttr, buildMobileStyleTag, } from '../styles/style-snippets.js';
|
|
35
37
|
/**
|
|
36
38
|
* 注入到 index.html <head> 最前列的 Polyfill 脚本代码片段
|
|
@@ -699,3 +701,106 @@ export function registerRemoteWebUiPairingBridge(ctx, store) {
|
|
|
699
701
|
};
|
|
700
702
|
decide().catch(() => { });
|
|
701
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* 恒定时间字符串比较,防止针对 secret token 的时序攻击
|
|
706
|
+
*/
|
|
707
|
+
function safeStringEqual(a, b) {
|
|
708
|
+
if (typeof a !== 'string' || typeof b !== 'string')
|
|
709
|
+
return false;
|
|
710
|
+
try {
|
|
711
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
712
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
713
|
+
if (bufA.length !== bufB.length)
|
|
714
|
+
return false;
|
|
715
|
+
return timingSafeEqual(bufA, bufB);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* 判定一个请求是否已获得 dsh-remote-mobile 门禁授权(包括回环、免密直连与已认证会话)
|
|
723
|
+
*/
|
|
724
|
+
export function isRequestAuthorizedByGate(req, store) {
|
|
725
|
+
if (!req)
|
|
726
|
+
return false;
|
|
727
|
+
// 1. 本机 127.0.0.1 / ::1 回环无条件放行
|
|
728
|
+
if (isLoopbackRequest(req))
|
|
729
|
+
return true;
|
|
730
|
+
const clientIp = getClientIp(req);
|
|
731
|
+
const options = store.getOptions();
|
|
732
|
+
// 2. Tailscale 私网免密直连放行
|
|
733
|
+
if (options.allowTailscale && isTailscaleIp(clientIp))
|
|
734
|
+
return true;
|
|
735
|
+
// 3. 局域网 LAN 免密直连放行
|
|
736
|
+
if (options.allowLan && isLanIp(clientIp))
|
|
737
|
+
return true;
|
|
738
|
+
// 4. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
|
|
739
|
+
const token = store.extractTokenFromRequest(req);
|
|
740
|
+
if (token && store.validateToken(token, clientIp))
|
|
741
|
+
return true;
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* 透明接管 DSH 官方底座的 Token 鉴权服务 (connection.browserAuth & connection.requestRejection)
|
|
746
|
+
*
|
|
747
|
+
* 【设计职责】
|
|
748
|
+
* 官方新版在底座硬编码了临时进程 Token(dsh web: http://.../?token=xxxx)以及基于签名 Cookie 的
|
|
749
|
+
* 全链路 RPC/WebSocket 401 阻断。对于已安装本插件的用户而言,两层鉴权导致体验严重割裂且使手机书签失效。
|
|
750
|
+
* 本函数对底层 connection 服务实施透明短路代理:
|
|
751
|
+
* 1. 凡通过本插件门禁(本机、Tailscale/LAN 免密直连、已扫码配对/长期密码认证)的请求,底座鉴权全链路放行;
|
|
752
|
+
* 2. 彻底消除进程重启后移动端遭遇官方 401 的痛点,对外提供唯一定制化移动端门禁体系;
|
|
753
|
+
* 3. 未授权外部请求保持官方与插件原本拦截逻辑,且保留官方原生合法 token 的交换能力。
|
|
754
|
+
*
|
|
755
|
+
* @param connection - DSH 底座提供的 connection 服务实例
|
|
756
|
+
* @param store - 会话与配置管理器实例
|
|
757
|
+
*/
|
|
758
|
+
export function takeOverConnectionAuth(connection, store) {
|
|
759
|
+
if (!connection || connection.__dsh_rm_auth_takeover__)
|
|
760
|
+
return;
|
|
761
|
+
connection.__dsh_rm_auth_takeover__ = true;
|
|
762
|
+
// 1. 接管底层统一的 browserAuth.isAuthenticated (供各类 RPC/WebSocket 与内部检查调用)
|
|
763
|
+
if (connection.browserAuth && typeof connection.browserAuth.isAuthenticated === 'function') {
|
|
764
|
+
const origIsAuthenticated = connection.browserAuth.isAuthenticated.bind(connection.browserAuth);
|
|
765
|
+
connection.browserAuth.isAuthenticated = (request) => {
|
|
766
|
+
if (isRequestAuthorizedByGate(request, store)) {
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
return origIsAuthenticated(request);
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
// 2. 接管 connection.requestRejection (底层拦截未授权的 RPC / WebSocket 请求)
|
|
773
|
+
if (typeof connection.requestRejection === 'function') {
|
|
774
|
+
const origRequestRejection = connection.requestRejection.bind(connection);
|
|
775
|
+
connection.requestRejection = (request) => {
|
|
776
|
+
if (isRequestAuthorizedByGate(request, store)) {
|
|
777
|
+
return undefined; // 明确放行,杜绝 401
|
|
778
|
+
}
|
|
779
|
+
return origRequestRejection(request);
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
// 3. 接管 connection.authorizeIndex (控制根路径 GET / 下发 index.html)
|
|
783
|
+
if (typeof connection.authorizeIndex === 'function') {
|
|
784
|
+
const origAuthorizeIndex = connection.authorizeIndex.bind(connection);
|
|
785
|
+
connection.authorizeIndex = (req, res) => {
|
|
786
|
+
if (isRequestAuthorizedByGate(req, store)) {
|
|
787
|
+
// 若携带官方进程 token 且与当前 launchToken 匹配,优先走官方换取官方 Cookie 与重定向
|
|
788
|
+
if (req.url && req.url.includes('token=')) {
|
|
789
|
+
try {
|
|
790
|
+
const url = new URL(req.url, 'http://dsh.invalid');
|
|
791
|
+
const tokens = url.searchParams.getAll('token');
|
|
792
|
+
if (tokens.length > 0 &&
|
|
793
|
+
connection.browserAuth?.launchToken &&
|
|
794
|
+
safeStringEqual(tokens[0], connection.browserAuth.launchToken)) {
|
|
795
|
+
return origAuthorizeIndex(req, res);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch { }
|
|
799
|
+
}
|
|
800
|
+
// 已授权设备直接放行提供 HTML,跳过官方 401 阻断
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
return origAuthorizeIndex(req, res);
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -3464,8 +3464,8 @@ function StylesCard({ lang, showToast }) {
|
|
|
3464
3464
|
}
|
|
3465
3465
|
|
|
3466
3466
|
// src/client/version.js
|
|
3467
|
-
var PLUGIN_VERSION = "v1.
|
|
3468
|
-
var BUILD_TIME = "2026-09-
|
|
3467
|
+
var PLUGIN_VERSION = "v1.5.0";
|
|
3468
|
+
var BUILD_TIME = "2026-09-04 20:16:12";
|
|
3469
3469
|
|
|
3470
3470
|
// src/client/components/MainSection.jsx
|
|
3471
3471
|
var import_jsx_runtime9 = require("react/jsx-runtime");
|
package/lib/index.js
CHANGED
|
@@ -15,8 +15,8 @@ import { SessionStore } from './auth/token.js';
|
|
|
15
15
|
import { createGlobalAuthGate } from './auth/gate.js';
|
|
16
16
|
import { createRoutes } from './routes/api.js';
|
|
17
17
|
import { StyleSnippetStore } from './styles/style-snippets.js';
|
|
18
|
-
import { getLocalTailscaleIp } from './auth/tailscale.js';
|
|
19
|
-
import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, } from './bridge/compat.js';
|
|
18
|
+
import { getLocalTailscaleIp, getLocalLanIp } from './auth/tailscale.js';
|
|
19
|
+
import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, takeOverConnectionAuth, } from './bridge/compat.js';
|
|
20
20
|
/** Cordis 插件名称标识 */
|
|
21
21
|
export const name = 'dsh-remote-mobile';
|
|
22
22
|
/** DSH 设置命名空间 */
|
|
@@ -98,18 +98,109 @@ export function apply(ctx, config = {}) {
|
|
|
98
98
|
// 4. 向 Cordis 容器桥接注册 remoteWebUiPairing 服务(延迟裁决:与其他远程接入
|
|
99
99
|
// 类插件共存时自动让出,保证启动不冲突,详见 compat.ts)
|
|
100
100
|
registerRemoteWebUiPairingBridge(ctx, store);
|
|
101
|
-
// 5.
|
|
102
|
-
|
|
101
|
+
// 5. 透明接管 DSH 底座 connection 鉴权服务(短路官方 Token 401 阻断)
|
|
102
|
+
/**
|
|
103
|
+
* connection 鉴权接管采用三点冗余策略:
|
|
104
|
+
* 1) 插件 apply 时立即尝试 —— 覆盖 connection 先于本插件就绪的场景
|
|
105
|
+
* 2) ctx.inject 动态注入 —— 覆盖 connection 后于本插件就绪的场景
|
|
106
|
+
* 3) printReadyBanner 中兜底 —— 覆盖上述两者都未捕获到的极端时序
|
|
107
|
+
*
|
|
108
|
+
* takeOverConnectionAuth 内部通过 __dsh_rm_auth_takeover__ 标记保证幂等,
|
|
109
|
+
* 同一 connection 实例不会被重复接管。
|
|
110
|
+
*/
|
|
111
|
+
const attachConnectionAuth = (conn) => {
|
|
112
|
+
if (conn) {
|
|
113
|
+
takeOverConnectionAuth(conn, store);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
// 1) 优先通过 ctx.get 安全获取(未注入时不抛错返回 undefined)
|
|
117
|
+
try {
|
|
118
|
+
const conn = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
|
|
119
|
+
if (conn)
|
|
120
|
+
attachConnectionAuth(conn);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
ctx.logger?.debug?.('[dsh-remote-mobile] 尝试直接获取 connection 失败:', e);
|
|
124
|
+
}
|
|
125
|
+
// 2) 声明动态子注入,在 connection 服务就绪时接管
|
|
126
|
+
if (typeof ctx.inject === 'function') {
|
|
127
|
+
try {
|
|
128
|
+
ctx.inject(['connection'], (connCtx) => {
|
|
129
|
+
try {
|
|
130
|
+
// 统一通过 get 获取服务实例,避免属性访问与方法获取返回不同对象
|
|
131
|
+
const conn = typeof connCtx?.get === 'function' ? connCtx.get('connection') : connCtx?.connection;
|
|
132
|
+
if (conn)
|
|
133
|
+
attachConnectionAuth(conn);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
ctx.logger?.debug?.('[dsh-remote-mobile] inject 接收 connection 注入失败:', e);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
ctx.logger?.debug?.('[dsh-remote-mobile] 注册 connection 动态注入失败:', e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// 6. 服务就绪时输出提示横幅与接入地址
|
|
145
|
+
let printed = false;
|
|
146
|
+
const printReadyBanner = () => {
|
|
147
|
+
if (printed)
|
|
148
|
+
return;
|
|
149
|
+
printed = true;
|
|
103
150
|
if (ctx.webServer && ctx.webServer.server) {
|
|
104
151
|
patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store, styleStore);
|
|
105
152
|
}
|
|
153
|
+
try {
|
|
154
|
+
const conn = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
|
|
155
|
+
if (conn)
|
|
156
|
+
attachConnectionAuth(conn);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
ctx.logger?.debug?.('[dsh-remote-mobile] 就绪回调获取 connection 失败:', e);
|
|
160
|
+
}
|
|
106
161
|
const tsIp = getLocalTailscaleIp();
|
|
162
|
+
const lanIp = getLocalLanIp();
|
|
163
|
+
const port = ctx.webServer?.port ?? 3080;
|
|
107
164
|
const opts = store.getOptions();
|
|
165
|
+
const divider = '─'.repeat(72);
|
|
166
|
+
console.log(`\n┌${divider}`);
|
|
167
|
+
console.log(`│ [dsh-remote-mobile] 官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token`);
|
|
168
|
+
console.log(`│ 远程安全网关已就绪,支持手机浏览器访问:`);
|
|
169
|
+
console.log(`│ • 本机回环: http://127.0.0.1:${port}`);
|
|
170
|
+
if (lanIp && lanIp !== '127.0.0.1') {
|
|
171
|
+
console.log(`│ • 局域网: http://${lanIp}:${port} (同 Wi-Fi 局域网直连)`);
|
|
172
|
+
}
|
|
173
|
+
if (tsIp) {
|
|
174
|
+
console.log(`│ • 虚拟私网: http://${tsIp}:${port} (Tailscale 私网异地直连)`);
|
|
175
|
+
}
|
|
176
|
+
console.log(`│ 访问与配置说明:`);
|
|
177
|
+
console.log(`│ - 手机/远程设备打开对应地址即可直接访问`);
|
|
178
|
+
console.log(`│ - 在本机打开 Web 界面 ->「设置」->「远程与移动端」进行安全管理:`);
|
|
179
|
+
console.log(`│ 可开启免密直连(局域网/Tailscale)、查看 6 位临时配对码或设置长期密码`);
|
|
180
|
+
console.log(`│ - 项目文档: https://github.com/IceApriler/dsh-remote-mobile`);
|
|
181
|
+
console.log(`└${divider}\n`);
|
|
108
182
|
ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (上下文虚拟化桥接就绪,样式片段: ${styleStore.list().length} 个)`);
|
|
109
183
|
if (tsIp) {
|
|
110
|
-
ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}
|
|
184
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:${port} (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
|
|
111
185
|
}
|
|
112
|
-
}
|
|
186
|
+
};
|
|
187
|
+
// 1) 优先在 loader 插件树就绪后紧跟官方提示打印
|
|
188
|
+
try {
|
|
189
|
+
const loader = typeof ctx.get === 'function' ? ctx.get('loader') : undefined;
|
|
190
|
+
const settled = loader?.await?.();
|
|
191
|
+
if (settled && typeof settled.then === 'function') {
|
|
192
|
+
settled.then(() => {
|
|
193
|
+
setTimeout(printReadyBanner, 120);
|
|
194
|
+
}).catch((e) => {
|
|
195
|
+
ctx.logger?.debug?.('[dsh-remote-mobile] loader await 异常:', e);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
ctx.logger?.debug?.('[dsh-remote-mobile] 获取 loader 失败:', e);
|
|
201
|
+
}
|
|
202
|
+
// 2) 兜底定时器(避免无 loader 环境时不输出)
|
|
203
|
+
setTimeout(printReadyBanner, 1500);
|
|
113
204
|
}
|
|
114
205
|
// 导出所有子模块类型与核心工具
|
|
115
206
|
export * from './auth/tailscale.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-mobile",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "DeepSeek Harness 远程与移动端安全网关插件:零修改 DSH 底层代码安全开放局域网与 Tailscale 连接 | DeepSeek Harness (DSH) Remote & Mobile Security Guard: safely opens Tailscale/LAN with zero core modifications, QR scan auth, RSA encryption & brute-force defense.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|