dsh-remote-mobile 1.7.0 → 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 +31 -0
- package/README.md +81 -43
- package/README_EN.md +82 -44
- package/lib/auth/gate.js +3 -1
- package/lib/auth/prefix.d.ts +19 -0
- package/lib/auth/prefix.js +41 -0
- package/lib/auth/token.d.ts +55 -3
- package/lib/auth/token.js +202 -76
- package/lib/client.js +103 -77
- package/lib/index.d.ts +2 -1
- package/lib/index.js +10 -5
- package/lib/routes/api.js +4 -2
- package/lib/routes/login-page.js +36 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/auth/token.d.ts
CHANGED
|
@@ -1,20 +1,72 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import type { IncomingMessage } from 'node:http';
|
|
3
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;
|
|
4
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+ 起仅作一次性迁移来源,启动时会被改名) */
|
|
5
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;
|
|
6
15
|
/**
|
|
7
16
|
* 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
|
|
8
17
|
*/
|
|
9
18
|
export declare function readFromSettingsYaml(filePath?: string): Partial<SessionStoreOptions>;
|
|
10
19
|
/**
|
|
11
|
-
*
|
|
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 格式解析(向后兼容与测试)。
|
|
12
35
|
*/
|
|
13
36
|
export declare function readGlobalLocale(filePath?: string): 'zh' | 'en';
|
|
14
37
|
/**
|
|
15
|
-
*
|
|
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 内容
|
|
16
68
|
*/
|
|
17
|
-
export declare function
|
|
69
|
+
export declare function resolveInitialStoreOptions(explicit?: SessionStoreOptions, own?: Partial<SessionStoreOptions> | null, legacy?: Partial<SessionStoreOptions>): Required<SessionStoreOptions>;
|
|
18
70
|
export interface SessionRecord {
|
|
19
71
|
token: string;
|
|
20
72
|
createdAt: number;
|
package/lib/auth/token.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { randomBytes, randomInt } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import { hashSecret, verifySecretHash } from './crypto.js';
|
|
7
7
|
import { isTailscaleIp, isLanIp } from './tailscale.js';
|
|
8
8
|
export const AUTH_COOKIE_NAME = 'dsh_mobile_token';
|
|
9
|
-
|
|
10
|
-
export const
|
|
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');
|
|
11
20
|
/**
|
|
12
21
|
* 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
|
|
13
22
|
*/
|
|
@@ -72,63 +81,143 @@ export function readFromSettingsYaml(filePath = GLOBAL_SETTINGS_FILE) {
|
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
/**
|
|
75
|
-
*
|
|
84
|
+
* 从旧版 settings.yaml 文本内容中解析 locale.preference(返回 null 表示未命中)
|
|
76
85
|
*/
|
|
77
|
-
export function
|
|
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) {
|
|
78
113
|
try {
|
|
79
114
|
if (!existsSync(filePath))
|
|
80
|
-
return
|
|
81
|
-
|
|
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';
|
|
115
|
+
return null;
|
|
116
|
+
return parser(readFileSync(filePath, 'utf8'));
|
|
87
117
|
}
|
|
88
118
|
catch {
|
|
89
|
-
return
|
|
119
|
+
return null;
|
|
90
120
|
}
|
|
91
121
|
}
|
|
92
122
|
/**
|
|
93
|
-
*
|
|
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 格式解析(向后兼容与测试)。
|
|
94
130
|
*/
|
|
95
|
-
export function
|
|
96
|
-
|
|
131
|
+
export function readGlobalLocale(filePath) {
|
|
132
|
+
if (filePath) {
|
|
133
|
+
return readLocaleFromFile(filePath, parseLocaleFromSettingsYaml) ?? 'zh';
|
|
134
|
+
}
|
|
135
|
+
// 1. 当前 profile 的 cordis.patch.yml(web 优先,其余按目录名排序兜底)
|
|
97
136
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
` allowLan: ${Boolean(options.allowLan)}`,
|
|
106
|
-
` secretHash: ${options.secretHash ? `'${options.secretHash}'` : `''`}`,
|
|
107
|
-
` maxFailedAttempts: ${options.maxFailedAttempts || 5}`,
|
|
108
|
-
` lockDurationMs: ${options.lockDurationMs || 900000}`,
|
|
109
|
-
` maxVisitsPerMinute: ${options.maxVisitsPerMinute || 60}`,
|
|
110
|
-
];
|
|
111
|
-
const newBlock = fields.join('\n');
|
|
112
|
-
const lines = content.split(/\r?\n/);
|
|
113
|
-
const startIdx = lines.findIndex((l) => l.trim() === ns);
|
|
114
|
-
let updatedContent = '';
|
|
115
|
-
if (startIdx === -1) {
|
|
116
|
-
updatedContent = content.trimEnd() + '\n\n' + newBlock + '\n';
|
|
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)));
|
|
117
144
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
lines.splice(startIdx, endIdx - startIdx, newBlock);
|
|
128
|
-
updatedContent = lines.join('\n');
|
|
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;
|
|
129
154
|
}
|
|
130
|
-
|
|
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 });
|
|
131
216
|
renameSync(tmpPath, filePath);
|
|
217
|
+
try {
|
|
218
|
+
chmodSync(filePath, 0o600);
|
|
219
|
+
}
|
|
220
|
+
catch { }
|
|
132
221
|
return true;
|
|
133
222
|
}
|
|
134
223
|
catch {
|
|
@@ -140,6 +229,56 @@ export function writeBackToSettingsYaml(options, filePath = GLOBAL_SETTINGS_FILE
|
|
|
140
229
|
return false;
|
|
141
230
|
}
|
|
142
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
|
+
}
|
|
143
282
|
/**
|
|
144
283
|
* 将 User-Agent 字符串解析为人类友好的设备名称
|
|
145
284
|
*/
|
|
@@ -198,37 +337,23 @@ export class SessionStore extends EventEmitter {
|
|
|
198
337
|
constructor(options = {}) {
|
|
199
338
|
super();
|
|
200
339
|
this.setMaxListeners(100);
|
|
201
|
-
// 严密隔离单测环境:当处于 node --test
|
|
340
|
+
// 严密隔离单测环境:当处于 node --test 环境或使用了自定义设备文件时,严禁读写全局配置文件
|
|
202
341
|
const isTestEnv = Boolean(process.env.NODE_TEST_CONTEXT ||
|
|
203
342
|
process.argv.some((arg) => arg.includes('test')) ||
|
|
204
343
|
(options.devicesFile && options.devicesFile !== DEFAULT_PERSIST_FILE));
|
|
205
|
-
this.settingsFilePath = isTestEnv ? null :
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
else if (typeof options.secret === 'string' && options.secret) {
|
|
213
|
-
secretHash = hashSecret(options.secret);
|
|
214
|
-
}
|
|
215
|
-
else if (typeof yamlOpts.secretHash === 'string' && yamlOpts.secretHash) {
|
|
216
|
-
secretHash = yamlOpts.secretHash;
|
|
217
|
-
}
|
|
218
|
-
else if (typeof yamlOpts.secret === 'string' && yamlOpts.secret) {
|
|
219
|
-
secretHash = hashSecret(yamlOpts.secret);
|
|
220
|
-
}
|
|
221
|
-
this.options = {
|
|
222
|
-
allowTailscale: mergedOpts.allowTailscale ?? false,
|
|
223
|
-
allowLan: mergedOpts.allowLan ?? false,
|
|
224
|
-
secret: '',
|
|
225
|
-
secretHash,
|
|
226
|
-
devicesFile: mergedOpts.devicesFile ?? DEFAULT_PERSIST_FILE,
|
|
227
|
-
maxFailedAttempts: mergedOpts.maxFailedAttempts ?? 5,
|
|
228
|
-
lockDurationMs: mergedOpts.lockDurationMs ?? 15 * 60 * 1000,
|
|
229
|
-
maxVisitsPerMinute: mergedOpts.maxVisitsPerMinute ?? 60,
|
|
230
|
-
};
|
|
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);
|
|
231
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
|
+
}
|
|
232
357
|
this.loadPersistedData();
|
|
233
358
|
activeStores.add(this);
|
|
234
359
|
}
|
|
@@ -275,9 +400,10 @@ export class SessionStore extends EventEmitter {
|
|
|
275
400
|
}
|
|
276
401
|
catch { }
|
|
277
402
|
}
|
|
278
|
-
//
|
|
403
|
+
// 同步持久化到插件自有配置文件 ~/.dsh/remote-mobile/settings.json
|
|
404
|
+
// (DSH 0.1.7+ 起官方已弃用 ~/.dsh/settings.yaml 作为实时配置源)
|
|
279
405
|
if (this.settingsFilePath) {
|
|
280
|
-
|
|
406
|
+
writePluginSettingsJson(this.options, this.settingsFilePath);
|
|
281
407
|
}
|
|
282
408
|
}
|
|
283
409
|
// 1. 如果关闭了 Tailscale 免密直连,自动清理所有 Tailscale 免密设备,并将审计状态重置为 ⚪ 待认证
|