@qzsy/dsh-bridge 2.18.0 → 3.0.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 +12 -1098
- package/LICENSE +21 -21
- package/README.md +72 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +382 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +302 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
package/lib/restart-helper.mjs
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
// 无托管器场景下的 DSH 重启助手(由 BridgeService.restartDsh 派生为独立进程)。
|
|
2
|
-
//
|
|
3
|
-
// 为什么需要它:旧进程调用 process.exit(0) 后,端口不会立刻释放;若新进程马上启动,
|
|
4
|
-
// 很容易撞上 EADDRINUSE 然后无声退出(stdio 被 ignore 时连报错都看不到),
|
|
5
|
-
// 结果就是"点了重启,dsh 再也没起来"。本助手按顺序做三件事并把过程写进日志:
|
|
6
|
-
// 1. 等旧进程真正退出;
|
|
7
|
-
// 2. 等端口释放(能连上说明还被占着);
|
|
8
|
-
// 3. 才派生新的 dsh,并在最长 120s 内确认端口可连接;失败则记日志并以非 0 退出。
|
|
9
|
-
//
|
|
10
|
-
// 用法(由 lib/index.js 组装 payload):node restart-helper.mjs '<json>'
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
12
|
-
import { appendFileSync, openSync, mkdirSync } from 'node:fs';
|
|
13
|
-
import { dirname } from 'node:path';
|
|
14
|
-
import { connect } from 'node:net';
|
|
15
|
-
|
|
16
|
-
const payload = JSON.parse(process.argv[2] ?? '{}');
|
|
17
|
-
const {
|
|
18
|
-
pid, port, argv, cwd, execPath, logFile, childLog,
|
|
19
|
-
// 超时可注入(单测用),生产用默认值
|
|
20
|
-
waitTimeoutMs = 30_000,
|
|
21
|
-
readyTimeoutMs = 120_000,
|
|
22
|
-
} = payload;
|
|
23
|
-
const startedAt = Date.now();
|
|
24
|
-
|
|
25
|
-
function log(message) {
|
|
26
|
-
try { appendFileSync(logFile, `[${new Date().toISOString()}] [helper] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
-
|
|
31
|
-
function processAlive(targetPid) {
|
|
32
|
-
try {
|
|
33
|
-
process.kill(targetPid, 0);
|
|
34
|
-
return true;
|
|
35
|
-
} catch {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** 端口上是否有人在监听(能建立连接即视为被占用) */
|
|
41
|
-
async function portAccepts(portNumber) {
|
|
42
|
-
return await new Promise((resolve) => {
|
|
43
|
-
let settled = false;
|
|
44
|
-
const done = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
45
|
-
let socket;
|
|
46
|
-
try {
|
|
47
|
-
socket = connect({ host: '127.0.0.1', port: portNumber });
|
|
48
|
-
} catch {
|
|
49
|
-
done(false);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
socket.setTimeout(1200);
|
|
53
|
-
socket.once('connect', () => { done(true); socket.destroy(); });
|
|
54
|
-
socket.once('timeout', () => { done(false); socket.destroy(); });
|
|
55
|
-
socket.once('error', () => { done(false); socket.destroy(); });
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function waitUntil(check, timeoutMs, label) {
|
|
60
|
-
const deadline = Date.now() + timeoutMs;
|
|
61
|
-
for (;;) {
|
|
62
|
-
if (await check()) return true;
|
|
63
|
-
if (Date.now() >= deadline) {
|
|
64
|
-
log(`等待超时:${label}(${timeoutMs}ms)`);
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
await sleep(400);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function main() {
|
|
72
|
-
log(`启动:oldPid=${pid} port=${port} cwd=${cwd}`);
|
|
73
|
-
|
|
74
|
-
if (!(await waitUntil(() => !processAlive(pid), waitTimeoutMs, `旧进程 ${pid} 退出`))) {
|
|
75
|
-
log('旧进程仍在运行,放弃本次重启(不做任何破坏性动作)');
|
|
76
|
-
process.exit(2);
|
|
77
|
-
}
|
|
78
|
-
if (!(await waitUntil(async () => !(await portAccepts(port)), waitTimeoutMs, `端口 ${port} 释放`))) {
|
|
79
|
-
log(`端口 ${port} 仍被占用,放弃本次重启`);
|
|
80
|
-
process.exit(3);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let stdio = 'ignore';
|
|
84
|
-
try {
|
|
85
|
-
mkdirSync(dirname(childLog), { recursive: true });
|
|
86
|
-
stdio = openSync(childLog, 'a');
|
|
87
|
-
} catch { /* 打不开就用 ignore */ }
|
|
88
|
-
|
|
89
|
-
// spawn 的失败是异步 'error' 事件(例如 execPath 不存在 → ENOENT),只 try/catch 抓不到;
|
|
90
|
-
// 不挂监听会变成未捕获异常,而且父进程给助手设的是 stdio:'ignore' → 失败细节全部丢失。
|
|
91
|
-
// 这里挂监听并把原因写进日志,且**确认 spawn 成功后才**记录"已派生"。
|
|
92
|
-
const spawned = await new Promise((resolve) => {
|
|
93
|
-
let settled = false;
|
|
94
|
-
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
95
|
-
let child;
|
|
96
|
-
try {
|
|
97
|
-
const childEnv = { ...process.env };
|
|
98
|
-
if (process.platform !== 'win32') {
|
|
99
|
-
const execDir = dirname(execPath);
|
|
100
|
-
const defaultPaths = [execDir, '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean);
|
|
101
|
-
const existingPath = childEnv.PATH || '';
|
|
102
|
-
const missingPaths = defaultPaths.filter((p) => !existingPath.split(':').includes(p));
|
|
103
|
-
if (missingPaths.length > 0) {
|
|
104
|
-
childEnv.PATH = existingPath ? `${existingPath}:${missingPaths.join(':')}` : missingPaths.join(':');
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
child = spawn(execPath, argv, { cwd, env: childEnv, detached: true, stdio: ['ignore', stdio, stdio] });
|
|
108
|
-
} catch (err) {
|
|
109
|
-
log(`派生新 dsh 失败(同步异常):${err?.message ?? err}`);
|
|
110
|
-
settle(null);
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
child.once('error', (err) => {
|
|
114
|
-
log(`派生新 dsh 失败:${err?.message ?? err}`);
|
|
115
|
-
settle(null);
|
|
116
|
-
});
|
|
117
|
-
child.once('spawn', () => {
|
|
118
|
-
log(`已派生新 dsh:pid=${child.pid ?? '(未知)'}(输出见 ${childLog})`);
|
|
119
|
-
settle(child);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
if (!spawned) process.exit(4);
|
|
124
|
-
spawned.unref();
|
|
125
|
-
|
|
126
|
-
const up = await waitUntil(() => portAccepts(port), readyTimeoutMs, `新 dsh 监听 ${port}`);
|
|
127
|
-
log(up
|
|
128
|
-
? `✅ 新 dsh 已就绪,耗时 ${Math.round((Date.now() - startedAt) / 1000)}s`
|
|
129
|
-
: `❌ 新 dsh 未能在 ${Math.round(readyTimeoutMs / 1000)}s 内就绪,请查看 ${childLog}`);
|
|
130
|
-
process.exit(up ? 0 : 1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
void main();
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// 日志值净化:防止日志注入(log injection / log forging)
|
|
2
|
-
//
|
|
3
|
-
// 背景(CodeQL js/log-injection):日志里拼接的**外部可控字符串**(用户消息、
|
|
4
|
-
// 远端返回的错误信息、文件名等)可能包含换行(`\n` / `\r`)与控制字符。攻击者一旦
|
|
5
|
-
// 能注入换行,就能在日志文件里**伪造一整行新日志**(例如伪造一条"审批已通过"),
|
|
6
|
-
// 也可以借超长单行撑爆日志采集器。
|
|
7
|
-
//
|
|
8
|
-
// 本模块只做一件事:把单行日志里要拼进去的动态值变成"一行且安全"。
|
|
9
|
-
// 用法:logger.warn('... 失败: %s', sanitizeLogValue(err?.message))
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* 需要剔除的字符:C0 控制字符 + DEL + C1 控制字符。
|
|
13
|
-
* 匹配控制字符正是本模块的**目的**,故显式豁免 no-control-regex。
|
|
14
|
-
*/
|
|
15
|
-
// eslint-disable-next-line no-control-regex
|
|
16
|
-
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g
|
|
17
|
-
|
|
18
|
-
/** 单条日志值默认最大长度(超出截断,避免超长输入撑爆日志/采集器) */
|
|
19
|
-
export const LOG_VALUE_MAX_LEN = 500
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 净化一个将写入日志的动态值。
|
|
23
|
-
*
|
|
24
|
-
* - 换行/回车 → 空格(保留可读性,同时杜绝伪造新行)
|
|
25
|
-
* - 其它控制字符 → 直接剔除
|
|
26
|
-
* - 超长截断并标注实际长度
|
|
27
|
-
* - 非字符串安全转换(Error → message;对象 → JSON;null/undefined → '')
|
|
28
|
-
*
|
|
29
|
-
* @param {unknown} value 原始值
|
|
30
|
-
* @param {{ maxLen?: number }} [opts]
|
|
31
|
-
* @returns {string} 可安全拼接进单行日志的字符串
|
|
32
|
-
*/
|
|
33
|
-
export function sanitizeLogValue(value, { maxLen = LOG_VALUE_MAX_LEN } = {}) {
|
|
34
|
-
let text
|
|
35
|
-
if (value == null) return ''
|
|
36
|
-
if (typeof value === 'string') text = value
|
|
37
|
-
else if (value instanceof Error) text = value.message || value.name || 'Error'
|
|
38
|
-
else if (typeof value === 'object') {
|
|
39
|
-
try { text = JSON.stringify(value) } catch { text = String(value) }
|
|
40
|
-
} else text = String(value)
|
|
41
|
-
|
|
42
|
-
const oneLine = text
|
|
43
|
-
// 先折叠换行(含 \r\n,以及 U+2028/U+2029 这两个 Unicode 行分隔符)为一个空格
|
|
44
|
-
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
|
45
|
-
// 再剔除其余控制字符
|
|
46
|
-
.replace(CONTROL_CHARS, '')
|
|
47
|
-
|
|
48
|
-
const limit = Number.isFinite(maxLen) && maxLen > 0 ? maxLen : LOG_VALUE_MAX_LEN
|
|
49
|
-
return oneLine.length > limit ? `${oneLine.slice(0, limit)}…(${oneLine.length} chars)` : oneLine
|
|
50
|
-
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
// 跨平台文件系统与工作区路径安全校验模块
|
|
2
|
-
// 防范任意路径遍历、系统关键目录越权与符号链接逃逸
|
|
3
|
-
|
|
4
|
-
import { resolve, normalize, isAbsolute, basename } from 'node:path';
|
|
5
|
-
import { homedir, platform } from 'node:os';
|
|
6
|
-
import { stat, realpath } from 'node:fs/promises';
|
|
7
|
-
|
|
8
|
-
const isWin = platform() === 'win32';
|
|
9
|
-
|
|
10
|
-
// 敏感隐藏文件夹 / 文件名黑名单(全平台)
|
|
11
|
-
const SENSITIVE_NAMES = new Set([
|
|
12
|
-
'.ssh',
|
|
13
|
-
'.gnupg',
|
|
14
|
-
'.aws',
|
|
15
|
-
'.azure',
|
|
16
|
-
'.kube',
|
|
17
|
-
'.git',
|
|
18
|
-
'.svn',
|
|
19
|
-
'.hg',
|
|
20
|
-
'.bash_history',
|
|
21
|
-
'.zsh_history',
|
|
22
|
-
'.profile',
|
|
23
|
-
'.bash_profile',
|
|
24
|
-
'.bashrc',
|
|
25
|
-
'.zshrc',
|
|
26
|
-
'.netrc',
|
|
27
|
-
'id_rsa',
|
|
28
|
-
'id_ed25519',
|
|
29
|
-
'id_ecdsa',
|
|
30
|
-
'id_dsa',
|
|
31
|
-
'credentials',
|
|
32
|
-
'shadow',
|
|
33
|
-
'passwd',
|
|
34
|
-
'.env',
|
|
35
|
-
'.npmrc',
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
// Windows 系统敏感前缀
|
|
39
|
-
const WIN_SYSTEM_PREFIXES = [
|
|
40
|
-
'\\\\', // UNC 路径限制
|
|
41
|
-
'windows',
|
|
42
|
-
'winnt',
|
|
43
|
-
'program files',
|
|
44
|
-
'program files (x86)',
|
|
45
|
-
'system volume information',
|
|
46
|
-
'$recycle.bin',
|
|
47
|
-
'recovery',
|
|
48
|
-
'perflogs',
|
|
49
|
-
'boot',
|
|
50
|
-
'programdata\\microsoft',
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
// POSIX 系统敏感前缀
|
|
54
|
-
const POSIX_SYSTEM_PREFIXES = [
|
|
55
|
-
'/etc',
|
|
56
|
-
'/root',
|
|
57
|
-
'/sys',
|
|
58
|
-
'/proc',
|
|
59
|
-
'/dev',
|
|
60
|
-
'/boot',
|
|
61
|
-
'/lib',
|
|
62
|
-
'/lib64',
|
|
63
|
-
'/lib32',
|
|
64
|
-
'/usr/bin',
|
|
65
|
-
'/usr/sbin',
|
|
66
|
-
'/bin',
|
|
67
|
-
'/sbin',
|
|
68
|
-
'/private',
|
|
69
|
-
'/var/run',
|
|
70
|
-
'/var/root',
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* 判断文件名/单级目录名是否属于敏感目录
|
|
75
|
-
*/
|
|
76
|
-
export function isSensitiveFolderName(name) {
|
|
77
|
-
if (!name || typeof name !== 'string') return true;
|
|
78
|
-
const lower = name.toLowerCase().trim();
|
|
79
|
-
if (SENSITIVE_NAMES.has(lower)) return true;
|
|
80
|
-
if (/^(\$recycle\.bin|system volume information|\.ssh|\.gnupg|\.aws|\.git)$/i.test(lower)) return true;
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 校验路径是否安全可作为工作区或被远程浏览
|
|
86
|
-
* @param {string} targetPath 目标路径
|
|
87
|
-
* @param {object} options 配置项
|
|
88
|
-
* @returns {Promise<{ valid: boolean, error?: string, path?: string, realPath?: string }>}
|
|
89
|
-
*/
|
|
90
|
-
export async function isSafeWorkspacePath(targetPath, options = {}) {
|
|
91
|
-
if (!targetPath || typeof targetPath !== 'string') {
|
|
92
|
-
return { valid: false, error: '路径不能为空' };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const trimmed = targetPath.trim();
|
|
96
|
-
if (!trimmed || trimmed.includes('\0')) {
|
|
97
|
-
return { valid: false, error: '非法路径字符 (Null byte)' };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 1. 基础路径归一化
|
|
101
|
-
let normalized;
|
|
102
|
-
try {
|
|
103
|
-
let raw = trimmed;
|
|
104
|
-
if (isWin && /^[A-Za-z]:$/.test(raw)) {
|
|
105
|
-
raw = `${raw}\\`;
|
|
106
|
-
}
|
|
107
|
-
normalized = normalize(resolve(raw));
|
|
108
|
-
} catch (err) {
|
|
109
|
-
return { valid: false, error: `路径解析失败: ${err.message}` };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const lowerNormalized = normalized.toLowerCase();
|
|
113
|
-
|
|
114
|
-
// 2. 检查 Windows 敏感路径
|
|
115
|
-
if (isWin) {
|
|
116
|
-
// 提取盘符后面的相对部分
|
|
117
|
-
const driveMatch = normalized.match(/^[A-Za-z]:\\(.*)$/);
|
|
118
|
-
if (driveMatch) {
|
|
119
|
-
const rest = driveMatch[1].toLowerCase();
|
|
120
|
-
for (const prefix of WIN_SYSTEM_PREFIXES) {
|
|
121
|
-
if (rest === prefix || rest.startsWith(`${prefix}\\`)) {
|
|
122
|
-
return { valid: false, error: `安全拦截:禁止访问系统敏感目录 (${prefix})` };
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
} else if (normalized.startsWith('\\\\')) {
|
|
126
|
-
return { valid: false, error: '安全拦截:禁止访问 UNC 网络共享路径' };
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
// 3. 检查 POSIX 敏感路径
|
|
130
|
-
for (const prefix of POSIX_SYSTEM_PREFIXES) {
|
|
131
|
-
if (lowerNormalized === prefix || lowerNormalized.startsWith(`${prefix}/`)) {
|
|
132
|
-
return { valid: false, error: `安全拦截:禁止访问系统关键目录 (${prefix})` };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 4. 检查敏感隐藏目录片段(如路径中包含 /.ssh/ 或 \.ssh\)
|
|
138
|
-
const pathParts = normalized.split(/[\\/]/).filter(Boolean);
|
|
139
|
-
for (const part of pathParts) {
|
|
140
|
-
if (isSensitiveFolderName(part)) {
|
|
141
|
-
return { valid: false, error: `安全拦截:禁止访问敏感配置目录「${part}」` };
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// 5. 校验物理文件系统状态与符号链接(Symlink)真实目标
|
|
146
|
-
let realTargetPath;
|
|
147
|
-
try {
|
|
148
|
-
const s = await stat(normalized);
|
|
149
|
-
if (!s.isDirectory()) {
|
|
150
|
-
return { valid: false, error: `指定路径不是文件夹: ${normalized}` };
|
|
151
|
-
}
|
|
152
|
-
realTargetPath = await realpath(normalized);
|
|
153
|
-
} catch (err) {
|
|
154
|
-
// 若仅是做路径规范性预校验(未必须已存在)
|
|
155
|
-
if (options.allowNonExistent) {
|
|
156
|
-
return { valid: true, path: normalized };
|
|
157
|
-
}
|
|
158
|
-
return { valid: false, error: `无法访问指定路径 (${err.message})` };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// 6. 对符号链接解析后的真实物理路径进行二次黑名单校验(防范符号链接逃逸)
|
|
162
|
-
if (realTargetPath && realTargetPath !== normalized) {
|
|
163
|
-
const realLower = realTargetPath.toLowerCase();
|
|
164
|
-
if (isWin) {
|
|
165
|
-
const realDriveMatch = realTargetPath.match(/^[A-Za-z]:\\(.*)$/);
|
|
166
|
-
if (realDriveMatch) {
|
|
167
|
-
const rest = realDriveMatch[1].toLowerCase();
|
|
168
|
-
for (const prefix of WIN_SYSTEM_PREFIXES) {
|
|
169
|
-
if (rest === prefix || rest.startsWith(`${prefix}\\`)) {
|
|
170
|
-
return { valid: false, error: `安全拦截:符号链接指向系统敏感目录 (${prefix})` };
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
} else {
|
|
175
|
-
for (const prefix of POSIX_SYSTEM_PREFIXES) {
|
|
176
|
-
if (realLower === prefix || realLower.startsWith(`${prefix}/`)) {
|
|
177
|
-
return { valid: false, error: `安全拦截:符号链接指向系统关键目录 (${prefix})` };
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const realParts = realTargetPath.split(/[\\/]/).filter(Boolean);
|
|
183
|
-
for (const part of realParts) {
|
|
184
|
-
if (isSensitiveFolderName(part)) {
|
|
185
|
-
return { valid: false, error: `安全拦截:符号链接指向敏感配置目录「${part}」` };
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return {
|
|
191
|
-
valid: true,
|
|
192
|
-
path: normalized,
|
|
193
|
-
realPath: realTargetPath || normalized,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// 滑动窗口请求速率限制器 (Rate Limiter)
|
|
2
|
-
// 防范暴力破解、高频遍历与 DoS 攻击
|
|
3
|
-
|
|
4
|
-
export class RateLimiter {
|
|
5
|
-
/**
|
|
6
|
-
* @param {object} options
|
|
7
|
-
* @param {number} options.maxRequests 窗口期内允许的最大请求数(默认 30)
|
|
8
|
-
* @param {number} options.windowMs 时间窗口毫秒数(默认 60000ms = 1分钟)
|
|
9
|
-
*/
|
|
10
|
-
constructor({ maxRequests = 30, windowMs = 60000 } = {}) {
|
|
11
|
-
this.maxRequests = maxRequests;
|
|
12
|
-
this.windowMs = windowMs;
|
|
13
|
-
/** @type {Map<string, number[]>} key -> timestamp[] */
|
|
14
|
-
this.records = new Map();
|
|
15
|
-
|
|
16
|
-
// 定期清理过期记录(每 5 分钟清理一次)
|
|
17
|
-
this.cleanupTimer = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
18
|
-
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 检查指定 Key(如 IP / 用户 / Session)是否允许本次请求
|
|
23
|
-
* @param {string} key 限制标识
|
|
24
|
-
* @param {number} [customMax] 单次自定义最大请求数
|
|
25
|
-
* @returns {{ allowed: boolean, remaining: number, retryAfterSec: number }}
|
|
26
|
-
*/
|
|
27
|
-
check(key = 'default', customMax) {
|
|
28
|
-
const limit = customMax || this.maxRequests;
|
|
29
|
-
const now = Date.now();
|
|
30
|
-
const windowStart = now - this.windowMs;
|
|
31
|
-
|
|
32
|
-
let timestamps = this.records.get(key);
|
|
33
|
-
if (!timestamps) {
|
|
34
|
-
timestamps = [];
|
|
35
|
-
this.records.set(key, timestamps);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 过滤掉当前窗口期之前的记录
|
|
39
|
-
const valid = timestamps.filter(t => t > windowStart);
|
|
40
|
-
this.records.set(key, valid);
|
|
41
|
-
|
|
42
|
-
if (valid.length >= limit) {
|
|
43
|
-
const oldestInWindow = valid[0];
|
|
44
|
-
const resetTime = oldestInWindow + this.windowMs;
|
|
45
|
-
const retryAfterSec = Math.max(1, Math.ceil((resetTime - now) / 1000));
|
|
46
|
-
return {
|
|
47
|
-
allowed: false,
|
|
48
|
-
remaining: 0,
|
|
49
|
-
retryAfterSec,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
valid.push(now);
|
|
54
|
-
return {
|
|
55
|
-
allowed: true,
|
|
56
|
-
remaining: limit - valid.length,
|
|
57
|
-
retryAfterSec: 0,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 重置指定 key 或全部记录
|
|
63
|
-
* @param {string} [key]
|
|
64
|
-
*/
|
|
65
|
-
reset(key) {
|
|
66
|
-
if (key) this.records.delete(key);
|
|
67
|
-
else this.records.clear();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* 清理所有过期记录
|
|
72
|
-
*/
|
|
73
|
-
cleanup() {
|
|
74
|
-
const now = Date.now();
|
|
75
|
-
const windowStart = now - this.windowMs;
|
|
76
|
-
for (const [key, list] of this.records.entries()) {
|
|
77
|
-
const valid = list.filter(t => t > windowStart);
|
|
78
|
-
if (valid.length === 0) {
|
|
79
|
-
this.records.delete(key);
|
|
80
|
-
} else {
|
|
81
|
-
this.records.set(key, valid);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
dispose() {
|
|
87
|
-
if (this.cleanupTimer) {
|
|
88
|
-
clearInterval(this.cleanupTimer);
|
|
89
|
-
this.cleanupTimer = null;
|
|
90
|
-
}
|
|
91
|
-
this.records.clear();
|
|
92
|
-
}
|
|
93
|
-
}
|
package/lib/session-strip.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// DSH Bridge - 会话响应投影剥离(共享逻辑)
|
|
2
|
-
//
|
|
3
|
-
// contextHeaders / contextTimeline 是 DSH 会话投影里的两个大字段:每轮完整系统提示
|
|
4
|
-
// + 工具定义(单会话 4.8MB+,session/list 数百会话可达 60MB+),但没有客户端 UI
|
|
5
|
-
// 读取它们(会话打开时通过 WebSocket 实时获取,见 dsh-session-projection-cache 注释)。
|
|
6
|
-
// 剥离后大幅降低传输体积(历史记录:session.history gzip 1.2MB -> ~150KB,约 8 倍;
|
|
7
|
-
// 该端点已不在 DSH 0.2.1-rc.2 中,数字仅作收益量级参考)。
|
|
8
|
-
//
|
|
9
|
-
// 本模块被两个转发层复用:
|
|
10
|
-
// - tunnel-client.mjs(自建隧道)
|
|
11
|
-
// - index.js ProxyServer(局域网 / Cloudflare 隧道 / 外部隧道登记 —— 所有公网入口)
|
|
12
|
-
// 命中失败(非 200 / 解析失败 / 结构不符)时原样返回,绝不破坏响应。
|
|
13
|
-
//
|
|
14
|
-
// DSH web 服务器默认开启 gzip 压缩(compression: gzip, level 1, threshold 1KB),
|
|
15
|
-
// 所以 API 响应可能是 gzip 编码的。必须先解压才能 JSON.parse,否则解析失败,
|
|
16
|
-
// catch {} 静默跳过剥离 → 完整 58MB+ 响应原样传输 → 隧道慢。
|
|
17
|
-
|
|
18
|
-
import { gunzipSync } from 'node:zlib';
|
|
19
|
-
|
|
20
|
-
// DSH 真实 RPC 路径是斜杠形式:dsh-client-connection 用 `${channel}/${endpoint}`
|
|
21
|
-
// 拼 URL(channel=/api、endpoint=session/list),服务端 endpointFromPath 也按斜杠解析。
|
|
22
|
-
// 点号形式可能是旧版/外部 REST 端点,保留以向后兼容(issue #82:此前只写点号,
|
|
23
|
-
// 导致真实流量上剥离从未生效)。
|
|
24
|
-
//
|
|
25
|
-
// 关于 session/history(已核实,2026-10,DSH 0.2.1-rc.2):
|
|
26
|
-
// 该版本 dsh-api-session-controller 枚举出的 20 个 session 端点(见
|
|
27
|
-
// dsh-api-session-controller/lib/typert.remote-client.d.ts 与
|
|
28
|
-
// dsh-api-remotes/lib/client.js)只有 session/list、session/page、session/projections
|
|
29
|
-
// 等,**没有 session/history**——会话历史读取走 session/page。
|
|
30
|
-
// 因此斜杠 /api/session/history 在当前 DSH 版本上大概率是死路径;这里仍保留该分支
|
|
31
|
-
// 作为兼容面(旧版本可能用过该端点,且外部/自定义网关可能仍以该路径转发),
|
|
32
|
-
// 不因「当前版本无此端点」而删除。它只扩大命中集,不会误伤 session/page 等端点。
|
|
33
|
-
const STRIP_PATHS = new Set([
|
|
34
|
-
'/api/session/list',
|
|
35
|
-
'/api/session/history', // 当前 DSH 0.2.1-rc.2 无此端点,保留兼容(见上)
|
|
36
|
-
'/api/session.list',
|
|
37
|
-
'/api/session.history',
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* 判断请求路径是否属于需要剥离会话投影的端点集合。
|
|
42
|
-
* 同时兼容斜杠(DSH RPC 真实路径)与点号(历史 REST 路径)两种写法。
|
|
43
|
-
* 注:session/list 是当前 DSH 版本的真实活端点;session/history 属兼容面
|
|
44
|
-
* (DSH 0.2.1-rc.2 已无该端点,历史读取走 session/page),保留不删。
|
|
45
|
-
* @param {string} pathname 请求路径,可带 query
|
|
46
|
-
* @returns {boolean}
|
|
47
|
-
*/
|
|
48
|
-
export function isSessionProjectionPath(pathname) {
|
|
49
|
-
const cleanPath = String(pathname || '').split('?')[0];
|
|
50
|
-
return STRIP_PATHS.has(cleanPath);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* 剥离 session/list(活端点)响应中的 contextHeaders 与 contextTimeline。
|
|
55
|
-
* session/history 分支为兼容面(当前 DSH 0.2.1-rc.2 无该端点),结构同
|
|
56
|
-
* projections.values,命中才会删除。
|
|
57
|
-
* @param {string} pathname 请求路径(不含 query)
|
|
58
|
-
* @param {Buffer} bodyBuf 原始响应体
|
|
59
|
-
* @param {string} [contentEncoding] 响应的 content-encoding 头值(用于判断是否 gzip)
|
|
60
|
-
* @returns {{ body: Buffer, stripped: boolean }} stripped=true 表示发生了剥离(调用方需更新 content-length 和 content-encoding)
|
|
61
|
-
*/
|
|
62
|
-
export function stripSessionProjections(pathname, bodyBuf, contentEncoding) {
|
|
63
|
-
if (!isSessionProjectionPath(pathname) || !Buffer.isBuffer(bodyBuf)) {
|
|
64
|
-
return { body: bodyBuf, stripped: false };
|
|
65
|
-
}
|
|
66
|
-
try {
|
|
67
|
-
// 如果 DSH 服务器 gzip 压缩了响应,先解压再解析
|
|
68
|
-
const isGzipped = String(contentEncoding ?? '').toLowerCase() === 'gzip';
|
|
69
|
-
const parseBuf = isGzipped ? gunzipSync(bodyBuf) : bodyBuf;
|
|
70
|
-
const json = JSON.parse(parseBuf.toString('utf8'));
|
|
71
|
-
if (json?.result?.ok) {
|
|
72
|
-
const v = json.result.value;
|
|
73
|
-
let changed = false;
|
|
74
|
-
// session.list: items[].projections.values
|
|
75
|
-
if (v?.items) {
|
|
76
|
-
for (const item of v.items) {
|
|
77
|
-
const proj = item?.projections?.values;
|
|
78
|
-
if (proj && (proj.contextHeaders !== undefined || proj.contextTimeline !== undefined)) {
|
|
79
|
-
delete proj.contextHeaders;
|
|
80
|
-
delete proj.contextTimeline;
|
|
81
|
-
changed = true;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
// session.history: projections.values
|
|
86
|
-
if (v?.projections?.values) {
|
|
87
|
-
const proj = v.projections.values;
|
|
88
|
-
if (proj.contextHeaders !== undefined || proj.contextTimeline !== undefined) {
|
|
89
|
-
delete proj.contextHeaders;
|
|
90
|
-
delete proj.contextTimeline;
|
|
91
|
-
changed = true;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
if (changed) {
|
|
95
|
-
return { body: Buffer.from(JSON.stringify(json), 'utf8'), stripped: true };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
} catch { /* 解析/解压失败则原样发送 */ }
|
|
99
|
-
return { body: bodyBuf, stripped: false };
|
|
100
|
-
}
|
package/lib/sse-keepalive.js
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// SSE 空闲保活(issue #71)
|
|
2
|
-
//
|
|
3
|
-
// 背景:DSH 的 `/plugins/events` 等 `text/event-stream` 端点在首帧之后可以长时间
|
|
4
|
-
// 不产生任何字节(宿主自身不发心跳)。经本插件反向代理出去的链路——局域网 3082、
|
|
5
|
-
// Cloudflare 隧道、外部隧道登记、自建隧道本地腿——此前原样 pipe,链路空闲后会被
|
|
6
|
-
// 中间设备按空闲流回收掐断。issue #71 实测:空闲的 /plugins/events 在 Cloudflare
|
|
7
|
-
// 边缘 5/5 于 125.5–126.1s 被远端 cancel(cloudflared 日志 `stream ... canceled by
|
|
8
|
-
// remote`),而每 ~20s 有帧流动的对照流全部存活;本机 dsh.ypbin.cn 独立复现同样
|
|
9
|
-
// 在 126.9s 被掐。结果是远端面板实时通道每约 2 分钟断一次,必须手动刷新。
|
|
10
|
-
//
|
|
11
|
-
// 处理方式:对满足护栏条件的 SSE 响应,在代理侧静默超过
|
|
12
|
-
// SSE_KEEPALIVE_INTERVAL_MS 时向客户端写入一帧 SSE 注释行 `: keepalive\n\n`。
|
|
13
|
-
// SSE 规范(WHATWG EventSource)规定以 ':' 开头的行为注释、客户端必须忽略,
|
|
14
|
-
// 因此对任何合规解析器零影响;本仓库 dsh-taskboard 的 `: ping` 已是同类先例。
|
|
15
|
-
//
|
|
16
|
-
// 三个安全护栏(缺一不注入,见 isSseKeepaliveCandidate):
|
|
17
|
-
// 1. content-type 必须是 text/event-stream —— 只碰真正的事件流;
|
|
18
|
-
// 2. 未压缩(无 gzip/br/deflate content-encoding)——往压缩流里插明文会直接
|
|
19
|
-
// 损坏字节流,客户端 gunzip 即报错;
|
|
20
|
-
// 3. 无 content-length —— 定长响应插入额外字节会破坏消息帧界。
|
|
21
|
-
//
|
|
22
|
-
// 开关:ProxyServer 的 `sseKeepalive`(默认 true,cordis 配置 `sseKeepalive` 可关)。
|
|
23
|
-
// 间隔固定 25s:远小于实测 126s 回收窗口,也低于常见 60s 空闲阈值;每 25s 仅
|
|
24
|
-
// 12 字节,带宽代价可忽略。
|
|
25
|
-
|
|
26
|
-
/** 保活帧:SSE 注释行,规范要求客户端忽略 */
|
|
27
|
-
export const SSE_KEEPALIVE_FRAME = ': keepalive\n\n';
|
|
28
|
-
|
|
29
|
-
/** 静默多久注入一帧(远小于 Cloudflare 实测 ~126s 空闲回收窗口) */
|
|
30
|
-
export const SSE_KEEPALIVE_INTERVAL_MS = 25_000;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 判定一份上游响应头是否允许注入 SSE 保活帧。三条护栏见文件头注释。
|
|
34
|
-
* @param {Record<string, string | string[] | undefined>} [headers] 上游响应头
|
|
35
|
-
* @returns {boolean} true = 可以注入
|
|
36
|
-
*/
|
|
37
|
-
export function isSseKeepaliveCandidate(headers = {}) {
|
|
38
|
-
const contentType = String(headers['content-type'] ?? '');
|
|
39
|
-
if (!contentType.toLowerCase().includes('text/event-stream')) return false;
|
|
40
|
-
// 定长响应绝不能插字节(消息帧界会被破坏)
|
|
41
|
-
if (headers['content-length'] !== undefined && headers['content-length'] !== null) return false;
|
|
42
|
-
// 压缩流绝不能插明文(会损坏 gzip/br 字节流)
|
|
43
|
-
const encoding = String(headers['content-encoding'] ?? '');
|
|
44
|
-
if (/(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(encoding)) return false;
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* 创建一个 SSE 空闲保活计时器。
|
|
50
|
-
*
|
|
51
|
-
* 语义:
|
|
52
|
-
* - 创建即开始第一段静默倒计时(上游首帧通常紧随其后,会重置计时);
|
|
53
|
-
* - `poke()` 表示"刚见到上游数据",从零重新起算静默窗口;
|
|
54
|
-
* - 静默满 intervalMs 写入一帧保活,并继续按 intervalMs 周期写(直到有数据或停止);
|
|
55
|
-
* - `stop()` 幂等终止,之后 poke/写入均不再发生;
|
|
56
|
-
* - `write` 抛错视为连接已坏:停表且不向外抛(定时器回调里的异常会打崩事件循环),
|
|
57
|
-
* 通过 onWriteError 上报,连接收尾由调用方的 close/error 负责。
|
|
58
|
-
*
|
|
59
|
-
* @param {object} options
|
|
60
|
-
* @param {(frame: string) => void} options.write 写一帧的回调(调用方负责判断连接可写)
|
|
61
|
-
* @param {number} [options.intervalMs] 静默间隔,默认 SSE_KEEPALIVE_INTERVAL_MS
|
|
62
|
-
* @param {{ setTimeout: Function, clearTimeout: Function }} [options.timers] 可注入定时器(测试用)
|
|
63
|
-
* @param {(err: unknown) => void} [options.onWriteError] 写失败回调
|
|
64
|
-
* @returns {{ poke: () => void, stop: () => void, readonly stopped: boolean }}
|
|
65
|
-
*/
|
|
66
|
-
export function createSseKeepalive(options = {}) {
|
|
67
|
-
const { write, intervalMs = SSE_KEEPALIVE_INTERVAL_MS, timers, onWriteError } = options;
|
|
68
|
-
if (typeof write !== 'function') throw new TypeError('createSseKeepalive: write 必须是函数');
|
|
69
|
-
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
70
|
-
throw new RangeError(`createSseKeepalive: intervalMs 必须是正数,收到 ${intervalMs}`);
|
|
71
|
-
}
|
|
72
|
-
const setT = timers?.setTimeout ?? globalThis.setTimeout;
|
|
73
|
-
const clearT = timers?.clearTimeout ?? globalThis.clearTimeout;
|
|
74
|
-
|
|
75
|
-
let timer = null;
|
|
76
|
-
let stopped = false;
|
|
77
|
-
|
|
78
|
-
const schedule = () => {
|
|
79
|
-
if (stopped) return;
|
|
80
|
-
timer = setT(() => {
|
|
81
|
-
timer = null;
|
|
82
|
-
if (stopped) return;
|
|
83
|
-
try {
|
|
84
|
-
write(SSE_KEEPALIVE_FRAME);
|
|
85
|
-
} catch (err) {
|
|
86
|
-
stopped = true;
|
|
87
|
-
try { onWriteError?.(err); } catch { /* 上报失败不得影响事件循环 */ }
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
schedule();
|
|
91
|
-
}, intervalMs);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const poke = () => {
|
|
95
|
-
if (stopped) return;
|
|
96
|
-
// 从"最后一次见到上游数据"重新起算:清掉旧倒计时再排新的
|
|
97
|
-
if (timer !== null) clearT(timer);
|
|
98
|
-
schedule();
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const stop = () => {
|
|
102
|
-
stopped = true;
|
|
103
|
-
if (timer !== null) {
|
|
104
|
-
clearT(timer);
|
|
105
|
-
timer = null;
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
schedule();
|
|
110
|
-
return {
|
|
111
|
-
poke,
|
|
112
|
-
stop,
|
|
113
|
-
get stopped() { return stopped; },
|
|
114
|
-
};
|
|
115
|
-
}
|