dsh-api-runner 0.3.0 → 0.3.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.
- package/README.md +35 -4
- package/client/client.js +673 -329
- package/package.json +6 -4
- package/scripts/check-leak.mjs +150 -0
- package/src/db.js +543 -37
- package/src/main.js +77 -13
- package/src/ops.js +46 -8
- package/test/client.mjs +41 -2
- package/test/db.mjs +190 -24
- package/test/plugin.mjs +76 -50
- package/test/security.mjs +200 -0
package/src/db.js
CHANGED
|
@@ -33,6 +33,31 @@ const DEFAULT_PORTS = {
|
|
|
33
33
|
dm: 5236,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 是否毁灭性操作:即使有读写权限也要额外确认。
|
|
38
|
+
*
|
|
39
|
+
* 判定标准偏保守——宁可误拒,不可误放:
|
|
40
|
+
* - DROP / TRUNCATE:直接删结构或清空表
|
|
41
|
+
* - DELETE / UPDATE 没有 WHERE:影响整表
|
|
42
|
+
* - 任何 GRANT / REVOKE:改权限
|
|
43
|
+
*/
|
|
44
|
+
export function isDestructiveSql(sql) {
|
|
45
|
+
const stripped = stripStringsAndComments(String(sql || '')).trim();
|
|
46
|
+
if (/^\s*(drop|truncate|grant|revoke)\b/i.test(stripped)) return true;
|
|
47
|
+
// DELETE / UPDATE 缺少 WHERE 子句 = 全表操作
|
|
48
|
+
if (/^\s*(delete|update)\b/i.test(stripped) && !/\bwhere\b/i.test(stripped)) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 授权等级:none < readonly < readwrite */
|
|
53
|
+
export const ACCESS_LEVELS = ['none', 'readonly', 'readwrite'];
|
|
54
|
+
|
|
55
|
+
/** 等级比较:是否满足所需等级 */
|
|
56
|
+
function levelAtLeast(actual, required) {
|
|
57
|
+
const order = { none: 0, readonly: 1, readwrite: 2 };
|
|
58
|
+
return (order[actual] ?? 0) >= (order[required] ?? 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
/** 结果集默认上限 */
|
|
37
62
|
const DEFAULT_MAX_ROWS = 1000;
|
|
38
63
|
/** 查询默认超时 */
|
|
@@ -110,11 +135,147 @@ export function splitStatements(sql) {
|
|
|
110
135
|
return out.map((item) => item.trim()).filter((item) => item !== '');
|
|
111
136
|
}
|
|
112
137
|
|
|
113
|
-
/**
|
|
138
|
+
/** 是否只读语句(仅看开头,作为第一道粗筛;真正的判定见 classifySql) */
|
|
114
139
|
export function isReadOnlySql(sql) {
|
|
115
140
|
return READONLY_PREFIX.test(String(sql || ''));
|
|
116
141
|
}
|
|
117
142
|
|
|
143
|
+
/**
|
|
144
|
+
* 危险关键字:出现在**任何位置**都视为写操作,即使语句以 SELECT 开头。
|
|
145
|
+
*
|
|
146
|
+
* 为什么要全串扫描而不是只看前缀:
|
|
147
|
+
* - `WITH x AS (SELECT 1) DELETE FROM t` 以 WITH 开头,但会删数据
|
|
148
|
+
* - `SELECT * FROM t FOR UPDATE` 会加锁,阻塞别人写入
|
|
149
|
+
* - `SELECT ... INTO OUTFILE` / `INTO DUMPFILE` 能往磁盘写文件
|
|
150
|
+
* - `SELECT load_file('/etc/passwd')` 能读任意文件
|
|
151
|
+
* - `EXPLAIN ANALYZE DELETE ...` 会真的执行
|
|
152
|
+
* 这些用「开头是不是 SELECT」都拦不住。
|
|
153
|
+
*/
|
|
154
|
+
const DANGEROUS_KEYWORDS = [
|
|
155
|
+
'insert', 'update', 'delete', 'merge', 'upsert',
|
|
156
|
+
'create', 'alter', 'drop', 'truncate', 'rename',
|
|
157
|
+
'grant', 'revoke', 'comment',
|
|
158
|
+
'exec', 'execute', 'call', 'perform',
|
|
159
|
+
'lock', 'unlock',
|
|
160
|
+
'copy', 'load', 'outfile', 'dumpfile',
|
|
161
|
+
'attach', 'detach', 'vacuum', 'reindex',
|
|
162
|
+
'set', 'commit', 'rollback', 'savepoint', 'begin',
|
|
163
|
+
'declare', 'prepare', 'deallocate',
|
|
164
|
+
'analyze', 'optimize', 'repair', 'shutdown', 'kill',
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/** 需要额外警惕的「以 SELECT 开头但会写」的组合 */
|
|
168
|
+
const SELECT_WRITE_PATTERNS = [
|
|
169
|
+
/\bfor\s+(update|share|no\s+key\s+update)\b/i, // 加锁
|
|
170
|
+
/\binto\s+(outfile|dumpfile)\b/i, // 写文件
|
|
171
|
+
/\binto\s+@?[\w.]+\s+from\b/i, // SELECT INTO 建表
|
|
172
|
+
/\bload_file\s*\(/i, // 读服务器文件
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* PRAGMA 的两种形态:
|
|
177
|
+
* - 查询式:`PRAGMA table_info('t')`、`PRAGMA index_list(t)` —— 只读,放行
|
|
178
|
+
* - 赋值式:`PRAGMA journal_mode = WAL`、`PRAGMA user_version = 5` —— 改配置,拦
|
|
179
|
+
* 白名单只放行已知的只读 pragma,其余一律按写处理。
|
|
180
|
+
*/
|
|
181
|
+
const READONLY_PRAGMA = /^\s*pragma\s+(table_info|table_xinfo|index_info|index_list|index_xinfo|foreign_key_list|foreign_key_check|database_list|collation_list|function_list|module_list|pragma_list|compile_options|integrity_check|quick_check|encoding|page_count|page_size|freelist_count|schema_version|user_version|journal_mode|wal_checkpoint)\b/i;
|
|
182
|
+
/** 允许「读」但不允许「写」的 pragma:带 `=` 时是写 */
|
|
183
|
+
const MUTABLE_PRAGMA = /^\s*pragma\s+(journal_mode|user_version|synchronous|foreign_keys|cache_size|busy_timeout|locking_mode|page_size|auto_vacuum|wal_autocheckpoint|mmap_size|temp_store|encoding)\b/i;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 去掉 SQL 里的字符串字面量和注释,只留「结构性内容」。
|
|
187
|
+
* 关键字扫描必须基于这个结果,否则 `WHERE note = 'delete'` 会被误判成写操作。
|
|
188
|
+
*/
|
|
189
|
+
export function stripStringsAndComments(sql) {
|
|
190
|
+
const text = String(sql || '');
|
|
191
|
+
let out = '';
|
|
192
|
+
let i = 0;
|
|
193
|
+
let quote = '';
|
|
194
|
+
while (i < text.length) {
|
|
195
|
+
const ch = text[i];
|
|
196
|
+
const next = text[i + 1];
|
|
197
|
+
if (quote) {
|
|
198
|
+
if (ch === '\\' && (quote === "'" || quote === '"')) { i += 2; continue; }
|
|
199
|
+
if (ch === quote) {
|
|
200
|
+
if (next === quote) { i += 2; continue; }
|
|
201
|
+
quote = '';
|
|
202
|
+
}
|
|
203
|
+
i += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (ch === "'" || ch === '"' || ch === '`') { quote = ch; i += 1; continue; }
|
|
207
|
+
if (ch === '-' && next === '-') {
|
|
208
|
+
const nl = text.indexOf('\n', i);
|
|
209
|
+
i = nl === -1 ? text.length : nl + 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (ch === '/' && next === '*') {
|
|
213
|
+
const close = text.indexOf('*/', i + 2);
|
|
214
|
+
i = close === -1 ? text.length : close + 2;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
out += ch;
|
|
218
|
+
i += 1;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 判定一条 SQL 的性质。
|
|
225
|
+
*
|
|
226
|
+
* @returns {{ kind: 'read'|'write'|'unknown', reason?: string, keyword?: string }}
|
|
227
|
+
* - read:确定是只读查询,可以在只读模式下执行
|
|
228
|
+
* - write:包含写/危险操作,只读模式下必须拒绝
|
|
229
|
+
* - unknown:认不出来,按最严格的处理(只读模式下拒绝)
|
|
230
|
+
*/
|
|
231
|
+
export function classifySql(sql) {
|
|
232
|
+
const text = String(sql || '').trim();
|
|
233
|
+
if (text === '') return { kind: 'unknown', reason: 'SQL 为空' };
|
|
234
|
+
|
|
235
|
+
const stripped = stripStringsAndComments(text);
|
|
236
|
+
|
|
237
|
+
// 多语句:哪怕每一条都只读,也不允许(防止用分号绕过)
|
|
238
|
+
if (splitStatements(text).length > 1) {
|
|
239
|
+
return { kind: 'write', reason: '包含多条语句' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 危险关键字:全串扫描(已剔除字符串和注释)
|
|
243
|
+
for (const kw of DANGEROUS_KEYWORDS) {
|
|
244
|
+
const re = new RegExp(`\\b${kw}\\b`, 'i');
|
|
245
|
+
if (re.test(stripped)) {
|
|
246
|
+
return { kind: 'write', reason: `包含写操作关键字 ${kw.toUpperCase()}`, keyword: kw };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// SELECT 开头但带锁/写文件等
|
|
251
|
+
for (const pattern of SELECT_WRITE_PATTERNS) {
|
|
252
|
+
if (pattern.test(stripped)) {
|
|
253
|
+
return { kind: 'write', reason: '查询语句包含加锁或写文件动作' };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// PRAGMA 单独判定:赋值式会改库配置,必须拦
|
|
258
|
+
if (/^\s*pragma\b/i.test(stripped)) {
|
|
259
|
+
// 可变的 pragma 带 `=` 或带参数但不是查询式 → 写
|
|
260
|
+
if (MUTABLE_PRAGMA.test(stripped) && /\s*=\s*/.test(stripped)) {
|
|
261
|
+
return { kind: 'write', reason: 'PRAGMA 赋值会修改数据库配置' };
|
|
262
|
+
}
|
|
263
|
+
// 只读白名单
|
|
264
|
+
if (READONLY_PRAGMA.test(stripped)) {
|
|
265
|
+
return { kind: 'read' };
|
|
266
|
+
}
|
|
267
|
+
// 认不出来的 pragma:按最严格处理
|
|
268
|
+
return { kind: 'unknown', reason: '无法确认这条 PRAGMA 是只读的' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 结构上必须以只读关键字开头
|
|
272
|
+
if (!READONLY_PREFIX.test(text)) {
|
|
273
|
+
return { kind: 'unknown', reason: '无法识别为只读查询(只允许 SELECT / SHOW / DESCRIBE / EXPLAIN / PRAGMA)' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { kind: 'read' };
|
|
277
|
+
}
|
|
278
|
+
|
|
118
279
|
/**
|
|
119
280
|
* 机器特征派生密钥。
|
|
120
281
|
*
|
|
@@ -160,6 +321,19 @@ export function decryptSecret(stored) {
|
|
|
160
321
|
}
|
|
161
322
|
}
|
|
162
323
|
|
|
324
|
+
/**
|
|
325
|
+
* 把连接串里的密码打码,用于界面回显与日志。
|
|
326
|
+
* 形如 `dm://FUND:***@192.168.0.144:8185?...` —— 保留用户名和其余部分,
|
|
327
|
+
* 用户能确认「填的是哪个连接」,但看不到密码。
|
|
328
|
+
*/
|
|
329
|
+
export function maskUrlPassword(connectString) {
|
|
330
|
+
const text = String(connectString || '');
|
|
331
|
+
// scheme://user:pass@rest
|
|
332
|
+
return text.replace(/^([a-z][a-z0-9+.-]*:\/\/)([^:/@]*):([^@]*)@/i, (_all, scheme, user, pass) => {
|
|
333
|
+
return `${scheme}${user}:${pass ? '***' : ''}@`;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
163
337
|
/** 生成连接 id */
|
|
164
338
|
function makeId(name) {
|
|
165
339
|
const base = String(name || 'db')
|
|
@@ -179,6 +353,11 @@ export class DbManager {
|
|
|
179
353
|
this.settingsStore = options.settingsStore || null;
|
|
180
354
|
this.maxRows = Number(options.maxRows) || DEFAULT_MAX_ROWS;
|
|
181
355
|
this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
356
|
+
/**
|
|
357
|
+
* 试连的独立超时(默认 15s)。
|
|
358
|
+
* 比查询超时短:试连是交互动作,用户盯着等,不能让驱动自己卡 60 秒。
|
|
359
|
+
*/
|
|
360
|
+
this.probeTimeoutMs = Number(options.probeTimeoutMs) || 15000;
|
|
182
361
|
/** 连接池缓存:connectionId -> driver handle */
|
|
183
362
|
this.pools = new Map();
|
|
184
363
|
this._setDir(options.dataDir || this._preferredDataDir());
|
|
@@ -248,6 +427,93 @@ export class DbManager {
|
|
|
248
427
|
writeStateNamespace(this.file, 'connections', { items });
|
|
249
428
|
}
|
|
250
429
|
|
|
430
|
+
// ---------------------------------------------------------------- 项目授权
|
|
431
|
+
/**
|
|
432
|
+
* 授权模型:**按项目(工作空间)授权,默认拒绝**。
|
|
433
|
+
*
|
|
434
|
+
* 为什么默认拒绝而不是默认允许:
|
|
435
|
+
* 一个连到生产库的连接,如果默认对所有项目开放,那么任何一个会话
|
|
436
|
+
* 都能读它——这不可接受。所以新连接创建后是「未授权」状态,
|
|
437
|
+
* 必须由用户在面板里显式授权给某个项目。
|
|
438
|
+
*
|
|
439
|
+
* 存储:`<数据目录>/db-connections.json` 的 `access` 命名空间,
|
|
440
|
+
* 形如 { "<projectKey>": { "<connectionId>": "readonly" } }。
|
|
441
|
+
*/
|
|
442
|
+
_loadAccess() {
|
|
443
|
+
const ns = readStateNamespace(this.file, 'access');
|
|
444
|
+
return isPlainObject(ns.projects) ? ns.projects : {};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_saveAccess(projects) {
|
|
448
|
+
writeStateNamespace(this.file, 'access', { projects });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 某个项目对所有连接的授权表:{ connectionId: level } */
|
|
452
|
+
accessOf(projectKey) {
|
|
453
|
+
const key = String(projectKey || 'default');
|
|
454
|
+
const all = this._loadAccess();
|
|
455
|
+
const row = all[key];
|
|
456
|
+
return isPlainObject(row) ? row : {};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 设置某项目对某连接的授权等级 */
|
|
460
|
+
setAccess(projectKey, connectionId, level) {
|
|
461
|
+
const key = String(projectKey || 'default');
|
|
462
|
+
const cid = String(connectionId || '');
|
|
463
|
+
if (cid === '') return { ok: false, code: 'MISSING_CONNECTION', message: '需要 connection_id' };
|
|
464
|
+
const lv = String(level || 'none');
|
|
465
|
+
if (!ACCESS_LEVELS.includes(lv)) {
|
|
466
|
+
return { ok: false, code: 'BAD_LEVEL', message: `授权等级只能是 ${ACCESS_LEVELS.join(' / ')}` };
|
|
467
|
+
}
|
|
468
|
+
const all = this._loadAccess();
|
|
469
|
+
const row = isPlainObject(all[key]) ? { ...all[key] } : {};
|
|
470
|
+
if (lv === 'none') delete row[cid];
|
|
471
|
+
else row[cid] = lv;
|
|
472
|
+
all[key] = row;
|
|
473
|
+
this._saveAccess(all);
|
|
474
|
+
return { ok: true, projectKey: key, connectionId: cid, level: lv, access: row };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 某项目的完整授权视图(给面板用):连接 + 当前等级 */
|
|
478
|
+
accessView(projectKey) {
|
|
479
|
+
const key = String(projectKey || 'default');
|
|
480
|
+
const row = this.accessOf(key);
|
|
481
|
+
const connections = this.listConnections().map((item) => ({
|
|
482
|
+
id: item.id,
|
|
483
|
+
name: item.name,
|
|
484
|
+
type: item.type,
|
|
485
|
+
target: item.urlMode ? item.connectString : (item.type === 'sqlite' ? item.file : `${item.host}:${item.port}/${item.database}`),
|
|
486
|
+
level: row[item.id] || 'none',
|
|
487
|
+
}));
|
|
488
|
+
const authorized = connections.filter((item) => item.level !== 'none').length;
|
|
489
|
+
return { ok: true, projectKey: key, connections, authorized, total: connections.length };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 解析某个项目对某连接的访问权。
|
|
494
|
+
*
|
|
495
|
+
* options.projectKey 缺省时用 'default'(没有会话上下文的情况),
|
|
496
|
+
* 这样工具调用和面板请求都能落到同一个可预期的档位。
|
|
497
|
+
*
|
|
498
|
+
* connectionOverride:面板试连时用草稿配置,此时没有连接 id,
|
|
499
|
+
* 允许跳过授权检查(试连只做最轻量的 SELECT 1,不读业务数据)。
|
|
500
|
+
*/
|
|
501
|
+
resolveAccess(connectionId, projectKey, connectionOverride) {
|
|
502
|
+
if (connectionOverride) return { ok: true, level: 'readonly', projectKey: String(projectKey || 'default') };
|
|
503
|
+
const key = String(projectKey || 'default');
|
|
504
|
+
const cid = String(connectionId || '');
|
|
505
|
+
if (cid === '') return { ok: false, code: 'MISSING_CONNECTION', message: '需要 connection_id' };
|
|
506
|
+
const level = this.accessOf(key)[cid] || 'none';
|
|
507
|
+
if (level === 'none') {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
code: 'NOT_AUTHORIZED',
|
|
511
|
+
message: `项目「${key}」没有获得这条连接的授权。请到面板「项目授权」里授权(只读 / 读写)后再试。`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return { ok: true, level, projectKey: key };
|
|
515
|
+
}
|
|
516
|
+
|
|
251
517
|
/** 脱敏后的连接视图:对外一律走这个 */
|
|
252
518
|
_view(item) {
|
|
253
519
|
return {
|
|
@@ -261,6 +527,9 @@ export class DbManager {
|
|
|
261
527
|
/** 只给掩码,绝不回传明文 */
|
|
262
528
|
hasPassword: Boolean(item.password),
|
|
263
529
|
passwordMask: item.password ? maskSecret('********') : '',
|
|
530
|
+
/** URL 模式:回显时把密码打码,避免明文出现在界面/日志里 */
|
|
531
|
+
connectString: item.connectString ? maskUrlPassword(item.connectString) : '',
|
|
532
|
+
urlMode: Boolean(item.connectString),
|
|
264
533
|
extra: isPlainObject(item.extra) ? item.extra : {},
|
|
265
534
|
createdAt: item.createdAt || '',
|
|
266
535
|
updatedAt: item.updatedAt || '',
|
|
@@ -303,13 +572,22 @@ export class DbManager {
|
|
|
303
572
|
const name = String(input.name || existing?.name || '').trim();
|
|
304
573
|
if (name === '') return { ok: false, code: 'MISSING_NAME', message: '连接名称不能为空' };
|
|
305
574
|
|
|
306
|
-
|
|
575
|
+
/**
|
|
576
|
+
* 两种录入方式:
|
|
577
|
+
* - 分字段:host / port / database / username / password
|
|
578
|
+
* - URL:connectString 原样使用(dm://...、mysql://... 等)
|
|
579
|
+
* URL 模式下不再强制 host,字段校验要按模式分支,否则「填了 URL 却报缺 host」。
|
|
580
|
+
*/
|
|
581
|
+
const connectString = String(input.connectString ?? existing?.connectString ?? '').trim();
|
|
582
|
+
const urlMode = connectString !== '';
|
|
583
|
+
|
|
584
|
+
// sqlite 用 database 字段存文件路径;其它类型(非 URL 模式)需要 host
|
|
307
585
|
const database = String(input.database ?? existing?.database ?? '').trim();
|
|
308
586
|
if (type === 'sqlite' && database === '') {
|
|
309
587
|
return { ok: false, code: 'MISSING_DATABASE', message: 'SQLite 连接需要填写数据库文件路径(:memory: 表示内存库)' };
|
|
310
588
|
}
|
|
311
|
-
if (type !== 'sqlite' && String(input.host ?? existing?.host ?? '').trim() === '') {
|
|
312
|
-
return { ok: false, code: 'MISSING_HOST', message: `${type} 连接需要填写 host
|
|
589
|
+
if (type !== 'sqlite' && !urlMode && String(input.host ?? existing?.host ?? '').trim() === '') {
|
|
590
|
+
return { ok: false, code: 'MISSING_HOST', message: `${type} 连接需要填写 host(或改用 URL 模式)` };
|
|
313
591
|
}
|
|
314
592
|
|
|
315
593
|
// 密码:显式传了新值就加密存;传空或未传则沿用旧值
|
|
@@ -329,6 +607,8 @@ export class DbManager {
|
|
|
329
607
|
database,
|
|
330
608
|
username: String(input.username ?? existing?.username ?? '').trim(),
|
|
331
609
|
password,
|
|
610
|
+
/** URL 模式下的完整连接串(空表示分字段模式) */
|
|
611
|
+
connectString,
|
|
332
612
|
extra: isPlainObject(input.extra) ? input.extra : (existing?.extra || {}),
|
|
333
613
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
334
614
|
updatedAt: new Date().toISOString(),
|
|
@@ -402,6 +682,16 @@ export class DbManager {
|
|
|
402
682
|
} catch {
|
|
403
683
|
throw new Error('缺少 mysql2 依赖:请在该插件目录执行 npm install mysql2');
|
|
404
684
|
}
|
|
685
|
+
// URL 模式直接交给 mysql2 解析(它原生支持 mysql:// 串)
|
|
686
|
+
if (config.connectString) {
|
|
687
|
+
const pool = mysql.createPool({
|
|
688
|
+
uri: config.connectString,
|
|
689
|
+
waitForConnections: true,
|
|
690
|
+
connectionLimit: 4,
|
|
691
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
692
|
+
});
|
|
693
|
+
return { type: 'mysql', raw: pool };
|
|
694
|
+
}
|
|
405
695
|
const pool = mysql.createPool({
|
|
406
696
|
host: config.host,
|
|
407
697
|
port: Number(config.port) || 3306,
|
|
@@ -424,6 +714,15 @@ export class DbManager {
|
|
|
424
714
|
throw new Error('缺少 pg 依赖:请在该插件目录执行 npm install pg');
|
|
425
715
|
}
|
|
426
716
|
const Pool = pg.Pool || pg.default?.Pool;
|
|
717
|
+
// URL 模式:pg 的 Pool 支持 connectionString
|
|
718
|
+
if (config.connectString) {
|
|
719
|
+
const pool = new Pool({
|
|
720
|
+
connectionString: config.connectString,
|
|
721
|
+
max: 4,
|
|
722
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
723
|
+
});
|
|
724
|
+
return { type: 'postgres', raw: pool };
|
|
725
|
+
}
|
|
427
726
|
const pool = new Pool({
|
|
428
727
|
host: config.host,
|
|
429
728
|
port: Number(config.port) || 5432,
|
|
@@ -445,10 +744,13 @@ export class DbManager {
|
|
|
445
744
|
throw new Error(`缺少 oracledb 依赖(原生模块,可能需匹配 Node ABI):${error?.message || error}`);
|
|
446
745
|
}
|
|
447
746
|
const mod = oracledb.default || oracledb;
|
|
747
|
+
// URL 模式:oracle 的 connectString 本身就是 host:port/service 形式,直接透传
|
|
748
|
+
const connectString = config.connectString
|
|
749
|
+
|| `${config.host}:${Number(config.port) || 1521}/${config.database || ''}`;
|
|
448
750
|
const pool = await mod.createPool({
|
|
449
751
|
user: config.username,
|
|
450
752
|
password: config.password,
|
|
451
|
-
connectString
|
|
753
|
+
connectString,
|
|
452
754
|
poolMax: 4,
|
|
453
755
|
});
|
|
454
756
|
return { type: 'oracle', raw: pool, mod };
|
|
@@ -477,20 +779,32 @@ export class DbManager {
|
|
|
477
779
|
const mod = dmdb.default || dmdb;
|
|
478
780
|
try {
|
|
479
781
|
/**
|
|
480
|
-
*
|
|
481
|
-
* dm
|
|
482
|
-
*
|
|
483
|
-
*
|
|
782
|
+
* connectString 的格式:
|
|
783
|
+
* - url 模式:原样使用用户填的 dm://... 串
|
|
784
|
+
* - 分字段模式:dm://user:password@host:port
|
|
785
|
+
*
|
|
786
|
+
* **必须显式给 poolAlias**:dmdb 的 poolAlias 默认是 "default",
|
|
787
|
+
* 第二个连接池就会报「数据库别名已存在」,而且后面测试/编辑/删除全废。
|
|
788
|
+
* 这里用连接 id 派生出唯一别名,保证多个达梦连接能共存。
|
|
484
789
|
*/
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
790
|
+
let connectString = String(config.connectString || '').trim();
|
|
791
|
+
if (connectString === '') {
|
|
792
|
+
const user = encodeURIComponent(String(config.username || ''));
|
|
793
|
+
const pass = encodeURIComponent(String(config.password || ''));
|
|
794
|
+
const port = Number(config.port) || 5236;
|
|
795
|
+
connectString = `dm://${user}:${pass}@${config.host}:${port}`;
|
|
796
|
+
}
|
|
797
|
+
if (!/^dm:\/\//i.test(connectString)) {
|
|
798
|
+
throw new Error(`达梦连接串必须以 dm:// 开头,当前是:${connectString.slice(0, 40)}`);
|
|
799
|
+
}
|
|
800
|
+
const poolAlias = `dsh-api-runner-${String(config.id || 'conn').replace(/[^\w-]/g, '_')}`;
|
|
489
801
|
const pool = await mod.createPool({
|
|
490
802
|
connectString,
|
|
803
|
+
poolAlias,
|
|
491
804
|
poolMax: 4,
|
|
805
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
492
806
|
});
|
|
493
|
-
return { type: 'dm', raw: pool, mod };
|
|
807
|
+
return { type: 'dm', raw: pool, mod, poolAlias };
|
|
494
808
|
} catch (error) {
|
|
495
809
|
// dmdb 连接失败时内部状态是半初始化的,close 会再抛一次;吞掉它
|
|
496
810
|
try {
|
|
@@ -513,8 +827,21 @@ export class DbManager {
|
|
|
513
827
|
// mysql2 / pg 的 end() 返回 Promise,这里不等待,失败也不影响主流程
|
|
514
828
|
Promise.resolve(handle.raw.end()).catch(() => {});
|
|
515
829
|
} else if (handle.raw && typeof handle.raw.close === 'function') {
|
|
830
|
+
// dmdb / oracledb 的 close 也返回 Promise
|
|
516
831
|
Promise.resolve(handle.raw.close()).catch(() => {});
|
|
517
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* dmdb 把自己的池登记在 `dmdb.pools` 全局表里,close 之后**不一定**
|
|
835
|
+
* 会删掉这条登记(它内部只在特定分支删)。残留的别名会让下次
|
|
836
|
+
* 创建同名池时报「数据库别名已存在」,所以这里主动清一次。
|
|
837
|
+
*/
|
|
838
|
+
if (handle.type === 'dm' && handle.poolAlias && handle.mod?.pools) {
|
|
839
|
+
try {
|
|
840
|
+
handle.mod.pools.delete(handle.poolAlias);
|
|
841
|
+
} catch {
|
|
842
|
+
/* 注册表结构变化时忽略 */
|
|
843
|
+
}
|
|
844
|
+
}
|
|
518
845
|
} catch {
|
|
519
846
|
/* 关闭失败不影响删除操作 */
|
|
520
847
|
}
|
|
@@ -552,14 +879,49 @@ export class DbManager {
|
|
|
552
879
|
}
|
|
553
880
|
const single = statements[0];
|
|
554
881
|
|
|
555
|
-
|
|
882
|
+
/**
|
|
883
|
+
* 权限判定顺序(从严格到宽松,任何一步不通过就拒绝):
|
|
884
|
+
* 1. 项目授权:当前工作空间必须对该连接有授权
|
|
885
|
+
* 2. **只读开关**:options.readonly=true 时,任何非只读语句一律拒绝
|
|
886
|
+
* 3. 授权等级:写操作要求 readwrite
|
|
887
|
+
* 4. 毁灭性操作:即使有读写权限也额外拦截
|
|
888
|
+
*
|
|
889
|
+
* 第 2 步是模型侧的硬闸门:main.js 对工具调用固定传 readonly:true,
|
|
890
|
+
* 所以模型无论怎么构造参数都写不了数据。
|
|
891
|
+
*/
|
|
892
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
893
|
+
if (!auth.ok) return auth;
|
|
894
|
+
|
|
895
|
+
const verdict = classifySql(single);
|
|
896
|
+
|
|
897
|
+
// 第 2 步:只读模式 —— 非只读语句直接拒,不看授权等级
|
|
898
|
+
if (options.readonly === true && verdict.kind !== 'read') {
|
|
556
899
|
return {
|
|
557
900
|
ok: false,
|
|
558
901
|
code: 'READONLY_VIOLATION',
|
|
559
|
-
message:
|
|
902
|
+
message: `只读模式已开启:只允许 SELECT / SHOW / DESCRIBE / EXPLAIN 等查询语句。${verdict.reason ? `(${verdict.reason})` : ''}要执行写操作请由人在面板里操作。`,
|
|
560
903
|
};
|
|
561
904
|
}
|
|
562
905
|
|
|
906
|
+
if (verdict.kind !== 'read') {
|
|
907
|
+
// 第 3 步:写操作必须 readwrite 授权
|
|
908
|
+
if (auth.level !== 'readwrite') {
|
|
909
|
+
return {
|
|
910
|
+
ok: false,
|
|
911
|
+
code: 'PERMISSION_DENIED',
|
|
912
|
+
message: `当前项目对这条连接只有「${auth.level === 'readonly' ? '只读' : '未授权'}」权限,不能执行写操作。${verdict.reason ? `(${verdict.reason})` : ''}`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
// 第 4 步:毁灭性操作
|
|
916
|
+
if (isDestructiveSql(single) && options.allowDestructive !== true) {
|
|
917
|
+
return {
|
|
918
|
+
ok: false,
|
|
919
|
+
code: 'DESTRUCTIVE_BLOCKED',
|
|
920
|
+
message: '拒绝执行:这条语句会破坏数据结构或数据(DROP / TRUNCATE / 无 WHERE 的 DELETE / UPDATE 等)。即使有读写权限也不允许通过工具执行,请到数据库客户端人工操作。',
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
563
925
|
const opened = await this._handle(id);
|
|
564
926
|
if (!opened.ok) return opened;
|
|
565
927
|
const handle = opened.handle;
|
|
@@ -670,7 +1032,9 @@ export class DbManager {
|
|
|
670
1032
|
|
|
671
1033
|
// ---------------------------------------------------------------- 元数据
|
|
672
1034
|
/** 列出库(MySQL/PG 有库的概念;SQLite 只有文件) */
|
|
673
|
-
async listDatabases(id) {
|
|
1035
|
+
async listDatabases(id, options = {}) {
|
|
1036
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
1037
|
+
if (!auth.ok) return auth;
|
|
674
1038
|
const opened = await this._handle(id);
|
|
675
1039
|
if (!opened.ok) return opened;
|
|
676
1040
|
const handle = opened.handle;
|
|
@@ -703,7 +1067,9 @@ export class DbManager {
|
|
|
703
1067
|
}
|
|
704
1068
|
|
|
705
1069
|
/** 列出表 */
|
|
706
|
-
async listTables(id, database = '') {
|
|
1070
|
+
async listTables(id, database = '', options = {}) {
|
|
1071
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
1072
|
+
if (!auth.ok) return auth;
|
|
707
1073
|
const opened = await this._handle(id);
|
|
708
1074
|
if (!opened.ok) return opened;
|
|
709
1075
|
const handle = opened.handle;
|
|
@@ -758,9 +1124,11 @@ export class DbManager {
|
|
|
758
1124
|
}
|
|
759
1125
|
|
|
760
1126
|
/** 表结构:字段 + 索引 */
|
|
761
|
-
async describeTable(id, table) {
|
|
1127
|
+
async describeTable(id, table, options = {}) {
|
|
762
1128
|
const name = String(table || '').trim();
|
|
763
1129
|
if (name === '') return { ok: false, code: 'MISSING_TABLE', message: '需要表名' };
|
|
1130
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
1131
|
+
if (!auth.ok) return auth;
|
|
764
1132
|
const opened = await this._handle(id);
|
|
765
1133
|
if (!opened.ok) return opened;
|
|
766
1134
|
const handle = opened.handle;
|
|
@@ -881,32 +1249,151 @@ export class DbManager {
|
|
|
881
1249
|
}
|
|
882
1250
|
}
|
|
883
1251
|
|
|
884
|
-
/**
|
|
885
|
-
|
|
1252
|
+
/**
|
|
1253
|
+
* 试连:真正建一次连接并执行最轻量的探测语句。
|
|
1254
|
+
*
|
|
1255
|
+
* @param {string|object} target - 连接 id,或一份草稿配置
|
|
1256
|
+
* @param {{timeoutMs?:number}} options - 超时(默认 probeTimeoutMs)
|
|
1257
|
+
*/
|
|
1258
|
+
async testConnection(target, options = {}) {
|
|
886
1259
|
const started = Date.now();
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
this.
|
|
1260
|
+
const timeoutMs = Number(options.timeoutMs) || this.probeTimeoutMs;
|
|
1261
|
+
/**
|
|
1262
|
+
* 支持三种入参:
|
|
1263
|
+
* 1. 已有连接的 id(字符串)
|
|
1264
|
+
* 2. 带 id 的完整配置对象(编辑时用当前表单值试,不落盘)
|
|
1265
|
+
* 3. 没有 id 的草稿配置(新增时试连)
|
|
1266
|
+
*
|
|
1267
|
+
* 草稿**不临时存记录**:那样会污染连接列表,而且 dmdb 的连接池别名
|
|
1268
|
+
* 按 id 生成,临时 id 和正式 id 会互相撞。这里直接构造临时句柄试连。
|
|
1269
|
+
*/
|
|
1270
|
+
if (typeof target === 'string' && target !== '') {
|
|
1271
|
+
return this._withTimeout(
|
|
1272
|
+
this._probe(target, started),
|
|
1273
|
+
timeoutMs,
|
|
1274
|
+
`连接超时(${timeoutMs}ms)`,
|
|
1275
|
+
).catch((error) => ({
|
|
1276
|
+
ok: false,
|
|
1277
|
+
code: 'PROBE_TIMEOUT',
|
|
1278
|
+
message: this._safeError(error),
|
|
1279
|
+
durationMs: Date.now() - started,
|
|
1280
|
+
}));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (isPlainObject(target)) {
|
|
1284
|
+
const draft = { ...target };
|
|
1285
|
+
if (draft.id) {
|
|
1286
|
+
// 编辑已有连接:用保存过的密码(表单里的密码框是空的)
|
|
1287
|
+
const full = this._fullConfig(draft.id);
|
|
1288
|
+
if (full && (draft.password === undefined || draft.password === '')) draft.password = full.password;
|
|
1289
|
+
if (full && !draft.connectString && full.connectString) draft.connectString = full.connectString;
|
|
900
1290
|
}
|
|
1291
|
+
const normalized = this._normalizeDraft(draft);
|
|
1292
|
+
if (!normalized.ok) return { ...normalized, durationMs: Date.now() - started };
|
|
1293
|
+
return this._probeDraft(normalized.config, started, timeoutMs);
|
|
901
1294
|
}
|
|
902
|
-
|
|
903
|
-
return
|
|
1295
|
+
|
|
1296
|
+
return { ok: false, code: 'MISSING_ARGUMENT', message: '需要连接 id 或配置' };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** 把草稿补成完整配置(补齐默认端口等),不落盘 */
|
|
1300
|
+
_normalizeDraft(draft) {
|
|
1301
|
+
const type = String(draft.type || 'sqlite').toLowerCase();
|
|
1302
|
+
if (!DB_TYPES.includes(type)) {
|
|
1303
|
+
return { ok: false, code: 'BAD_TYPE', message: `不支持的数据库类型:${type}` };
|
|
1304
|
+
}
|
|
1305
|
+
const connectString = String(draft.connectString || '').trim();
|
|
1306
|
+
/**
|
|
1307
|
+
* 试连必须用**独立于正式连接**的临时 id。
|
|
1308
|
+
*
|
|
1309
|
+
* 之前这里直接沿用 draft.id(编辑时就是已保存连接的 id),
|
|
1310
|
+
* 于是 dmdb 会用同一个 poolAlias 再建一次池,撞上「连接池别名已存在」;
|
|
1311
|
+
* 而且撞完后该连接的后续测试/编辑/删除全废。
|
|
1312
|
+
* 统一加 probe- 前缀,保证试连的池和正式池永不重名。
|
|
1313
|
+
*/
|
|
1314
|
+
const probeId = `probe-${crypto.randomBytes(4).toString('hex')}`;
|
|
1315
|
+
const config = {
|
|
1316
|
+
id: probeId,
|
|
1317
|
+
type,
|
|
1318
|
+
host: String(draft.host || '').trim(),
|
|
1319
|
+
port: Number(draft.port) || DEFAULT_PORTS[type] || 0,
|
|
1320
|
+
database: String(draft.database || '').trim(),
|
|
1321
|
+
username: String(draft.username || '').trim(),
|
|
1322
|
+
password: String(draft.password || ''),
|
|
1323
|
+
connectString,
|
|
1324
|
+
extra: isPlainObject(draft.extra) ? draft.extra : {},
|
|
1325
|
+
};
|
|
1326
|
+
if (type === 'sqlite' && config.database === '') {
|
|
1327
|
+
return { ok: false, code: 'MISSING_DATABASE', message: 'SQLite 需要填写数据库文件路径(:memory: 表示内存库)' };
|
|
1328
|
+
}
|
|
1329
|
+
if (type !== 'sqlite' && connectString === '' && config.host === '') {
|
|
1330
|
+
return { ok: false, code: 'MISSING_HOST', message: `${type} 需要填写 host(或改用 URL 模式)` };
|
|
1331
|
+
}
|
|
1332
|
+
return { ok: true, config };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* 用临时句柄试连,结束后一定关掉。
|
|
1337
|
+
*
|
|
1338
|
+
* **必须有超时**:达梦/MySQL 连不上时驱动自己会卡 30–60 秒,
|
|
1339
|
+
* 期间面板所有按钮都被 disabled,用户会以为「点不动了」。
|
|
1340
|
+
* 这里在打开阶段和探测阶段各加一层超时,最坏情况 ~15 秒返回。
|
|
1341
|
+
*/
|
|
1342
|
+
async _probeDraft(config, started, timeoutMs) {
|
|
1343
|
+
const budget = Number(timeoutMs) || this.probeTimeoutMs || 15000;
|
|
1344
|
+
let handle;
|
|
1345
|
+
try {
|
|
1346
|
+
handle = await this._withTimeout(
|
|
1347
|
+
(async () => {
|
|
1348
|
+
if (config.type === 'sqlite') return this._openSqlite(config);
|
|
1349
|
+
if (config.type === 'mysql') return this._openMysql(config);
|
|
1350
|
+
if (config.type === 'postgres') return this._openPostgres(config);
|
|
1351
|
+
if (config.type === 'oracle') return this._openOracle(config);
|
|
1352
|
+
if (config.type === 'dm') return this._openDm(config);
|
|
1353
|
+
throw new Error(`不支持的数据库类型:${config.type}`);
|
|
1354
|
+
})(),
|
|
1355
|
+
budget,
|
|
1356
|
+
`连接超时(${budget}ms)`,
|
|
1357
|
+
);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
return { ok: false, code: 'CONNECT_FAILED', message: this._safeError(error), durationMs: Date.now() - started };
|
|
1360
|
+
}
|
|
1361
|
+
const remaining = Math.max(2000, budget - (Date.now() - started));
|
|
1362
|
+
const probe = await this._withTimeout(
|
|
1363
|
+
this._probeHandle(handle, started),
|
|
1364
|
+
remaining,
|
|
1365
|
+
`探测超时(${remaining}ms)`,
|
|
1366
|
+
).catch((error) => ({
|
|
1367
|
+
ok: false,
|
|
1368
|
+
code: 'PROBE_TIMEOUT',
|
|
1369
|
+
message: this._safeError(error),
|
|
1370
|
+
durationMs: Date.now() - started,
|
|
1371
|
+
}));
|
|
1372
|
+
// 试连用的池不能留着,否则 dmdb 别名/资源都会累积
|
|
1373
|
+
this._closeHandle(handle);
|
|
1374
|
+
return probe;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** 给任意 Promise 套一个超时;超时后不再等它,但也不阻塞后续清理 */
|
|
1378
|
+
_withTimeout(promise, ms, message) {
|
|
1379
|
+
const timeout = Number(ms);
|
|
1380
|
+
if (!timeout || timeout <= 0) return promise;
|
|
1381
|
+
let timer;
|
|
1382
|
+
const guard = new Promise((_, reject) => {
|
|
1383
|
+
timer = setTimeout(() => reject(new Error(message)), timeout);
|
|
1384
|
+
timer.unref?.();
|
|
1385
|
+
});
|
|
1386
|
+
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
|
|
904
1387
|
}
|
|
905
1388
|
|
|
906
1389
|
async _probe(id, started) {
|
|
907
1390
|
const opened = await this._handle(id);
|
|
908
1391
|
if (!opened.ok) return { ...opened, durationMs: Date.now() - started };
|
|
909
|
-
|
|
1392
|
+
return this._probeHandle(opened.handle, started);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** 对任意句柄跑最轻量的探测语句 */
|
|
1396
|
+
async _probeHandle(handle, started) {
|
|
910
1397
|
try {
|
|
911
1398
|
if (handle.type === 'sqlite') handle.raw.prepare('SELECT 1').all();
|
|
912
1399
|
else if (handle.type === 'mysql') await handle.raw.query('SELECT 1');
|
|
@@ -919,6 +1406,25 @@ export class DbManager {
|
|
|
919
1406
|
}
|
|
920
1407
|
}
|
|
921
1408
|
|
|
1409
|
+
/** 关闭任意句柄(不涉及 this.pools 注册表) */
|
|
1410
|
+
_closeHandle(handle) {
|
|
1411
|
+
if (!handle) return;
|
|
1412
|
+
try {
|
|
1413
|
+
if (handle.type === 'sqlite') handle.raw.close();
|
|
1414
|
+
else if (handle.raw && typeof handle.raw.end === 'function') Promise.resolve(handle.raw.end()).catch(() => {});
|
|
1415
|
+
else if (handle.raw && typeof handle.raw.close === 'function') Promise.resolve(handle.raw.close()).catch(() => {});
|
|
1416
|
+
if (handle.type === 'dm' && handle.poolAlias && handle.mod?.pools) {
|
|
1417
|
+
try {
|
|
1418
|
+
handle.mod.pools.delete(handle.poolAlias);
|
|
1419
|
+
} catch {
|
|
1420
|
+
/* 注册表结构变化时忽略 */
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
} catch {
|
|
1424
|
+
/* 关闭失败不影响结果 */
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
922
1428
|
/** 关闭所有连接池(插件卸载时调用) */
|
|
923
1429
|
dispose() {
|
|
924
1430
|
for (const key of [...this.pools.keys()]) this._closePool(key);
|