@feiyang666/dsh-mobile-remote 1.4.1 → 1.4.2
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 +10 -0
- package/README.md +3 -2
- package/README.zh.md +3 -2
- package/RELEASE_NOTES.md +12 -26
- package/lib/client.js +978 -976
- package/lib/external.js +729 -729
- package/lib/index.js +110 -0
- package/lib/password.js +218 -218
- package/lib/qrcode.js +2300 -2300
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1094,6 +1094,91 @@ html[data-dsh-mobile] input[type='text'],
|
|
|
1094
1094
|
html[data-dsh-mobile] input[type='search'] {
|
|
1095
1095
|
font-size: 16px !important;
|
|
1096
1096
|
}
|
|
1097
|
+
|
|
1098
|
+
/* ----- 移动端 Tooltip 抑制 -----
|
|
1099
|
+
* DSH 原生 Tooltip 气泡由 hover / focus 触发(<span role="tooltip">)。
|
|
1100
|
+
* 触屏设备上没有 hover,点击按钮后焦点停留在按钮上,气泡会一直显示在屏幕上
|
|
1101
|
+
* (点击空白 / 滚动也不会消失)——即“停止 / 开始 / 关闭菜单栏等按钮的提示文字
|
|
1102
|
+
* 常驻屏幕”问题。移动端直接隐藏气泡,按钮仍保留 aria-label 无障碍名。
|
|
1103
|
+
*/
|
|
1104
|
+
html[data-dsh-mobile] [role='tooltip'] {
|
|
1105
|
+
display: none !important;
|
|
1106
|
+
visibility: hidden !important;
|
|
1107
|
+
opacity: 0 !important;
|
|
1108
|
+
pointer-events: none !important;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/* ----- 移动端设置页适配 -----
|
|
1112
|
+
* 原生设置弹窗是 800px 双栏布局(188px 导航栏 + 内容列),手机上内容被挤得
|
|
1113
|
+
* 几乎没有空间。由 MOBILE_JS 的 MutationObserver 把设置弹窗打上
|
|
1114
|
+
* data-dsh-settings 标记(结构:role=dialog 且首个子元素为 <nav>),这里改为
|
|
1115
|
+
* 全屏单栏:导航栏变顶部横向滚动标签,内容区独占剩余空间。
|
|
1116
|
+
* 不依赖 harness 内部哈希类名,harness 升级后仍能直接适配。
|
|
1117
|
+
*/
|
|
1118
|
+
html[data-dsh-mobile] [data-dsh-settings] {
|
|
1119
|
+
width: 100vw !important;
|
|
1120
|
+
max-width: 100vw !important;
|
|
1121
|
+
height: 100vh !important;
|
|
1122
|
+
height: 100dvh !important;
|
|
1123
|
+
max-height: 100vh !important;
|
|
1124
|
+
max-height: 100dvh !important;
|
|
1125
|
+
border-radius: 0 !important;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/* 导航栏:横向标签条,可横向滚动 */
|
|
1129
|
+
html[data-dsh-mobile] [data-dsh-settings] > nav {
|
|
1130
|
+
display: flex !important;
|
|
1131
|
+
flex-direction: row !important;
|
|
1132
|
+
align-items: center !important;
|
|
1133
|
+
width: 100% !important;
|
|
1134
|
+
flex: none !important;
|
|
1135
|
+
padding: 8px 10px !important;
|
|
1136
|
+
gap: 6px !important;
|
|
1137
|
+
overflow-x: auto !important;
|
|
1138
|
+
-webkit-overflow-scrolling: touch;
|
|
1139
|
+
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18)) !important;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/* 导航标题(设置面板的标题文案)在移动端隐藏,省出空间给标签 */
|
|
1143
|
+
html[data-dsh-mobile] [data-dsh-settings] > nav > div:first-child {
|
|
1144
|
+
display: none !important;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/* 导航列表:横向排列、宽度自适应内容 */
|
|
1148
|
+
html[data-dsh-mobile] [data-dsh-settings] > nav > div + div {
|
|
1149
|
+
display: flex !important;
|
|
1150
|
+
flex-direction: row !important;
|
|
1151
|
+
width: max-content !important;
|
|
1152
|
+
gap: 6px !important;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/* 导航项:胶囊标签,加大触控目标 */
|
|
1156
|
+
html[data-dsh-mobile] [data-dsh-settings] nav button {
|
|
1157
|
+
flex: none !important;
|
|
1158
|
+
height: 36px !important;
|
|
1159
|
+
padding: 0 14px !important;
|
|
1160
|
+
gap: 6px !important;
|
|
1161
|
+
border-radius: 999px !important;
|
|
1162
|
+
white-space: nowrap !important;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/* 内容列:占满剩余空间 */
|
|
1166
|
+
html[data-dsh-mobile] [data-dsh-settings] > div {
|
|
1167
|
+
width: 100% !important;
|
|
1168
|
+
flex: 1 1 auto !important;
|
|
1169
|
+
min-width: 0 !important;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/* 顶部 header 的按钮(关闭等)加大触控目标 */
|
|
1173
|
+
html[data-dsh-mobile] [data-dsh-settings] > div > div:first-child button {
|
|
1174
|
+
min-width: 36px !important;
|
|
1175
|
+
min-height: 36px !important;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/* 内容区边距收紧,让 section 内容有更多可用宽度 */
|
|
1179
|
+
html[data-dsh-mobile] [data-dsh-settings] > div > div:last-child {
|
|
1180
|
+
padding: 0 12px 16px !important;
|
|
1181
|
+
}
|
|
1097
1182
|
`
|
|
1098
1183
|
|
|
1099
1184
|
/* ------------------------------------------------------------------ */
|
|
@@ -1190,6 +1275,31 @@ const MOBILE_JS = `
|
|
|
1190
1275
|
|
|
1191
1276
|
document.documentElement.setAttribute('data-dsh-mobile', '');
|
|
1192
1277
|
|
|
1278
|
+
// 标记设置弹窗:原生设置页 = role="dialog" 且首个子元素为 <nav>(双栏弹窗)。
|
|
1279
|
+
// 打上 data-dsh-settings 标记后,上面的移动端 CSS 可精确适配,无需依赖
|
|
1280
|
+
// harness 内部的哈希类名——harness 升级后只要弹窗结构仍是
|
|
1281
|
+
// “dialog 直下 nav + 内容列”,本插件无需修改即可直接适配。
|
|
1282
|
+
function tagSettingsPanel() {
|
|
1283
|
+
var dialogs = document.querySelectorAll('[role="dialog"][aria-modal="true"]');
|
|
1284
|
+
for (var i = 0; i < dialogs.length; i++) {
|
|
1285
|
+
var dlg = dialogs[i];
|
|
1286
|
+
if (dlg.getAttribute('data-dsh-settings') === '1') continue;
|
|
1287
|
+
var kids = dlg.children;
|
|
1288
|
+
var hasNav = false;
|
|
1289
|
+
for (var j = 0; j < kids.length; j++) {
|
|
1290
|
+
if (kids[j].tagName === 'NAV') { hasNav = true; break; }
|
|
1291
|
+
}
|
|
1292
|
+
if (hasNav) dlg.setAttribute('data-dsh-settings', '1');
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
tagSettingsPanel();
|
|
1296
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
1297
|
+
// 设置弹窗是动态挂载/卸载的,用 MutationObserver 监听其出现并打标。
|
|
1298
|
+
var obs = new MutationObserver(function () { tagSettingsPanel(); });
|
|
1299
|
+
try { obs.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {}
|
|
1300
|
+
window.__DSH_MOBILE_REMOTE_SETTINGS_OBSERVER__ = obs;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1193
1303
|
// 调试钩子:在控制台暴露状态,便于移动端远程排查。
|
|
1194
1304
|
window.__DSH_MOBILE_REMOTE_READY__ = {
|
|
1195
1305
|
mobile: true,
|
package/lib/password.js
CHANGED
|
@@ -1,218 +1,218 @@
|
|
|
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
|
-
/* 认证会话(内存) */
|
|
138
|
-
/* ------------------------------------------------------------------ */
|
|
139
|
-
|
|
140
|
-
const sessions = new Map() // token -> expiry(ms)
|
|
141
|
-
|
|
142
|
-
/** 清掉所有会话(改密 / 重启时调用)。 */
|
|
143
|
-
export function clearSessions() {
|
|
144
|
-
sessions.clear()
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** 签发一个会话 token。 */
|
|
148
|
-
function issueToken() {
|
|
149
|
-
return randomBytes(24).toString('base64url')
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* 校验口令并签发会话 token(若需要)。成功返回 { ok:true, token };
|
|
154
|
-
* 失败返回 { ok:false, error }。
|
|
155
|
-
* @param {string} password
|
|
156
|
-
* @param {string} [clientIp] 用于防爆破计数
|
|
157
|
-
*/
|
|
158
|
-
export function login(password, clientIp) {
|
|
159
|
-
const ip = clientIp || 'unknown'
|
|
160
|
-
if (!hasPassword()) {
|
|
161
|
-
return { ok: false, error: '远程访问密码尚未设置' }
|
|
162
|
-
}
|
|
163
|
-
if (isLocked(ip)) {
|
|
164
|
-
return { ok: false, error: '尝试次数过多,请稍后再试' }
|
|
165
|
-
}
|
|
166
|
-
if (!verifyPassword(password)) {
|
|
167
|
-
registerFailure(ip)
|
|
168
|
-
return { ok: false, error: '密码错误' }
|
|
169
|
-
}
|
|
170
|
-
clearFailures(ip)
|
|
171
|
-
const token = issueToken()
|
|
172
|
-
sessions.set(token, Date.now() + SESSION_TTL)
|
|
173
|
-
return { ok: true, token }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** 校验一个会话 token 是否有效(未过期)。 */
|
|
177
|
-
export function verifyToken(token) {
|
|
178
|
-
if (!token || typeof token !== 'string') return false
|
|
179
|
-
const exp = sessions.get(token)
|
|
180
|
-
if (!exp) return false
|
|
181
|
-
if (Date.now() > exp) {
|
|
182
|
-
sessions.delete(token)
|
|
183
|
-
return false
|
|
184
|
-
}
|
|
185
|
-
return true
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** 移除一个会话 token(登出)。 */
|
|
189
|
-
export function revokeToken(token) {
|
|
190
|
-
if (token) sessions.delete(token)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/* ------------------------------------------------------------------ */
|
|
194
|
-
/* 防爆破(按 IP) */
|
|
195
|
-
/* ------------------------------------------------------------------ */
|
|
196
|
-
|
|
197
|
-
const attempts = new Map() // ip -> { count, lockUntil }
|
|
198
|
-
|
|
199
|
-
function isLocked(ip) {
|
|
200
|
-
const a = attempts.get(ip)
|
|
201
|
-
return a ? (a.lockUntil && Date.now() < a.lockUntil) : false
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function registerFailure(ip) {
|
|
205
|
-
const now = Date.now()
|
|
206
|
-
const prev = attempts.get(ip)
|
|
207
|
-
let count = prev ? prev.count : 0
|
|
208
|
-
count += 1
|
|
209
|
-
if (count >= MAX_ATTEMPTS) {
|
|
210
|
-
attempts.set(ip, { count, lockUntil: now + LOCK_WINDOW })
|
|
211
|
-
} else {
|
|
212
|
-
attempts.set(ip, { count })
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function clearFailures(ip) {
|
|
217
|
-
attempts.delete(ip)
|
|
218
|
-
}
|
|
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
|
+
/* 认证会话(内存) */
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
|
|
140
|
+
const sessions = new Map() // token -> expiry(ms)
|
|
141
|
+
|
|
142
|
+
/** 清掉所有会话(改密 / 重启时调用)。 */
|
|
143
|
+
export function clearSessions() {
|
|
144
|
+
sessions.clear()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 签发一个会话 token。 */
|
|
148
|
+
function issueToken() {
|
|
149
|
+
return randomBytes(24).toString('base64url')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 校验口令并签发会话 token(若需要)。成功返回 { ok:true, token };
|
|
154
|
+
* 失败返回 { ok:false, error }。
|
|
155
|
+
* @param {string} password
|
|
156
|
+
* @param {string} [clientIp] 用于防爆破计数
|
|
157
|
+
*/
|
|
158
|
+
export function login(password, clientIp) {
|
|
159
|
+
const ip = clientIp || 'unknown'
|
|
160
|
+
if (!hasPassword()) {
|
|
161
|
+
return { ok: false, error: '远程访问密码尚未设置' }
|
|
162
|
+
}
|
|
163
|
+
if (isLocked(ip)) {
|
|
164
|
+
return { ok: false, error: '尝试次数过多,请稍后再试' }
|
|
165
|
+
}
|
|
166
|
+
if (!verifyPassword(password)) {
|
|
167
|
+
registerFailure(ip)
|
|
168
|
+
return { ok: false, error: '密码错误' }
|
|
169
|
+
}
|
|
170
|
+
clearFailures(ip)
|
|
171
|
+
const token = issueToken()
|
|
172
|
+
sessions.set(token, Date.now() + SESSION_TTL)
|
|
173
|
+
return { ok: true, token }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 校验一个会话 token 是否有效(未过期)。 */
|
|
177
|
+
export function verifyToken(token) {
|
|
178
|
+
if (!token || typeof token !== 'string') return false
|
|
179
|
+
const exp = sessions.get(token)
|
|
180
|
+
if (!exp) return false
|
|
181
|
+
if (Date.now() > exp) {
|
|
182
|
+
sessions.delete(token)
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 移除一个会话 token(登出)。 */
|
|
189
|
+
export function revokeToken(token) {
|
|
190
|
+
if (token) sessions.delete(token)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
/* 防爆破(按 IP) */
|
|
195
|
+
/* ------------------------------------------------------------------ */
|
|
196
|
+
|
|
197
|
+
const attempts = new Map() // ip -> { count, lockUntil }
|
|
198
|
+
|
|
199
|
+
function isLocked(ip) {
|
|
200
|
+
const a = attempts.get(ip)
|
|
201
|
+
return a ? (a.lockUntil && Date.now() < a.lockUntil) : false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function registerFailure(ip) {
|
|
205
|
+
const now = Date.now()
|
|
206
|
+
const prev = attempts.get(ip)
|
|
207
|
+
let count = prev ? prev.count : 0
|
|
208
|
+
count += 1
|
|
209
|
+
if (count >= MAX_ATTEMPTS) {
|
|
210
|
+
attempts.set(ip, { count, lockUntil: now + LOCK_WINDOW })
|
|
211
|
+
} else {
|
|
212
|
+
attempts.set(ip, { count })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function clearFailures(ip) {
|
|
217
|
+
attempts.delete(ip)
|
|
218
|
+
}
|