dsh-remote-mobile 1.1.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 +178 -0
- package/README_EN.md +178 -0
- package/cordis.patch.yml +4 -0
- package/lib/auth/crypto.d.ts +24 -0
- package/lib/auth/crypto.js +144 -0
- package/lib/auth/gate.d.ts +14 -0
- package/lib/auth/gate.js +87 -0
- package/lib/auth/tailscale.d.ts +36 -0
- package/lib/auth/tailscale.js +128 -0
- package/lib/auth/token.d.ts +130 -0
- package/lib/auth/token.js +845 -0
- package/lib/bridge/compat.d.ts +50 -0
- package/lib/bridge/compat.js +191 -0
- package/lib/client.d.ts +5 -0
- package/lib/client.js +3606 -0
- package/lib/index.d.ts +32 -0
- package/lib/index.js +117 -0
- package/lib/routes/api.d.ts +11 -0
- package/lib/routes/api.js +364 -0
- package/lib/routes/login-page.d.ts +4 -0
- package/lib/routes/login-page.js +339 -0
- package/lib/types/auth.d.ts +49 -0
- package/lib/types/auth.js +4 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# dsh-remote-mobile
|
|
2
|
+
|
|
3
|
+
[English Documentation](./README_EN.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
`dsh-remote-mobile` 是专为 DeepSeek Hub (DSH) 设计的远程与移动端安全接入插件。
|
|
6
|
+
|
|
7
|
+
DSH 核心服务默认仅允许本地回环地址(`127.0.0.1`)访问 Web 界面。本项目通过门禁中间件与请求上下文虚拟化机制,安全地开放了 Tailscale 虚拟局域网以及本地局域网(LAN)访问能力,并提供扫码配对、长期密码认证、防暴力破解限频、落盘持久化和 SSE 实时状态推送。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🌟 主要功能
|
|
12
|
+
|
|
13
|
+
### 1. 网络接入支持
|
|
14
|
+
- **Tailscale 虚拟私网**:自动识别本机的 Tailscale IP(`100.64.0.0/10` CGNAT 网段),生成专属访问二维码,支持端到端加密免密直连选项。
|
|
15
|
+
- **局域网 (LAN)**:自动识别 RFC 1918 私有 IP(如 `192.168.x.x`、`10.x.x.x`、`172.16-31.x.x`),提供独立的局域网访问二维码与高危醒目警示配置。
|
|
16
|
+
- **二维码快速配对**:支持手机相机或微信扫码直接打开登录页面。
|
|
17
|
+
|
|
18
|
+
### 2. 多重认证机制
|
|
19
|
+
- **动态配对码**:生成 6 位短期配对码(5 分钟有效,一次性消费),手机扫码输入后换取 365 天有效期的认证 Cookie。
|
|
20
|
+
- **长期访问密码**:支持设置访问密码(需包含字母与数字,长度不小于 6 位),验证成功后同样派发 365 天长效 Cookie。
|
|
21
|
+
- **免密直连模式**:可针对 Tailscale 或局域网环境单独开启免密直连。关闭免密后会自动清理临时设备凭证并重置状态。
|
|
22
|
+
- **设备会话管理**:实时查看已连接设备的类型、操作系统、浏览器、来源 IP 及最近活跃时间,支持单设备注销与一键全部踢出。
|
|
23
|
+
|
|
24
|
+
### 3. 企业级安全防护
|
|
25
|
+
- **传输层加密**:登录认证接口支持 RSA 密钥加密(RSA-OAEP-SHA256 与 PKCS#1 v1.5 兼容),密文在客户端加密后再通过网络传输。
|
|
26
|
+
- **慢哈希存储**:服务端采用 `scrypt` 加盐慢哈希算法(`scrypt:${salt}:${hash}`)对密码进行落盘存储,比对过程采用 `crypto.timingSafeEqual` 恒定时间比对以防范时序侧信道攻击。
|
|
27
|
+
- **防暴力破解与限频**:
|
|
28
|
+
- 连续输错 5 次凭证自动锁定该 IP 15 分钟,拦截后续验证请求并返回 HTTP 429;
|
|
29
|
+
- 滑动窗口限频(默认 60 次/分钟),防止高频刷量;
|
|
30
|
+
- 访问审计与锁定状态持久化保存,服务重启后自动恢复;
|
|
31
|
+
- 支持管理员在面板中手动一键解除 IP 锁定。
|
|
32
|
+
- **请求头安全**:仅信任底层 Socket 真实连接地址,防范 `X-Forwarded-For` 伪造。
|
|
33
|
+
|
|
34
|
+
### 4. 实时状态同步与国际化支持
|
|
35
|
+
- **SSE 事件通道**:基于 Server-Sent Events 实现新设备配对、重连上线、会话撤销及安全告警的实时通知,无需前端轮询。
|
|
36
|
+
- **全界面中英文双语自适应**:根据 DSH 语言偏好设置与浏览器语言,动态自适应中英文面板、提示及手机端登录界面。
|
|
37
|
+
- **非 HTTPS 兼容补丁**:自动向 HTML 页面注入 `crypto.randomUUID` Polyfill,解决部分移动端浏览器在 HTTP 非安全上下文下缺少原生 API 的问题。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🛠️ 安装与前置配置
|
|
42
|
+
|
|
43
|
+
### 步骤 1:安装插件
|
|
44
|
+
|
|
45
|
+
#### 方式 1:通过 DSH 命令行一键安装(推荐)
|
|
46
|
+
|
|
47
|
+
使用 DSH 内置的插件管理工具直接从 npm 官方仓库安装到 web profile:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dsh plugin --profile web add dsh-remote-mobile
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#### 方式 2:通过 Web 设置页「插件管理」图形化安装
|
|
54
|
+
|
|
55
|
+
在浏览器打开 DSH Web 控制台:
|
|
56
|
+
1. 点击左下角 **设置 ⚙️ -> 插件**;
|
|
57
|
+
2. 切换到顶部的 **「插件管理」** Tab;
|
|
58
|
+
3. 在输入框中输入 npm 包名 **`dsh-remote-mobile`**,点击 **「安装」**;
|
|
59
|
+
4. 安装完成后重启 DSH 即可生效。
|
|
60
|
+
|
|
61
|
+
#### 方式 3:手动在 Profile 目录中通过包管理器安装
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 1. 进入 DSH Web Profile 目录
|
|
65
|
+
cd ~/.dsh/profiles/web
|
|
66
|
+
|
|
67
|
+
# 2. 通过 pnpm 安装
|
|
68
|
+
pnpm add dsh-remote-mobile
|
|
69
|
+
|
|
70
|
+
# 3. 检查 ~/.dsh/profiles/web/package.json
|
|
71
|
+
# 确保 dsh.profile.bundles 数组中已包含 "dsh-remote-mobile"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### 方式 4:本地开发与源码安装
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 1. 克隆或下载源码至本地目录
|
|
78
|
+
cd ~/Myfile/www-self/dsh-remote-mobile
|
|
79
|
+
|
|
80
|
+
# 2. 安装依赖并编译打包
|
|
81
|
+
npm install
|
|
82
|
+
npm run build && npm test && npm pack
|
|
83
|
+
|
|
84
|
+
# 3. 在 DSH 中以本地包形式安装
|
|
85
|
+
cd ~/.dsh/profiles/web
|
|
86
|
+
pnpm add /path/to/dsh-remote-mobile/dsh-remote-mobile-1.0.0.tgz
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### 步骤 2:开放网络监听配置(前置必配)
|
|
92
|
+
|
|
93
|
+
由于 DSH 默认出于安全考虑仅监听 `127.0.0.1`(本地回环),为了使 Tailscale 私网或局域网设备能够连通,请在 `~/.dsh/profiles/web/cordis.patch.yml` 中确保包含以下配置:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
# 1. 覆盖 webserver 配置监听 0.0.0.0:3080(必选:允许接收外部连接)
|
|
97
|
+
- id: webserver
|
|
98
|
+
name: '@deepseek-ai/dsh-host-webserver'
|
|
99
|
+
inject: [webStartup]
|
|
100
|
+
config:
|
|
101
|
+
host: '0.0.0.0'
|
|
102
|
+
port: 3080
|
|
103
|
+
|
|
104
|
+
# 2. 禁用 @linxin666/dsh-web-ui-all 全家桶自带的远程插件(必选:防止路由冲突与界面覆盖)
|
|
105
|
+
- id: web-ui-remote-web-ui
|
|
106
|
+
disabled: true
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
> **💡 说明**:插件自身的注册已由 DSH Bundle 自动处理,**无需**在 `cordis.patch.yml` 中额外添加 `id: remote-mobile`。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### 步骤 3:启动服务
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
dsh web
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
启动后,在 PC 端浏览器打开 `http://127.0.0.1:3080`,进入 **设置 ⚙️ -> 远程与移动端 (Remote & Mobile)** 即可查看配对二维码或配置访问密码。
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## ⚙️ 配置项说明
|
|
124
|
+
|
|
125
|
+
在 `~/.dsh/settings.yaml` 中可配置插件参数(**推荐直接在 Web 面板中可视化调整**,修改后自动写回持久化):
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
dsh-remote-mobile:
|
|
129
|
+
# 免密直连开关
|
|
130
|
+
allowTailscale: true # boolean,默认 false:Tailscale 节点访问是否免密
|
|
131
|
+
allowLan: false # boolean,默认 false:局域网 (Wi-Fi) 设备访问是否免密
|
|
132
|
+
|
|
133
|
+
# 长期访问密码哈希(在面板中输入明文会自动生成加盐哈希保存)
|
|
134
|
+
secretHash: '' # string,默认空
|
|
135
|
+
|
|
136
|
+
# 全局高级安全防护参数
|
|
137
|
+
maxVisitsPerMinute: 60 # number,默认 60:单 IP 每分钟打开 /auth 页面最大允许次数
|
|
138
|
+
maxFailedAttempts: 5 # number,默认 5:密码连续输错阈值
|
|
139
|
+
lockDurationMs: 900000 # number,默认 900000 (15分钟):IP 锁定持续时间(毫秒)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| `allowTailscale` | `boolean` | `false` | 是否允许 Tailscale 私网 IP 免密访问 |
|
|
145
|
+
| `allowLan` | `boolean` | `false` | 是否允许局域网私有 IP 免密访问(高危警示) |
|
|
146
|
+
| `secretHash` | `string` | `""` | 长期密码的 scrypt 加盐哈希值 |
|
|
147
|
+
| `maxVisitsPerMinute` | `number` | `60` | 单 IP 每分钟最大访问登录页次数 |
|
|
148
|
+
| `maxFailedAttempts` | `number` | `5` | 连续认证失败最大次数(达到后自动封锁 IP) |
|
|
149
|
+
| `lockDurationMs` | `number` | `900000` | 触发防暴破后的 IP 锁定时间(毫秒) |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 📂 本地文件存储位置
|
|
154
|
+
|
|
155
|
+
- **设备会话与安全审计数据**:`~/.dsh/remote-mobile/devices.json`
|
|
156
|
+
- **RSA 传输加密密钥对**:`~/.dsh/remote-mobile/rsa-keys.json`(权限模式为 `0600`,首次启动自动就地生成)
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 🔒 安全建议
|
|
161
|
+
|
|
162
|
+
1. **局域网免密风险**:若在公共 Wi-Fi、咖啡厅或共享网络环境下使用,**严禁开启 `allowLan`(局域网免密直连)**。
|
|
163
|
+
2. **Tailscale 推荐**:建议优先配合 Tailscale 虚拟专用网络使用,既能保障公网不可达,又能获得端到端加密传输保障。
|
|
164
|
+
3. **密码复杂度**:若使用长期密码,建议设置包含字母与数字且 6 位以上的高强度密码。
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 🗑️ 卸载插件
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
dsh plugin --profile web remove dsh-remote-mobile
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 📄 开源许可
|
|
177
|
+
|
|
178
|
+
[MIT License](./LICENSE)
|
package/README_EN.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# dsh-remote-mobile
|
|
2
|
+
|
|
3
|
+
English Documentation | [简体中文](./README.md)
|
|
4
|
+
|
|
5
|
+
`dsh-remote-mobile` is a remote and mobile security access plugin tailored for DeepSeek Hub (DSH).
|
|
6
|
+
|
|
7
|
+
By default, the DSH core service restricts Web UI access to localhost (`127.0.0.1`). This project safely enables Tailscale VPN and Local Area Network (LAN/Wi-Fi) remote connectivity using a custom authentication gate and context virtualization. It features QR code pairing, persistent passwords, brute-force rate-limiting, local data persistence, and realtime SSE push notifications.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🌟 Key Features
|
|
12
|
+
|
|
13
|
+
### 1. Network Connectivity
|
|
14
|
+
- **Tailscale Virtual Private Network**: Automatically detects Tailscale CGNAT IPs (`100.64.0.0/10`), generates a dedicated access QR code, and offers an end-to-end encrypted passwordless bypass option.
|
|
15
|
+
- **Local Area Network (LAN / Wi-Fi)**: Automatically detects RFC 1918 private IPs (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`), provides an independent LAN QR code, and includes high-risk visual safety warnings.
|
|
16
|
+
- **Quick QR Pairing**: Direct scanning with mobile camera or WeChat to open the authorization portal.
|
|
17
|
+
|
|
18
|
+
### 2. Multi-Tiered Authentication
|
|
19
|
+
- **Dynamic 6-Digit Code**: Generates temporary one-time pairing codes (valid for 5 minutes). Entering it on mobile grants a 365-day persistent session cookie.
|
|
20
|
+
- **Persistent Password (Secret)**: Set a custom password (letters + numbers, 6+ chars) to gain persistent authentication on any mobile device.
|
|
21
|
+
- **Passwordless Bypass Mode**: Selectively enable bypass for Tailscale or LAN. Turning off bypass automatically purges temporary credentials and resets device statuses.
|
|
22
|
+
- **Device Management**: View connected device types, OS, browsers, IP addresses, and last seen timestamps. Supports individual device revocation and one-click global purge.
|
|
23
|
+
|
|
24
|
+
### 3. Enterprise-Grade Security
|
|
25
|
+
- **Transport Layer Encryption**: Login verification supports RSA encryption (RSA-OAEP-SHA256 & PKCS#1 v1.5 compatible). Passwords and codes are encrypted on the browser before network transit.
|
|
26
|
+
- **Slow Salted Hashing**: Stores passwords locally using `scrypt` slow hashing (`scrypt:${salt}:${hash}`). Employs `crypto.timingSafeEqual` for constant-time comparisons against timing attacks.
|
|
27
|
+
- **Brute-Force & Rate-Limiting Defense**:
|
|
28
|
+
- Automatically locks out an IP for 15 minutes after 5 consecutive failed attempts (returns HTTP 429);
|
|
29
|
+
- Sliding-window rate limiter (default: 60 requests/min) to prevent high-frequency abuse;
|
|
30
|
+
- Audit logs and lockout states persist across DSH restarts;
|
|
31
|
+
- Manual one-click IP unlock support from the management UI.
|
|
32
|
+
- **Socket-Level Verification**: Only inspects low-level socket remote addresses, defending against forged `X-Forwarded-For` headers.
|
|
33
|
+
|
|
34
|
+
### 4. Realtime Push & Full Internationalization (i18n)
|
|
35
|
+
- **Server-Sent Events (SSE)**: Delivers instant push notifications for new device pairing, reconnections, revocations, and security alerts without client polling.
|
|
36
|
+
- **Bilingual Adaptive UI**: Dynamically switches between English and Simplified Chinese based on DSH preferences and browser settings.
|
|
37
|
+
- **Non-HTTPS Crypto Polyfill**: Injects a lightweight `crypto.randomUUID` Polyfill into HTML headers to support mobile browsers running in HTTP non-secure contexts.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🛠️ Installation & Setup
|
|
42
|
+
|
|
43
|
+
### Step 1: Install the Plugin
|
|
44
|
+
|
|
45
|
+
#### Option 1: One-Click via DSH CLI (Recommended)
|
|
46
|
+
|
|
47
|
+
Use the DSH built-in plugin manager to install from npm directly into the web profile:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dsh plugin --profile web add dsh-remote-mobile
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
#### Option 2: Visual Install via Web Settings (Plugin Manager)
|
|
54
|
+
|
|
55
|
+
Open the DSH Web UI in your browser:
|
|
56
|
+
1. Navigate to **Settings ⚙️ -> Plugins**;
|
|
57
|
+
2. Switch to the **"Plugin Manager"** (插件管理) tab at the top;
|
|
58
|
+
3. Enter the npm package name **`dsh-remote-mobile`** in the input field and click **"Install"** (安装);
|
|
59
|
+
4. Restart DSH to activate the plugin.
|
|
60
|
+
|
|
61
|
+
#### Option 3: Manual Installation via Package Manager in Profile Directory
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# 1. Enter the DSH Web Profile directory
|
|
65
|
+
cd ~/.dsh/profiles/web
|
|
66
|
+
|
|
67
|
+
# 2. Install via pnpm
|
|
68
|
+
pnpm add dsh-remote-mobile
|
|
69
|
+
|
|
70
|
+
# 3. Check ~/.dsh/profiles/web/package.json
|
|
71
|
+
# Ensure "dsh-remote-mobile" is present in dsh.profile.bundles
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
#### Option 4: Local Development & Source Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 1. Clone or download the repository locally
|
|
78
|
+
cd ~/Myfile/www-self/dsh-remote-mobile
|
|
79
|
+
|
|
80
|
+
# 2. Install dependencies, build and pack
|
|
81
|
+
npm install
|
|
82
|
+
npm run build && npm test && npm pack
|
|
83
|
+
|
|
84
|
+
# 3. Install the packed tarball into DSH
|
|
85
|
+
cd ~/.dsh/profiles/web
|
|
86
|
+
pnpm add /path/to/dsh-remote-mobile/dsh-remote-mobile-1.0.0.tgz
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### Step 2: Configure Network Binding (Required)
|
|
92
|
+
|
|
93
|
+
Since DSH defaults to listening only on `127.0.0.1` (localhost), you need to bind the webserver to `0.0.0.0` in `~/.dsh/profiles/web/cordis.patch.yml` to accept connections from Tailscale or LAN:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
# 1. Override webserver to listen on 0.0.0.0:3080 (Required for remote access)
|
|
97
|
+
- id: webserver
|
|
98
|
+
name: '@deepseek-ai/dsh-host-webserver'
|
|
99
|
+
inject: [webStartup]
|
|
100
|
+
config:
|
|
101
|
+
host: '0.0.0.0'
|
|
102
|
+
port: 3080
|
|
103
|
+
|
|
104
|
+
# 2. Disable the built-in remote plugin from @linxin666/dsh-web-ui-all (Required: prevent route conflicts and UI overlaps)
|
|
105
|
+
- id: web-ui-remote-web-ui
|
|
106
|
+
disabled: true
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
> **💡 Note**: The plugin is automatically registered by the DSH Bundle loader. You **do not** need to add `id: remote-mobile` to `cordis.patch.yml`.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### Step 3: Start DSH
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
dsh web
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Open `http://127.0.0.1:3080` in your PC browser and navigate to **Settings ⚙️ -> Remote & Mobile** to configure pairing.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## ⚙️ Configuration
|
|
124
|
+
|
|
125
|
+
Options can be customized in `~/.dsh/settings.yaml` (or interactively configured via **Settings ⚙️ -> Remote & Mobile**):
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
dsh-remote-mobile:
|
|
129
|
+
# Bypass options
|
|
130
|
+
allowTailscale: true # boolean, default: false - Allow passwordless Tailscale access
|
|
131
|
+
allowLan: false # boolean, default: false - Allow passwordless LAN (Wi-Fi) access
|
|
132
|
+
|
|
133
|
+
# Persistent password hash (auto-generated when saved in the UI)
|
|
134
|
+
secretHash: '' # string, default: empty
|
|
135
|
+
|
|
136
|
+
# Advanced security policies
|
|
137
|
+
maxVisitsPerMinute: 60 # number, default: 60 - Max visits to /auth per minute per IP
|
|
138
|
+
maxFailedAttempts: 5 # number, default: 5 - Lockout threshold for wrong passwords
|
|
139
|
+
lockDurationMs: 900000 # number, default: 900000 (15 min) - Lockout duration in ms
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Option | Type | Default | Description |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| `allowTailscale` | `boolean` | `false` | Enable passwordless access for Tailscale nodes |
|
|
145
|
+
| `allowLan` | `boolean` | `false` | Enable passwordless access for local Wi-Fi devices (High Risk) |
|
|
146
|
+
| `secretHash` | `string` | `""` | Salted scrypt hash for persistent password |
|
|
147
|
+
| `maxVisitsPerMinute` | `number` | `60` | Max /auth page visits per minute per IP |
|
|
148
|
+
| `maxFailedAttempts` | `number` | `5` | Maximum failed attempts before IP lockout |
|
|
149
|
+
| `lockDurationMs` | `number` | `900000` | IP lockout duration (ms) |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 📂 Local Storage Paths
|
|
154
|
+
|
|
155
|
+
- **Device Sessions & Security Audit**: `~/.dsh/remote-mobile/devices.json`
|
|
156
|
+
- **RSA Transport Keypair**: `~/.dsh/remote-mobile/rsa-keys.json` (auto-generated on first launch with `0600` permissions)
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 🔒 Security Best Practices
|
|
161
|
+
|
|
162
|
+
1. **LAN Bypass Risk**: **NEVER enable `allowLan`** on public Wi-Fi, cafes, hotels, or untrusted shared networks.
|
|
163
|
+
2. **Tailscale Recommended**: Prioritize Tailscale for encrypted private access without exposing ports to the public internet.
|
|
164
|
+
3. **Password Strength**: When setting a persistent password, choose a strong alphanumeric combination (6+ characters).
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 🗑️ Uninstalling
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
dsh plugin --profile web remove dsh-remote-mobile
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 📄 License
|
|
177
|
+
|
|
178
|
+
[MIT License](./LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const RSA_KEY_FILE: string;
|
|
2
|
+
/**
|
|
3
|
+
* 获取服务端的持久化 RSA 公钥 (PEM 格式)
|
|
4
|
+
*/
|
|
5
|
+
export declare function getPublicKeyPem(): string;
|
|
6
|
+
/**
|
|
7
|
+
* 使用 RSA 私钥解密客户端发来的 Base64 密文 (多填充算法自适应兼容)
|
|
8
|
+
*/
|
|
9
|
+
export declare function decryptWithPrivateKey(encryptedBase64: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* 校验长期密码强度:必须 >= 6 位且包含字母与数字
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateSecretStrength(secret: string): {
|
|
14
|
+
valid: boolean;
|
|
15
|
+
reason?: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* 将明文密码转换为加盐哈希(采用 scrypt 慢哈希算法,杜绝彩虹表与 GPU 暴力破解)
|
|
19
|
+
*/
|
|
20
|
+
export declare function hashSecret(plainSecret: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* 比对明文密码与存储的加盐哈希(恒定时间比对,抵御时序侧信道攻击)
|
|
23
|
+
*/
|
|
24
|
+
export declare function verifySecretHash(plainSecret: string, storedHashOrPlain: string): boolean;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { generateKeyPairSync, privateDecrypt, constants, randomBytes, createHash, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
export const RSA_KEY_FILE = `${homedir()}/.dsh/remote-mobile/rsa-keys.json`;
|
|
6
|
+
let keyPair;
|
|
7
|
+
function loadOrCreateKeyPair() {
|
|
8
|
+
try {
|
|
9
|
+
if (existsSync(RSA_KEY_FILE)) {
|
|
10
|
+
const content = readFileSync(RSA_KEY_FILE, 'utf8');
|
|
11
|
+
const data = JSON.parse(content);
|
|
12
|
+
if (data && data.publicKey && data.privateKey) {
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
const pair = generateKeyPairSync('rsa', {
|
|
19
|
+
modulusLength: 2048,
|
|
20
|
+
publicKeyEncoding: {
|
|
21
|
+
type: 'spki',
|
|
22
|
+
format: 'pem',
|
|
23
|
+
},
|
|
24
|
+
privateKeyEncoding: {
|
|
25
|
+
type: 'pkcs8',
|
|
26
|
+
format: 'pem',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(dirname(RSA_KEY_FILE), { recursive: true });
|
|
31
|
+
writeFileSync(RSA_KEY_FILE, JSON.stringify(pair, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
return pair;
|
|
35
|
+
}
|
|
36
|
+
keyPair = loadOrCreateKeyPair();
|
|
37
|
+
/**
|
|
38
|
+
* 获取服务端的持久化 RSA 公钥 (PEM 格式)
|
|
39
|
+
*/
|
|
40
|
+
export function getPublicKeyPem() {
|
|
41
|
+
return keyPair.publicKey;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 使用 RSA 私钥解密客户端发来的 Base64 密文 (多填充算法自适应兼容)
|
|
45
|
+
*/
|
|
46
|
+
export function decryptWithPrivateKey(encryptedBase64) {
|
|
47
|
+
if (!encryptedBase64)
|
|
48
|
+
return '';
|
|
49
|
+
try {
|
|
50
|
+
const buffer = Buffer.from(encryptedBase64, 'base64');
|
|
51
|
+
// 2048 位 RSA 密文标准长度必须为 256 字节
|
|
52
|
+
if (buffer.length !== 256)
|
|
53
|
+
return '';
|
|
54
|
+
// 1. 优先尝试 RSA-OAEP SHA-256 (Web Crypto 规范标准)
|
|
55
|
+
try {
|
|
56
|
+
const decrypted = privateDecrypt({
|
|
57
|
+
key: keyPair.privateKey,
|
|
58
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
59
|
+
oaepHash: 'sha256',
|
|
60
|
+
}, buffer);
|
|
61
|
+
return decrypted.toString('utf8');
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
// 2. 尝试 RSA-OAEP SHA-1 兼容模式
|
|
65
|
+
try {
|
|
66
|
+
const decrypted = privateDecrypt({
|
|
67
|
+
key: keyPair.privateKey,
|
|
68
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
69
|
+
oaepHash: 'sha1',
|
|
70
|
+
}, buffer);
|
|
71
|
+
return decrypted.toString('utf8');
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
// 3. 尝试 PKCS#1 v1.5 填充兼容模式
|
|
75
|
+
try {
|
|
76
|
+
const decrypted = privateDecrypt({
|
|
77
|
+
key: keyPair.privateKey,
|
|
78
|
+
padding: constants.RSA_PKCS1_PADDING,
|
|
79
|
+
}, buffer);
|
|
80
|
+
return decrypted.toString('utf8');
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 校验长期密码强度:必须 >= 6 位且包含字母与数字
|
|
89
|
+
*/
|
|
90
|
+
export function validateSecretStrength(secret) {
|
|
91
|
+
const trimmed = (secret || '').trim();
|
|
92
|
+
if (!trimmed)
|
|
93
|
+
return { valid: true }; // 允许留空(不设置长期密码)
|
|
94
|
+
if (trimmed.length < 6) {
|
|
95
|
+
return { valid: false, reason: '密码长度不能少于 6 位' };
|
|
96
|
+
}
|
|
97
|
+
const hasLetter = /[A-Za-z]/.test(trimmed);
|
|
98
|
+
const hasDigit = /\d/.test(trimmed);
|
|
99
|
+
if (!hasLetter || !hasDigit) {
|
|
100
|
+
return { valid: false, reason: '密码必须同时包含字母和数字组合' };
|
|
101
|
+
}
|
|
102
|
+
return { valid: true };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 将明文密码转换为加盐哈希(采用 scrypt 慢哈希算法,杜绝彩虹表与 GPU 暴力破解)
|
|
106
|
+
*/
|
|
107
|
+
export function hashSecret(plainSecret) {
|
|
108
|
+
if (!plainSecret)
|
|
109
|
+
return '';
|
|
110
|
+
const salt = randomBytes(16).toString('hex');
|
|
111
|
+
const hash = scryptSync(plainSecret, salt, 64).toString('hex');
|
|
112
|
+
return `scrypt:${salt}:${hash}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 比对明文密码与存储的加盐哈希(恒定时间比对,抵御时序侧信道攻击)
|
|
116
|
+
*/
|
|
117
|
+
export function verifySecretHash(plainSecret, storedHashOrPlain) {
|
|
118
|
+
if (!storedHashOrPlain || !plainSecret)
|
|
119
|
+
return false;
|
|
120
|
+
if (storedHashOrPlain.startsWith('scrypt:')) {
|
|
121
|
+
const parts = storedHashOrPlain.split(':');
|
|
122
|
+
if (parts.length !== 3)
|
|
123
|
+
return false;
|
|
124
|
+
const salt = parts[1];
|
|
125
|
+
const expectedHash = parts[2];
|
|
126
|
+
const actualHash = scryptSync(plainSecret, salt, 64).toString('hex');
|
|
127
|
+
if (actualHash.length !== expectedHash.length)
|
|
128
|
+
return false;
|
|
129
|
+
return timingSafeEqual(Buffer.from(actualHash, 'hex'), Buffer.from(expectedHash, 'hex'));
|
|
130
|
+
}
|
|
131
|
+
// 兼容老版本 sha256 存储
|
|
132
|
+
if (storedHashOrPlain.startsWith('sha256:')) {
|
|
133
|
+
const parts = storedHashOrPlain.split(':');
|
|
134
|
+
if (parts.length !== 3)
|
|
135
|
+
return false;
|
|
136
|
+
const salt = parts[1];
|
|
137
|
+
const expectedHash = parts[2];
|
|
138
|
+
const actualHash = createHash('sha256').update(plainSecret + ':' + salt).digest('hex');
|
|
139
|
+
if (actualHash.length !== expectedHash.length)
|
|
140
|
+
return false;
|
|
141
|
+
return timingSafeEqual(Buffer.from(actualHash, 'hex'), Buffer.from(expectedHash, 'hex'));
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { SessionStore } from './token.js';
|
|
3
|
+
/**
|
|
4
|
+
* 判断请求是否来自本机回环
|
|
5
|
+
*/
|
|
6
|
+
export declare function isLoopbackRequest(req: IncomingMessage): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* 判断是否为无需鉴权的公开静态/登录路径
|
|
9
|
+
*/
|
|
10
|
+
export declare function isPublicPath(url?: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* 构造全局 HTTP & WebSocket 门禁拦截处理器
|
|
13
|
+
*/
|
|
14
|
+
export declare function createGlobalAuthGate(store: SessionStore): (req: IncomingMessage, res: ServerResponse, next?: () => void) => boolean;
|
package/lib/auth/gate.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isTailscaleIp, isLanIp, getClientIp } from './tailscale.js';
|
|
2
|
+
/**
|
|
3
|
+
* 判断请求是否来自本机回环
|
|
4
|
+
*/
|
|
5
|
+
export function isLoopbackRequest(req) {
|
|
6
|
+
const ip = getClientIp(req);
|
|
7
|
+
return ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 判断是否为无需鉴权的公开静态/登录路径
|
|
11
|
+
*/
|
|
12
|
+
export function isPublicPath(url = '') {
|
|
13
|
+
const pathname = url.split('?')[0];
|
|
14
|
+
if (pathname === '/auth')
|
|
15
|
+
return true;
|
|
16
|
+
if (pathname.startsWith('/api/remote-mobile/public-key'))
|
|
17
|
+
return true;
|
|
18
|
+
if (pathname.startsWith('/api/remote-mobile/verify'))
|
|
19
|
+
return true;
|
|
20
|
+
if (pathname.startsWith('/plugins/'))
|
|
21
|
+
return true;
|
|
22
|
+
if (pathname.startsWith('/assets/'))
|
|
23
|
+
return true;
|
|
24
|
+
if (pathname === '/favicon.ico')
|
|
25
|
+
return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 构造全局 HTTP & WebSocket 门禁拦截处理器
|
|
30
|
+
*/
|
|
31
|
+
export function createGlobalAuthGate(store) {
|
|
32
|
+
return function authGateMiddleware(req, res, next) {
|
|
33
|
+
const pass = () => {
|
|
34
|
+
if (typeof next === 'function')
|
|
35
|
+
next();
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
38
|
+
// 1. 本机 127.0.0.1 回环无条件放行(保证本机 PC 始终可用)
|
|
39
|
+
if (isLoopbackRequest(req)) {
|
|
40
|
+
return pass();
|
|
41
|
+
}
|
|
42
|
+
// 2. 公开登录页面与认证接口放行
|
|
43
|
+
if (isPublicPath(req.url)) {
|
|
44
|
+
return pass();
|
|
45
|
+
}
|
|
46
|
+
const clientIp = getClientIp(req);
|
|
47
|
+
const options = store.getOptions();
|
|
48
|
+
// 3. 如果开启了 Tailscale 私网免密直连,且客户端来源是 Tailscale 虚拟网段,自动归因并放行
|
|
49
|
+
if (options.allowTailscale && isTailscaleIp(clientIp)) {
|
|
50
|
+
const ua = req.headers['user-agent'] || '';
|
|
51
|
+
store.registerBypassDevice(clientIp, ua, 'Tailscale');
|
|
52
|
+
return pass();
|
|
53
|
+
}
|
|
54
|
+
// 4. 如果开启了局域网 LAN 免密直连,且客户端来源是局域网私有网段 (10.x / 172.16-31.x / 192.168.x),自动归因并放行
|
|
55
|
+
if (options.allowLan && isLanIp(clientIp)) {
|
|
56
|
+
const ua = req.headers['user-agent'] || '';
|
|
57
|
+
store.registerBypassDevice(clientIp, ua, 'LAN');
|
|
58
|
+
return pass();
|
|
59
|
+
}
|
|
60
|
+
// 5. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
|
|
61
|
+
const token = store.extractTokenFromRequest(req);
|
|
62
|
+
if (token && store.validateToken(token, clientIp)) {
|
|
63
|
+
return pass();
|
|
64
|
+
}
|
|
65
|
+
// 5. 未授权访问拦截处理:
|
|
66
|
+
// 只有主文档请求(GET 根路径或请求头明确包含 text/html)才 302 重定向到 /auth 登录页,子资源与 API 请求一律返回 401
|
|
67
|
+
const accept = req.headers['accept'] || '';
|
|
68
|
+
const isHtmlRequest = req.method === 'GET' && (accept.includes('text/html') || req.url === '/' || req.url?.startsWith('/?'));
|
|
69
|
+
if (isHtmlRequest) {
|
|
70
|
+
res.writeHead(302, {
|
|
71
|
+
Location: '/auth',
|
|
72
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
73
|
+
});
|
|
74
|
+
res.end();
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
res.writeHead(401, {
|
|
78
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
79
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
80
|
+
});
|
|
81
|
+
res.end(JSON.stringify({
|
|
82
|
+
error: 'Unauthorized',
|
|
83
|
+
message: '设备未授权或会话已注销,请前往 /auth 重新配对。',
|
|
84
|
+
}));
|
|
85
|
+
return false;
|
|
86
|
+
};
|
|
87
|
+
}
|