@yezack/dsh-remote-mobile 0.0.0-stage → 1.9.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 +215 -0
- package/LICENSE +21 -0
- package/README.md +455 -3
- package/README_EN.md +454 -0
- package/cordis.patch.yml +4 -0
- package/lib/auth/crypto.d.ts +24 -0
- package/lib/auth/crypto.js +158 -0
- package/lib/auth/gate.d.ts +29 -0
- package/lib/auth/gate.js +139 -0
- package/lib/auth/prefix.d.ts +19 -0
- package/lib/auth/prefix.js +41 -0
- package/lib/auth/tailscale.d.ts +36 -0
- package/lib/auth/tailscale.js +128 -0
- package/lib/auth/token.d.ts +194 -0
- package/lib/auth/token.js +1009 -0
- package/lib/bridge/compat.d.ts +145 -0
- package/lib/bridge/compat.js +837 -0
- package/lib/client.d.ts +5 -0
- package/lib/client.js +4650 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +236 -0
- package/lib/routes/api.d.ts +16 -0
- package/lib/routes/api.js +455 -0
- package/lib/routes/login-page.d.ts +4 -0
- package/lib/routes/login-page.js +418 -0
- package/lib/styles/style-snippets.d.ts +133 -0
- package/lib/styles/style-snippets.js +1039 -0
- package/package.json +81 -6
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } 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
|
+
/** DSH_HOME 目录(支持 DSH_HOME 覆盖) */
|
|
10
|
+
export const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
11
|
+
/** 插件自有数据目录 */
|
|
12
|
+
export const REMOTE_MOBILE_DIR = join(DSH_HOME, 'remote-mobile');
|
|
13
|
+
export const DEFAULT_PERSIST_FILE = join(REMOTE_MOBILE_DIR, 'devices.json');
|
|
14
|
+
/** 插件自有配置持久化文件(DSH 0.1.7+ 起官方弃用 settings.yaml,插件改为自持存储) */
|
|
15
|
+
export const SETTINGS_FILE = join(REMOTE_MOBILE_DIR, 'settings.json');
|
|
16
|
+
/** DSH 旧版全局设置文件(0.1.7+ 起仅作一次性迁移来源,启动时会被改名) */
|
|
17
|
+
export const GLOBAL_SETTINGS_FILE = join(DSH_HOME, 'settings.yaml');
|
|
18
|
+
/** DSH 迁移后遗留的旧设置存档(legacy settings.yaml → settings.yaml.imported) */
|
|
19
|
+
export const LEGACY_SETTINGS_IMPORTED_FILE = join(DSH_HOME, 'settings.yaml.imported');
|
|
20
|
+
/**
|
|
21
|
+
* 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
|
|
22
|
+
*/
|
|
23
|
+
export function readFromSettingsYaml(filePath = GLOBAL_SETTINGS_FILE) {
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(filePath))
|
|
26
|
+
return {};
|
|
27
|
+
const content = readFileSync(filePath, 'utf8');
|
|
28
|
+
const ns = 'dsh-remote-mobile:';
|
|
29
|
+
const lines = content.split(/\r?\n/);
|
|
30
|
+
const startIdx = lines.findIndex((l) => l.trim() === ns);
|
|
31
|
+
if (startIdx === -1)
|
|
32
|
+
return {};
|
|
33
|
+
const result = {};
|
|
34
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && line.includes(':')) {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
41
|
+
continue;
|
|
42
|
+
const colonIdx = trimmed.indexOf(':');
|
|
43
|
+
if (colonIdx === -1)
|
|
44
|
+
continue;
|
|
45
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
46
|
+
let val = trimmed.slice(colonIdx + 1).trim();
|
|
47
|
+
if (val.includes('#')) {
|
|
48
|
+
val = val.split('#')[0].trim();
|
|
49
|
+
}
|
|
50
|
+
if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
|
|
51
|
+
val = val.slice(1, -1);
|
|
52
|
+
}
|
|
53
|
+
if (key === 'allowTailscale')
|
|
54
|
+
result.allowTailscale = val === 'true';
|
|
55
|
+
else if (key === 'allowLan')
|
|
56
|
+
result.allowLan = val === 'true';
|
|
57
|
+
else if (key === 'secretHash')
|
|
58
|
+
result.secretHash = val;
|
|
59
|
+
else if (key === 'secret')
|
|
60
|
+
result.secret = val;
|
|
61
|
+
else if (key === 'maxFailedAttempts') {
|
|
62
|
+
const num = parseInt(val, 10);
|
|
63
|
+
if (!isNaN(num) && num > 0)
|
|
64
|
+
result.maxFailedAttempts = num;
|
|
65
|
+
}
|
|
66
|
+
else if (key === 'lockDurationMs') {
|
|
67
|
+
const num = parseInt(val, 10);
|
|
68
|
+
if (!isNaN(num) && num > 0)
|
|
69
|
+
result.lockDurationMs = num;
|
|
70
|
+
}
|
|
71
|
+
else if (key === 'maxVisitsPerMinute') {
|
|
72
|
+
const num = parseInt(val, 10);
|
|
73
|
+
if (!isNaN(num) && num > 0)
|
|
74
|
+
result.maxVisitsPerMinute = num;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 从旧版 settings.yaml 文本内容中解析 locale.preference(返回 null 表示未命中)
|
|
85
|
+
*/
|
|
86
|
+
export function parseLocaleFromSettingsYaml(content) {
|
|
87
|
+
const match = content.match(/^locale:\s*\r?\n\s+preference:\s*['"]?([a-zA-Z_-]+)['"]?/m);
|
|
88
|
+
if (match && match[1])
|
|
89
|
+
return match[1].toLowerCase().startsWith('en') ? 'en' : 'zh';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 从 cordis.patch.yml 文本内容中解析 locale entry 的 config.preference(返回 null 表示未命中)
|
|
94
|
+
*/
|
|
95
|
+
export function parseLocaleFromPatch(content) {
|
|
96
|
+
const lines = content.split(/\r?\n/);
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
// 精确匹配 `- id: locale`(避免误命中 locale-xxx 等其它 entry)
|
|
99
|
+
if (!/^\s*-\s*id:\s*['"]?locale['"]?\s*$/.test(lines[i]))
|
|
100
|
+
continue;
|
|
101
|
+
for (let j = i + 1; j < Math.min(i + 40, lines.length); j++) {
|
|
102
|
+
// 进入下一个 patch entry,说明本条已结束
|
|
103
|
+
if (/^\s*-\s*id:/.test(lines[j]))
|
|
104
|
+
break;
|
|
105
|
+
const m = lines[j].match(/^\s*preference:\s*['"]?([a-zA-Z_-]+)['"]?/);
|
|
106
|
+
if (m && m[1])
|
|
107
|
+
return m[1].toLowerCase().startsWith('en') ? 'en' : 'zh';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function readLocaleFromFile(filePath, parser) {
|
|
113
|
+
try {
|
|
114
|
+
if (!existsSync(filePath))
|
|
115
|
+
return null;
|
|
116
|
+
return parser(readFileSync(filePath, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 读取 DSH 全局语言偏好。
|
|
124
|
+
*
|
|
125
|
+
* DSH 0.1.7+ 起配置真实落点为当前 profile 的 cordis.patch.yml(旧版 settings.yaml
|
|
126
|
+
* 已被一次性迁移并改名为 settings.yaml.imported),因此优先读取 profile patch,
|
|
127
|
+
* 再回退旧 settings.yaml / settings.yaml.imported。
|
|
128
|
+
*
|
|
129
|
+
* @param filePath 显式传入时按旧版 settings.yaml 格式解析(向后兼容与测试)。
|
|
130
|
+
*/
|
|
131
|
+
export function readGlobalLocale(filePath) {
|
|
132
|
+
if (filePath) {
|
|
133
|
+
return readLocaleFromFile(filePath, parseLocaleFromSettingsYaml) ?? 'zh';
|
|
134
|
+
}
|
|
135
|
+
// 1. 当前 profile 的 cordis.patch.yml(web 优先,其余按目录名排序兜底)
|
|
136
|
+
try {
|
|
137
|
+
const profilesDir = join(DSH_HOME, 'profiles');
|
|
138
|
+
let names = [];
|
|
139
|
+
try {
|
|
140
|
+
names = readdirSync(profilesDir, { withFileTypes: true })
|
|
141
|
+
.filter((e) => e.isDirectory())
|
|
142
|
+
.map((e) => e.name)
|
|
143
|
+
.sort((a, b) => (a === 'web' ? -1 : b === 'web' ? 1 : a.localeCompare(b)));
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
146
|
+
const candidates = [
|
|
147
|
+
...names.map((n) => join(profilesDir, n, 'cordis.patch.yml')),
|
|
148
|
+
join(DSH_HOME, 'cordis.patch.yml'),
|
|
149
|
+
];
|
|
150
|
+
for (const c of candidates) {
|
|
151
|
+
const loc = readLocaleFromFile(c, parseLocaleFromPatch);
|
|
152
|
+
if (loc)
|
|
153
|
+
return loc;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { }
|
|
157
|
+
// 2. 回退旧版 settings.yaml / settings.yaml.imported
|
|
158
|
+
for (const p of [GLOBAL_SETTINGS_FILE, LEGACY_SETTINGS_IMPORTED_FILE]) {
|
|
159
|
+
const loc = readLocaleFromFile(p, parseLocaleFromSettingsYaml);
|
|
160
|
+
if (loc)
|
|
161
|
+
return loc;
|
|
162
|
+
}
|
|
163
|
+
return 'zh';
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 读取插件自有持久化配置文件 ~/.dsh/remote-mobile/settings.json
|
|
167
|
+
*
|
|
168
|
+
* 注:若文件中 secretHash 为空串 ""(表示用户清空了密码),此处会跳过赋值以归一化为 undefined。
|
|
169
|
+
* 上层 resolveInitialStoreOptions 必须根据文件存在性(own !== null)做出决策,严禁在自有配置存在时用 legacy 兜底回退。
|
|
170
|
+
*/
|
|
171
|
+
export function readPluginSettingsJson(filePath = SETTINGS_FILE) {
|
|
172
|
+
try {
|
|
173
|
+
if (!existsSync(filePath))
|
|
174
|
+
return {};
|
|
175
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
176
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
177
|
+
return {};
|
|
178
|
+
const result = {};
|
|
179
|
+
if (typeof data.allowTailscale === 'boolean')
|
|
180
|
+
result.allowTailscale = data.allowTailscale;
|
|
181
|
+
if (typeof data.allowLan === 'boolean')
|
|
182
|
+
result.allowLan = data.allowLan;
|
|
183
|
+
if (typeof data.secretHash === 'string' && data.secretHash)
|
|
184
|
+
result.secretHash = data.secretHash;
|
|
185
|
+
if (typeof data.maxFailedAttempts === 'number' && data.maxFailedAttempts > 0)
|
|
186
|
+
result.maxFailedAttempts = data.maxFailedAttempts;
|
|
187
|
+
if (typeof data.lockDurationMs === 'number' && data.lockDurationMs > 0)
|
|
188
|
+
result.lockDurationMs = data.lockDurationMs;
|
|
189
|
+
if (typeof data.maxVisitsPerMinute === 'number' && data.maxVisitsPerMinute > 0)
|
|
190
|
+
result.maxVisitsPerMinute = data.maxVisitsPerMinute;
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 将插件配置原子写回自有配置文件 ~/.dsh/remote-mobile/settings.json(权限 0600)。
|
|
199
|
+
*
|
|
200
|
+
* DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源(启动时一次性迁移为
|
|
201
|
+
* settings.yaml.imported),因此插件不再依赖该文件,改为自持存储,避免升级后配置丢失。
|
|
202
|
+
*/
|
|
203
|
+
export function writePluginSettingsJson(options, filePath = SETTINGS_FILE) {
|
|
204
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
205
|
+
try {
|
|
206
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
207
|
+
const payload = {
|
|
208
|
+
allowTailscale: Boolean(options.allowTailscale),
|
|
209
|
+
allowLan: Boolean(options.allowLan),
|
|
210
|
+
secretHash: options.secretHash || '',
|
|
211
|
+
maxFailedAttempts: options.maxFailedAttempts || 5,
|
|
212
|
+
lockDurationMs: options.lockDurationMs || 900000,
|
|
213
|
+
maxVisitsPerMinute: options.maxVisitsPerMinute || 60,
|
|
214
|
+
};
|
|
215
|
+
writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
216
|
+
renameSync(tmpPath, filePath);
|
|
217
|
+
try {
|
|
218
|
+
chmodSync(filePath, 0o600);
|
|
219
|
+
}
|
|
220
|
+
catch { }
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
try {
|
|
225
|
+
if (existsSync(tmpPath))
|
|
226
|
+
unlinkSync(tmpPath);
|
|
227
|
+
}
|
|
228
|
+
catch { }
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 兼容读取 DSH 旧版 settings.yaml / 迁移后的 settings.yaml.imported 中的
|
|
234
|
+
* dsh-remote-mobile 段,用于首次启动时把旧配置迁移到自有 settings.json。
|
|
235
|
+
*/
|
|
236
|
+
export function readLegacyDshSettings(liveFile = GLOBAL_SETTINGS_FILE, importedFile = LEGACY_SETTINGS_IMPORTED_FILE) {
|
|
237
|
+
return { ...readFromSettingsYaml(importedFile), ...readFromSettingsYaml(liveFile) };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 解析 SessionStore 的初始运行配置(纯函数决策逻辑)。
|
|
241
|
+
*
|
|
242
|
+
* 优先级与防反噬规则:
|
|
243
|
+
* 1. 显式传入的 explicit 参数优先级最高;
|
|
244
|
+
* 2. 若自有配置文件已存在(own !== null),则以自有配置 own 为准,此时 legacy 配置被彻底忽略(防止已清空的密码死灰复燃);
|
|
245
|
+
* 3. 仅当自有配置文件尚不存在(own === null,即首次安装或旧版升级)时,才回退读取 legacy 配置进行一次性迁移;
|
|
246
|
+
* 4. 最终缺失项采用系统安全默认值。
|
|
247
|
+
*
|
|
248
|
+
* @param explicit 构造函数传入的显式参数
|
|
249
|
+
* @param own 插件自有 settings.json 内容;null 表示文件尚不存在(首次启动迁移状态)
|
|
250
|
+
* @param legacy 旧版 settings.yaml / settings.yaml.imported 内容
|
|
251
|
+
*/
|
|
252
|
+
export function resolveInitialStoreOptions(explicit = {}, own = null, legacy = {}) {
|
|
253
|
+
// 当自有配置文件已存在时,legacy 彻底失效;仅在 own 为 null 时继承 legacy
|
|
254
|
+
const base = own !== null ? own : legacy;
|
|
255
|
+
const merged = { ...base, ...explicit };
|
|
256
|
+
// 密码优先级:explicit > base(base 即 own 或 legacy,已由上面的门控决定;
|
|
257
|
+
// 自有配置存在但未设密码时 base 无密码,保持为空,绝不回退 legacy)
|
|
258
|
+
let secretHash = '';
|
|
259
|
+
if (typeof explicit.secretHash === 'string' && explicit.secretHash) {
|
|
260
|
+
secretHash = explicit.secretHash;
|
|
261
|
+
}
|
|
262
|
+
else if (typeof explicit.secret === 'string' && explicit.secret) {
|
|
263
|
+
secretHash = hashSecret(explicit.secret);
|
|
264
|
+
}
|
|
265
|
+
else if (typeof base.secretHash === 'string' && base.secretHash) {
|
|
266
|
+
secretHash = base.secretHash;
|
|
267
|
+
}
|
|
268
|
+
else if (typeof base.secret === 'string' && base.secret) {
|
|
269
|
+
secretHash = hashSecret(base.secret);
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
allowTailscale: merged.allowTailscale ?? false,
|
|
273
|
+
allowLan: merged.allowLan ?? false,
|
|
274
|
+
secret: '',
|
|
275
|
+
secretHash,
|
|
276
|
+
devicesFile: merged.devicesFile ?? DEFAULT_PERSIST_FILE,
|
|
277
|
+
maxFailedAttempts: merged.maxFailedAttempts ?? 5,
|
|
278
|
+
lockDurationMs: merged.lockDurationMs ?? 15 * 60 * 1000,
|
|
279
|
+
maxVisitsPerMinute: merged.maxVisitsPerMinute ?? 60,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 将 User-Agent 字符串解析为人类友好的设备名称
|
|
284
|
+
*/
|
|
285
|
+
export function parseDeviceName(ua = '') {
|
|
286
|
+
const isIPhone = /iPhone/i.test(ua);
|
|
287
|
+
const isIPad = /iPad/i.test(ua);
|
|
288
|
+
const isAndroid = /Android/i.test(ua);
|
|
289
|
+
const isMac = /Macintosh/i.test(ua) && !isIPhone && !isIPad;
|
|
290
|
+
const isWindows = /Windows/i.test(ua);
|
|
291
|
+
const isLinux = /Linux/i.test(ua) && !isAndroid;
|
|
292
|
+
let browser = 'Browser';
|
|
293
|
+
if (/EdgA?\/|Edge\//i.test(ua))
|
|
294
|
+
browser = 'Edge';
|
|
295
|
+
else if (/Chrome\//i.test(ua) && !/Edg/i.test(ua))
|
|
296
|
+
browser = 'Chrome';
|
|
297
|
+
else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua))
|
|
298
|
+
browser = 'Safari';
|
|
299
|
+
else if (/Firefox\//i.test(ua))
|
|
300
|
+
browser = 'Firefox';
|
|
301
|
+
else if (/MicroMessenger/i.test(ua))
|
|
302
|
+
browser = '微信内置浏览器';
|
|
303
|
+
if (isIPhone)
|
|
304
|
+
return `📱 iPhone (${browser})`;
|
|
305
|
+
if (isIPad)
|
|
306
|
+
return `📱 iPad (${browser})`;
|
|
307
|
+
if (isAndroid)
|
|
308
|
+
return `🤖 Android 手机 (${browser})`;
|
|
309
|
+
if (isMac)
|
|
310
|
+
return `💻 Mac (${browser})`;
|
|
311
|
+
if (isWindows)
|
|
312
|
+
return `💻 Windows (${browser})`;
|
|
313
|
+
if (isLinux)
|
|
314
|
+
return `💻 Linux (${browser})`;
|
|
315
|
+
return `📱 移动设备 (${browser})`;
|
|
316
|
+
}
|
|
317
|
+
const activeStores = new Set();
|
|
318
|
+
if (typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
319
|
+
process.on('beforeExit', () => {
|
|
320
|
+
for (const s of activeStores) {
|
|
321
|
+
try {
|
|
322
|
+
s.flushPersistedData();
|
|
323
|
+
}
|
|
324
|
+
catch { }
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
export class SessionStore extends EventEmitter {
|
|
329
|
+
sessions = new Map();
|
|
330
|
+
shortCodes = new Map();
|
|
331
|
+
ipStats = new Map();
|
|
332
|
+
options;
|
|
333
|
+
settingsMutator;
|
|
334
|
+
debounceTimer = null;
|
|
335
|
+
persistPath;
|
|
336
|
+
settingsFilePath;
|
|
337
|
+
constructor(options = {}) {
|
|
338
|
+
super();
|
|
339
|
+
this.setMaxListeners(100);
|
|
340
|
+
// 严密隔离单测环境:当处于 node --test 环境或使用了自定义设备文件时,严禁读写全局配置文件
|
|
341
|
+
const isTestEnv = Boolean(process.env.NODE_TEST_CONTEXT ||
|
|
342
|
+
process.argv.some((arg) => arg.includes('test')) ||
|
|
343
|
+
(options.devicesFile && options.devicesFile !== DEFAULT_PERSIST_FILE));
|
|
344
|
+
this.settingsFilePath = isTestEnv ? null : SETTINGS_FILE;
|
|
345
|
+
// 仅在自有 settings.json 尚不存在时,才尝试读取旧版 settings.yaml / settings.yaml.imported 进行一次性兼容迁移;
|
|
346
|
+
// 一旦 settings.json 存在,即以自有配置为准,杜绝旧文件中已废弃的密码或参数在重启后反向覆盖当前配置。
|
|
347
|
+
const hasOwnSettings = !isTestEnv && existsSync(SETTINGS_FILE);
|
|
348
|
+
const ownOpts = isTestEnv ? {} : (hasOwnSettings ? readPluginSettingsJson(SETTINGS_FILE) : null);
|
|
349
|
+
const legacyOpts = isTestEnv || hasOwnSettings ? {} : readLegacyDshSettings();
|
|
350
|
+
this.options = resolveInitialStoreOptions(options, ownOpts, legacyOpts);
|
|
351
|
+
this.persistPath = this.options.devicesFile;
|
|
352
|
+
// 首次启动迁移:自有配置不存在但旧 settings.yaml / settings.yaml.imported 中仍有配置时,
|
|
353
|
+
// 落盘一次到自有 settings.json,把旧配置(如长期密码哈希、限频)救回。
|
|
354
|
+
if (!isTestEnv && !hasOwnSettings && Object.keys(legacyOpts).length > 0) {
|
|
355
|
+
writePluginSettingsJson(this.options, SETTINGS_FILE);
|
|
356
|
+
}
|
|
357
|
+
this.loadPersistedData();
|
|
358
|
+
activeStores.add(this);
|
|
359
|
+
}
|
|
360
|
+
getOptions() {
|
|
361
|
+
return { ...this.options };
|
|
362
|
+
}
|
|
363
|
+
hasSecret() {
|
|
364
|
+
return Boolean(this.options.secretHash || this.options.secret);
|
|
365
|
+
}
|
|
366
|
+
setSettingsMutator(mutator) {
|
|
367
|
+
this.settingsMutator = mutator;
|
|
368
|
+
}
|
|
369
|
+
updateOptions(newOpts, syncToSettings = true) {
|
|
370
|
+
const prevTailscale = this.options.allowTailscale;
|
|
371
|
+
const prevLan = this.options.allowLan;
|
|
372
|
+
if (typeof newOpts.secret === 'string') {
|
|
373
|
+
if (!newOpts.secret) {
|
|
374
|
+
newOpts.secret = '';
|
|
375
|
+
newOpts.secretHash = '';
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
newOpts.secretHash = hashSecret(newOpts.secret);
|
|
379
|
+
newOpts.secret = '';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this.options = { ...this.options, ...newOpts };
|
|
383
|
+
if (syncToSettings) {
|
|
384
|
+
if (this.settingsMutator) {
|
|
385
|
+
try {
|
|
386
|
+
const patch = {};
|
|
387
|
+
if (typeof newOpts.allowTailscale === 'boolean')
|
|
388
|
+
patch.allowTailscale = newOpts.allowTailscale;
|
|
389
|
+
if (typeof newOpts.allowLan === 'boolean')
|
|
390
|
+
patch.allowLan = newOpts.allowLan;
|
|
391
|
+
if (typeof newOpts.secretHash === 'string')
|
|
392
|
+
patch.secretHash = newOpts.secretHash;
|
|
393
|
+
if (typeof newOpts.maxFailedAttempts === 'number')
|
|
394
|
+
patch.maxFailedAttempts = newOpts.maxFailedAttempts;
|
|
395
|
+
if (typeof newOpts.lockDurationMs === 'number')
|
|
396
|
+
patch.lockDurationMs = newOpts.lockDurationMs;
|
|
397
|
+
if (typeof newOpts.maxVisitsPerMinute === 'number')
|
|
398
|
+
patch.maxVisitsPerMinute = newOpts.maxVisitsPerMinute;
|
|
399
|
+
this.settingsMutator(patch);
|
|
400
|
+
}
|
|
401
|
+
catch { }
|
|
402
|
+
}
|
|
403
|
+
// 同步持久化到插件自有配置文件 ~/.dsh/remote-mobile/settings.json
|
|
404
|
+
// (DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源)
|
|
405
|
+
if (this.settingsFilePath) {
|
|
406
|
+
writePluginSettingsJson(this.options, this.settingsFilePath);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// 1. 如果关闭了 Tailscale 免密直连,自动清理所有 Tailscale 免密设备,并将审计状态重置为 ⚪ 待认证
|
|
410
|
+
if (prevTailscale && newOpts.allowTailscale === false) {
|
|
411
|
+
for (const [token, s] of Array.from(this.sessions.entries())) {
|
|
412
|
+
if (s.isBypass && (s.authType?.includes('Tailscale') || token.startsWith('bypass:tailscale:'))) {
|
|
413
|
+
this.sessions.delete(token);
|
|
414
|
+
if (s.ip && this.ipStats.has(s.ip)) {
|
|
415
|
+
const stat = this.ipStats.get(s.ip);
|
|
416
|
+
if (stat.authType?.includes('Tailscale') || stat.authType?.includes('免密')) {
|
|
417
|
+
stat.authType = '⚪ 待认证';
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
this.emit('device-revoked', { bypassType: 'Tailscale' });
|
|
423
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
424
|
+
}
|
|
425
|
+
// 2. 如果关闭了 局域网 LAN 免密直连,自动清理所有 LAN 免密设备,并将审计状态重置为 ⚪ 待认证
|
|
426
|
+
if (prevLan && newOpts.allowLan === false) {
|
|
427
|
+
for (const [token, s] of Array.from(this.sessions.entries())) {
|
|
428
|
+
if (s.isBypass && (s.authType?.includes('局域网') || s.authType?.includes('LAN') || token.startsWith('bypass:lan:'))) {
|
|
429
|
+
this.sessions.delete(token);
|
|
430
|
+
if (s.ip && this.ipStats.has(s.ip)) {
|
|
431
|
+
const stat = this.ipStats.get(s.ip);
|
|
432
|
+
if (stat.authType?.includes('局域网') || stat.authType?.includes('LAN') || stat.authType?.includes('免密')) {
|
|
433
|
+
stat.authType = '⚪ 待认证';
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
this.emit('device-revoked', { bypassType: 'LAN' });
|
|
439
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
440
|
+
}
|
|
441
|
+
this.savePersistedData();
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* 记录 IP 打开 /auth 页面的计数并进行限频判定与设备指纹解析
|
|
445
|
+
*/
|
|
446
|
+
recordAuthVisit(ip, userAgent = '') {
|
|
447
|
+
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') {
|
|
448
|
+
return { allowed: true };
|
|
449
|
+
}
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
const devName = parseDeviceName(userAgent);
|
|
452
|
+
let stat = this.ipStats.get(ip);
|
|
453
|
+
if (!stat) {
|
|
454
|
+
stat = {
|
|
455
|
+
ip,
|
|
456
|
+
authVisits: 0,
|
|
457
|
+
authVisitsInWindow: 0,
|
|
458
|
+
windowStart: now,
|
|
459
|
+
failedAttempts: 0,
|
|
460
|
+
totalAttempts: 0,
|
|
461
|
+
lockedUntil: 0,
|
|
462
|
+
lastAttemptAt: 0,
|
|
463
|
+
lastSeenAt: 0,
|
|
464
|
+
authType: '⚪ 待认证',
|
|
465
|
+
deviceName: devName,
|
|
466
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
467
|
+
};
|
|
468
|
+
this.ipStats.set(ip, stat);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
if (devName && devName !== '💻 未知设备' && devName !== '📱 移动设备 (Browser)') {
|
|
472
|
+
stat.deviceName = devName;
|
|
473
|
+
}
|
|
474
|
+
if (userAgent) {
|
|
475
|
+
stat.userAgent = userAgent.slice(0, 150);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// 当设备打开登录页时,若该 IP 当前没有正式授权会话且未处于免密直连中,状态明确更新为 ⚪ 待认证
|
|
479
|
+
const hasValidSession = Array.from(this.sessions.values()).some((s) => s.ip === ip && !s.isBypass);
|
|
480
|
+
const isTailscaleBypassActive = this.options.allowTailscale && isTailscaleIp(ip);
|
|
481
|
+
const isLanBypassActive = this.options.allowLan && isLanIp(ip);
|
|
482
|
+
if (!hasValidSession && !isTailscaleBypassActive && !isLanBypassActive) {
|
|
483
|
+
stat.authType = '⚪ 待认证';
|
|
484
|
+
// 同步清理残留的 bypass session
|
|
485
|
+
for (const [token, s] of Array.from(this.sessions.entries())) {
|
|
486
|
+
if (s.ip === ip && s.isBypass) {
|
|
487
|
+
this.sessions.delete(token);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
stat.lastSeenAt = now;
|
|
492
|
+
stat.authVisits += 1;
|
|
493
|
+
// 检查是否在锁定期内
|
|
494
|
+
if (stat.lockedUntil > now) {
|
|
495
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
496
|
+
return {
|
|
497
|
+
allowed: false,
|
|
498
|
+
lockedUntil: stat.lockedUntil,
|
|
499
|
+
reason: `IP 已被锁定,请在 ${Math.ceil((stat.lockedUntil - now) / 60000)} 分钟后再试`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
// 1 分钟滑动窗口限频
|
|
503
|
+
if (now - stat.windowStart > 60000) {
|
|
504
|
+
stat.windowStart = now;
|
|
505
|
+
stat.authVisitsInWindow = 1;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
stat.authVisitsInWindow += 1;
|
|
509
|
+
if (stat.authVisitsInWindow > this.options.maxVisitsPerMinute) {
|
|
510
|
+
stat.lockedUntil = now + 5 * 60 * 1000; // 恶意刷页面封锁 5 分钟
|
|
511
|
+
this.emit('ip-security-alert', {
|
|
512
|
+
ip,
|
|
513
|
+
reason: '短时间内高频打开登录页,已自动安全拦截 5 分钟',
|
|
514
|
+
remainingSeconds: 300,
|
|
515
|
+
level: 'danger',
|
|
516
|
+
});
|
|
517
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
518
|
+
this.savePersistedData();
|
|
519
|
+
return {
|
|
520
|
+
allowed: false,
|
|
521
|
+
lockedUntil: stat.lockedUntil,
|
|
522
|
+
reason: '页面请求过于频繁,已被临时安全保护',
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
527
|
+
this.savePersistedDataDebounced();
|
|
528
|
+
return { allowed: true };
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* 检查 IP 是否被防暴力破解锁定
|
|
532
|
+
*/
|
|
533
|
+
isIpLocked(ip) {
|
|
534
|
+
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip === 'localhost') {
|
|
535
|
+
return { locked: false };
|
|
536
|
+
}
|
|
537
|
+
const stat = this.ipStats.get(ip);
|
|
538
|
+
if (!stat || stat.lockedUntil <= Date.now()) {
|
|
539
|
+
return { locked: false };
|
|
540
|
+
}
|
|
541
|
+
const remainingSeconds = Math.ceil((stat.lockedUntil - Date.now()) / 1000);
|
|
542
|
+
return { locked: true, lockedUntil: stat.lockedUntil, remainingSeconds };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* 获取所有 IP 的安全审计统计数据
|
|
546
|
+
*/
|
|
547
|
+
getIpSecurityStats() {
|
|
548
|
+
const list = Array.from(this.ipStats.values());
|
|
549
|
+
list.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
550
|
+
return list;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* 管理员手动解锁某个 IP
|
|
554
|
+
*/
|
|
555
|
+
unlockIp(ip) {
|
|
556
|
+
const stat = this.ipStats.get(ip);
|
|
557
|
+
if (stat) {
|
|
558
|
+
stat.lockedUntil = 0;
|
|
559
|
+
stat.failedAttempts = 0;
|
|
560
|
+
stat.authVisitsInWindow = 0;
|
|
561
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
562
|
+
this.savePersistedData();
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
generateShortCode() {
|
|
568
|
+
const code = randomInt(100000, 1000000).toString();
|
|
569
|
+
const token = randomBytes(24).toString('hex');
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
const expiresAt = now + 5 * 60 * 1000; // 5 分钟配对有效期
|
|
572
|
+
const record = { code, token, createdAt: now, expiresAt };
|
|
573
|
+
this.shortCodes.set(code, record);
|
|
574
|
+
this.shortCodes.set(token, record);
|
|
575
|
+
// 定时清理,调用 unref 避免阻塞 Node 进程退出/测试执行
|
|
576
|
+
const timer = setTimeout(() => {
|
|
577
|
+
this.shortCodes.delete(code);
|
|
578
|
+
this.shortCodes.delete(token);
|
|
579
|
+
}, 5 * 60 * 1000 + 1000);
|
|
580
|
+
if (typeof timer.unref === 'function') {
|
|
581
|
+
;
|
|
582
|
+
timer.unref();
|
|
583
|
+
}
|
|
584
|
+
return { code, token, expiresAt };
|
|
585
|
+
}
|
|
586
|
+
verify(credential, userAgent, clientIp) {
|
|
587
|
+
const trimmed = (credential || '').trim();
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
const deviceName = parseDeviceName(userAgent);
|
|
590
|
+
const ip = clientIp || '';
|
|
591
|
+
// 0. 防暴力破解检查:IP 是否处于封锁期
|
|
592
|
+
if (ip) {
|
|
593
|
+
const lockCheck = this.isIpLocked(ip);
|
|
594
|
+
if (lockCheck.locked) {
|
|
595
|
+
return {
|
|
596
|
+
success: false,
|
|
597
|
+
locked: true,
|
|
598
|
+
remainingSeconds: lockCheck.remainingSeconds,
|
|
599
|
+
reason: `连续尝试错误过多,IP 已被安全锁定,请在 ${Math.ceil((lockCheck.remainingSeconds || 0) / 60)} 分钟后再试`,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
let stat = ip ? this.ipStats.get(ip) : undefined;
|
|
604
|
+
if (!stat && ip) {
|
|
605
|
+
stat = {
|
|
606
|
+
ip,
|
|
607
|
+
authVisits: 1,
|
|
608
|
+
authVisitsInWindow: 1,
|
|
609
|
+
windowStart: now,
|
|
610
|
+
failedAttempts: 0,
|
|
611
|
+
totalAttempts: 0,
|
|
612
|
+
lockedUntil: 0,
|
|
613
|
+
lastAttemptAt: now,
|
|
614
|
+
lastSeenAt: now,
|
|
615
|
+
};
|
|
616
|
+
this.ipStats.set(ip, stat);
|
|
617
|
+
}
|
|
618
|
+
if (stat) {
|
|
619
|
+
stat.totalAttempts += 1;
|
|
620
|
+
stat.lastAttemptAt = now;
|
|
621
|
+
stat.lastSeenAt = now;
|
|
622
|
+
if (deviceName && deviceName !== '💻 未知设备' && deviceName !== '📱 移动设备 (Browser)') {
|
|
623
|
+
stat.deviceName = deviceName;
|
|
624
|
+
}
|
|
625
|
+
if (userAgent) {
|
|
626
|
+
stat.userAgent = userAgent.slice(0, 150);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// 1. 验证 6 位一次性配对码
|
|
630
|
+
const short = this.shortCodes.get(trimmed);
|
|
631
|
+
if (short) {
|
|
632
|
+
if (short.expiresAt < now) {
|
|
633
|
+
this.shortCodes.delete(short.code);
|
|
634
|
+
this.shortCodes.delete(short.token);
|
|
635
|
+
this.handleFailedAttempt(stat, now);
|
|
636
|
+
return { success: false, reason: '配对码已过期,请在 PC 上刷新获取' };
|
|
637
|
+
}
|
|
638
|
+
this.shortCodes.delete(short.code);
|
|
639
|
+
this.shortCodes.delete(short.token);
|
|
640
|
+
// 认证成功,重置失败计数并记录认证归因
|
|
641
|
+
if (stat) {
|
|
642
|
+
stat.failedAttempts = 0;
|
|
643
|
+
stat.lockedUntil = 0;
|
|
644
|
+
stat.authType = '📱 扫码配对认证';
|
|
645
|
+
}
|
|
646
|
+
// 配对成功后派发 365 天长效会话 Token
|
|
647
|
+
const sessionToken = randomBytes(32).toString('hex');
|
|
648
|
+
const session = {
|
|
649
|
+
token: sessionToken,
|
|
650
|
+
createdAt: now,
|
|
651
|
+
lastSeenAt: now,
|
|
652
|
+
ip,
|
|
653
|
+
deviceName,
|
|
654
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
655
|
+
authType: '扫码配对码 (365天免登)',
|
|
656
|
+
};
|
|
657
|
+
this.sessions.set(sessionToken, session);
|
|
658
|
+
this.savePersistedData();
|
|
659
|
+
this.emit('device-connected', session);
|
|
660
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
661
|
+
return { success: true, token: sessionToken };
|
|
662
|
+
}
|
|
663
|
+
// 2. 验证长期密码 (通过加盐哈希安全校验,防时序攻击与明文泄露)
|
|
664
|
+
const targetHash = this.options.secretHash || (this.options.secret ? hashSecret(this.options.secret) : '');
|
|
665
|
+
if (targetHash && verifySecretHash(trimmed, targetHash)) {
|
|
666
|
+
// 认证成功,重置失败计数并记录认证归因
|
|
667
|
+
if (stat) {
|
|
668
|
+
stat.failedAttempts = 0;
|
|
669
|
+
stat.lockedUntil = 0;
|
|
670
|
+
stat.authType = '🔑 长期密码认证';
|
|
671
|
+
}
|
|
672
|
+
const sessionToken = randomBytes(32).toString('hex');
|
|
673
|
+
const session = {
|
|
674
|
+
token: sessionToken,
|
|
675
|
+
createdAt: now,
|
|
676
|
+
lastSeenAt: now,
|
|
677
|
+
ip,
|
|
678
|
+
deviceName,
|
|
679
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
680
|
+
authType: '长期密码认证 (365天免登)',
|
|
681
|
+
};
|
|
682
|
+
this.sessions.set(sessionToken, session);
|
|
683
|
+
this.savePersistedData();
|
|
684
|
+
this.emit('device-connected', session);
|
|
685
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
686
|
+
return { success: true, token: sessionToken };
|
|
687
|
+
}
|
|
688
|
+
// 认证失败,累加失败次数并判定是否锁定
|
|
689
|
+
const lockInfo = this.handleFailedAttempt(stat, now);
|
|
690
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
691
|
+
this.savePersistedData();
|
|
692
|
+
if (lockInfo.justLocked) {
|
|
693
|
+
this.emit('ip-security-alert', {
|
|
694
|
+
ip: stat?.ip || ip,
|
|
695
|
+
reason: `连续输入错误达 ${this.options.maxFailedAttempts} 次,已触发安全锁定 15 分钟!`,
|
|
696
|
+
remainingSeconds: lockInfo.remainingSeconds,
|
|
697
|
+
level: 'danger',
|
|
698
|
+
});
|
|
699
|
+
return {
|
|
700
|
+
success: false,
|
|
701
|
+
locked: true,
|
|
702
|
+
remainingSeconds: lockInfo.remainingSeconds,
|
|
703
|
+
reason: `连续输入错误达 ${this.options.maxFailedAttempts} 次,IP 已被安全锁定 15 分钟!`,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const remainingTries = Math.max(0, this.options.maxFailedAttempts - (stat?.failedAttempts || 1));
|
|
707
|
+
return {
|
|
708
|
+
success: false,
|
|
709
|
+
reason: remainingTries > 0
|
|
710
|
+
? `配对码或长期密码错误(还可尝试 ${remainingTries} 次)`
|
|
711
|
+
: '配对码或长期密码错误',
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
handleFailedAttempt(stat, now) {
|
|
715
|
+
if (!stat)
|
|
716
|
+
return { justLocked: false };
|
|
717
|
+
stat.failedAttempts += 1;
|
|
718
|
+
if (stat.failedAttempts >= this.options.maxFailedAttempts) {
|
|
719
|
+
stat.lockedUntil = now + this.options.lockDurationMs;
|
|
720
|
+
const remainingSeconds = Math.ceil(this.options.lockDurationMs / 1000);
|
|
721
|
+
return { justLocked: true, remainingSeconds };
|
|
722
|
+
}
|
|
723
|
+
return { justLocked: false };
|
|
724
|
+
}
|
|
725
|
+
validateToken(token, clientIp) {
|
|
726
|
+
if (!token)
|
|
727
|
+
return false;
|
|
728
|
+
const session = this.sessions.get(token);
|
|
729
|
+
if (!session)
|
|
730
|
+
return false;
|
|
731
|
+
const now = Date.now();
|
|
732
|
+
// 服务端 365 天过期校验(与客户端 Cookie Max-Age 对齐)
|
|
733
|
+
const MAX_SESSION_AGE = 365 * 24 * 60 * 60 * 1000;
|
|
734
|
+
if (now - session.createdAt > MAX_SESSION_AGE) {
|
|
735
|
+
this.sessions.delete(token);
|
|
736
|
+
this.savePersistedData();
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
// 设备距离上次活跃超过 5 分钟,判定为重连上线,触发上线提醒(避免连续请求重复弹窗)
|
|
740
|
+
const isReconnecting = (now - (session.lastSeenAt || 0)) > 5 * 60 * 1000;
|
|
741
|
+
session.lastSeenAt = now;
|
|
742
|
+
if (clientIp) {
|
|
743
|
+
session.ip = clientIp;
|
|
744
|
+
let stat = this.ipStats.get(clientIp);
|
|
745
|
+
if (!stat) {
|
|
746
|
+
stat = {
|
|
747
|
+
ip: clientIp,
|
|
748
|
+
authVisits: 0,
|
|
749
|
+
authVisitsInWindow: 0,
|
|
750
|
+
windowStart: now,
|
|
751
|
+
failedAttempts: 0,
|
|
752
|
+
totalAttempts: 0,
|
|
753
|
+
lockedUntil: 0,
|
|
754
|
+
lastAttemptAt: 0,
|
|
755
|
+
lastSeenAt: now,
|
|
756
|
+
};
|
|
757
|
+
this.ipStats.set(clientIp, stat);
|
|
758
|
+
}
|
|
759
|
+
stat.lastSeenAt = now;
|
|
760
|
+
if (session.deviceName && session.deviceName !== '💻 未知设备' && session.deviceName !== '📱 移动设备 (Browser)') {
|
|
761
|
+
stat.deviceName = session.deviceName;
|
|
762
|
+
}
|
|
763
|
+
if (session.authType) {
|
|
764
|
+
if (session.authType.includes('扫码') || session.authType.includes('配对')) {
|
|
765
|
+
stat.authType = '📱 扫码配对认证';
|
|
766
|
+
}
|
|
767
|
+
else if (session.authType.includes('密码')) {
|
|
768
|
+
stat.authType = '🔑 长期密码认证';
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (isReconnecting) {
|
|
773
|
+
this.emit('device-online', session);
|
|
774
|
+
this.savePersistedDataDebounced();
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
extractTokenFromRequest(req) {
|
|
779
|
+
const cookies = req.headers.cookie || '';
|
|
780
|
+
const match = cookies.match(new RegExp(`(?:^|;\\s*)${AUTH_COOKIE_NAME}=([^;]+)`));
|
|
781
|
+
if (match)
|
|
782
|
+
return match[1];
|
|
783
|
+
const authHeader = req.headers.authorization || '';
|
|
784
|
+
if (authHeader.startsWith('Bearer '))
|
|
785
|
+
return authHeader.slice(7).trim();
|
|
786
|
+
if (req.url && (req.url.includes('rm_token=') || req.url.includes('auth_token='))) {
|
|
787
|
+
try {
|
|
788
|
+
const u = new URL(req.url, 'http://localhost');
|
|
789
|
+
const t = u.searchParams.get('rm_token') || u.searchParams.get('auth_token');
|
|
790
|
+
if (t)
|
|
791
|
+
return t;
|
|
792
|
+
}
|
|
793
|
+
catch { }
|
|
794
|
+
}
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* 自动登记并归因免密直连设备(Tailscale / LAN)
|
|
799
|
+
*/
|
|
800
|
+
registerBypassDevice(ip, userAgent = '', type) {
|
|
801
|
+
const token = `bypass:${type.toLowerCase()}:${ip}`;
|
|
802
|
+
const now = Date.now();
|
|
803
|
+
const deviceName = parseDeviceName(userAgent);
|
|
804
|
+
const authType = type === 'Tailscale' ? '⚡ Tailscale 免密直连' : '⚡ 局域网 LAN 免密直连';
|
|
805
|
+
let session = this.sessions.get(token);
|
|
806
|
+
let isNew = false;
|
|
807
|
+
let isReconnecting = false;
|
|
808
|
+
if (!session) {
|
|
809
|
+
isNew = true;
|
|
810
|
+
session = {
|
|
811
|
+
token,
|
|
812
|
+
createdAt: now,
|
|
813
|
+
lastSeenAt: now,
|
|
814
|
+
ip,
|
|
815
|
+
deviceName,
|
|
816
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
817
|
+
authType,
|
|
818
|
+
isBypass: true,
|
|
819
|
+
};
|
|
820
|
+
this.sessions.set(token, session);
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
isReconnecting = (now - (session.lastSeenAt || 0)) > 5 * 60 * 1000;
|
|
824
|
+
session.lastSeenAt = now;
|
|
825
|
+
if (deviceName && deviceName !== '💻 未知设备' && deviceName !== '📱 移动设备 (Browser)') {
|
|
826
|
+
session.deviceName = deviceName;
|
|
827
|
+
}
|
|
828
|
+
if (userAgent) {
|
|
829
|
+
session.userAgent = userAgent.slice(0, 150);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// 同步更新 IP 安全审计中的归因标记与设备型号
|
|
833
|
+
let stat = this.ipStats.get(ip);
|
|
834
|
+
if (!stat) {
|
|
835
|
+
stat = {
|
|
836
|
+
ip,
|
|
837
|
+
authVisits: 0,
|
|
838
|
+
authVisitsInWindow: 0,
|
|
839
|
+
windowStart: now,
|
|
840
|
+
failedAttempts: 0,
|
|
841
|
+
totalAttempts: 0,
|
|
842
|
+
lockedUntil: 0,
|
|
843
|
+
lastAttemptAt: 0,
|
|
844
|
+
lastSeenAt: now,
|
|
845
|
+
authType,
|
|
846
|
+
deviceName,
|
|
847
|
+
userAgent: (userAgent || '').slice(0, 150),
|
|
848
|
+
};
|
|
849
|
+
this.ipStats.set(ip, stat);
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
stat.lastSeenAt = now;
|
|
853
|
+
stat.authType = authType;
|
|
854
|
+
if (deviceName && deviceName !== '💻 未知设备' && deviceName !== '📱 移动设备 (Browser)') {
|
|
855
|
+
stat.deviceName = deviceName;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (isNew) {
|
|
859
|
+
this.savePersistedData();
|
|
860
|
+
this.emit('device-connected', session);
|
|
861
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
862
|
+
}
|
|
863
|
+
else if (isReconnecting) {
|
|
864
|
+
this.savePersistedData();
|
|
865
|
+
this.emit('device-online', session);
|
|
866
|
+
this.emit('ip-security-updated', { stats: this.getIpSecurityStats() });
|
|
867
|
+
}
|
|
868
|
+
return session;
|
|
869
|
+
}
|
|
870
|
+
getSessionsList() {
|
|
871
|
+
const list = Array.from(this.sessions.values()).map((s) => ({
|
|
872
|
+
token: s.token,
|
|
873
|
+
deviceName: s.deviceName || parseDeviceName(s.userAgent),
|
|
874
|
+
ip: s.ip || '未知',
|
|
875
|
+
createdAt: s.createdAt,
|
|
876
|
+
lastSeenAt: s.lastSeenAt,
|
|
877
|
+
authType: s.authType || '配对认证',
|
|
878
|
+
isBypass: Boolean(s.isBypass),
|
|
879
|
+
}));
|
|
880
|
+
list.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
881
|
+
return {
|
|
882
|
+
count: list.length,
|
|
883
|
+
devices: list,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
revokeDevice(token) {
|
|
887
|
+
if (!token)
|
|
888
|
+
return false;
|
|
889
|
+
const ok = this.sessions.delete(token);
|
|
890
|
+
this.savePersistedData();
|
|
891
|
+
this.emit('device-revoked', { token });
|
|
892
|
+
return ok;
|
|
893
|
+
}
|
|
894
|
+
revokeAllSessions() {
|
|
895
|
+
this.sessions.clear();
|
|
896
|
+
this.savePersistedData();
|
|
897
|
+
this.emit('device-revoked', { all: true });
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* 清空所有 IP 安全审计与防暴破统计记录
|
|
901
|
+
*/
|
|
902
|
+
clearAllIpSecurityStats() {
|
|
903
|
+
this.ipStats.clear();
|
|
904
|
+
this.savePersistedData();
|
|
905
|
+
this.emit('ip-security-updated', { stats: [] });
|
|
906
|
+
}
|
|
907
|
+
loadPersistedData() {
|
|
908
|
+
try {
|
|
909
|
+
if (existsSync(this.persistPath)) {
|
|
910
|
+
const content = readFileSync(this.persistPath, 'utf8');
|
|
911
|
+
const data = JSON.parse(content);
|
|
912
|
+
if (Array.isArray(data)) {
|
|
913
|
+
for (const s of data) {
|
|
914
|
+
if (s && s.token)
|
|
915
|
+
this.sessions.set(s.token, s);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
else if (data && typeof data === 'object') {
|
|
919
|
+
if (Array.isArray(data.devices)) {
|
|
920
|
+
for (const s of data.devices) {
|
|
921
|
+
if (s && s.token) {
|
|
922
|
+
// 如果当前未开启对应的免密直连,忽略并清理残留的 bypass session
|
|
923
|
+
if (s.isBypass) {
|
|
924
|
+
if (s.token.startsWith('bypass:tailscale:') && !this.options.allowTailscale)
|
|
925
|
+
continue;
|
|
926
|
+
if (s.token.startsWith('bypass:lan:') && !this.options.allowLan)
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
this.sessions.set(s.token, s);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (Array.isArray(data.ipStats)) {
|
|
934
|
+
for (const stat of data.ipStats) {
|
|
935
|
+
if (stat && stat.ip) {
|
|
936
|
+
this.ipStats.set(stat.ip, stat);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// 关联纠偏:如果已授权设备列表中有该 IP,确保审计归因与设备实际认证方式一致
|
|
941
|
+
for (const s of this.sessions.values()) {
|
|
942
|
+
if (s.ip && this.ipStats.has(s.ip)) {
|
|
943
|
+
const stat = this.ipStats.get(s.ip);
|
|
944
|
+
if (!stat.authType || stat.authType === '⚪ 待认证') {
|
|
945
|
+
if (s.authType?.includes('扫码') || s.authType?.includes('配对')) {
|
|
946
|
+
stat.authType = '📱 扫码配对认证';
|
|
947
|
+
}
|
|
948
|
+
else if (s.authType?.includes('密码')) {
|
|
949
|
+
stat.authType = '🔑 长期密码认证';
|
|
950
|
+
}
|
|
951
|
+
else if (s.authType) {
|
|
952
|
+
stat.authType = s.authType;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (!stat.deviceName && s.deviceName) {
|
|
956
|
+
stat.deviceName = s.deviceName;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
catch { }
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* 同步立即持久化落盘(由关键路径调用:配对成功、密码认证、撤销设备、手动改密/配置等)
|
|
967
|
+
*/
|
|
968
|
+
savePersistedData() {
|
|
969
|
+
this.flushPersistedData();
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* 500ms 防抖持久化落盘(由高频访问路径调用:每次打开 /auth、validateToken 重连时间戳更新等)
|
|
973
|
+
*/
|
|
974
|
+
savePersistedDataDebounced() {
|
|
975
|
+
if (this.debounceTimer)
|
|
976
|
+
return;
|
|
977
|
+
this.debounceTimer = setTimeout(() => {
|
|
978
|
+
this.debounceTimer = null;
|
|
979
|
+
this.flushPersistedData();
|
|
980
|
+
}, 500);
|
|
981
|
+
if (typeof this.debounceTimer.unref === 'function') {
|
|
982
|
+
;
|
|
983
|
+
this.debounceTimer.unref();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* 立即执行待写的持久化数据落盘并清理定时器
|
|
988
|
+
*/
|
|
989
|
+
flushPersistedData() {
|
|
990
|
+
if (this.debounceTimer) {
|
|
991
|
+
clearTimeout(this.debounceTimer);
|
|
992
|
+
this.debounceTimer = null;
|
|
993
|
+
}
|
|
994
|
+
try {
|
|
995
|
+
mkdirSync(dirname(this.persistPath), { recursive: true });
|
|
996
|
+
const payload = {
|
|
997
|
+
devices: Array.from(this.sessions.values()),
|
|
998
|
+
ipStats: Array.from(this.ipStats.values()),
|
|
999
|
+
};
|
|
1000
|
+
writeFileSync(this.persistPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
1001
|
+
// 会话文件内含长效 Token(即完整凭证),权限与 rsa-keys.json 对齐收紧为仅当前用户可读写
|
|
1002
|
+
try {
|
|
1003
|
+
chmodSync(this.persistPath, 0o600);
|
|
1004
|
+
}
|
|
1005
|
+
catch { }
|
|
1006
|
+
}
|
|
1007
|
+
catch { }
|
|
1008
|
+
}
|
|
1009
|
+
}
|