@qzsy/dsh-bridge 2.18.0 → 3.0.1
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 +15 -1094
- package/LICENSE +21 -21
- package/README.md +73 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +414 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +303 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
package/lib/auth/manager.js
DELETED
|
@@ -1,596 +0,0 @@
|
|
|
1
|
-
// lib/auth/manager.js
|
|
2
|
-
// 远程访问安全认证管理器:密码加盐哈希、Session 管理、免密安全 Token、防暴力破解与全协议鉴权拦截
|
|
3
|
-
|
|
4
|
-
import { randomBytes, pbkdf2 as pbkdf2Callback, timingSafeEqual } from 'node:crypto'
|
|
5
|
-
import { promisify } from 'node:util'
|
|
6
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
-
import { homedir } from 'node:os'
|
|
8
|
-
import { join, dirname } from 'node:path'
|
|
9
|
-
|
|
10
|
-
const pbkdf2Async = promisify(pbkdf2Callback)
|
|
11
|
-
|
|
12
|
-
const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
|
|
13
|
-
const MAX_FAILED_ATTEMPTS = 5
|
|
14
|
-
const LOCKOUT_PERIOD_MS = 60 * 1000 // 连续失败封禁 60 秒
|
|
15
|
-
|
|
16
|
-
// T4.1:PBKDF2-HMAC-SHA256 迭代次数升级到 OWASP 建议值。
|
|
17
|
-
// 新哈希带算法前缀(pbkdf2-sha256$<iterations>$<hex>);裸 hex 视为旧的 10000 次
|
|
18
|
-
// 格式,登录成功后透明重哈希升级(无需用户重设密码)。
|
|
19
|
-
const PBKDF2_ITERATIONS = 600000
|
|
20
|
-
const LEGACY_PBKDF2_ITERATIONS = 10000
|
|
21
|
-
const PBKDF2_KEYLEN = 32
|
|
22
|
-
const PBKDF2_PREFIX = 'pbkdf2-sha256$'
|
|
23
|
-
|
|
24
|
-
// 登录 Session 持久化:宿主(dsh web 进程)重启后,已登录设备免重输访问密码。
|
|
25
|
-
// 落盘文件与 config.json 同目录(~/.dsh/dsh-bridge/sessions.json,权限 600);
|
|
26
|
-
// sessionToken 与 secretToken 同级机密(48 位随机 hex),同目录存储不扩大暴露面。
|
|
27
|
-
// 改密码 / 切模式 / 重新生成 token 时的 sessions.clear() 会同步清空文件(吊销语义保持);
|
|
28
|
-
// 文件缺失或损坏时安全降级为空 Map(等价于旧行为:重新登录一次)。
|
|
29
|
-
// 路径可通过构造参数 sessionsFile 注入(测试传临时目录,避免读写真实环境)。
|
|
30
|
-
const DEFAULT_SESSIONS_FILE = join(
|
|
31
|
-
process.env.DSH_HOME ?? join(homedir(), '.dsh'),
|
|
32
|
-
'dsh-bridge', 'sessions.json'
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
class PersistentSessionMap extends Map {
|
|
36
|
-
/** @param {string} file 会话落盘文件路径 */
|
|
37
|
-
constructor(file) {
|
|
38
|
-
super()
|
|
39
|
-
this._file = file
|
|
40
|
-
}
|
|
41
|
-
_flush() {
|
|
42
|
-
try {
|
|
43
|
-
mkdirSync(dirname(this._file), { recursive: true })
|
|
44
|
-
writeFileSync(this._file, JSON.stringify([...this.entries()]), { mode: 0o600 })
|
|
45
|
-
} catch { /* 落盘失败降级为纯内存语义,不阻断认证流程 */ }
|
|
46
|
-
}
|
|
47
|
-
static load(file) {
|
|
48
|
-
const m = new PersistentSessionMap(file)
|
|
49
|
-
try {
|
|
50
|
-
const arr = JSON.parse(readFileSync(file, 'utf8'))
|
|
51
|
-
if (Array.isArray(arr)) {
|
|
52
|
-
const now = Date.now()
|
|
53
|
-
for (const [k, v] of arr) {
|
|
54
|
-
// 只恢复结构合法且未过期的会话
|
|
55
|
-
if (typeof k === 'string' && v && Number.isFinite(v.expiresAt) && v.expiresAt > now) {
|
|
56
|
-
Map.prototype.set.call(m, k, { createdAt: Number(v.createdAt) || 0, expiresAt: Number(v.expiresAt) })
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
} catch { /* 文件缺失/损坏:降级为空 */ }
|
|
61
|
-
return m
|
|
62
|
-
}
|
|
63
|
-
set(k, v) { super.set(k, v); this._flush(); return this }
|
|
64
|
-
delete(k) { const r = super.delete(k); if (r) this._flush(); return r }
|
|
65
|
-
clear() { super.clear(); this._flush() }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export class AuthManager {
|
|
69
|
-
/**
|
|
70
|
-
* @param {object} opts
|
|
71
|
-
* @param {object} [opts.config]
|
|
72
|
-
* @param {boolean} [opts.config.enabled=false]
|
|
73
|
-
* @param {'token_and_password'|'password_only'|'token_only'} [opts.config.mode='token_and_password']
|
|
74
|
-
* @param {string} [opts.config.passwordHash]
|
|
75
|
-
* @param {string} [opts.config.passwordSalt]
|
|
76
|
-
* @param {string} [opts.config.secretToken]
|
|
77
|
-
* @param {boolean} [opts.config.allowLoopback=true]
|
|
78
|
-
* @param {boolean} [opts.config.adminProtection=true] 管理保护独立开关:关闭后管理操作免 adminToken
|
|
79
|
-
* @param {(patch: object) => Promise<void>|void} [opts.onPersist]
|
|
80
|
-
* @param {string} [opts.sessionsFile] 登录会话持久化文件路径(默认 ~/.dsh/dsh-bridge/sessions.json;测试注入临时目录)
|
|
81
|
-
* @param {object} [opts.logger]
|
|
82
|
-
*/
|
|
83
|
-
constructor({ config = {}, onPersist, sessionsFile, logger = console } = {}) {
|
|
84
|
-
this.logger = logger
|
|
85
|
-
this.onPersist = onPersist
|
|
86
|
-
|
|
87
|
-
this.enabled = Boolean(config.enabled)
|
|
88
|
-
this.mode = config.mode || 'token_and_password'
|
|
89
|
-
this.scope = config.scope || 'all' // 'all' | 'public_only' | 'lan_only'
|
|
90
|
-
this.adminPolicy = config.adminPolicy || 'password_unlock' // 'password_unlock' | 'local_only' | 'open'
|
|
91
|
-
// 管理保护独立于访问认证:默认开启(现有用户保持受保护),关闭后管理操作免 adminToken
|
|
92
|
-
this.adminProtection = config.adminProtection !== false
|
|
93
|
-
this.passwordHash = config.passwordHash || ''
|
|
94
|
-
this.passwordSalt = config.passwordSalt || ''
|
|
95
|
-
this.adminPasswordHash = config.adminPasswordHash || ''
|
|
96
|
-
this.adminPasswordSalt = config.adminPasswordSalt || ''
|
|
97
|
-
this.secretToken = config.secretToken || this._generateToken()
|
|
98
|
-
this.allowLoopback = config.allowLoopback !== false
|
|
99
|
-
// 内部隧道专用鉴权密钥(内存生成,用于辨别本地自建隧道转发流量与真实本机访问)
|
|
100
|
-
this.internalTunnelSecret = randomBytes(24).toString('hex')
|
|
101
|
-
|
|
102
|
-
// Session 存储与重启恢复:sessionToken -> { createdAt, expiresAt }
|
|
103
|
-
// 持久化到 sessions.json(路径可经 sessionsFile 注入),宿主(dsh web)重启后已登录设备免重输访问密码
|
|
104
|
-
this.sessions = PersistentSessionMap.load(sessionsFile ?? DEFAULT_SESSIONS_FILE)
|
|
105
|
-
// 管理员解锁 Session 内存存储:adminToken -> { createdAt, expiresAt }
|
|
106
|
-
this.adminSessions = new Map()
|
|
107
|
-
// 防暴力破解:ip -> { failedCount, lockUntil }
|
|
108
|
-
this.rateLimits = new Map()
|
|
109
|
-
|
|
110
|
-
// 定期清理过期 Session (每小时)
|
|
111
|
-
this._cleanupTimer = setInterval(() => this._cleanupExpired(), 60 * 60 * 1000)
|
|
112
|
-
if (this._cleanupTimer.unref) this._cleanupTimer.unref()
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
_generateToken() {
|
|
116
|
-
return 'dsh_' + randomBytes(18).toString('hex')
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 异步派生:600k 次迭代约数百毫秒,同步实现会卡住整个事件循环
|
|
120
|
-
async _hashPassword(password, salt, iterations = PBKDF2_ITERATIONS) {
|
|
121
|
-
const derived = await pbkdf2Async(String(password), salt, iterations, PBKDF2_KEYLEN, 'sha256')
|
|
122
|
-
return `${PBKDF2_PREFIX}${iterations}$${derived.toString('hex')}`
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// 校验密码与存储哈希;兼容裸 hex 旧格式(10000 次)。返回 legacy 标记供透明升级。
|
|
126
|
-
async _verifyHash(inputPassword, storedHash, storedSalt) {
|
|
127
|
-
if (!storedHash || !storedSalt) return { ok: false, legacy: false }
|
|
128
|
-
let iterations = LEGACY_PBKDF2_ITERATIONS
|
|
129
|
-
let expected = storedHash
|
|
130
|
-
if (storedHash.startsWith(PBKDF2_PREFIX)) {
|
|
131
|
-
const rest = storedHash.slice(PBKDF2_PREFIX.length)
|
|
132
|
-
const sep = rest.indexOf('$')
|
|
133
|
-
if (sep === -1) return { ok: false, legacy: false }
|
|
134
|
-
iterations = Number(rest.slice(0, sep))
|
|
135
|
-
expected = rest.slice(sep + 1)
|
|
136
|
-
if (!Number.isFinite(iterations) || iterations < 1) return { ok: false, legacy: false }
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
const derived = await pbkdf2Async(String(inputPassword), storedSalt, iterations, PBKDF2_KEYLEN, 'sha256')
|
|
140
|
-
const expectedBuf = Buffer.from(expected, 'hex')
|
|
141
|
-
const ok = derived.length === expectedBuf.length && timingSafeEqual(derived, expectedBuf)
|
|
142
|
-
return { ok, legacy: ok && !storedHash.startsWith(PBKDF2_PREFIX) }
|
|
143
|
-
} catch {
|
|
144
|
-
return { ok: false, legacy: false }
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 旧格式哈希在登录成功后透明升级到当前格式(新盐 + 新迭代数)并持久化
|
|
149
|
-
async _upgradeHash(kind, password) {
|
|
150
|
-
const salt = randomBytes(16).toString('hex')
|
|
151
|
-
const hash = await this._hashPassword(password, salt)
|
|
152
|
-
if (kind === 'admin') {
|
|
153
|
-
this.adminPasswordHash = hash
|
|
154
|
-
this.adminPasswordSalt = salt
|
|
155
|
-
} else {
|
|
156
|
-
this.passwordHash = hash
|
|
157
|
-
this.passwordSalt = salt
|
|
158
|
-
}
|
|
159
|
-
await this._persist()
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
get hasPassword() {
|
|
163
|
-
return Boolean(this.passwordHash && this.passwordSalt)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
get hasAdminPassword() {
|
|
167
|
-
return Boolean(this.adminPasswordHash && this.adminPasswordSalt)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* 获取安全认证状态(默认脱敏保护,防止普通接口泄露完整 Secret Token)
|
|
172
|
-
*/
|
|
173
|
-
getStatus({ masked = true } = {}) {
|
|
174
|
-
let token = this.secretToken;
|
|
175
|
-
if (masked && token) {
|
|
176
|
-
token = `${token.slice(0, 8)}****************`;
|
|
177
|
-
}
|
|
178
|
-
return {
|
|
179
|
-
enabled: this.enabled,
|
|
180
|
-
mode: this.mode,
|
|
181
|
-
scope: this.scope,
|
|
182
|
-
adminPolicy: this.adminPolicy,
|
|
183
|
-
adminProtection: this.adminProtection,
|
|
184
|
-
hasPassword: this.hasPassword,
|
|
185
|
-
hasAdminPassword: this.hasAdminPassword,
|
|
186
|
-
secretToken: token,
|
|
187
|
-
allowLoopback: this.allowLoopback,
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* 仅限经过鉴权的管理员会话或公开策略下获取原始未脱敏 Secret Token
|
|
193
|
-
*/
|
|
194
|
-
getRawSecretToken(adminToken) {
|
|
195
|
-
if (this.adminPolicy !== 'open') {
|
|
196
|
-
const hasAnyPassword = this.hasAdminPassword || this.hasPassword
|
|
197
|
-
if (hasAnyPassword && (!adminToken || !this.validateAdminSession(adminToken))) {
|
|
198
|
-
return null
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return this.secretToken
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* 公开状态查询(供未认证访客/登录页使用,严格不包含 secretToken)
|
|
206
|
-
*/
|
|
207
|
-
getPublicStatus() {
|
|
208
|
-
return {
|
|
209
|
-
enabled: this.enabled,
|
|
210
|
-
mode: this.mode,
|
|
211
|
-
scope: this.scope,
|
|
212
|
-
adminPolicy: this.adminPolicy,
|
|
213
|
-
adminProtection: this.adminProtection,
|
|
214
|
-
hasPassword: this.hasPassword,
|
|
215
|
-
hasAdminPassword: this.hasAdminPassword,
|
|
216
|
-
allowLoopback: this.allowLoopback,
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async setEnabled(enabled) {
|
|
221
|
-
this.enabled = Boolean(enabled)
|
|
222
|
-
this.sessions.clear()
|
|
223
|
-
this.adminSessions.clear()
|
|
224
|
-
await this._persist()
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** 管理保护独立开关:关闭后管理操作免 adminToken(与访问认证解耦) */
|
|
228
|
-
async setAdminProtection(protection) {
|
|
229
|
-
this.adminProtection = protection !== false
|
|
230
|
-
if (!this.adminProtection) this.adminSessions.clear()
|
|
231
|
-
await this._persist()
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async setMode(mode) {
|
|
235
|
-
if (['token_and_password', 'password_only', 'token_only'].includes(mode)) {
|
|
236
|
-
this.mode = mode
|
|
237
|
-
// 切换认证模式时清空所有现有 Session,确保新策略立即对所有设备生效
|
|
238
|
-
this.sessions.clear()
|
|
239
|
-
this.adminSessions.clear()
|
|
240
|
-
await this._persist()
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async setScope(scope) {
|
|
245
|
-
if (['all', 'public_only', 'lan_only'].includes(scope)) {
|
|
246
|
-
this.scope = scope
|
|
247
|
-
this.sessions.clear()
|
|
248
|
-
this.adminSessions.clear()
|
|
249
|
-
await this._persist()
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async setAdminPolicy(policy) {
|
|
254
|
-
if (['password_unlock', 'local_only', 'open'].includes(policy)) {
|
|
255
|
-
this.adminPolicy = policy
|
|
256
|
-
this.adminSessions.clear()
|
|
257
|
-
await this._persist()
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// 设置外部访客访问密码
|
|
262
|
-
async setPassword(password) {
|
|
263
|
-
if (!password) {
|
|
264
|
-
this.passwordHash = ''
|
|
265
|
-
this.passwordSalt = ''
|
|
266
|
-
} else {
|
|
267
|
-
const salt = randomBytes(16).toString('hex')
|
|
268
|
-
this.passwordHash = await this._hashPassword(password, salt)
|
|
269
|
-
this.passwordSalt = salt
|
|
270
|
-
}
|
|
271
|
-
// 更改访问密码时使所有普通访客 Session 失效
|
|
272
|
-
this.sessions.clear()
|
|
273
|
-
await this._persist()
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// 设置后台管理员密码
|
|
277
|
-
async setAdminPassword(password) {
|
|
278
|
-
if (!password) {
|
|
279
|
-
this.adminPasswordHash = ''
|
|
280
|
-
this.adminPasswordSalt = ''
|
|
281
|
-
} else {
|
|
282
|
-
const salt = randomBytes(16).toString('hex')
|
|
283
|
-
this.adminPasswordHash = await this._hashPassword(password, salt)
|
|
284
|
-
this.adminPasswordSalt = salt
|
|
285
|
-
}
|
|
286
|
-
// 更改管理密码时使所有远程管理员解锁 Session 失效
|
|
287
|
-
this.adminSessions.clear()
|
|
288
|
-
await this._persist()
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async regenerateSecretToken() {
|
|
292
|
-
this.secretToken = this._generateToken()
|
|
293
|
-
this.sessions.clear()
|
|
294
|
-
await this._persist()
|
|
295
|
-
return this.secretToken
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// 管理员解锁控制
|
|
299
|
-
createAdminSession(maxAgeMs = 30 * 60 * 1000) {
|
|
300
|
-
const adminToken = randomBytes(24).toString('hex')
|
|
301
|
-
const now = Date.now()
|
|
302
|
-
this.adminSessions.set(adminToken, {
|
|
303
|
-
createdAt: now,
|
|
304
|
-
expiresAt: now + maxAgeMs,
|
|
305
|
-
})
|
|
306
|
-
return adminToken
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
validateAdminSession(adminToken) {
|
|
310
|
-
if (!adminToken) return false
|
|
311
|
-
const sess = this.adminSessions.get(adminToken)
|
|
312
|
-
if (!sess) return false
|
|
313
|
-
if (Date.now() > sess.expiresAt) {
|
|
314
|
-
this.adminSessions.delete(adminToken)
|
|
315
|
-
return false
|
|
316
|
-
}
|
|
317
|
-
return true
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
revokeAdminSession(adminToken) {
|
|
321
|
-
if (adminToken) this.adminSessions.delete(adminToken)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
async verifyAdminPassword(inputPassword, clientIp = '') {
|
|
325
|
-
if (this.isIpBlocked(clientIp)) {
|
|
326
|
-
return { success: false, error: '尝试次数过多,请稍后再试' }
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const targetHash = this.adminPasswordHash || this.passwordHash
|
|
330
|
-
const targetSalt = this.adminPasswordSalt || this.passwordSalt
|
|
331
|
-
|
|
332
|
-
if (!targetHash || !targetSalt) {
|
|
333
|
-
return { success: true }
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const check = await this._verifyHash(inputPassword, targetHash, targetSalt)
|
|
337
|
-
if (check.ok) {
|
|
338
|
-
// 旧格式透明升级:管理位为空时实际校验的是访客密码,升级落在对应字段
|
|
339
|
-
if (check.legacy) {
|
|
340
|
-
await this._upgradeHash(this.adminPasswordHash ? 'admin' : 'password', inputPassword)
|
|
341
|
-
}
|
|
342
|
-
this.recordSuccess(clientIp)
|
|
343
|
-
return { success: true }
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
this.recordFailedAttempt(clientIp)
|
|
347
|
-
return { success: false, error: '管理员密码错误' }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
async unlockAdmin(password, clientIp = '') {
|
|
351
|
-
if (this.adminPolicy === 'local_only') {
|
|
352
|
-
return { ok: false, error: '当前已配置为仅限电脑本机管理设置' }
|
|
353
|
-
}
|
|
354
|
-
// RPC 层拿不到真实 clientIp(代理以回环转发),因此用独立的全局失败计数防爆破:
|
|
355
|
-
// 攻击者 5 次失败即锁 60s,暴力破解不可行;代价是攻击者可短时干扰解锁(可接受的权衡)
|
|
356
|
-
if (this._adminUnlockLockUntil && Date.now() < this._adminUnlockLockUntil) {
|
|
357
|
-
return { ok: false, error: '尝试次数过多,请 60 秒后再试' }
|
|
358
|
-
}
|
|
359
|
-
const hasAnyPassword = this.hasAdminPassword || this.hasPassword
|
|
360
|
-
if (!hasAnyPassword) {
|
|
361
|
-
const adminToken = this.createAdminSession()
|
|
362
|
-
return { ok: true, adminToken }
|
|
363
|
-
}
|
|
364
|
-
const verify = await this.verifyAdminPassword(password, clientIp)
|
|
365
|
-
if (verify.success) {
|
|
366
|
-
this._adminUnlockFailures = 0
|
|
367
|
-
const adminToken = this.createAdminSession()
|
|
368
|
-
return { ok: true, adminToken }
|
|
369
|
-
}
|
|
370
|
-
this._adminUnlockFailures = (this._adminUnlockFailures || 0) + 1
|
|
371
|
-
if (this._adminUnlockFailures >= MAX_FAILED_ATTEMPTS) {
|
|
372
|
-
this._adminUnlockLockUntil = Date.now() + LOCKOUT_PERIOD_MS
|
|
373
|
-
this._adminUnlockFailures = 0
|
|
374
|
-
this.logger.warn?.('[dsh-bridge auth] admin unlock locked out for 60s due to repeated failed attempts')
|
|
375
|
-
}
|
|
376
|
-
return { ok: false, error: verify.error || '管理员密码错误' }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async _persist() {
|
|
380
|
-
if (typeof this.onPersist === 'function') {
|
|
381
|
-
try {
|
|
382
|
-
await this.onPersist({
|
|
383
|
-
enabled: this.enabled,
|
|
384
|
-
mode: this.mode,
|
|
385
|
-
scope: this.scope,
|
|
386
|
-
adminPolicy: this.adminPolicy,
|
|
387
|
-
adminProtection: this.adminProtection,
|
|
388
|
-
passwordHash: this.passwordHash,
|
|
389
|
-
passwordSalt: this.passwordSalt,
|
|
390
|
-
adminPasswordHash: this.adminPasswordHash,
|
|
391
|
-
adminPasswordSalt: this.adminPasswordSalt,
|
|
392
|
-
secretToken: this.secretToken,
|
|
393
|
-
allowLoopback: this.allowLoopback,
|
|
394
|
-
})
|
|
395
|
-
} catch (err) {
|
|
396
|
-
this.logger.error?.('[dsh-bridge auth] persist failed:', err?.message ?? err)
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ---- 认证校验逻辑 ----
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* 校验客户端密码
|
|
405
|
-
*/
|
|
406
|
-
async verifyPassword(inputPassword, clientIp = '') {
|
|
407
|
-
if (this.isIpBlocked(clientIp)) {
|
|
408
|
-
return { success: false, error: '尝试次数过多,请稍后再试' }
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// 严禁 token_only 模式下通过密码登录接口获取 Session
|
|
412
|
-
if (this.mode === 'token_only') {
|
|
413
|
-
return { success: false, error: '当前仅允许专属安全 Token 扫码访问,不支持密码登录' }
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (!this.hasPassword) {
|
|
417
|
-
// 管理员尚未设置任何访问密码:无哈希可校验,放行(与 token_and_password 一致)。
|
|
418
|
-
// 历史实现曾在此拒绝 password_only 模式的空白登录,但那会形成死锁:
|
|
419
|
-
// 无密码 → 登录墙永远 401 → 进不去面板 → 看不到设密码表单 → 永远无法设密码。
|
|
420
|
-
// 未设密码 = 没有受保护秘密,应先让管理员进入完成密码初始化;
|
|
421
|
-
// 设置密码后,墙才会真正生效(此时任意/错误密码均被拒绝)。
|
|
422
|
-
return { success: true }
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const check = await this._verifyHash(inputPassword, this.passwordHash, this.passwordSalt)
|
|
426
|
-
if (check.ok) {
|
|
427
|
-
// 旧格式(裸 hex,10000 次)透明升级到当前格式
|
|
428
|
-
if (check.legacy) {
|
|
429
|
-
await this._upgradeHash('password', inputPassword)
|
|
430
|
-
}
|
|
431
|
-
this.recordSuccess(clientIp)
|
|
432
|
-
return { success: true }
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
this.recordFailedAttempt(clientIp)
|
|
436
|
-
return { success: false, error: '访问密码错误' }
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* 校验安全 Token
|
|
441
|
-
*/
|
|
442
|
-
validateSecretToken(token) {
|
|
443
|
-
if (!token || !this.secretToken) return false
|
|
444
|
-
const bufA = Buffer.from(String(token))
|
|
445
|
-
const bufB = Buffer.from(String(this.secretToken))
|
|
446
|
-
if (bufA.length !== bufB.length) return false
|
|
447
|
-
return timingSafeEqual(bufA, bufB)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* 创建 Session
|
|
452
|
-
*/
|
|
453
|
-
createSession(maxAgeMs = DEFAULT_SESSION_MAX_AGE_MS) {
|
|
454
|
-
const sessionToken = randomBytes(24).toString('hex')
|
|
455
|
-
const now = Date.now()
|
|
456
|
-
this.sessions.set(sessionToken, {
|
|
457
|
-
createdAt: now,
|
|
458
|
-
expiresAt: now + maxAgeMs,
|
|
459
|
-
})
|
|
460
|
-
return sessionToken
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* 校验 Session
|
|
465
|
-
*/
|
|
466
|
-
validateSession(sessionToken) {
|
|
467
|
-
if (!sessionToken) return false
|
|
468
|
-
const sess = this.sessions.get(sessionToken)
|
|
469
|
-
if (!sess) return false
|
|
470
|
-
if (Date.now() > sess.expiresAt) {
|
|
471
|
-
this.sessions.delete(sessionToken)
|
|
472
|
-
return false
|
|
473
|
-
}
|
|
474
|
-
return true
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* 销毁 Session
|
|
479
|
-
*/
|
|
480
|
-
revokeSession(sessionToken) {
|
|
481
|
-
if (sessionToken) this.sessions.delete(sessionToken)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ---- 防暴力破解 ----
|
|
485
|
-
|
|
486
|
-
isIpBlocked(ip) {
|
|
487
|
-
if (!ip) return false
|
|
488
|
-
const record = this.rateLimits.get(ip)
|
|
489
|
-
if (!record) return false
|
|
490
|
-
if (record.lockUntil && Date.now() < record.lockUntil) {
|
|
491
|
-
return true
|
|
492
|
-
}
|
|
493
|
-
if (record.lockUntil && Date.now() >= record.lockUntil) {
|
|
494
|
-
this.rateLimits.delete(ip)
|
|
495
|
-
return false
|
|
496
|
-
}
|
|
497
|
-
return false
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
recordFailedAttempt(ip) {
|
|
501
|
-
if (!ip) return
|
|
502
|
-
const now = Date.now()
|
|
503
|
-
const record = this.rateLimits.get(ip) || { failedCount: 0, lockUntil: 0 }
|
|
504
|
-
record.failedCount += 1
|
|
505
|
-
if (record.failedCount >= MAX_FAILED_ATTEMPTS) {
|
|
506
|
-
record.lockUntil = now + LOCKOUT_PERIOD_MS
|
|
507
|
-
this.logger.warn?.(`[dsh-bridge auth] IP ${ip} locked out for 60s due to repeated failed attempts`)
|
|
508
|
-
}
|
|
509
|
-
this.rateLimits.set(ip, record)
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
recordSuccess(ip) {
|
|
513
|
-
if (ip) this.rateLimits.delete(ip)
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ---- HTTP / WebSocket 请求总入口鉴权 ----
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* 检查请求是否已授权
|
|
520
|
-
* @param {import('node:http').IncomingMessage} req
|
|
521
|
-
* @returns {{ authenticated: boolean, fromToken?: boolean, sessionToken?: string, loopback?: boolean, bypass?: boolean, lanBypass?: boolean, publicBypass?: boolean }}
|
|
522
|
-
*/
|
|
523
|
-
verifyRequest(req) {
|
|
524
|
-
// 1. 未开启认证 -> 允许直通
|
|
525
|
-
if (!this.enabled) {
|
|
526
|
-
return { authenticated: true, bypass: true }
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const remote = req.socket?.remoteAddress || ''
|
|
530
|
-
const isLoopback = (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1')
|
|
531
|
-
|
|
532
|
-
// 辨别是否来自自建隧道或 Cloudflare 隧道 (只有从 127.0.0.1 传入且带有合法内部凭据/Cloudflare 标头才算)
|
|
533
|
-
const internalTunnelHeader = req.headers?.['x-dsh-internal-tunnel']
|
|
534
|
-
const isCustomTunnel = Boolean(isLoopback && internalTunnelHeader && internalTunnelHeader === this.internalTunnelSecret)
|
|
535
|
-
const isCloudflare = Boolean(isLoopback && (req.headers?.['cf-ray'] || req.headers?.['cf-connecting-ip']))
|
|
536
|
-
const isPublicTunnel = isCustomTunnel || isCloudflare
|
|
537
|
-
|
|
538
|
-
// 2. 本地环回免认证(真正的宿主机物理浏览器 127.0.0.1 访问,非 Tunnel 转发)
|
|
539
|
-
if (this.allowLoopback && isLoopback && !isPublicTunnel) {
|
|
540
|
-
const host = String(req.headers?.host || '')
|
|
541
|
-
if (host.startsWith('127.0.0.1') || host.startsWith('localhost') || host === '') {
|
|
542
|
-
return { authenticated: true, loopback: true }
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// 3. 检查防护范围 (scope: 'all' | 'public_only' | 'lan_only')
|
|
547
|
-
if (this.scope === 'public_only' && !isPublicTunnel) {
|
|
548
|
-
return { authenticated: true, lanBypass: true }
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if (this.scope === 'lan_only' && isPublicTunnel) {
|
|
552
|
-
return { authenticated: true, publicBypass: true }
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// 4. 从 Query 参数中解析 ?auth=token 或 ?token=token (用于扫码免密登录)
|
|
556
|
-
if (this.mode !== 'password_only') {
|
|
557
|
-
try {
|
|
558
|
-
const urlObj = new URL(req.url, 'http://localhost')
|
|
559
|
-
const queryToken = urlObj.searchParams.get('auth') || urlObj.searchParams.get('token')
|
|
560
|
-
if (queryToken && this.validateSecretToken(queryToken)) {
|
|
561
|
-
return { authenticated: true, fromToken: true, secretToken: queryToken }
|
|
562
|
-
}
|
|
563
|
-
} catch {}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// 5. 从 Cookie 中解析 Session Token: dsh_bridge_auth=<token>
|
|
567
|
-
const cookieHeader = req.headers?.cookie || ''
|
|
568
|
-
const match = /(?:^|;\s*)dsh_bridge_auth=([a-f0-9]+)/i.exec(cookieHeader)
|
|
569
|
-
if (match) {
|
|
570
|
-
const sessionToken = match[1]
|
|
571
|
-
if (this.validateSession(sessionToken)) {
|
|
572
|
-
return { authenticated: true, sessionToken }
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// 未授权
|
|
577
|
-
return { authenticated: false }
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
_cleanupExpired() {
|
|
581
|
-
const now = Date.now()
|
|
582
|
-
for (const [t, s] of this.sessions.entries()) {
|
|
583
|
-
if (now > s.expiresAt) this.sessions.delete(t)
|
|
584
|
-
}
|
|
585
|
-
for (const [ip, r] of this.rateLimits.entries()) {
|
|
586
|
-
if (r.lockUntil && now > r.lockUntil) this.rateLimits.delete(ip)
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
dispose() {
|
|
591
|
-
clearInterval(this._cleanupTimer)
|
|
592
|
-
// 不清空 sessions:进程退出时内存随进程自然消失;
|
|
593
|
-
// 若此处 clear() 会把持久化文件一并清空,宿主重启后已登录设备仍会全部丢失
|
|
594
|
-
this.rateLimits.clear()
|
|
595
|
-
}
|
|
596
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// DSH Bridge - RPC constants (dependency-free, safe to import from browser client)
|
|
2
|
-
|
|
3
|
-
export const BRIDGE_RPC_CHANNEL = '/dsh-bridge';
|
|
4
|
-
|
|
5
|
-
export const BRIDGE_ENDPOINTS = {
|
|
6
|
-
getStatus: 'getStatus',
|
|
7
|
-
startCustomTunnel: 'startCustomTunnel',
|
|
8
|
-
stopCustomTunnel: 'stopCustomTunnel',
|
|
9
|
-
startCloudflared: 'startCloudflared',
|
|
10
|
-
stopCloudflared: 'stopCloudflared',
|
|
11
|
-
resetCloudflared: 'resetCloudflared',
|
|
12
|
-
saveCloudflaredConfig: 'saveCloudflaredConfig',
|
|
13
|
-
setTunnelAutoStart: 'setTunnelAutoStart',
|
|
14
|
-
saveCustomTunnelConfig: 'saveCustomTunnelConfig',
|
|
15
|
-
// SaaS 隧道(设备码绑定;契约 docs/architecture/saas-tunnel.md §4/§7)
|
|
16
|
-
saasGetStatus: 'saasGetStatus',
|
|
17
|
-
saasRequestDeviceCode: 'saasRequestDeviceCode',
|
|
18
|
-
saasBindDevice: 'saasBindDevice',
|
|
19
|
-
saasUnbindDevice: 'saasUnbindDevice',
|
|
20
|
-
saveExternalTunnel: 'saveExternalTunnel',
|
|
21
|
-
setLanIp: 'setLanIp',
|
|
22
|
-
checkVersion: 'checkVersion',
|
|
23
|
-
upgradePlugin: 'upgradePlugin',
|
|
24
|
-
upgradeDsh: 'upgradeDsh',
|
|
25
|
-
restartDsh: 'restartDsh',
|
|
26
|
-
exportBackup: 'exportBackup',
|
|
27
|
-
importBackup: 'importBackup',
|
|
28
|
-
diagnoseNetwork: 'diagnoseNetwork',
|
|
29
|
-
getSystemMetrics: 'getSystemMetrics',
|
|
30
|
-
// 远程工作区管理与目录浏览
|
|
31
|
-
listRemoteDirectories: 'listRemoteDirectories',
|
|
32
|
-
addRemoteWorkspace: 'addRemoteWorkspace',
|
|
33
|
-
listWorkspaces: 'listWorkspaces',
|
|
34
|
-
// 访问安全认证(密码保护 / 扫码免密 Token)
|
|
35
|
-
authGetStatus: 'authGetStatus',
|
|
36
|
-
authUpdateConfig: 'authUpdateConfig',
|
|
37
|
-
authRegenerateToken: 'authRegenerateToken',
|
|
38
|
-
authAdminUnlock: 'authAdminUnlock',
|
|
39
|
-
authAdminLock: 'authAdminLock',
|
|
40
|
-
// 首次启用引导:用户确认已了解后置位,避免重复打扰
|
|
41
|
-
dismissFirstRunGuide: 'dismissFirstRunGuide',
|
|
42
|
-
// 页面改写层用户偏好(设置面板可见开关,issue #55 讨论衍生)
|
|
43
|
-
uiGetFlags: 'uiGetFlags',
|
|
44
|
-
uiUpdateConfig: 'uiUpdateConfig',
|
|
45
|
-
// 平台管理器(多 IM 平台统一接口)
|
|
46
|
-
listPlatforms: 'listPlatforms',
|
|
47
|
-
platformLogin: 'platformLogin',
|
|
48
|
-
platformSetAllowFrom: 'platformSetAllowFrom',
|
|
49
|
-
platformSetConfig: 'platformSetConfig',
|
|
50
|
-
platformStop: 'platformStop',
|
|
51
|
-
platformStart: 'platformStart',
|
|
52
|
-
platformUnbind: 'platformUnbind',
|
|
53
|
-
// issue #78:同平台多账号运行时增删(高危,需管理员鉴权)
|
|
54
|
-
platformAddAccount: 'platformAddAccount',
|
|
55
|
-
platformRemoveAccount: 'platformRemoveAccount',
|
|
56
|
-
// 本机可用的 DSH agent preset(设置页下拉用;老版本 DSH 返回 available:false)
|
|
57
|
-
listAgentPresets: 'listAgentPresets',
|
|
58
|
-
// 微信 Bot(v1.x 向后兼容别名,deprecated)
|
|
59
|
-
wechatGetStatus: 'wechatGetStatus',
|
|
60
|
-
wechatLogin: 'wechatLogin',
|
|
61
|
-
wechatSetAllowFrom: 'wechatSetAllowFrom',
|
|
62
|
-
wechatSetConfig: 'wechatSetConfig',
|
|
63
|
-
wechatStop: 'wechatStop',
|
|
64
|
-
wechatStart: 'wechatStart',
|
|
65
|
-
wechatUnbind: 'wechatUnbind',
|
|
66
|
-
};
|