@feiyang666/dsh-mobile-remote 1.9.0 → 2.0.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 +45 -0
- package/README.md +3 -2
- package/README.zh.md +6 -8
- package/RELEASE_NOTES.md +36 -43
- package/cordis.patch.yml +4 -1
- package/lib/client.js +1096 -1059
- package/lib/external.js +97 -8
- package/lib/index.js +143 -956
- package/lib/lanproxy.js +40 -10
- package/lib/password.js +245 -245
- package/package.json +2 -2
- package/lib/patch.js.bak-0000 +0 -244
package/lib/lanproxy.js
CHANGED
|
@@ -59,6 +59,12 @@ export function createLanProxy(options = {}) {
|
|
|
59
59
|
let listenPort = 0
|
|
60
60
|
/** 转发目标端口(宿主端口;监听建立前就已确定,避免启动窗口内 503)。 */
|
|
61
61
|
let targetPort = 0
|
|
62
|
+
/**
|
|
63
|
+
* 转发目标地址。宿主可能只绑定了某块局域网网卡(例如官方桌面端用
|
|
64
|
+
* `--host 192.168.x.x` 启动),此时 127.0.0.1 上没有任何监听,转发必须打到
|
|
65
|
+
* 宿主真实绑定的那块网卡,否则旁挂听众自己也会 502。
|
|
66
|
+
*/
|
|
67
|
+
let upstreamHost = '127.0.0.1'
|
|
62
68
|
/** 当前对外提供的局域网地址列表。 */
|
|
63
69
|
let hosts = []
|
|
64
70
|
/** 每次 start/stop 递增,用于丢弃过期的启动结果。 */
|
|
@@ -68,6 +74,7 @@ export function createLanProxy(options = {}) {
|
|
|
68
74
|
running: listeners.length > 0,
|
|
69
75
|
hosts: hosts.slice(),
|
|
70
76
|
port: listenPort,
|
|
77
|
+
upstream: `${upstreamHost}:${listenPort || targetPort || 0}`,
|
|
71
78
|
})
|
|
72
79
|
|
|
73
80
|
function targetPortOf() {
|
|
@@ -92,7 +99,7 @@ export function createLanProxy(options = {}) {
|
|
|
92
99
|
headers['x-forwarded-proto'] = 'http'
|
|
93
100
|
if (realHost) headers['x-forwarded-host'] = realHost
|
|
94
101
|
const upstream = http.request({
|
|
95
|
-
host:
|
|
102
|
+
host: upstreamHost,
|
|
96
103
|
port,
|
|
97
104
|
method: req.method,
|
|
98
105
|
path: req.url,
|
|
@@ -125,7 +132,7 @@ export function createLanProxy(options = {}) {
|
|
|
125
132
|
headers['x-forwarded-proto'] = 'http'
|
|
126
133
|
if (realHost) headers['x-forwarded-host'] = realHost
|
|
127
134
|
|
|
128
|
-
const upstream = net.connect(port,
|
|
135
|
+
const upstream = net.connect(port, upstreamHost, () => {
|
|
129
136
|
const lines = [`${req.method || 'GET'} ${req.url || '/'} HTTP/1.1`]
|
|
130
137
|
for (const [key, value] of Object.entries(headers)) {
|
|
131
138
|
if (value === undefined) continue
|
|
@@ -172,21 +179,37 @@ export function createLanProxy(options = {}) {
|
|
|
172
179
|
* 在给定局域网地址上开启监听。
|
|
173
180
|
* @param {string[]} lanHosts - 局域网 IPv4 列表(按优先级排序)
|
|
174
181
|
* @param {number} port - 宿主当前监听端口
|
|
175
|
-
* @
|
|
182
|
+
* @param {{upstreamHost?: string, skipHosts?: string[]}} [startOptions]
|
|
183
|
+
* upstreamHost:转发目标(宿主真实监听的地址,未绑回环时是网卡 IP);
|
|
184
|
+
* skipHosts:宿主已经自己占用的地址(旁挂听众不能再绑,否则 EADDRINUSE)。
|
|
185
|
+
* @returns {Promise<{running: boolean, hosts: string[], port: number, upstream: string, failures: Array<{host: string, error: string}>}>}
|
|
176
186
|
*/
|
|
177
|
-
async function start(lanHosts, port) {
|
|
178
|
-
const
|
|
187
|
+
async function start(lanHosts, port, startOptions = {}) {
|
|
188
|
+
const skip = new Set((startOptions.skipHosts || []).filter(Boolean))
|
|
189
|
+
const wanted = [...new Set((lanHosts || []).filter((h) => h && !skip.has(h)))]
|
|
190
|
+
const nextUpstream = String(startOptions.upstreamHost || '127.0.0.1')
|
|
179
191
|
if (!wanted.length) {
|
|
180
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
running: false,
|
|
194
|
+
hosts: [],
|
|
195
|
+
port: 0,
|
|
196
|
+
upstream: `${nextUpstream}:${port || 0}`,
|
|
197
|
+
failures: [],
|
|
198
|
+
skipped: [...skip],
|
|
199
|
+
}
|
|
181
200
|
}
|
|
182
|
-
if (
|
|
183
|
-
|
|
201
|
+
if (
|
|
202
|
+
listeners.length > 0 && listenPort === port && upstreamHost === nextUpstream
|
|
203
|
+
&& hosts.join(',') === wanted.join(',')
|
|
204
|
+
) {
|
|
205
|
+
return { running: true, hosts: hosts.slice(), port: listenPort, upstream: state().upstream, failures: [] }
|
|
184
206
|
}
|
|
185
207
|
await stop()
|
|
186
208
|
// stop() 已推进代次;此刻记录本轮的代次,后续任何 stop() 都会让它过期
|
|
187
209
|
const mark = generation
|
|
188
210
|
// 先确定转发目标:监听建立期间到达的请求也能正确转发
|
|
189
211
|
targetPort = port
|
|
212
|
+
upstreamHost = nextUpstream
|
|
190
213
|
const opened = []
|
|
191
214
|
const failures = []
|
|
192
215
|
for (const host of wanted) {
|
|
@@ -201,13 +224,19 @@ export function createLanProxy(options = {}) {
|
|
|
201
224
|
for (const item of opened) { try { item.server.close() } catch { /* ignore */ } }
|
|
202
225
|
return state().running
|
|
203
226
|
? { ...state(), failures }
|
|
204
|
-
: { running: false, hosts: [], port: 0, failures }
|
|
227
|
+
: { running: false, hosts: [], port: 0, upstream: `${nextUpstream}:${port || 0}`, failures }
|
|
205
228
|
}
|
|
206
229
|
listeners = opened
|
|
207
230
|
listenPort = opened.length > 0 ? port : 0
|
|
208
231
|
hosts = opened.map((item) => item.host)
|
|
209
232
|
if (!opened.length) log('lan proxy: no listener could bind', failures)
|
|
210
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
running: opened.length > 0,
|
|
235
|
+
hosts: hosts.slice(),
|
|
236
|
+
port: listenPort,
|
|
237
|
+
upstream: `${nextUpstream}:${listenPort || port || 0}`,
|
|
238
|
+
failures,
|
|
239
|
+
}
|
|
211
240
|
}
|
|
212
241
|
|
|
213
242
|
/** 关闭全部监听。 */
|
|
@@ -217,6 +246,7 @@ export function createLanProxy(options = {}) {
|
|
|
217
246
|
listeners = []
|
|
218
247
|
listenPort = 0
|
|
219
248
|
targetPort = 0
|
|
249
|
+
upstreamHost = '127.0.0.1'
|
|
220
250
|
hosts = []
|
|
221
251
|
await Promise.all(closing.map(({ server }) => new Promise((resolve) => {
|
|
222
252
|
try { server.close(() => resolve()) } catch { resolve() }
|
package/lib/password.js
CHANGED
|
@@ -1,245 +1,245 @@
|
|
|
1
|
-
// dsh-mobile-remote — 远程访问密码(本模块,host 侧)。
|
|
2
|
-
//
|
|
3
|
-
// 职责:
|
|
4
|
-
// 1. **远程访问密码的本地安全存储与会话管理**。密码由管理员在本机设置面板
|
|
5
|
-
// 设置(本模块路径 ~/.dsh/plugins/dsh-mobile-remote/config.json)。
|
|
6
|
-
// 存储时**只存 scrypt 哈希 + 随机盐**,绝不落盘明文。
|
|
7
|
-
// 2. **认证会话**:远程访问者输入正确密码后,向浏览器下发一个随机 token,
|
|
8
|
-
// host 侧在内存中维护该 token 的过期时间;后续带有效 token 的请求视为
|
|
9
|
-
// 已认证,无需重复输入。
|
|
10
|
-
//
|
|
11
|
-
// 设计要点(通用 / 可自托管):
|
|
12
|
-
// - 完全本地,**不依赖任何中转后端**(dsh-update-server / frps / frpc)。
|
|
13
|
-
// 任何部署者——包括自建中转服务的用户——都能用本模块给远程访问加密码。
|
|
14
|
-
// - 全部使用 Node 内置 crypto,零第三方依赖。
|
|
15
|
-
// - 校验采用 timingSafeEqual,避免时序攻击;登录做简单频率限制防爆破。
|
|
16
|
-
|
|
17
|
-
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
|
|
18
|
-
import { homedir } from 'node:os'
|
|
19
|
-
import { join } from 'node:path'
|
|
20
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
21
|
-
|
|
22
|
-
/** 本模块配置目录(与 external.js 的 externalDir 同级,放 config.json)。 */
|
|
23
|
-
function configDir() {
|
|
24
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
25
|
-
return join(home, 'plugins', 'dsh-mobile-remote')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 配置文件绝对路径。 */
|
|
29
|
-
function configFilePath() {
|
|
30
|
-
return join(configDir(), 'config.json')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** scrypt 派生参数(成本因子,本机校验,取适中值保证安全与速度平衡)。 */
|
|
34
|
-
const SCRYPT_N = 16384
|
|
35
|
-
const SCRYPT_R = 8
|
|
36
|
-
const SCRYPT_P = 1
|
|
37
|
-
const KEY_LEN = 32
|
|
38
|
-
|
|
39
|
-
/** 会话有效期(毫秒):认证通过后的一段时间内免重复输入。 */
|
|
40
|
-
const SESSION_TTL = 24 * 60 * 60 * 1000 // 24h
|
|
41
|
-
|
|
42
|
-
/** 登录防爆破:同一 IP 连续失败上限与封禁窗口。 */
|
|
43
|
-
const MAX_ATTEMPTS = 8
|
|
44
|
-
const LOCK_WINDOW = 10 * 60 * 1000 // 10 分钟
|
|
45
|
-
|
|
46
|
-
/* ------------------------------------------------------------------ */
|
|
47
|
-
/* 底层读写 */
|
|
48
|
-
/* ------------------------------------------------------------------ */
|
|
49
|
-
|
|
50
|
-
function readRawConfig() {
|
|
51
|
-
try {
|
|
52
|
-
const file = configFilePath()
|
|
53
|
-
if (!existsSync(file)) return {}
|
|
54
|
-
return JSON.parse(readFileSync(file, 'utf8'))
|
|
55
|
-
} catch {
|
|
56
|
-
return {}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function writeRawConfig(obj) {
|
|
61
|
-
mkdirSync(configDir(), { recursive: true })
|
|
62
|
-
writeFileSync(configFilePath(), JSON.stringify(obj, null, 2), 'utf8')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/* ------------------------------------------------------------------ */
|
|
66
|
-
/* 密码设置 / 校验 */
|
|
67
|
-
/* ------------------------------------------------------------------ */
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 推导口令哈希。scrypt(password, salt, KEY_LEN, { N, r, p })。
|
|
71
|
-
* 用 HMAC 风格再叠加一个本地 pepper 常量,避免直接逆向比对(可选增强)。
|
|
72
|
-
*/
|
|
73
|
-
function derive(password, salt) {
|
|
74
|
-
return scryptSync(String(password), Buffer.from(salt, 'hex'), KEY_LEN, {
|
|
75
|
-
N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P,
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** 判断是否已设置远程访问密码。 */
|
|
80
|
-
export function hasPassword() {
|
|
81
|
-
const cfg = readRawConfig()
|
|
82
|
-
return !!(cfg && cfg.pwdHash && cfg.pwdSalt)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 校验口令是否正确。返回 boolean。使用 timingSafeEqual 防时序攻击。
|
|
87
|
-
* @param {string} password
|
|
88
|
-
*/
|
|
89
|
-
export function verifyPassword(password) {
|
|
90
|
-
const cfg = readRawConfig()
|
|
91
|
-
if (!cfg || !cfg.pwdHash || !cfg.pwdSalt) return false
|
|
92
|
-
if (typeof password !== 'string' || !password) return false
|
|
93
|
-
try {
|
|
94
|
-
const expected = Buffer.from(cfg.pwdHash, 'hex')
|
|
95
|
-
const actual = derive(password, cfg.pwdSalt)
|
|
96
|
-
if (expected.length !== actual.length) return false
|
|
97
|
-
return timingSafeEqual(expected, actual)
|
|
98
|
-
} catch {
|
|
99
|
-
return false
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* 设置 / 修改远程访问密码。未传或为空表示清除密码(关闭门禁)。
|
|
105
|
-
* 仅存哈希 + 盐,绝不落盘明文。
|
|
106
|
-
* @param {string|null} password
|
|
107
|
-
*/
|
|
108
|
-
export function setPassword(password) {
|
|
109
|
-
const cfg = readRawConfig()
|
|
110
|
-
if (!password) {
|
|
111
|
-
delete cfg.pwdHash
|
|
112
|
-
delete cfg.pwdSalt
|
|
113
|
-
// 清除密码的同时清空所有已签发会话
|
|
114
|
-
sessions.clear()
|
|
115
|
-
// 兼容旧字段
|
|
116
|
-
delete cfg.pwdSetAt
|
|
117
|
-
writeRawConfig(cfg)
|
|
118
|
-
return { ok: true, enabled: false }
|
|
119
|
-
}
|
|
120
|
-
const salt = randomBytes(16).toString('hex')
|
|
121
|
-
const hash = derive(password, salt).toString('hex')
|
|
122
|
-
cfg.pwdHash = hash
|
|
123
|
-
cfg.pwdSalt = salt
|
|
124
|
-
cfg.pwdSetAt = new Date().toISOString()
|
|
125
|
-
// 修改密码后旧会话全部失效
|
|
126
|
-
sessions.clear()
|
|
127
|
-
writeRawConfig(cfg)
|
|
128
|
-
return { ok: true, enabled: true }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** 是否启用远程访问密码门禁。 */
|
|
132
|
-
export function isEnabled() {
|
|
133
|
-
return hasPassword()
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/* ------------------------------------------------------------------ */
|
|
137
|
-
/* 插件本地配置(与密码共用 config.json,未知字段原样保留) */
|
|
138
|
-
/* ------------------------------------------------------------------ */
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* 读取插件配置里的一个值(不存在返回 fallback)。
|
|
142
|
-
* @param {string} key
|
|
143
|
-
* @param {*} [fallback]
|
|
144
|
-
*/
|
|
145
|
-
export function getSetting(key, fallback = null) {
|
|
146
|
-
const cfg = readRawConfig()
|
|
147
|
-
return Object.prototype.hasOwnProperty.call(cfg, key) ? cfg[key] : fallback
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* 写入插件配置里的一个值(保留其它字段,例如密码哈希)。
|
|
152
|
-
* @param {string} key
|
|
153
|
-
* @param {*} value
|
|
154
|
-
*/
|
|
155
|
-
export function setSetting(key, value) {
|
|
156
|
-
const cfg = readRawConfig()
|
|
157
|
-
if (value === undefined) delete cfg[key]
|
|
158
|
-
else cfg[key] = value
|
|
159
|
-
writeRawConfig(cfg)
|
|
160
|
-
return value
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/* ------------------------------------------------------------------ */
|
|
164
|
-
/* 认证会话(内存) */
|
|
165
|
-
/* ------------------------------------------------------------------ */
|
|
166
|
-
|
|
167
|
-
const sessions = new Map() // token -> expiry(ms)
|
|
168
|
-
|
|
169
|
-
/** 清掉所有会话(改密 / 重启时调用)。 */
|
|
170
|
-
export function clearSessions() {
|
|
171
|
-
sessions.clear()
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** 签发一个会话 token。 */
|
|
175
|
-
function issueToken() {
|
|
176
|
-
return randomBytes(24).toString('base64url')
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* 校验口令并签发会话 token(若需要)。成功返回 { ok:true, token };
|
|
181
|
-
* 失败返回 { ok:false, error }。
|
|
182
|
-
* @param {string} password
|
|
183
|
-
* @param {string} [clientIp] 用于防爆破计数
|
|
184
|
-
*/
|
|
185
|
-
export function login(password, clientIp) {
|
|
186
|
-
const ip = clientIp || 'unknown'
|
|
187
|
-
if (!hasPassword()) {
|
|
188
|
-
return { ok: false, error: '远程访问密码尚未设置' }
|
|
189
|
-
}
|
|
190
|
-
if (isLocked(ip)) {
|
|
191
|
-
return { ok: false, error: '尝试次数过多,请稍后再试' }
|
|
192
|
-
}
|
|
193
|
-
if (!verifyPassword(password)) {
|
|
194
|
-
registerFailure(ip)
|
|
195
|
-
return { ok: false, error: '密码错误' }
|
|
196
|
-
}
|
|
197
|
-
clearFailures(ip)
|
|
198
|
-
const token = issueToken()
|
|
199
|
-
sessions.set(token, Date.now() + SESSION_TTL)
|
|
200
|
-
return { ok: true, token }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** 校验一个会话 token 是否有效(未过期)。 */
|
|
204
|
-
export function verifyToken(token) {
|
|
205
|
-
if (!token || typeof token !== 'string') return false
|
|
206
|
-
const exp = sessions.get(token)
|
|
207
|
-
if (!exp) return false
|
|
208
|
-
if (Date.now() > exp) {
|
|
209
|
-
sessions.delete(token)
|
|
210
|
-
return false
|
|
211
|
-
}
|
|
212
|
-
return true
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** 移除一个会话 token(登出)。 */
|
|
216
|
-
export function revokeToken(token) {
|
|
217
|
-
if (token) sessions.delete(token)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/* ------------------------------------------------------------------ */
|
|
221
|
-
/* 防爆破(按 IP) */
|
|
222
|
-
/* ------------------------------------------------------------------ */
|
|
223
|
-
|
|
224
|
-
const attempts = new Map() // ip -> { count, lockUntil }
|
|
225
|
-
|
|
226
|
-
function isLocked(ip) {
|
|
227
|
-
const a = attempts.get(ip)
|
|
228
|
-
return a ? (a.lockUntil && Date.now() < a.lockUntil) : false
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function registerFailure(ip) {
|
|
232
|
-
const now = Date.now()
|
|
233
|
-
const prev = attempts.get(ip)
|
|
234
|
-
let count = prev ? prev.count : 0
|
|
235
|
-
count += 1
|
|
236
|
-
if (count >= MAX_ATTEMPTS) {
|
|
237
|
-
attempts.set(ip, { count, lockUntil: now + LOCK_WINDOW })
|
|
238
|
-
} else {
|
|
239
|
-
attempts.set(ip, { count })
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function clearFailures(ip) {
|
|
244
|
-
attempts.delete(ip)
|
|
245
|
-
}
|
|
1
|
+
// dsh-mobile-remote — 远程访问密码(本模块,host 侧)。
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// 1. **远程访问密码的本地安全存储与会话管理**。密码由管理员在本机设置面板
|
|
5
|
+
// 设置(本模块路径 ~/.dsh/plugins/dsh-mobile-remote/config.json)。
|
|
6
|
+
// 存储时**只存 scrypt 哈希 + 随机盐**,绝不落盘明文。
|
|
7
|
+
// 2. **认证会话**:远程访问者输入正确密码后,向浏览器下发一个随机 token,
|
|
8
|
+
// host 侧在内存中维护该 token 的过期时间;后续带有效 token 的请求视为
|
|
9
|
+
// 已认证,无需重复输入。
|
|
10
|
+
//
|
|
11
|
+
// 设计要点(通用 / 可自托管):
|
|
12
|
+
// - 完全本地,**不依赖任何中转后端**(dsh-update-server / frps / frpc)。
|
|
13
|
+
// 任何部署者——包括自建中转服务的用户——都能用本模块给远程访问加密码。
|
|
14
|
+
// - 全部使用 Node 内置 crypto,零第三方依赖。
|
|
15
|
+
// - 校验采用 timingSafeEqual,避免时序攻击;登录做简单频率限制防爆破。
|
|
16
|
+
|
|
17
|
+
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
/** 本模块配置目录(与 external.js 的 externalDir 同级,放 config.json)。 */
|
|
23
|
+
function configDir() {
|
|
24
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
25
|
+
return join(home, 'plugins', 'dsh-mobile-remote')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 配置文件绝对路径。 */
|
|
29
|
+
function configFilePath() {
|
|
30
|
+
return join(configDir(), 'config.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** scrypt 派生参数(成本因子,本机校验,取适中值保证安全与速度平衡)。 */
|
|
34
|
+
const SCRYPT_N = 16384
|
|
35
|
+
const SCRYPT_R = 8
|
|
36
|
+
const SCRYPT_P = 1
|
|
37
|
+
const KEY_LEN = 32
|
|
38
|
+
|
|
39
|
+
/** 会话有效期(毫秒):认证通过后的一段时间内免重复输入。 */
|
|
40
|
+
const SESSION_TTL = 24 * 60 * 60 * 1000 // 24h
|
|
41
|
+
|
|
42
|
+
/** 登录防爆破:同一 IP 连续失败上限与封禁窗口。 */
|
|
43
|
+
const MAX_ATTEMPTS = 8
|
|
44
|
+
const LOCK_WINDOW = 10 * 60 * 1000 // 10 分钟
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ */
|
|
47
|
+
/* 底层读写 */
|
|
48
|
+
/* ------------------------------------------------------------------ */
|
|
49
|
+
|
|
50
|
+
function readRawConfig() {
|
|
51
|
+
try {
|
|
52
|
+
const file = configFilePath()
|
|
53
|
+
if (!existsSync(file)) return {}
|
|
54
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
55
|
+
} catch {
|
|
56
|
+
return {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeRawConfig(obj) {
|
|
61
|
+
mkdirSync(configDir(), { recursive: true })
|
|
62
|
+
writeFileSync(configFilePath(), JSON.stringify(obj, null, 2), 'utf8')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ------------------------------------------------------------------ */
|
|
66
|
+
/* 密码设置 / 校验 */
|
|
67
|
+
/* ------------------------------------------------------------------ */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 推导口令哈希。scrypt(password, salt, KEY_LEN, { N, r, p })。
|
|
71
|
+
* 用 HMAC 风格再叠加一个本地 pepper 常量,避免直接逆向比对(可选增强)。
|
|
72
|
+
*/
|
|
73
|
+
function derive(password, salt) {
|
|
74
|
+
return scryptSync(String(password), Buffer.from(salt, 'hex'), KEY_LEN, {
|
|
75
|
+
N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 判断是否已设置远程访问密码。 */
|
|
80
|
+
export function hasPassword() {
|
|
81
|
+
const cfg = readRawConfig()
|
|
82
|
+
return !!(cfg && cfg.pwdHash && cfg.pwdSalt)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 校验口令是否正确。返回 boolean。使用 timingSafeEqual 防时序攻击。
|
|
87
|
+
* @param {string} password
|
|
88
|
+
*/
|
|
89
|
+
export function verifyPassword(password) {
|
|
90
|
+
const cfg = readRawConfig()
|
|
91
|
+
if (!cfg || !cfg.pwdHash || !cfg.pwdSalt) return false
|
|
92
|
+
if (typeof password !== 'string' || !password) return false
|
|
93
|
+
try {
|
|
94
|
+
const expected = Buffer.from(cfg.pwdHash, 'hex')
|
|
95
|
+
const actual = derive(password, cfg.pwdSalt)
|
|
96
|
+
if (expected.length !== actual.length) return false
|
|
97
|
+
return timingSafeEqual(expected, actual)
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 设置 / 修改远程访问密码。未传或为空表示清除密码(关闭门禁)。
|
|
105
|
+
* 仅存哈希 + 盐,绝不落盘明文。
|
|
106
|
+
* @param {string|null} password
|
|
107
|
+
*/
|
|
108
|
+
export function setPassword(password) {
|
|
109
|
+
const cfg = readRawConfig()
|
|
110
|
+
if (!password) {
|
|
111
|
+
delete cfg.pwdHash
|
|
112
|
+
delete cfg.pwdSalt
|
|
113
|
+
// 清除密码的同时清空所有已签发会话
|
|
114
|
+
sessions.clear()
|
|
115
|
+
// 兼容旧字段
|
|
116
|
+
delete cfg.pwdSetAt
|
|
117
|
+
writeRawConfig(cfg)
|
|
118
|
+
return { ok: true, enabled: false }
|
|
119
|
+
}
|
|
120
|
+
const salt = randomBytes(16).toString('hex')
|
|
121
|
+
const hash = derive(password, salt).toString('hex')
|
|
122
|
+
cfg.pwdHash = hash
|
|
123
|
+
cfg.pwdSalt = salt
|
|
124
|
+
cfg.pwdSetAt = new Date().toISOString()
|
|
125
|
+
// 修改密码后旧会话全部失效
|
|
126
|
+
sessions.clear()
|
|
127
|
+
writeRawConfig(cfg)
|
|
128
|
+
return { ok: true, enabled: true }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 是否启用远程访问密码门禁。 */
|
|
132
|
+
export function isEnabled() {
|
|
133
|
+
return hasPassword()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
/* 插件本地配置(与密码共用 config.json,未知字段原样保留) */
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 读取插件配置里的一个值(不存在返回 fallback)。
|
|
142
|
+
* @param {string} key
|
|
143
|
+
* @param {*} [fallback]
|
|
144
|
+
*/
|
|
145
|
+
export function getSetting(key, fallback = null) {
|
|
146
|
+
const cfg = readRawConfig()
|
|
147
|
+
return Object.prototype.hasOwnProperty.call(cfg, key) ? cfg[key] : fallback
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 写入插件配置里的一个值(保留其它字段,例如密码哈希)。
|
|
152
|
+
* @param {string} key
|
|
153
|
+
* @param {*} value
|
|
154
|
+
*/
|
|
155
|
+
export function setSetting(key, value) {
|
|
156
|
+
const cfg = readRawConfig()
|
|
157
|
+
if (value === undefined) delete cfg[key]
|
|
158
|
+
else cfg[key] = value
|
|
159
|
+
writeRawConfig(cfg)
|
|
160
|
+
return value
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ */
|
|
164
|
+
/* 认证会话(内存) */
|
|
165
|
+
/* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
const sessions = new Map() // token -> expiry(ms)
|
|
168
|
+
|
|
169
|
+
/** 清掉所有会话(改密 / 重启时调用)。 */
|
|
170
|
+
export function clearSessions() {
|
|
171
|
+
sessions.clear()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 签发一个会话 token。 */
|
|
175
|
+
function issueToken() {
|
|
176
|
+
return randomBytes(24).toString('base64url')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 校验口令并签发会话 token(若需要)。成功返回 { ok:true, token };
|
|
181
|
+
* 失败返回 { ok:false, error }。
|
|
182
|
+
* @param {string} password
|
|
183
|
+
* @param {string} [clientIp] 用于防爆破计数
|
|
184
|
+
*/
|
|
185
|
+
export function login(password, clientIp) {
|
|
186
|
+
const ip = clientIp || 'unknown'
|
|
187
|
+
if (!hasPassword()) {
|
|
188
|
+
return { ok: false, error: '远程访问密码尚未设置' }
|
|
189
|
+
}
|
|
190
|
+
if (isLocked(ip)) {
|
|
191
|
+
return { ok: false, error: '尝试次数过多,请稍后再试' }
|
|
192
|
+
}
|
|
193
|
+
if (!verifyPassword(password)) {
|
|
194
|
+
registerFailure(ip)
|
|
195
|
+
return { ok: false, error: '密码错误' }
|
|
196
|
+
}
|
|
197
|
+
clearFailures(ip)
|
|
198
|
+
const token = issueToken()
|
|
199
|
+
sessions.set(token, Date.now() + SESSION_TTL)
|
|
200
|
+
return { ok: true, token }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 校验一个会话 token 是否有效(未过期)。 */
|
|
204
|
+
export function verifyToken(token) {
|
|
205
|
+
if (!token || typeof token !== 'string') return false
|
|
206
|
+
const exp = sessions.get(token)
|
|
207
|
+
if (!exp) return false
|
|
208
|
+
if (Date.now() > exp) {
|
|
209
|
+
sessions.delete(token)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 移除一个会话 token(登出)。 */
|
|
216
|
+
export function revokeToken(token) {
|
|
217
|
+
if (token) sessions.delete(token)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ------------------------------------------------------------------ */
|
|
221
|
+
/* 防爆破(按 IP) */
|
|
222
|
+
/* ------------------------------------------------------------------ */
|
|
223
|
+
|
|
224
|
+
const attempts = new Map() // ip -> { count, lockUntil }
|
|
225
|
+
|
|
226
|
+
function isLocked(ip) {
|
|
227
|
+
const a = attempts.get(ip)
|
|
228
|
+
return a ? (a.lockUntil && Date.now() < a.lockUntil) : false
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function registerFailure(ip) {
|
|
232
|
+
const now = Date.now()
|
|
233
|
+
const prev = attempts.get(ip)
|
|
234
|
+
let count = prev ? prev.count : 0
|
|
235
|
+
count += 1
|
|
236
|
+
if (count >= MAX_ATTEMPTS) {
|
|
237
|
+
attempts.set(ip, { count, lockUntil: now + LOCK_WINDOW })
|
|
238
|
+
} else {
|
|
239
|
+
attempts.set(ip, { count })
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function clearFailures(ip) {
|
|
244
|
+
attempts.delete(ip)
|
|
245
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feiyang666/dsh-mobile-remote",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "DeepSeek Harness
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "DeepSeek Harness remote-access plugin: LAN access that adapts to how dsh binds (loopback side-car listener, or directly reusing the `--host <nic>` listener that dsh already owns), external tunnel through a relay server with real reachability probing (502 detection), QR code / on-off toggle / remote-access password gate and device telemetry. No mobile UI patches: dsh 0.2.2+ ships its own responsive/mobile layout.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
7
7
|
"harness",
|