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
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import { randomBytes, randomInt } 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
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { hashSecret, verifySecretHash } from './crypto.js';
|
|
7
|
+
import { isTailscaleIp, isLanIp } from './tailscale.js';
|
|
8
|
+
export const AUTH_COOKIE_NAME = 'dsh_mobile_token';
|
|
9
|
+
export const DEFAULT_PERSIST_FILE = `${homedir()}/.dsh/remote-mobile/devices.json`;
|
|
10
|
+
export const GLOBAL_SETTINGS_FILE = `${homedir()}/.dsh/settings.yaml`;
|
|
11
|
+
/**
|
|
12
|
+
* 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
|
|
13
|
+
*/
|
|
14
|
+
export function readFromSettingsYaml(filePath = GLOBAL_SETTINGS_FILE) {
|
|
15
|
+
try {
|
|
16
|
+
if (!existsSync(filePath))
|
|
17
|
+
return {};
|
|
18
|
+
const content = readFileSync(filePath, 'utf8');
|
|
19
|
+
const ns = 'dsh-remote-mobile:';
|
|
20
|
+
const lines = content.split(/\r?\n/);
|
|
21
|
+
const startIdx = lines.findIndex((l) => l.trim() === ns);
|
|
22
|
+
if (startIdx === -1)
|
|
23
|
+
return {};
|
|
24
|
+
const result = {};
|
|
25
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
26
|
+
const line = lines[i];
|
|
27
|
+
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && line.includes(':')) {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
32
|
+
continue;
|
|
33
|
+
const colonIdx = trimmed.indexOf(':');
|
|
34
|
+
if (colonIdx === -1)
|
|
35
|
+
continue;
|
|
36
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
37
|
+
let val = trimmed.slice(colonIdx + 1).trim();
|
|
38
|
+
if (val.includes('#')) {
|
|
39
|
+
val = val.split('#')[0].trim();
|
|
40
|
+
}
|
|
41
|
+
if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
|
|
42
|
+
val = val.slice(1, -1);
|
|
43
|
+
}
|
|
44
|
+
if (key === 'allowTailscale')
|
|
45
|
+
result.allowTailscale = val === 'true';
|
|
46
|
+
else if (key === 'allowLan')
|
|
47
|
+
result.allowLan = val === 'true';
|
|
48
|
+
else if (key === 'secretHash')
|
|
49
|
+
result.secretHash = val;
|
|
50
|
+
else if (key === 'secret')
|
|
51
|
+
result.secret = val;
|
|
52
|
+
else if (key === 'maxFailedAttempts') {
|
|
53
|
+
const num = parseInt(val, 10);
|
|
54
|
+
if (!isNaN(num) && num > 0)
|
|
55
|
+
result.maxFailedAttempts = num;
|
|
56
|
+
}
|
|
57
|
+
else if (key === 'lockDurationMs') {
|
|
58
|
+
const num = parseInt(val, 10);
|
|
59
|
+
if (!isNaN(num) && num > 0)
|
|
60
|
+
result.lockDurationMs = num;
|
|
61
|
+
}
|
|
62
|
+
else if (key === 'maxVisitsPerMinute') {
|
|
63
|
+
const num = parseInt(val, 10);
|
|
64
|
+
if (!isNaN(num) && num > 0)
|
|
65
|
+
result.maxVisitsPerMinute = num;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 从 ~/.dsh/settings.yaml 中读取全局语言偏好 (locale.preference)
|
|
76
|
+
*/
|
|
77
|
+
export function readGlobalLocale(filePath = GLOBAL_SETTINGS_FILE) {
|
|
78
|
+
try {
|
|
79
|
+
if (!existsSync(filePath))
|
|
80
|
+
return 'zh';
|
|
81
|
+
const content = readFileSync(filePath, 'utf8');
|
|
82
|
+
const match = content.match(/^locale:\s*\r?\n\s+preference:\s*['"]?([a-zA-Z_-]+)['"]?/m);
|
|
83
|
+
if (match && match[1]) {
|
|
84
|
+
return match[1].toLowerCase().startsWith('en') ? 'en' : 'zh';
|
|
85
|
+
}
|
|
86
|
+
return 'zh';
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return 'zh';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 将最新的插件配置安全写回 ~/.dsh/settings.yaml 中的 dsh-remote-mobile 命名空间
|
|
94
|
+
*/
|
|
95
|
+
export function writeBackToSettingsYaml(options, filePath = GLOBAL_SETTINGS_FILE) {
|
|
96
|
+
try {
|
|
97
|
+
if (!existsSync(filePath))
|
|
98
|
+
return false;
|
|
99
|
+
const content = readFileSync(filePath, 'utf8');
|
|
100
|
+
const ns = 'dsh-remote-mobile:';
|
|
101
|
+
const fields = [
|
|
102
|
+
'dsh-remote-mobile:',
|
|
103
|
+
` allowTailscale: ${Boolean(options.allowTailscale)}`,
|
|
104
|
+
` allowLan: ${Boolean(options.allowLan)}`,
|
|
105
|
+
` secretHash: ${options.secretHash ? `'${options.secretHash}'` : `''`}`,
|
|
106
|
+
` maxFailedAttempts: ${options.maxFailedAttempts || 5}`,
|
|
107
|
+
` lockDurationMs: ${options.lockDurationMs || 900000}`,
|
|
108
|
+
` maxVisitsPerMinute: ${options.maxVisitsPerMinute || 60}`,
|
|
109
|
+
];
|
|
110
|
+
const newBlock = fields.join('\n');
|
|
111
|
+
const lines = content.split(/\r?\n/);
|
|
112
|
+
const startIdx = lines.findIndex((l) => l.trim() === ns);
|
|
113
|
+
let updatedContent = '';
|
|
114
|
+
if (startIdx === -1) {
|
|
115
|
+
updatedContent = content.trimEnd() + '\n\n' + newBlock + '\n';
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
let endIdx = lines.length;
|
|
119
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
120
|
+
const line = lines[i];
|
|
121
|
+
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && line.includes(':')) {
|
|
122
|
+
endIdx = i;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
lines.splice(startIdx, endIdx - startIdx, newBlock);
|
|
127
|
+
updatedContent = lines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
writeFileSync(filePath, updatedContent, 'utf8');
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 将 User-Agent 字符串解析为人类友好的设备名称
|
|
138
|
+
*/
|
|
139
|
+
export function parseDeviceName(ua = '') {
|
|
140
|
+
const isIPhone = /iPhone/i.test(ua);
|
|
141
|
+
const isIPad = /iPad/i.test(ua);
|
|
142
|
+
const isAndroid = /Android/i.test(ua);
|
|
143
|
+
const isMac = /Macintosh/i.test(ua) && !isIPhone && !isIPad;
|
|
144
|
+
const isWindows = /Windows/i.test(ua);
|
|
145
|
+
const isLinux = /Linux/i.test(ua) && !isAndroid;
|
|
146
|
+
let browser = 'Browser';
|
|
147
|
+
if (/EdgA?\/|Edge\//i.test(ua))
|
|
148
|
+
browser = 'Edge';
|
|
149
|
+
else if (/Chrome\//i.test(ua) && !/Edg/i.test(ua))
|
|
150
|
+
browser = 'Chrome';
|
|
151
|
+
else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua))
|
|
152
|
+
browser = 'Safari';
|
|
153
|
+
else if (/Firefox\//i.test(ua))
|
|
154
|
+
browser = 'Firefox';
|
|
155
|
+
else if (/MicroMessenger/i.test(ua))
|
|
156
|
+
browser = '微信内置浏览器';
|
|
157
|
+
if (isIPhone)
|
|
158
|
+
return `📱 iPhone (${browser})`;
|
|
159
|
+
if (isIPad)
|
|
160
|
+
return `📱 iPad (${browser})`;
|
|
161
|
+
if (isAndroid)
|
|
162
|
+
return `🤖 Android 手机 (${browser})`;
|
|
163
|
+
if (isMac)
|
|
164
|
+
return `💻 Mac (${browser})`;
|
|
165
|
+
if (isWindows)
|
|
166
|
+
return `💻 Windows (${browser})`;
|
|
167
|
+
if (isLinux)
|
|
168
|
+
return `💻 Linux (${browser})`;
|
|
169
|
+
return `📱 移动设备 (${browser})`;
|
|
170
|
+
}
|
|
171
|
+
export class SessionStore extends EventEmitter {
|
|
172
|
+
sessions = new Map();
|
|
173
|
+
shortCodes = new Map();
|
|
174
|
+
ipStats = new Map();
|
|
175
|
+
options;
|
|
176
|
+
settingsMutator;
|
|
177
|
+
persistPath;
|
|
178
|
+
settingsFilePath;
|
|
179
|
+
constructor(options = {}) {
|
|
180
|
+
super();
|
|
181
|
+
this.setMaxListeners(100);
|
|
182
|
+
// 严密隔离单测环境:当处于 node --test 环境或使用了自定义设备文件时,严禁读写全局 settings.yaml
|
|
183
|
+
const isTestEnv = Boolean(process.env.NODE_TEST_CONTEXT ||
|
|
184
|
+
process.argv.some((arg) => arg.includes('test')) ||
|
|
185
|
+
(options.devicesFile && options.devicesFile !== DEFAULT_PERSIST_FILE));
|
|
186
|
+
this.settingsFilePath = isTestEnv ? null : GLOBAL_SETTINGS_FILE;
|
|
187
|
+
const yamlOpts = isTestEnv ? {} : readFromSettingsYaml(GLOBAL_SETTINGS_FILE);
|
|
188
|
+
const mergedOpts = { ...yamlOpts, ...options };
|
|
189
|
+
let secretHash = '';
|
|
190
|
+
if (typeof options.secretHash === 'string' && options.secretHash) {
|
|
191
|
+
secretHash = options.secretHash;
|
|
192
|
+
}
|
|
193
|
+
else if (typeof options.secret === 'string' && options.secret) {
|
|
194
|
+
secretHash = hashSecret(options.secret);
|
|
195
|
+
}
|
|
196
|
+
else if (typeof yamlOpts.secretHash === 'string' && yamlOpts.secretHash) {
|
|
197
|
+
secretHash = yamlOpts.secretHash;
|
|
198
|
+
}
|
|
199
|
+
else if (typeof yamlOpts.secret === 'string' && yamlOpts.secret) {
|
|
200
|
+
secretHash = hashSecret(yamlOpts.secret);
|
|
201
|
+
}
|
|
202
|
+
this.options = {
|
|
203
|
+
allowTailscale: mergedOpts.allowTailscale ?? false,
|
|
204
|
+
allowLan: mergedOpts.allowLan ?? false,
|
|
205
|
+
secret: '',
|
|
206
|
+
secretHash,
|
|
207
|
+
devicesFile: mergedOpts.devicesFile ?? DEFAULT_PERSIST_FILE,
|
|
208
|
+
maxFailedAttempts: mergedOpts.maxFailedAttempts ?? 5,
|
|
209
|
+
lockDurationMs: mergedOpts.lockDurationMs ?? 15 * 60 * 1000,
|
|
210
|
+
maxVisitsPerMinute: mergedOpts.maxVisitsPerMinute ?? 60,
|
|
211
|
+
};
|
|
212
|
+
this.persistPath = this.options.devicesFile;
|
|
213
|
+
this.loadPersistedData();
|
|
214
|
+
}
|
|
215
|
+
getOptions() {
|
|
216
|
+
return { ...this.options };
|
|
217
|
+
}
|
|
218
|
+
hasSecret() {
|
|
219
|
+
return Boolean(this.options.secretHash || this.options.secret);
|
|
220
|
+
}
|
|
221
|
+
setSettingsMutator(mutator) {
|
|
222
|
+
this.settingsMutator = mutator;
|
|
223
|
+
}
|
|
224
|
+
updateOptions(newOpts, syncToSettings = true) {
|
|
225
|
+
const prevTailscale = this.options.allowTailscale;
|
|
226
|
+
const prevLan = this.options.allowLan;
|
|
227
|
+
if (typeof newOpts.secret === 'string') {
|
|
228
|
+
if (!newOpts.secret) {
|
|
229
|
+
newOpts.secret = '';
|
|
230
|
+
newOpts.secretHash = '';
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
newOpts.secretHash = hashSecret(newOpts.secret);
|
|
234
|
+
newOpts.secret = '';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.options = { ...this.options, ...newOpts };
|
|
238
|
+
if (syncToSettings) {
|
|
239
|
+
if (this.settingsMutator) {
|
|
240
|
+
try {
|
|
241
|
+
const patch = {};
|
|
242
|
+
if (typeof newOpts.allowTailscale === 'boolean')
|
|
243
|
+
patch.allowTailscale = newOpts.allowTailscale;
|
|
244
|
+
if (typeof newOpts.allowLan === 'boolean')
|
|
245
|
+
patch.allowLan = newOpts.allowLan;
|
|
246
|
+
if (typeof newOpts.secretHash === 'string')
|
|
247
|
+
patch.secretHash = newOpts.secretHash;
|
|
248
|
+
if (typeof newOpts.maxFailedAttempts === 'number')
|
|
249
|
+
patch.maxFailedAttempts = newOpts.maxFailedAttempts;
|
|
250
|
+
if (typeof newOpts.lockDurationMs === 'number')
|
|
251
|
+
patch.lockDurationMs = newOpts.lockDurationMs;
|
|
252
|
+
if (typeof newOpts.maxVisitsPerMinute === 'number')
|
|
253
|
+
patch.maxVisitsPerMinute = newOpts.maxVisitsPerMinute;
|
|
254
|
+
this.settingsMutator(patch);
|
|
255
|
+
}
|
|
256
|
+
catch { }
|
|
257
|
+
}
|
|
258
|
+
// 仅在非测试环境或指定了 settingsFilePath 时,同步持久化至 ~/.dsh/settings.yaml
|
|
259
|
+
if (this.settingsFilePath) {
|
|
260
|
+
writeBackToSettingsYaml(this.options, this.settingsFilePath);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// 1. 如果关闭了 Tailscale 免密直连,自动清理所有 Tailscale 免密设备,并将审计状态重置为 ⚪ 待认证
|
|
264
|
+
if (prevTailscale && newOpts.allowTailscale === false) {
|
|
265
|
+
for (const [token, s] of Array.from(this.sessions.entries())) {
|
|
266
|
+
if (s.isBypass && (s.authType?.includes('Tailscale') || token.startsWith('bypass:tailscale:'))) {
|
|
267
|
+
this.sessions.delete(token);
|
|
268
|
+
if (s.ip && this.ipStats.has(s.ip)) {
|
|
269
|
+
const stat = this.ipStats.get(s.ip);
|
|
270
|
+
if (stat.authType?.includes('Tailscale') || stat.authType?.includes('免密')) {
|
|
271
|
+
stat.authType = '⚪ 待认证';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
this.emit('device-revoked', { bypassType: 'Tailscale' });
|
|
277
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
278
|
+
}
|
|
279
|
+
// 2. 如果关闭了 局域网 LAN 免密直连,自动清理所有 LAN 免密设备,并将审计状态重置为 ⚪ 待认证
|
|
280
|
+
if (prevLan && newOpts.allowLan === false) {
|
|
281
|
+
for (const [token, s] of Array.from(this.sessions.entries())) {
|
|
282
|
+
if (s.isBypass && (s.authType?.includes('局域网') || s.authType?.includes('LAN') || token.startsWith('bypass:lan:'))) {
|
|
283
|
+
this.sessions.delete(token);
|
|
284
|
+
if (s.ip && this.ipStats.has(s.ip)) {
|
|
285
|
+
const stat = this.ipStats.get(s.ip);
|
|
286
|
+
if (stat.authType?.includes('局域网') || stat.authType?.includes('LAN') || stat.authType?.includes('免密')) {
|
|
287
|
+
stat.authType = '⚪ 待认证';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.emit('device-revoked', { bypassType: 'LAN' });
|
|
293
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
294
|
+
}
|
|
295
|
+
this.savePersistedData();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 记录 IP 打开 /auth 页面的计数并进行限频判定与设备指纹解析
|
|
299
|
+
*/
|
|
300
|
+
recordAuthVisit(ip, userAgent = '') {
|
|
301
|
+
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') {
|
|
302
|
+
return { allowed: true };
|
|
303
|
+
}
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const devName = parseDeviceName(userAgent);
|
|
306
|
+
let stat = this.ipStats.get(ip);
|
|
307
|
+
if (!stat) {
|
|
308
|
+
stat = {
|
|
309
|
+
ip,
|
|
310
|
+
authVisits: 0,
|
|
311
|
+
authVisitsInWindow: 0,
|
|
312
|
+
windowStart: now,
|
|
313
|
+
failedAttempts: 0,
|
|
314
|
+
totalAttempts: 0,
|
|
315
|
+
lockedUntil: 0,
|
|
316
|
+
lastAttemptAt: 0,
|
|
317
|
+
lastSeenAt: 0,
|
|
318
|
+
authType: '⚪ 待认证',
|
|
319
|
+
deviceName: devName,
|
|
320
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
321
|
+
};
|
|
322
|
+
this.ipStats.set(ip, stat);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
if (devName && devName !== '💻 未知设备' && devName !== '📱 移动设备 (Browser)') {
|
|
326
|
+
stat.deviceName = devName;
|
|
327
|
+
}
|
|
328
|
+
if (userAgent) {
|
|
329
|
+
stat.userAgent = userAgent.slice(0, 150);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// 当设备打开登录页时,若该 IP 当前没有正式授权会话且未处于免密直连中,状态明确更新为 ⚪ 待认证
|
|
333
|
+
const hasValidSession = Array.from(this.sessions.values()).some((s) => s.ip === ip && !s.isBypass);
|
|
334
|
+
const isTailscaleBypassActive = this.options.allowTailscale && isTailscaleIp(ip);
|
|
335
|
+
const isLanBypassActive = this.options.allowLan && isLanIp(ip);
|
|
336
|
+
if (!hasValidSession && !isTailscaleBypassActive && !isLanBypassActive) {
|
|
337
|
+
stat.authType = '⚪ 待认证';
|
|
338
|
+
// 同步清理残留的 bypass session
|
|
339
|
+
for (const [token, s] of Array.from(this.sessions.entries())) {
|
|
340
|
+
if (s.ip === ip && s.isBypass) {
|
|
341
|
+
this.sessions.delete(token);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
stat.lastSeenAt = now;
|
|
346
|
+
stat.authVisits += 1;
|
|
347
|
+
// 检查是否在锁定期内
|
|
348
|
+
if (stat.lockedUntil > now) {
|
|
349
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
350
|
+
return {
|
|
351
|
+
allowed: false,
|
|
352
|
+
lockedUntil: stat.lockedUntil,
|
|
353
|
+
reason: `IP 已被锁定,请在 ${Math.ceil((stat.lockedUntil - now) / 60000)} 分钟后再试`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// 1 分钟滑动窗口限频
|
|
357
|
+
if (now - stat.windowStart > 60000) {
|
|
358
|
+
stat.windowStart = now;
|
|
359
|
+
stat.authVisitsInWindow = 1;
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
stat.authVisitsInWindow += 1;
|
|
363
|
+
if (stat.authVisitsInWindow > this.options.maxVisitsPerMinute) {
|
|
364
|
+
stat.lockedUntil = now + 5 * 60 * 1000; // 恶意刷页面封锁 5 分钟
|
|
365
|
+
this.emit('ip-security-alert', {
|
|
366
|
+
ip,
|
|
367
|
+
reason: '短时间内高频打开登录页,已自动安全拦截 5 分钟',
|
|
368
|
+
remainingSeconds: 300,
|
|
369
|
+
level: 'danger',
|
|
370
|
+
});
|
|
371
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
372
|
+
this.savePersistedData();
|
|
373
|
+
return {
|
|
374
|
+
allowed: false,
|
|
375
|
+
lockedUntil: stat.lockedUntil,
|
|
376
|
+
reason: '页面请求过于频繁,已被临时安全保护',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
381
|
+
this.savePersistedData();
|
|
382
|
+
return { allowed: true };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* 检查 IP 是否被防暴力破解锁定
|
|
386
|
+
*/
|
|
387
|
+
isIpLocked(ip) {
|
|
388
|
+
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') {
|
|
389
|
+
return { locked: false };
|
|
390
|
+
}
|
|
391
|
+
const stat = this.ipStats.get(ip);
|
|
392
|
+
if (!stat || stat.lockedUntil <= Date.now()) {
|
|
393
|
+
return { locked: false };
|
|
394
|
+
}
|
|
395
|
+
const remainingSeconds = Math.ceil((stat.lockedUntil - Date.now()) / 1000);
|
|
396
|
+
return { locked: true, lockedUntil: stat.lockedUntil, remainingSeconds };
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* 获取所有 IP 的安全审计统计数据
|
|
400
|
+
*/
|
|
401
|
+
getIpSecurityStats() {
|
|
402
|
+
const list = Array.from(this.ipStats.values());
|
|
403
|
+
list.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
404
|
+
return list;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 管理员手动解锁某个 IP
|
|
408
|
+
*/
|
|
409
|
+
unlockIp(ip) {
|
|
410
|
+
const stat = this.ipStats.get(ip);
|
|
411
|
+
if (stat) {
|
|
412
|
+
stat.lockedUntil = 0;
|
|
413
|
+
stat.failedAttempts = 0;
|
|
414
|
+
stat.authVisitsInWindow = 0;
|
|
415
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
416
|
+
this.savePersistedData();
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
generateShortCode() {
|
|
422
|
+
const code = randomInt(100000, 1000000).toString();
|
|
423
|
+
const token = randomBytes(24).toString('hex');
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
const expiresAt = now + 5 * 60 * 1000; // 5 分钟配对有效期
|
|
426
|
+
const record = { code, token, createdAt: now, expiresAt };
|
|
427
|
+
this.shortCodes.set(code, record);
|
|
428
|
+
this.shortCodes.set(token, record);
|
|
429
|
+
// 定时清理,调用 unref 避免阻塞 Node 进程退出/测试执行
|
|
430
|
+
const timer = setTimeout(() => {
|
|
431
|
+
this.shortCodes.delete(code);
|
|
432
|
+
this.shortCodes.delete(token);
|
|
433
|
+
}, 5 * 60 * 1000 + 1000);
|
|
434
|
+
if (typeof timer.unref === 'function') {
|
|
435
|
+
;
|
|
436
|
+
timer.unref();
|
|
437
|
+
}
|
|
438
|
+
return { code, token, expiresAt };
|
|
439
|
+
}
|
|
440
|
+
verify(credential, userAgent, clientIp) {
|
|
441
|
+
const trimmed = (credential || '').trim();
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const deviceName = parseDeviceName(userAgent);
|
|
444
|
+
const ip = clientIp || '';
|
|
445
|
+
// 0. 防暴力破解检查:IP 是否处于封锁期
|
|
446
|
+
if (ip) {
|
|
447
|
+
const lockCheck = this.isIpLocked(ip);
|
|
448
|
+
if (lockCheck.locked) {
|
|
449
|
+
return {
|
|
450
|
+
success: false,
|
|
451
|
+
locked: true,
|
|
452
|
+
remainingSeconds: lockCheck.remainingSeconds,
|
|
453
|
+
reason: `连续尝试错误过多,IP 已被安全锁定,请在 ${Math.ceil((lockCheck.remainingSeconds || 0) / 60)} 分钟后再试`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
let stat = ip ? this.ipStats.get(ip) : undefined;
|
|
458
|
+
if (!stat && ip) {
|
|
459
|
+
stat = {
|
|
460
|
+
ip,
|
|
461
|
+
authVisits: 1,
|
|
462
|
+
authVisitsInWindow: 1,
|
|
463
|
+
windowStart: now,
|
|
464
|
+
failedAttempts: 0,
|
|
465
|
+
totalAttempts: 0,
|
|
466
|
+
lockedUntil: 0,
|
|
467
|
+
lastAttemptAt: now,
|
|
468
|
+
lastSeenAt: now,
|
|
469
|
+
};
|
|
470
|
+
this.ipStats.set(ip, stat);
|
|
471
|
+
}
|
|
472
|
+
if (stat) {
|
|
473
|
+
stat.totalAttempts += 1;
|
|
474
|
+
stat.lastAttemptAt = now;
|
|
475
|
+
stat.lastSeenAt = now;
|
|
476
|
+
if (deviceName && deviceName !== '💻 未知设备' && deviceName !== '📱 移动设备 (Browser)') {
|
|
477
|
+
stat.deviceName = deviceName;
|
|
478
|
+
}
|
|
479
|
+
if (userAgent) {
|
|
480
|
+
stat.userAgent = userAgent.slice(0, 150);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// 1. 验证 6 位一次性配对码
|
|
484
|
+
const short = this.shortCodes.get(trimmed);
|
|
485
|
+
if (short) {
|
|
486
|
+
if (short.expiresAt < now) {
|
|
487
|
+
this.shortCodes.delete(short.code);
|
|
488
|
+
this.shortCodes.delete(short.token);
|
|
489
|
+
this.handleFailedAttempt(stat, now);
|
|
490
|
+
return { success: false, reason: '配对码已过期,请在 PC 上刷新获取' };
|
|
491
|
+
}
|
|
492
|
+
this.shortCodes.delete(short.code);
|
|
493
|
+
this.shortCodes.delete(short.token);
|
|
494
|
+
// 认证成功,重置失败计数并记录认证归因
|
|
495
|
+
if (stat) {
|
|
496
|
+
stat.failedAttempts = 0;
|
|
497
|
+
stat.lockedUntil = 0;
|
|
498
|
+
stat.authType = '📱 扫码配对认证';
|
|
499
|
+
}
|
|
500
|
+
// 配对成功后派发 365 天长效会话 Token
|
|
501
|
+
const sessionToken = randomBytes(32).toString('hex');
|
|
502
|
+
const session = {
|
|
503
|
+
token: sessionToken,
|
|
504
|
+
createdAt: now,
|
|
505
|
+
lastSeenAt: now,
|
|
506
|
+
ip,
|
|
507
|
+
deviceName,
|
|
508
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
509
|
+
authType: '扫码配对码 (365天免登)',
|
|
510
|
+
};
|
|
511
|
+
this.sessions.set(sessionToken, session);
|
|
512
|
+
this.savePersistedData();
|
|
513
|
+
this.emit('device-connected', session);
|
|
514
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
515
|
+
return { success: true, token: sessionToken };
|
|
516
|
+
}
|
|
517
|
+
// 2. 验证长期密码 (通过加盐哈希安全校验,防时序攻击与明文泄露)
|
|
518
|
+
const targetHash = this.options.secretHash || (this.options.secret ? hashSecret(this.options.secret) : '');
|
|
519
|
+
if (targetHash && verifySecretHash(trimmed, targetHash)) {
|
|
520
|
+
// 认证成功,重置失败计数并记录认证归因
|
|
521
|
+
if (stat) {
|
|
522
|
+
stat.failedAttempts = 0;
|
|
523
|
+
stat.lockedUntil = 0;
|
|
524
|
+
stat.authType = '🔑 长期密码认证';
|
|
525
|
+
}
|
|
526
|
+
const sessionToken = randomBytes(32).toString('hex');
|
|
527
|
+
const session = {
|
|
528
|
+
token: sessionToken,
|
|
529
|
+
createdAt: now,
|
|
530
|
+
lastSeenAt: now,
|
|
531
|
+
ip,
|
|
532
|
+
deviceName,
|
|
533
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
534
|
+
authType: '长期密码认证 (365天免登)',
|
|
535
|
+
};
|
|
536
|
+
this.sessions.set(sessionToken, session);
|
|
537
|
+
this.savePersistedData();
|
|
538
|
+
this.emit('device-connected', session);
|
|
539
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
540
|
+
return { success: true, token: sessionToken };
|
|
541
|
+
}
|
|
542
|
+
// 认证失败,累加失败次数并判定是否锁定
|
|
543
|
+
const lockInfo = this.handleFailedAttempt(stat, now);
|
|
544
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
545
|
+
this.savePersistedData();
|
|
546
|
+
if (lockInfo.justLocked) {
|
|
547
|
+
this.emit('ip-security-alert', {
|
|
548
|
+
ip: stat?.ip || ip,
|
|
549
|
+
reason: `连续输入错误达 ${this.options.maxFailedAttempts} 次,已触发安全锁定 15 分钟!`,
|
|
550
|
+
remainingSeconds: lockInfo.remainingSeconds,
|
|
551
|
+
level: 'danger',
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
success: false,
|
|
555
|
+
locked: true,
|
|
556
|
+
remainingSeconds: lockInfo.remainingSeconds,
|
|
557
|
+
reason: `连续输入错误达 ${this.options.maxFailedAttempts} 次,IP 已被安全锁定 15 分钟!`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const remainingTries = Math.max(0, this.options.maxFailedAttempts - (stat?.failedAttempts || 1));
|
|
561
|
+
return {
|
|
562
|
+
success: false,
|
|
563
|
+
reason: remainingTries > 0
|
|
564
|
+
? `配对码或长期密码错误(还可尝试 ${remainingTries} 次)`
|
|
565
|
+
: '配对码或长期密码错误',
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
handleFailedAttempt(stat, now) {
|
|
569
|
+
if (!stat)
|
|
570
|
+
return { justLocked: false };
|
|
571
|
+
stat.failedAttempts += 1;
|
|
572
|
+
if (stat.failedAttempts >= this.options.maxFailedAttempts) {
|
|
573
|
+
stat.lockedUntil = now + this.options.lockDurationMs;
|
|
574
|
+
const remainingSeconds = Math.ceil(this.options.lockDurationMs / 1000);
|
|
575
|
+
return { justLocked: true, remainingSeconds };
|
|
576
|
+
}
|
|
577
|
+
return { justLocked: false };
|
|
578
|
+
}
|
|
579
|
+
validateToken(token, clientIp) {
|
|
580
|
+
if (!token)
|
|
581
|
+
return false;
|
|
582
|
+
const session = this.sessions.get(token);
|
|
583
|
+
if (!session)
|
|
584
|
+
return false;
|
|
585
|
+
const now = Date.now();
|
|
586
|
+
// 服务端 365 天过期校验(与客户端 Cookie Max-Age 对齐)
|
|
587
|
+
const MAX_SESSION_AGE = 365 * 24 * 60 * 60 * 1000;
|
|
588
|
+
if (now - session.createdAt > MAX_SESSION_AGE) {
|
|
589
|
+
this.sessions.delete(token);
|
|
590
|
+
this.savePersistedData();
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
// 设备距离上次活跃超过 5 分钟,判定为重连上线,触发上线提醒(避免连续请求重复弹窗)
|
|
594
|
+
const isReconnecting = (now - (session.lastSeenAt || 0)) > 5 * 60 * 1000;
|
|
595
|
+
session.lastSeenAt = now;
|
|
596
|
+
if (clientIp) {
|
|
597
|
+
session.ip = clientIp;
|
|
598
|
+
let stat = this.ipStats.get(clientIp);
|
|
599
|
+
if (!stat) {
|
|
600
|
+
stat = {
|
|
601
|
+
ip: clientIp,
|
|
602
|
+
authVisits: 0,
|
|
603
|
+
authVisitsInWindow: 0,
|
|
604
|
+
windowStart: now,
|
|
605
|
+
failedAttempts: 0,
|
|
606
|
+
totalAttempts: 0,
|
|
607
|
+
lockedUntil: 0,
|
|
608
|
+
lastAttemptAt: 0,
|
|
609
|
+
lastSeenAt: now,
|
|
610
|
+
};
|
|
611
|
+
this.ipStats.set(clientIp, stat);
|
|
612
|
+
}
|
|
613
|
+
stat.lastSeenAt = now;
|
|
614
|
+
if (session.deviceName && session.deviceName !== '💻 未知设备' && session.deviceName !== '📱 移动设备 (Browser)') {
|
|
615
|
+
stat.deviceName = session.deviceName;
|
|
616
|
+
}
|
|
617
|
+
if (session.authType) {
|
|
618
|
+
if (session.authType.includes('扫码') || session.authType.includes('配对')) {
|
|
619
|
+
stat.authType = '📱 扫码配对认证';
|
|
620
|
+
}
|
|
621
|
+
else if (session.authType.includes('密码')) {
|
|
622
|
+
stat.authType = '🔑 长期密码认证';
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (isReconnecting) {
|
|
627
|
+
this.emit('device-online', session);
|
|
628
|
+
this.savePersistedData();
|
|
629
|
+
}
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
extractTokenFromRequest(req) {
|
|
633
|
+
const cookies = req.headers.cookie || '';
|
|
634
|
+
const match = cookies.match(new RegExp(`(?:^|;\\s*)${AUTH_COOKIE_NAME}=([^;]+)`));
|
|
635
|
+
if (match)
|
|
636
|
+
return match[1];
|
|
637
|
+
const authHeader = req.headers.authorization || '';
|
|
638
|
+
if (authHeader.startsWith('Bearer '))
|
|
639
|
+
return authHeader.slice(7).trim();
|
|
640
|
+
if (req.url && req.url.includes('token=')) {
|
|
641
|
+
try {
|
|
642
|
+
const u = new URL(req.url, 'http://localhost');
|
|
643
|
+
const t = u.searchParams.get('token');
|
|
644
|
+
if (t)
|
|
645
|
+
return t;
|
|
646
|
+
}
|
|
647
|
+
catch { }
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* 自动登记并归因免密直连设备(Tailscale / LAN)
|
|
653
|
+
*/
|
|
654
|
+
registerBypassDevice(ip, userAgent = '', type) {
|
|
655
|
+
const token = `bypass:${type.toLowerCase()}:${ip}`;
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
const deviceName = parseDeviceName(userAgent);
|
|
658
|
+
const authType = type === 'Tailscale' ? '⚡ Tailscale 免密直连' : '⚡ 局域网 LAN 免密直连';
|
|
659
|
+
let session = this.sessions.get(token);
|
|
660
|
+
let isNew = false;
|
|
661
|
+
let isReconnecting = false;
|
|
662
|
+
if (!session) {
|
|
663
|
+
isNew = true;
|
|
664
|
+
session = {
|
|
665
|
+
token,
|
|
666
|
+
createdAt: now,
|
|
667
|
+
lastSeenAt: now,
|
|
668
|
+
ip,
|
|
669
|
+
deviceName,
|
|
670
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
671
|
+
authType,
|
|
672
|
+
isBypass: true,
|
|
673
|
+
};
|
|
674
|
+
this.sessions.set(token, session);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
isReconnecting = (now - (session.lastSeenAt || 0)) > 5 * 60 * 1000;
|
|
678
|
+
session.lastSeenAt = now;
|
|
679
|
+
if (deviceName && deviceName !== '💻 未知设备' && deviceName !== '📱 移动设备 (Browser)') {
|
|
680
|
+
session.deviceName = deviceName;
|
|
681
|
+
}
|
|
682
|
+
if (userAgent) {
|
|
683
|
+
session.userAgent = userAgent.slice(0, 150);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// 同步更新 IP 安全审计中的归因标记与设备型号
|
|
687
|
+
let stat = this.ipStats.get(ip);
|
|
688
|
+
if (!stat) {
|
|
689
|
+
stat = {
|
|
690
|
+
ip,
|
|
691
|
+
authVisits: 0,
|
|
692
|
+
authVisitsInWindow: 0,
|
|
693
|
+
windowStart: now,
|
|
694
|
+
failedAttempts: 0,
|
|
695
|
+
totalAttempts: 0,
|
|
696
|
+
lockedUntil: 0,
|
|
697
|
+
lastAttemptAt: 0,
|
|
698
|
+
lastSeenAt: now,
|
|
699
|
+
authType,
|
|
700
|
+
deviceName,
|
|
701
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
702
|
+
};
|
|
703
|
+
this.ipStats.set(ip, stat);
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
stat.lastSeenAt = now;
|
|
707
|
+
stat.authType = authType;
|
|
708
|
+
if (deviceName && deviceName !== '💻 未知设备' && deviceName !== '📱 移动设备 (Browser)') {
|
|
709
|
+
stat.deviceName = deviceName;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (isNew) {
|
|
713
|
+
this.savePersistedData();
|
|
714
|
+
this.emit('device-connected', session);
|
|
715
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
716
|
+
}
|
|
717
|
+
else if (isReconnecting) {
|
|
718
|
+
this.savePersistedData();
|
|
719
|
+
this.emit('device-online', session);
|
|
720
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
721
|
+
}
|
|
722
|
+
return session;
|
|
723
|
+
}
|
|
724
|
+
getSessionsList() {
|
|
725
|
+
const list = Array.from(this.sessions.values()).map((s) => ({
|
|
726
|
+
token: s.token,
|
|
727
|
+
deviceName: s.deviceName || parseDeviceName(s.userAgent),
|
|
728
|
+
ip: s.ip || '未知',
|
|
729
|
+
createdAt: s.createdAt,
|
|
730
|
+
lastSeenAt: s.lastSeenAt,
|
|
731
|
+
authType: s.authType || '配对认证',
|
|
732
|
+
isBypass: Boolean(s.isBypass),
|
|
733
|
+
}));
|
|
734
|
+
list.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
735
|
+
return {
|
|
736
|
+
count: list.length,
|
|
737
|
+
devices: list,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
revokeDevice(token) {
|
|
741
|
+
if (!token)
|
|
742
|
+
return false;
|
|
743
|
+
const ok = this.sessions.delete(token);
|
|
744
|
+
this.savePersistedData();
|
|
745
|
+
this.emit('device-revoked', { token });
|
|
746
|
+
return ok;
|
|
747
|
+
}
|
|
748
|
+
revokeAllSessions() {
|
|
749
|
+
this.sessions.clear();
|
|
750
|
+
this.savePersistedData();
|
|
751
|
+
this.emit('device-revoked', { all: true });
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* 清空所有 IP 安全审计与防暴破统计记录
|
|
755
|
+
*/
|
|
756
|
+
clearAllIpSecurityStats() {
|
|
757
|
+
this.ipStats.clear();
|
|
758
|
+
this.savePersistedData();
|
|
759
|
+
this.emit('ip-security-updated', { stats: [] });
|
|
760
|
+
}
|
|
761
|
+
loadPersistedData() {
|
|
762
|
+
try {
|
|
763
|
+
if (existsSync(this.persistPath)) {
|
|
764
|
+
const content = readFileSync(this.persistPath, 'utf8');
|
|
765
|
+
const data = JSON.parse(content);
|
|
766
|
+
if (Array.isArray(data)) {
|
|
767
|
+
for (const s of data) {
|
|
768
|
+
if (s && s.token)
|
|
769
|
+
this.sessions.set(s.token, s);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
else if (data && typeof data === 'object') {
|
|
773
|
+
if (data.config) {
|
|
774
|
+
if (typeof data.config.allowTailscale === 'boolean') {
|
|
775
|
+
this.options.allowTailscale = data.config.allowTailscale;
|
|
776
|
+
}
|
|
777
|
+
if (typeof data.config.allowLan === 'boolean') {
|
|
778
|
+
this.options.allowLan = data.config.allowLan;
|
|
779
|
+
}
|
|
780
|
+
if (typeof data.config.secretHash === 'string') {
|
|
781
|
+
this.options.secretHash = data.config.secretHash;
|
|
782
|
+
}
|
|
783
|
+
else if (typeof data.config.secret === 'string' && data.config.secret) {
|
|
784
|
+
// 自动将老版本的明文 secret 迁移升级为安全加盐哈希
|
|
785
|
+
this.options.secretHash = hashSecret(data.config.secret);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (Array.isArray(data.devices)) {
|
|
789
|
+
for (const s of data.devices) {
|
|
790
|
+
if (s && s.token) {
|
|
791
|
+
// 如果当前未开启对应的免密直连,忽略并清理残留的 bypass session
|
|
792
|
+
if (s.isBypass) {
|
|
793
|
+
if (s.token.startsWith('bypass:tailscale:') && !this.options.allowTailscale)
|
|
794
|
+
continue;
|
|
795
|
+
if (s.token.startsWith('bypass:lan:') && !this.options.allowLan)
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
this.sessions.set(s.token, s);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (Array.isArray(data.ipStats)) {
|
|
803
|
+
for (const stat of data.ipStats) {
|
|
804
|
+
if (stat && stat.ip) {
|
|
805
|
+
this.ipStats.set(stat.ip, stat);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// 关联纠偏:如果已授权设备列表中有该 IP,确保审计归因与设备实际认证方式一致
|
|
810
|
+
for (const s of this.sessions.values()) {
|
|
811
|
+
if (s.ip && this.ipStats.has(s.ip)) {
|
|
812
|
+
const stat = this.ipStats.get(s.ip);
|
|
813
|
+
if (!stat.authType || stat.authType === '⚪ 待认证') {
|
|
814
|
+
if (s.authType?.includes('扫码') || s.authType?.includes('配对')) {
|
|
815
|
+
stat.authType = '📱 扫码配对认证';
|
|
816
|
+
}
|
|
817
|
+
else if (s.authType?.includes('密码')) {
|
|
818
|
+
stat.authType = '🔑 长期密码认证';
|
|
819
|
+
}
|
|
820
|
+
else if (s.authType) {
|
|
821
|
+
stat.authType = s.authType;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (!stat.deviceName && s.deviceName) {
|
|
825
|
+
stat.deviceName = s.deviceName;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
catch { }
|
|
833
|
+
}
|
|
834
|
+
savePersistedData() {
|
|
835
|
+
try {
|
|
836
|
+
mkdirSync(dirname(this.persistPath), { recursive: true });
|
|
837
|
+
const payload = {
|
|
838
|
+
devices: Array.from(this.sessions.values()),
|
|
839
|
+
ipStats: Array.from(this.ipStats.values()),
|
|
840
|
+
};
|
|
841
|
+
writeFileSync(this.persistPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
842
|
+
}
|
|
843
|
+
catch { }
|
|
844
|
+
}
|
|
845
|
+
}
|