@yezack/dsh-remote-mobile 0.0.0-stage → 1.9.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.
@@ -0,0 +1,29 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { SessionStore } from './token.js';
3
+ /**
4
+ * 判断请求是否来自本机回环
5
+ */
6
+ export declare function isLoopbackRequest(req: IncomingMessage): boolean;
7
+ /**
8
+ * 判断请求是否携带浏览器跨站信号(用于回环 CSRF / drive-by 防御)。
9
+ *
10
+ * Origin 与 Sec-Fetch-Site 由浏览器强制填写,页面脚本无法伪造或隐藏;
11
+ * 恶意网页驱使浏览器向本机回环发起的写请求必然携带这些信号。
12
+ * 不带这些头的客户端(curl / 本机脚本 / 旧内核 WebView)不受影响,保持向后兼容。
13
+ *
14
+ * 说明(有意取舍,非缺陷):
15
+ * - 仅比较 host:port,不比较 scheme:插件主要运行于 HTTP 局域网/回环场景,
16
+ * 同 host:port 下出现 HTTPS 恶意来源的现实可能性极低;
17
+ * - 无 Origin 时仅将 Sec-Fetch-Site: cross-site 判为跨站,same-site 不拦截:
18
+ * 回环场景无域名层级概念,子域场景不适用;收紧为「非 same-origin 即拦截」
19
+ * 会误伤不发送这些头的旧内核 WebView,与向后兼容目标冲突。
20
+ */
21
+ export declare function hasCrossSiteSignal(req: IncomingMessage): boolean;
22
+ /**
23
+ * 判断是否为无需鉴权的公开静态/登录路径
24
+ */
25
+ export declare function isPublicPath(url?: string): boolean;
26
+ /**
27
+ * 构造全局 HTTP & WebSocket 门禁拦截处理器
28
+ */
29
+ export declare function createGlobalAuthGate(store: SessionStore): (req: IncomingMessage, res: ServerResponse, next?: () => void) => boolean;
@@ -0,0 +1,139 @@
1
+ import { isTailscaleIp, isLanIp, getClientIp } from './tailscale.js';
2
+ import { resolveRedirectPath } from './prefix.js';
3
+ /**
4
+ * 判断请求是否来自本机回环
5
+ */
6
+ export function isLoopbackRequest(req) {
7
+ const ip = getClientIp(req);
8
+ return ip === '127.0.0.1' || ip === '::1' || ip === 'localhost';
9
+ }
10
+ const STATIC_ASSET_EXT_REGEX = /\.(js|mjs|cjs|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot|otf|map|json|txt|wasm)$/i;
11
+ /** 插件私有变更类 API 前缀(非 GET 请求需做跨站信号校验) */
12
+ const MUTATION_API_PREFIX = '/api/remote-mobile/';
13
+ /**
14
+ * 判断请求是否携带浏览器跨站信号(用于回环 CSRF / drive-by 防御)。
15
+ *
16
+ * Origin 与 Sec-Fetch-Site 由浏览器强制填写,页面脚本无法伪造或隐藏;
17
+ * 恶意网页驱使浏览器向本机回环发起的写请求必然携带这些信号。
18
+ * 不带这些头的客户端(curl / 本机脚本 / 旧内核 WebView)不受影响,保持向后兼容。
19
+ *
20
+ * 说明(有意取舍,非缺陷):
21
+ * - 仅比较 host:port,不比较 scheme:插件主要运行于 HTTP 局域网/回环场景,
22
+ * 同 host:port 下出现 HTTPS 恶意来源的现实可能性极低;
23
+ * - 无 Origin 时仅将 Sec-Fetch-Site: cross-site 判为跨站,same-site 不拦截:
24
+ * 回环场景无域名层级概念,子域场景不适用;收紧为「非 same-origin 即拦截」
25
+ * 会误伤不发送这些头的旧内核 WebView,与向后兼容目标冲突。
26
+ */
27
+ export function hasCrossSiteSignal(req) {
28
+ const origin = req.headers.origin;
29
+ if (typeof origin === 'string' && origin) {
30
+ if (origin === 'null')
31
+ return true; // 沙箱 iframe 等场景的不透明 Origin,一律视为跨站
32
+ try {
33
+ const originHost = new URL(origin).host;
34
+ return originHost !== '' && originHost !== (req.headers.host || '');
35
+ }
36
+ catch {
37
+ return true; // 无法解析的 Origin 视为不可信
38
+ }
39
+ }
40
+ return req.headers['sec-fetch-site'] === 'cross-site';
41
+ }
42
+ /**
43
+ * 判断是否为无需鉴权的公开静态/登录路径
44
+ */
45
+ export function isPublicPath(url = '') {
46
+ const pathname = url.split('?')[0];
47
+ if (pathname === '/auth')
48
+ return true;
49
+ if (pathname.startsWith('/api/remote-mobile/public-key'))
50
+ return true;
51
+ if (pathname.startsWith('/api/remote-mobile/verify'))
52
+ return true;
53
+ if (pathname.startsWith('/plugins/') && STATIC_ASSET_EXT_REGEX.test(pathname))
54
+ return true;
55
+ if (pathname.startsWith('/assets/'))
56
+ return true;
57
+ if (pathname === '/favicon.ico')
58
+ return true;
59
+ return false;
60
+ }
61
+ /**
62
+ * 构造全局 HTTP & WebSocket 门禁拦截处理器
63
+ */
64
+ export function createGlobalAuthGate(store) {
65
+ return function authGateMiddleware(req, res, next) {
66
+ const pass = () => {
67
+ if (typeof next === 'function')
68
+ next();
69
+ return true;
70
+ };
71
+ // 0. 变更类插件 API 的跨站写请求防御(先于回环放行,且运行在上下文虚拟化改写之前,
72
+ // 保证 Origin / Sec-Fetch-Site 为浏览器原始值):本机回环虽免认证,但恶意网页驱使
73
+ // 浏览器发起的跨站写请求(drive-by)必然携带跨站信号,在此直接拒绝。
74
+ const pathname = (req.url || '').split('?')[0];
75
+ if (pathname.startsWith(MUTATION_API_PREFIX) &&
76
+ req.method !== 'GET' &&
77
+ req.method !== 'HEAD' &&
78
+ hasCrossSiteSignal(req)) {
79
+ res.writeHead(403, {
80
+ 'Content-Type': 'application/json; charset=utf-8',
81
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
82
+ });
83
+ res.end(JSON.stringify({
84
+ error: 'Forbidden',
85
+ message: '已拦截来自其他网站的跨站写请求。',
86
+ }));
87
+ return false;
88
+ }
89
+ // 1. 本机 127.0.0.1 回环无条件放行(保证本机 PC 始终可用)
90
+ if (isLoopbackRequest(req)) {
91
+ return pass();
92
+ }
93
+ // 2. 公开登录页面与认证接口放行
94
+ if (isPublicPath(req.url)) {
95
+ return pass();
96
+ }
97
+ const clientIp = getClientIp(req);
98
+ const options = store.getOptions();
99
+ // 3. 如果开启了 Tailscale 私网免密直连,且客户端来源是 Tailscale 虚拟网段,自动归因并放行
100
+ if (options.allowTailscale && isTailscaleIp(clientIp)) {
101
+ const ua = req.headers['user-agent'] || '';
102
+ store.registerBypassDevice(clientIp, ua, 'Tailscale');
103
+ return pass();
104
+ }
105
+ // 4. 如果开启了局域网 LAN 免密直连,且客户端来源是局域网私有网段 (10.x / 172.16-31.x / 192.168.x),自动归因并放行
106
+ if (options.allowLan && isLanIp(clientIp)) {
107
+ const ua = req.headers['user-agent'] || '';
108
+ store.registerBypassDevice(clientIp, ua, 'LAN');
109
+ return pass();
110
+ }
111
+ // 5. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
112
+ const token = store.extractTokenFromRequest(req);
113
+ if (token && store.validateToken(token, clientIp)) {
114
+ return pass();
115
+ }
116
+ // 5. 未授权访问拦截处理:
117
+ // 只有主文档请求(GET 根路径或请求头明确包含 text/html)才 302 重定向到 /auth 登录页,子资源与 API 请求一律返回 401
118
+ const accept = req.headers['accept'] || '';
119
+ const isHtmlRequest = req.method === 'GET' && (accept.includes('text/html') || req.url === '/' || req.url?.startsWith('/?'));
120
+ if (isHtmlRequest) {
121
+ const authLocation = resolveRedirectPath(req.headers, '/auth');
122
+ res.writeHead(302, {
123
+ Location: authLocation,
124
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
125
+ });
126
+ res.end();
127
+ return false;
128
+ }
129
+ res.writeHead(401, {
130
+ 'Content-Type': 'application/json; charset=utf-8',
131
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
132
+ });
133
+ res.end(JSON.stringify({
134
+ error: 'Unauthorized',
135
+ message: '设备未授权或会话已注销,请前往 /auth 重新配对。',
136
+ }));
137
+ return false;
138
+ };
139
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 反向代理路径前缀解析与安全重定向工具
3
+ */
4
+ /**
5
+ * 校验并提取反向代理传递的前缀路径 (如 X-Forwarded-Prefix)
6
+ *
7
+ * 安全约束:
8
+ * 1. 必须以单个 / 开头,且不能以 // 开头(防御协议相对 URL 与开放重定向)
9
+ * 2. 不能包含协议标识 (http:, https:, etc.)
10
+ * 3. 不能包含反斜杠或控制字符(防御浏览器 URL 规范化逃逸与 CRLF 注入)
11
+ * 4. 去除末尾冗余的斜杠
12
+ */
13
+ export declare function resolveForwardedPrefix(raw: unknown): string;
14
+ /**
15
+ * 构造带网关前缀的重定向目标路径
16
+ * @param headers 请求头
17
+ * @param targetPath 目标相对路径,如 '/auth' 或 '/'
18
+ */
19
+ export declare function resolveRedirectPath(headers: Record<string, string | string[] | undefined> | undefined, targetPath: string): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 反向代理路径前缀解析与安全重定向工具
3
+ */
4
+ /**
5
+ * 校验并提取反向代理传递的前缀路径 (如 X-Forwarded-Prefix)
6
+ *
7
+ * 安全约束:
8
+ * 1. 必须以单个 / 开头,且不能以 // 开头(防御协议相对 URL 与开放重定向)
9
+ * 2. 不能包含协议标识 (http:, https:, etc.)
10
+ * 3. 不能包含反斜杠或控制字符(防御浏览器 URL 规范化逃逸与 CRLF 注入)
11
+ * 4. 去除末尾冗余的斜杠
12
+ */
13
+ export function resolveForwardedPrefix(raw) {
14
+ if (typeof raw !== 'string')
15
+ return '';
16
+ const trimmed = raw.trim();
17
+ if (!trimmed || trimmed === '/')
18
+ return '';
19
+ // 必须以单个 / 开头,拒绝 // (如 //evil.com)、带有协议字符、以及含反斜杠与控制字符
20
+ if (!trimmed.startsWith('/') ||
21
+ trimmed.startsWith('//') ||
22
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) ||
23
+ /[\\\x00-\x1F\x7F]/.test(trimmed)) {
24
+ return '';
25
+ }
26
+ return trimmed.replace(/\/+$/, '');
27
+ }
28
+ /**
29
+ * 构造带网关前缀的重定向目标路径
30
+ * @param headers 请求头
31
+ * @param targetPath 目标相对路径,如 '/auth' 或 '/'
32
+ */
33
+ export function resolveRedirectPath(headers, targetPath) {
34
+ const rawPrefix = headers?.['x-forwarded-prefix'];
35
+ const prefix = resolveForwardedPrefix(typeof rawPrefix === 'string' ? rawPrefix : Array.isArray(rawPrefix) ? rawPrefix[0] : '');
36
+ const normPath = targetPath.startsWith('/') ? targetPath : `/${targetPath}`;
37
+ if (!prefix) {
38
+ return normPath;
39
+ }
40
+ return `${prefix}${normPath}`;
41
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * 判断 IP 是否为 Tailscale 官方虚拟私网 IP (CGNAT 网段 100.64.0.0/10)
3
+ * 范围: 100.64.0.0 ~ 100.127.255.255
4
+ */
5
+ export declare function isTailscaleIp(rawIp?: string): boolean;
6
+ /**
7
+ * 判断 IP 是否为局域网私有 IP (RFC 1918 规范: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
8
+ */
9
+ export declare function isLanIp(rawIp?: string): boolean;
10
+ /**
11
+ * 提取请求的客户端真实 IP(基于底层 Socket 连接,防止外部伪造 X-Forwarded-For,且兼容虚拟化场景)
12
+ */
13
+ export declare function getClientIp(req: {
14
+ socket?: {
15
+ remoteAddress?: string;
16
+ __dsh_real_remote_address__?: string;
17
+ };
18
+ headers?: Record<string, string | string[] | undefined>;
19
+ }): string;
20
+ /**
21
+ * 获取当前本机的 Tailscale IP
22
+ */
23
+ export declare function getLocalTailscaleIp(): string | null;
24
+ /**
25
+ * 获取当前本机局域网 IP (优先常见 en0 / eth0 / wlan0 等)
26
+ */
27
+ export declare function getLocalLanIp(): string | null;
28
+ /**
29
+ * 获取本机所有可用的网络 IP
30
+ */
31
+ export declare function getAllNetworkIps(): {
32
+ name: string;
33
+ ip: string;
34
+ isTailscale: boolean;
35
+ isLan: boolean;
36
+ }[];
@@ -0,0 +1,128 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { networkInterfaces } from 'node:os';
3
+ /**
4
+ * 判断 IP 是否为 Tailscale 官方虚拟私网 IP (CGNAT 网段 100.64.0.0/10)
5
+ * 范围: 100.64.0.0 ~ 100.127.255.255
6
+ */
7
+ export function isTailscaleIp(rawIp) {
8
+ if (!rawIp)
9
+ return false;
10
+ // 规范化 IPv4 (处理 ::ffff:100.x.y.z 格式)
11
+ const ip = rawIp.replace(/^::ffff:/, '').trim();
12
+ if (!ip.startsWith('100.'))
13
+ return false;
14
+ const parts = ip.split('.');
15
+ if (parts.length !== 4)
16
+ return false;
17
+ const second = parseInt(parts[1], 10);
18
+ return second >= 64 && second <= 127;
19
+ }
20
+ /**
21
+ * 判断 IP 是否为局域网私有 IP (RFC 1918 规范: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
22
+ */
23
+ export function isLanIp(rawIp) {
24
+ if (!rawIp)
25
+ return false;
26
+ const ip = rawIp.replace(/^::ffff:/, '').trim();
27
+ if (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost' || isTailscaleIp(ip))
28
+ return false;
29
+ const parts = ip.split('.').map((p) => parseInt(p, 10));
30
+ if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255))
31
+ return false;
32
+ const [a, b] = parts;
33
+ // 10.0.0.0/8
34
+ if (a === 10)
35
+ return true;
36
+ // 172.16.0.0/12
37
+ if (a === 172 && b >= 16 && b <= 31)
38
+ return true;
39
+ // 192.168.0.0/16
40
+ if (a === 192 && b === 168)
41
+ return true;
42
+ return false;
43
+ }
44
+ /**
45
+ * 提取请求的客户端真实 IP(基于底层 Socket 连接,防止外部伪造 X-Forwarded-For,且兼容虚拟化场景)
46
+ */
47
+ export function getClientIp(req) {
48
+ const realAddress = req.socket?.__dsh_real_remote_address__ || req.socket?.remoteAddress;
49
+ return realAddress?.replace(/^::ffff:/, '') || '127.0.0.1';
50
+ }
51
+ /**
52
+ * 获取当前本机的 Tailscale IP
53
+ */
54
+ export function getLocalTailscaleIp() {
55
+ // 1. 尝试通过 CLI 快速获取
56
+ try {
57
+ const out = execSync('tailscale ip -4', { encoding: 'utf8', timeout: 800, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
58
+ if (out && isTailscaleIp(out)) {
59
+ return out;
60
+ }
61
+ }
62
+ catch {
63
+ // CLI 不可用或未在 PATH 中,回退到网卡扫描
64
+ }
65
+ // 2. 扫描系统网卡
66
+ try {
67
+ const interfaces = networkInterfaces();
68
+ for (const name of Object.keys(interfaces)) {
69
+ for (const info of interfaces[name] || []) {
70
+ if (info.family === 'IPv4' && !info.internal && isTailscaleIp(info.address)) {
71
+ return info.address;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ catch { }
77
+ return null;
78
+ }
79
+ /**
80
+ * 获取当前本机局域网 IP (优先常见 en0 / eth0 / wlan0 等)
81
+ */
82
+ export function getLocalLanIp() {
83
+ try {
84
+ const interfaces = networkInterfaces();
85
+ // 优先匹配局域网常规网卡名
86
+ const preferredOrder = ['en0', 'eth0', 'wlan0', 'Wi-Fi', 'en1', 'eth1'];
87
+ for (const name of preferredOrder) {
88
+ for (const info of interfaces[name] || []) {
89
+ if (info.family === 'IPv4' && !info.internal && isLanIp(info.address)) {
90
+ return info.address;
91
+ }
92
+ }
93
+ }
94
+ // 遍历所有其他可用网卡
95
+ for (const name of Object.keys(interfaces)) {
96
+ for (const info of interfaces[name] || []) {
97
+ if (info.family === 'IPv4' && !info.internal && isLanIp(info.address)) {
98
+ return info.address;
99
+ }
100
+ }
101
+ }
102
+ }
103
+ catch { }
104
+ return null;
105
+ }
106
+ /**
107
+ * 获取本机所有可用的网络 IP
108
+ */
109
+ export function getAllNetworkIps() {
110
+ const list = [];
111
+ try {
112
+ const interfaces = networkInterfaces();
113
+ for (const name of Object.keys(interfaces)) {
114
+ for (const info of interfaces[name] || []) {
115
+ if (info.family === 'IPv4' && !info.internal) {
116
+ list.push({
117
+ name,
118
+ ip: info.address,
119
+ isTailscale: isTailscaleIp(info.address),
120
+ isLan: isLanIp(info.address),
121
+ });
122
+ }
123
+ }
124
+ }
125
+ }
126
+ catch { }
127
+ return list;
128
+ }
@@ -0,0 +1,194 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { IncomingMessage } from 'node:http';
3
+ export declare const AUTH_COOKIE_NAME = "dsh_mobile_token";
4
+ /** DSH_HOME 目录(支持 DSH_HOME 覆盖) */
5
+ export declare const DSH_HOME: string;
6
+ /** 插件自有数据目录 */
7
+ export declare const REMOTE_MOBILE_DIR: string;
8
+ export declare const DEFAULT_PERSIST_FILE: string;
9
+ /** 插件自有配置持久化文件(DSH 0.1.7+ 起官方弃用 settings.yaml,插件改为自持存储) */
10
+ export declare const SETTINGS_FILE: string;
11
+ /** DSH 旧版全局设置文件(0.1.7+ 起仅作一次性迁移来源,启动时会被改名) */
12
+ export declare const GLOBAL_SETTINGS_FILE: string;
13
+ /** DSH 迁移后遗留的旧设置存档(legacy settings.yaml → settings.yaml.imported) */
14
+ export declare const LEGACY_SETTINGS_IMPORTED_FILE: string;
15
+ /**
16
+ * 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
17
+ */
18
+ export declare function readFromSettingsYaml(filePath?: string): Partial<SessionStoreOptions>;
19
+ /**
20
+ * 从旧版 settings.yaml 文本内容中解析 locale.preference(返回 null 表示未命中)
21
+ */
22
+ export declare function parseLocaleFromSettingsYaml(content: string): 'zh' | 'en' | null;
23
+ /**
24
+ * 从 cordis.patch.yml 文本内容中解析 locale entry 的 config.preference(返回 null 表示未命中)
25
+ */
26
+ export declare function parseLocaleFromPatch(content: string): 'zh' | 'en' | null;
27
+ /**
28
+ * 读取 DSH 全局语言偏好。
29
+ *
30
+ * DSH 0.1.7+ 起配置真实落点为当前 profile 的 cordis.patch.yml(旧版 settings.yaml
31
+ * 已被一次性迁移并改名为 settings.yaml.imported),因此优先读取 profile patch,
32
+ * 再回退旧 settings.yaml / settings.yaml.imported。
33
+ *
34
+ * @param filePath 显式传入时按旧版 settings.yaml 格式解析(向后兼容与测试)。
35
+ */
36
+ export declare function readGlobalLocale(filePath?: string): 'zh' | 'en';
37
+ /**
38
+ * 读取插件自有持久化配置文件 ~/.dsh/remote-mobile/settings.json
39
+ *
40
+ * 注:若文件中 secretHash 为空串 ""(表示用户清空了密码),此处会跳过赋值以归一化为 undefined。
41
+ * 上层 resolveInitialStoreOptions 必须根据文件存在性(own !== null)做出决策,严禁在自有配置存在时用 legacy 兜底回退。
42
+ */
43
+ export declare function readPluginSettingsJson(filePath?: string): Partial<SessionStoreOptions>;
44
+ /**
45
+ * 将插件配置原子写回自有配置文件 ~/.dsh/remote-mobile/settings.json(权限 0600)。
46
+ *
47
+ * DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源(启动时一次性迁移为
48
+ * settings.yaml.imported),因此插件不再依赖该文件,改为自持存储,避免升级后配置丢失。
49
+ */
50
+ export declare function writePluginSettingsJson(options: SessionStoreOptions, filePath?: string): boolean;
51
+ /**
52
+ * 兼容读取 DSH 旧版 settings.yaml / 迁移后的 settings.yaml.imported 中的
53
+ * dsh-remote-mobile 段,用于首次启动时把旧配置迁移到自有 settings.json。
54
+ */
55
+ export declare function readLegacyDshSettings(liveFile?: string, importedFile?: string): Partial<SessionStoreOptions>;
56
+ /**
57
+ * 解析 SessionStore 的初始运行配置(纯函数决策逻辑)。
58
+ *
59
+ * 优先级与防反噬规则:
60
+ * 1. 显式传入的 explicit 参数优先级最高;
61
+ * 2. 若自有配置文件已存在(own !== null),则以自有配置 own 为准,此时 legacy 配置被彻底忽略(防止已清空的密码死灰复燃);
62
+ * 3. 仅当自有配置文件尚不存在(own === null,即首次安装或旧版升级)时,才回退读取 legacy 配置进行一次性迁移;
63
+ * 4. 最终缺失项采用系统安全默认值。
64
+ *
65
+ * @param explicit 构造函数传入的显式参数
66
+ * @param own 插件自有 settings.json 内容;null 表示文件尚不存在(首次启动迁移状态)
67
+ * @param legacy 旧版 settings.yaml / settings.yaml.imported 内容
68
+ */
69
+ export declare function resolveInitialStoreOptions(explicit?: SessionStoreOptions, own?: Partial<SessionStoreOptions> | null, legacy?: Partial<SessionStoreOptions>): Required<SessionStoreOptions>;
70
+ export interface SessionRecord {
71
+ token: string;
72
+ createdAt: number;
73
+ lastSeenAt: number;
74
+ ip?: string;
75
+ deviceName?: string;
76
+ userAgent?: string;
77
+ authType?: string;
78
+ isBypass?: boolean;
79
+ }
80
+ export interface ShortCodeRecord {
81
+ code: string;
82
+ token: string;
83
+ createdAt: number;
84
+ expiresAt: number;
85
+ }
86
+ export interface IpSecurityStat {
87
+ ip: string;
88
+ authVisits: number;
89
+ authVisitsInWindow: number;
90
+ windowStart: number;
91
+ failedAttempts: number;
92
+ totalAttempts: number;
93
+ lockedUntil: number;
94
+ lastAttemptAt: number;
95
+ lastSeenAt: number;
96
+ authType?: string;
97
+ deviceName?: string;
98
+ userAgent?: string;
99
+ }
100
+ export interface SessionStoreOptions {
101
+ allowTailscale?: boolean;
102
+ allowLan?: boolean;
103
+ secret?: string;
104
+ secretHash?: string;
105
+ devicesFile?: string;
106
+ maxFailedAttempts?: number;
107
+ lockDurationMs?: number;
108
+ maxVisitsPerMinute?: number;
109
+ }
110
+ /**
111
+ * 将 User-Agent 字符串解析为人类友好的设备名称
112
+ */
113
+ export declare function parseDeviceName(ua?: string): string;
114
+ export declare class SessionStore extends EventEmitter {
115
+ private sessions;
116
+ private shortCodes;
117
+ private ipStats;
118
+ private options;
119
+ private settingsMutator?;
120
+ private debounceTimer;
121
+ readonly persistPath: string;
122
+ readonly settingsFilePath: string | null;
123
+ constructor(options?: SessionStoreOptions);
124
+ getOptions(): Required<SessionStoreOptions>;
125
+ hasSecret(): boolean;
126
+ setSettingsMutator(mutator: (patch: Partial<SessionStoreOptions>) => void): void;
127
+ updateOptions(newOpts: Partial<SessionStoreOptions>, syncToSettings?: boolean): void;
128
+ /**
129
+ * 记录 IP 打开 /auth 页面的计数并进行限频判定与设备指纹解析
130
+ */
131
+ recordAuthVisit(ip: string, userAgent?: string): {
132
+ allowed: boolean;
133
+ lockedUntil?: number;
134
+ reason?: string;
135
+ };
136
+ /**
137
+ * 检查 IP 是否被防暴力破解锁定
138
+ */
139
+ isIpLocked(ip: string): {
140
+ locked: boolean;
141
+ lockedUntil?: number;
142
+ remainingSeconds?: number;
143
+ };
144
+ /**
145
+ * 获取所有 IP 的安全审计统计数据
146
+ */
147
+ getIpSecurityStats(): IpSecurityStat[];
148
+ /**
149
+ * 管理员手动解锁某个 IP
150
+ */
151
+ unlockIp(ip: string): boolean;
152
+ generateShortCode(): {
153
+ code: string;
154
+ token: string;
155
+ expiresAt: number;
156
+ };
157
+ verify(credential: string, userAgent?: string, clientIp?: string): {
158
+ success: boolean;
159
+ token?: string;
160
+ reason?: string;
161
+ locked?: boolean;
162
+ remainingSeconds?: number;
163
+ };
164
+ private handleFailedAttempt;
165
+ validateToken(token: string, clientIp?: string): boolean;
166
+ extractTokenFromRequest(req: IncomingMessage): string | null;
167
+ /**
168
+ * 自动登记并归因免密直连设备(Tailscale / LAN)
169
+ */
170
+ registerBypassDevice(ip: string, userAgent: string | undefined, type: 'Tailscale' | 'LAN'): SessionRecord;
171
+ getSessionsList(): {
172
+ count: number;
173
+ devices: Array<Omit<SessionRecord, 'userAgent'>>;
174
+ };
175
+ revokeDevice(token: string): boolean;
176
+ revokeAllSessions(): void;
177
+ /**
178
+ * 清空所有 IP 安全审计与防暴破统计记录
179
+ */
180
+ clearAllIpSecurityStats(): void;
181
+ loadPersistedData(): void;
182
+ /**
183
+ * 同步立即持久化落盘(由关键路径调用:配对成功、密码认证、撤销设备、手动改密/配置等)
184
+ */
185
+ savePersistedData(): void;
186
+ /**
187
+ * 500ms 防抖持久化落盘(由高频访问路径调用:每次打开 /auth、validateToken 重连时间戳更新等)
188
+ */
189
+ savePersistedDataDebounced(): void;
190
+ /**
191
+ * 立即执行待写的持久化数据落盘并清理定时器
192
+ */
193
+ flushPersistedData(): void;
194
+ }