dsh-switchman 1.3.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.
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 配置解析:env 文件 / 环境变量 / 脱敏 / 安全错误格式化
5
+ * dotenv 只用 parse(),不污染 process.env;POSIX 下检查 env 文件权限
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const dotenv = require('dotenv');
11
+
12
+ // ── env 文件加载(不污染 process.env)──
13
+
14
+ /**
15
+ * 加载指定 env 文件,返回键值对
16
+ * 不读 cwd 的 .env,防止意外泄露;group/other 可读时直接拒绝(凭据安全)
17
+ * @param {string|undefined} envFile - env 文件路径
18
+ * @returns {object} 键值对
19
+ */
20
+ function loadEnvFile(envFile) {
21
+ if (!envFile) return {};
22
+ const resolved = path.resolve(envFile);
23
+ if (!fs.existsSync(resolved)) {
24
+ throw new Error(`env 文件不存在: ${resolved}`);
25
+ }
26
+
27
+ // POSIX 下检查 group/other 权限:同机其他用户可读即泄露密码,直接拒绝
28
+ try {
29
+ const stat = fs.statSync(resolved);
30
+ // eslint-disable-next-line no-bitwise
31
+ if ((stat.mode & 0o077) !== 0) {
32
+ throw new ConfigError(`env 文件 ${resolved} 权限过宽(group/other 可访问),请 chmod 600`);
33
+ }
34
+ } catch (e) {
35
+ // 仅跳过 Windows 等非 POSIX 系统的权限检查错误;其余错误(权限过宽)往上抛
36
+ if (e.message.includes('权限过宽')) throw e;
37
+ }
38
+
39
+ const content = fs.readFileSync(resolved, 'utf8');
40
+ return dotenv.parse(content);
41
+ }
42
+
43
+ /**
44
+ * 按 SKILL.md 优先级读取配置值:CLI > env 文件 > 进程环境变量 > undefined
45
+ * @param {object} envPairs - dotenv.parse() 结果(显式 --env-file,权限受控)
46
+ * @param {string} envKey - 环境变量名(如 DB_QUERY_MYSQL_HOST)
47
+ * @param {*} cliValue - CLI 传入的值;undefined 表示 CLI 未指定
48
+ * @returns {*} CLI 优先,其次 env 文件,再次进程环境变量,否则 undefined
49
+ */
50
+ function resolveEnv(envPairs, envKey, cliValue) {
51
+ if (cliValue !== undefined) return cliValue;
52
+ // env 文件优先(显式指定、权限受控 0600),其次进程环境变量
53
+ // (loadEnvFile 刻意不污染 process.env,必须在此显式读取兜底,否则 export DB_QUERY_* 不生效)
54
+ if (envPairs[envKey] !== undefined) return envPairs[envKey];
55
+ if (process.env[envKey] !== undefined) return process.env[envKey];
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * 从 env/CLI 解析数值并校验 min/max 边界
61
+ * 所有来源(CLI / env 文件 / 环境变量)统一跑边界检查,防止 env 文件绕过硬上限
62
+ *
63
+ * @param {object} envPairs - dotenv.parse() 结果
64
+ * @param {string} envKey - 环境变量名
65
+ * @param {*} cliValue - CLI 传入的值(已由 cli.js 做过边界校验)
66
+ * @param {object} bounds - { min?: number, max?: number }
67
+ * @returns {number}
68
+ */
69
+ class ConfigError extends Error {
70
+ constructor(msg) { super(msg); this.name = 'ConfigError'; }
71
+ }
72
+
73
+ function resolveEnvNumber(envPairs, envKey, cliValue, bounds) {
74
+ // CLI 来源:cli.js 的 coerceValue 已做过边界检查,直接返回
75
+ if (cliValue !== undefined) return cliValue;
76
+ // env 文件优先,其次进程环境变量(与 resolveEnv 优先级一致,符合 SKILL.md 承诺);
77
+ // env 来源的值需统一解析 + 边界校验,防止 env 绕过硬上限
78
+ const raw = envPairs[envKey] !== undefined ? envPairs[envKey] : process.env[envKey];
79
+ if (raw === undefined) return undefined;
80
+ const n = Number(raw);
81
+ if (!Number.isInteger(n)) {
82
+ throw new ConfigError(`环境变量 ${envKey}=${raw} 不是合法整数`);
83
+ }
84
+ if (bounds && bounds.min !== undefined && n < bounds.min) {
85
+ throw new ConfigError(`环境变量 ${envKey}=${n} 低于最小值 ${bounds.min}`);
86
+ }
87
+ if (bounds && bounds.max !== undefined && n > bounds.max) {
88
+ throw new ConfigError(`环境变量 ${envKey}=${n} 超过最大值 ${bounds.max}`);
89
+ }
90
+ return n;
91
+ }
92
+
93
+ // ── 连接目标脱敏 ──
94
+
95
+ /**
96
+ * 生成脱敏的连接目标信息,绝不包含密码或含密码 URI
97
+ * @param {object} opts - { host, port, user, database }
98
+ * @returns {string}
99
+ */
100
+ function sanitizeTarget(opts) {
101
+ const parts = [`host=${opts.host || '127.0.0.1'}`, `port=${opts.port}`];
102
+ if (opts.user) parts.push(`user=${opts.user}`);
103
+ if (opts.database !== undefined) parts.push(`db=${opts.database}`);
104
+ return parts.join(', ');
105
+ }
106
+
107
+ // ── 安全错误格式化 ──
108
+
109
+ // 屏蔽 URL/连接串里内联的密码(://user:password@host → ://user:***@host)。
110
+ // 用户名段用 * 以覆盖 redis 无用户名的标准形式(://:password@host),防御性脱敏
111
+ const URL_CRED_PATTERN = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/\s:@]*:)[^/\s@]+(@)/g;
112
+
113
+ /**
114
+ * 取错误 message 首行,脱敏截断:屏蔽内联凭据 + 截断到 200 字符防意外泄露
115
+ * @param {string} s
116
+ * @returns {string}
117
+ */
118
+ function sanitizeErrorLine(s) {
119
+ const line = String(s || '').split('\n')[0];
120
+ const masked = line.replace(URL_CRED_PATTERN, '$1***$2');
121
+ return masked.length > 200 ? masked.slice(0, 200) + '…' : masked;
122
+ }
123
+
124
+ // 连接/认证/握手阶段错误码 —— 归为「连接失败」,明确区分于查询执行错误
125
+ const CONNECTION_CODES = new Set([
126
+ // Node 网络与系统错误
127
+ 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND',
128
+ 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'ECONNABORTED', 'EDESTADDRREQ',
129
+ // DNS 解析错误
130
+ 'EAI_AGAIN', 'EAI_NONAME', 'EAI_SERVICE', 'EAI_FAIL', 'EAI_BADFLAGS',
131
+ // TLS 错误
132
+ 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED',
133
+ 'DEPTH_ZERO_SELF_SIGNED_CERT', 'ERR_TLS_INVALID_PROTOCOL_METHOD',
134
+ 'ERR_TLS_PROTOCOL_VERSION_CONFLICT',
135
+ // mysql2 连接/握手/选库阶段
136
+ 'HANDSHAKE_ERROR', 'PROTOCOL_CONNECTION_LOST',
137
+ 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', 'PROTOCOL_SEQUENCE_TIMEOUT',
138
+ 'ER_ACCESS_DENIED_ERROR', 'ER_DBACCESS_DENIED_ERROR',
139
+ 'ER_BAD_DB_ERROR', 'ER_BAD_HOST_ERROR',
140
+ // Redis 认证阶段
141
+ 'WRONGPASS', 'NOAUTH',
142
+ ]);
143
+
144
+ // node-redis 命令执行阶段错误码(与连接阶段区分)
145
+ const REDIS_EXEC_CODES = new Set([
146
+ 'ERR', 'WRONGTYPE', 'WRONGINT', 'WRONGBIT', 'WRONGKEY',
147
+ 'EXECABORT', 'NOREPLICAS', 'MISCONF', 'BUSYGROUP', 'NOGROUP',
148
+ 'LOADING', 'MASTERDOWN', 'READONLY', 'MOVED', 'ASK', 'CROSSSLOT',
149
+ 'NOSCRIPT', 'BUSYKEY', 'NOSUBKEY',
150
+ ]);
151
+
152
+ /**
153
+ * 安全地格式化数据库错误,让排障 agent 能立刻看清真正的失败原因。
154
+ *
155
+ * 关键安全前提:mysql2 的 ER_* / node-redis 的命令错误,其 message 来自
156
+ * 数据库服务端,不含客户端凭据(服务端不感知客户端密码);Node 网络错误
157
+ * message 形如 "connect ECONNREFUSED 127.0.0.1:3306" 也不含密码。因此按
158
+ * code 分类后暴露 code + message 首行(已脱敏截断)是安全的,且能让 agent
159
+ * 一次区分「连不上」还是「SQL 写错」,避免无效的二分排查。
160
+ * 仅对「既无 code 也无 message」的极端异常给中性兜底,且不再误报成「连接失败」。
161
+ * @param {Error} err
162
+ * @returns {string}
163
+ */
164
+ function safeMessage(err) {
165
+ const code = err.code || err.errno;
166
+ const line = sanitizeErrorLine(err.message);
167
+
168
+ if (code) {
169
+ const codeStr = String(code);
170
+ if (CONNECTION_CODES.has(codeStr)) {
171
+ return `连接失败 [${codeStr}]: ${line}`;
172
+ }
173
+ // mysql2 服务端执行错误:ER_BAD_FIELD_ERROR(1054)/ER_PARSE_ERROR/ER_NO_SUCH_TABLE 等
174
+ if (/^ER_/.test(codeStr)) {
175
+ return `查询执行错误 [${codeStr}]: ${line}`;
176
+ }
177
+ // node-redis 命令执行错误
178
+ if (REDIS_EXEC_CODES.has(codeStr)) {
179
+ return `查询执行错误 [${codeStr}]: ${line}`;
180
+ }
181
+ // 未知 code:仍暴露 code + 首行(已脱敏截断),比「详情已隐藏」更利于排障
182
+ return `[${codeStr}] ${line}`;
183
+ }
184
+
185
+ // 无 code:无法分类,给中性措辞,不再误导成「连接失败」
186
+ const name = err.name || 'Error';
187
+ return `${name}: ${line || '(无错误信息,请检查 SQL/命令与连接参数)'}`;
188
+ }
189
+
190
+ // ── TLS 选项构建 ──
191
+
192
+ /**
193
+ * 从 ca-file 路径构建 TLS 选项
194
+ * @param {string|undefined} caFile
195
+ * @returns {object|undefined}
196
+ */
197
+ function buildTlsOptions(caFile) {
198
+ if (!caFile) return undefined;
199
+ const resolved = path.resolve(caFile);
200
+ if (!fs.existsSync(resolved)) {
201
+ throw new Error(`CA 证书文件不存在: ${resolved}`);
202
+ }
203
+ const ca = fs.readFileSync(resolved);
204
+ return { ca, rejectUnauthorized: true };
205
+ }
206
+
207
+ module.exports = {
208
+ loadEnvFile,
209
+ resolveEnv,
210
+ resolveEnvNumber,
211
+ ConfigError,
212
+ sanitizeTarget,
213
+ safeMessage,
214
+ buildTlsOptions,
215
+ };