dsh-db-tool 1.8.4 → 1.8.8

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.
@@ -9,23 +9,9 @@ import mysql from 'mysql2/promise';
9
9
  import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell, quoteIdent, validPort } from '../sql-shared/common.js';
10
10
  /** ro 模式下 query 允许的读语句首词白名单 */
11
11
  const RO_READ_PREFIX = /^(select|show|desc|describe|explain|use|help|table)\b/i;
12
- /**
13
- * 吞掉 'connection' 回调里会话级 SET 的失败(ro / time_zone 仅是增强,失败不应拖垮连接与进程)。
14
- * mysql2 promise 池的 'connection' 事件由核心池 emit、经 lib/promise/inherit_events.js
15
- * 原样转发参数,回调收到的是 callback 版 PoolConnection:其 query() 返回 Query 命令
16
- * 对象(EventEmitter,无 onResult 时错误走 emit('error'),lib/commands/command.js),
17
- * 无监听会抛 uncaughtException 崩溃进程——.catch 防御对它恒不生效,必须挂 error 监听。
18
- * 若驱动版本改为返回 Promise,则走 .catch 吞 rejection(双分支防御)。
19
- */
20
- function swallowSetError(r) {
21
- if (r && typeof r.catch === 'function') {
22
- void r.catch(() => { });
23
- return;
24
- }
25
- if (r && typeof r.on === 'function') {
26
- r.on('error', () => { });
27
- }
28
- }
12
+ // 【历史教训,勿回退】'connection' 钩子里 SET 的失败处理只能走回调参数吞掉,
13
+ // 不得给返回的 Query 命令挂 error 监听防 uncaughtException——
14
+ // 该监听会打断池接管前的命令处理序列并使连接永久卡死(1.7.4 真机复现,见 ro 钩子处注释)。
29
15
  function connOptions(conn) {
30
16
  const base = {
31
17
  waitForConnections: true,
@@ -97,13 +83,17 @@ export async function createMysqlAdapter(conn, opts) {
97
83
  throw new Error('mysql 连接已关闭:请重新连接后重试');
98
84
  };
99
85
  /** ro 模式:每个新底层连接自动设为只读会话(应用层另有 query 白名单双保险)。
100
- * 回调收到的是 callback 版 PoolConnection,query() 返回 Query 命令对象(EventEmitter):
101
- * 吞掉 SET 失败防 uncaughtException(见 swallowSetError)。主池与跨库池共用同一注册。 */
86
+ * mysql2 promise 池 'connection' 回调拿到的是 callback 版连接;SET 必须回调式执行,
87
+ * 且绝不能对返回的 Query 命令对象挂 error 监听——swallowSetError 的 .on('error') 会
88
+ * 打断池接管前的命令处理序列,连接状态机卡死,该连接后续一切查询永挂
89
+ * (真机复现:ro 模式列库/列表/预览全部永久 pending,即「表显示不出来」直接根因;
90
+ * 非回调式与「回调式+error 监听」两种形态均挂,唯「回调式+空回调」正常)。
91
+ * 主池与跨库池共用同一注册。 */
102
92
  const attachReadOnly = (p) => {
103
93
  if (!readOnly)
104
94
  return;
105
95
  p.on('connection', (c) => {
106
- swallowSetError(c.query('SET SESSION TRANSACTION READ ONLY'));
96
+ c.query('SET SESSION TRANSACTION READ ONLY', () => { });
107
97
  });
108
98
  };
109
99
  attachReadOnly(pool);
@@ -237,6 +227,28 @@ export async function createMysqlAdapter(conn, opts) {
237
227
  };
238
228
  });
239
229
  }),
230
+ searchTables: (keyword, database, limit) => humanize('mysql 搜索表', async () => {
231
+ // 以 | 为 LIKE 转义符(避开反斜杠在 SQL 字面量/NO_BACKSLASH_ESCAPES 模式下的歧义):
232
+ // % _ | \ 四字符全部转义后子串匹配,命中值经参数绑定传输、无注入面
233
+ const esc = keyword.replace(/[\\%_|]/g, (m) => `|${m}`);
234
+ const cap = Math.max(1, Math.min(200, Math.floor(Number(limit) || 100)));
235
+ const [rows] = await pool.query(`SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, TABLE_COMMENT
236
+ FROM information_schema.TABLES
237
+ WHERE TABLE_NAME LIKE CONCAT('%', ?, '%') ESCAPE '|'
238
+ ${database ? 'AND TABLE_SCHEMA = ?' : "AND TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')"}
239
+ ORDER BY TABLE_SCHEMA, TABLE_NAME
240
+ LIMIT ${cap}`, database ? [esc, database] : [esc]);
241
+ return rows.map((r) => {
242
+ const rawType = String(r.TABLE_TYPE);
243
+ const comment = r.TABLE_COMMENT;
244
+ return {
245
+ database: String(r.TABLE_SCHEMA),
246
+ name: String(r.TABLE_NAME),
247
+ type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
248
+ comment: comment != null && comment !== '' ? String(comment) : undefined,
249
+ };
250
+ });
251
+ }),
240
252
  describeTable: (table, database) => humanize('mysql 查看表结构', async () => {
241
253
  const db = database ? assertIdent(database, '库名') : defaultDb();
242
254
  const tbl = assertIdent(table, '表名');
@@ -280,8 +292,10 @@ export const factory = (conn) => createMysqlAdapter(conn);
280
292
  export async function openMysqlRaw(conn) {
281
293
  const opts = connOptions(conn);
282
294
  const pool = mysql.createPool({ ...opts, dateStrings: true });
283
- // 回调式 query(带 err 回调吞错)。必须用回调式:非回调式返回的 Query 命令对象与
284
- // promise 池连接状态机冲突 → 该连接所有后续 query 永挂(真机实证 mysql2 3.24.4)
295
+ // 回调式 query(带 err 回调吞错)。两点缺一不可(1.7.4 真机复现):
296
+ // 1) 必须回调式——非回调式命令无 onResult,与 promise 池连接建立序列冲突;
297
+ // 2) 绝不能对返回的 Query 命令挂 error 监听——会打断池接管前的命令处理,
298
+ // 连接状态机卡死 → 该连接所有后续 query 永挂(mysql2 3.24.4)
285
299
  pool.on('connection', (c) => {
286
300
  // 运行时 c 是 callback 版连接(promise 池类型标注不反映),按回调式调用
287
301
  c.query("SET time_zone = '+00:00'", () => { });
@@ -344,7 +344,9 @@ export async function createRedisAdapter(conn, opts) {
344
344
  const countIdx = rest.findIndex((a, i) => i > 0 && a.toUpperCase() === 'COUNT');
345
345
  const entries = await client.xRange(key, rest[1] ?? '-', rest[2] ?? '+', countIdx > 0 ? { COUNT: Number(rest[countIdx + 1] ?? 20) } : undefined);
346
346
  const rows = [];
347
- for (const entry of entries) {
347
+ // xRange 返回 NullReply | StreamMessageReply[]:键不存在时服务端回空数组、
348
+ // 客户端类型却含 null(XRANGE.d.ts transformReply),直接迭代会 TypeError。
349
+ for (const entry of entries ?? []) {
348
350
  const fields = Object.entries(entry.message ?? {});
349
351
  if (fields.length === 0)
350
352
  rows.push([cell(entry.id), null, null]);
@@ -460,7 +462,8 @@ export async function createRedisAdapter(conn, opts) {
460
462
  case 'stream': {
461
463
  const entries = await client.xRange(key, '-', '+', { COUNT: n });
462
464
  const rows = [];
463
- for (const entry of entries) {
465
+ // 同上:xRange 类型含 null,空键时必须按空结果处理而非迭代崩溃
466
+ for (const entry of entries ?? []) {
464
467
  const fields = Object.entries(entry.message ?? {});
465
468
  if (fields.length === 0)
466
469
  rows.push([cell(entry.id), null, null]);
@@ -249,6 +249,31 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
249
249
  const f = conn.fields;
250
250
  return f && typeof f.schema === 'string' && f.schema ? f.schema : 'public';
251
251
  };
252
+ /** 集群内可连接库名(listDatabases 与跨库 searchTables 共用) */
253
+ const listDbNames = async () => {
254
+ const res = await pool.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true ORDER BY datname');
255
+ return res.rows.map((r) => String(r.datname));
256
+ };
257
+ /** 单库内搜表:schema 给定精确限定,否则该库全部用户 schema(排除 information_schema 与 pg_* 系) */
258
+ const searchOneDb = async (p, dbName, pattern, cap, schema) => {
259
+ const params = schema ? [pattern, schema] : [pattern];
260
+ const res = await p.query(`SELECT table_schema, table_name, table_type
261
+ FROM information_schema.tables
262
+ WHERE table_name ILIKE $1 ESCAPE '~'
263
+ AND table_schema <> 'information_schema' AND table_schema NOT LIKE 'pg\\_%'
264
+ ${schema ? 'AND table_schema = $2' : ''}
265
+ ORDER BY table_schema, table_name
266
+ LIMIT ${cap}`, params);
267
+ return res.rows.map((r) => {
268
+ const rawType = String(r.table_type);
269
+ return {
270
+ database: dbName,
271
+ schema: String(r.table_schema),
272
+ name: String(r.table_name),
273
+ type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
274
+ };
275
+ });
276
+ };
252
277
  /** 浏览目标解析:"db.schema"(跨库)| "schema"(当前库)| 空(当前库默认 schema)。
253
278
  * PG 标识符不允许裸点,按第一个点切分安全;多余点由 assertIdent 拒绝。 */
254
279
  const parseBrowseTarget = (ref, what) => {
@@ -332,10 +357,7 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
332
357
  message: typeof n === 'number' ? `执行成功,受影响 ${n} 行` : '执行成功',
333
358
  };
334
359
  }),
335
- listDatabases: () => humanize(`${kind} 列出数据库`, async () => {
336
- const res = await pool.query('SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn = true ORDER BY datname');
337
- return res.rows.map((r) => String(r.datname));
338
- }),
360
+ listDatabases: () => humanize(`${kind} 列出数据库`, async () => listDbNames()),
339
361
  // 库内 schema 清单(Navicat 官方层级:数据库 → 模式 → 表)。
340
362
  // database 参数 = 库名(UI 树第一层);pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
341
363
  listSchemas: (database) => humanize(`${kind} 列出模式`, async () => {
@@ -359,6 +381,33 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
359
381
  };
360
382
  });
361
383
  }),
384
+ // 跨库搜表。database 语义(与 UI 库下拉一致):'db'(该库全部用户 schema)|
385
+ // 'db.schema'(精确单 schema)| 'schema'(当前库)| 空(集群全部库逐池并发)。
386
+ // ILIKE 大小写不敏感;转义符 '~' 避开反斜杠歧义,% _ \ ~ 全转义,参数绑定无注入面。
387
+ searchTables: (keyword, database, limit) => humanize(`${kind} 搜索表`, async () => {
388
+ const cap = Math.max(1, Math.min(200, Math.floor(Number(limit) || 100)));
389
+ const pattern = '%' + keyword.replace(/[~%_\\]/g, (m) => `~${m}`) + '%';
390
+ if (database) {
391
+ const dot = database.indexOf('.');
392
+ const dbName = assertIdent(dot >= 0 ? database.slice(0, dot) : database, '数据库');
393
+ const schema = dot >= 0 ? assertIdent(database.slice(dot + 1), 'schema') : null;
394
+ return searchOneDb(poolFor(dbName), dbName, pattern, cap, schema);
395
+ }
396
+ const dbNames = await listDbNames();
397
+ const hits = [];
398
+ for (let i = 0; i < dbNames.length && hits.length < cap; i += 8) {
399
+ const batch = await Promise.all(dbNames.slice(i, i + 8).map(async (dbName) => {
400
+ try {
401
+ return await searchOneDb(poolFor(dbName), dbName, pattern, cap, null);
402
+ }
403
+ catch {
404
+ return []; // 个别库不可连接(权限/模板库)不拖垮整体搜索
405
+ }
406
+ }));
407
+ hits.push(...batch.flat());
408
+ }
409
+ return hits.slice(0, cap);
410
+ }),
362
411
  describeTable: (table, database) => humanize(`${kind} 查看表结构`, async () => {
363
412
  const { db, schema } = parseBrowseTarget(database, 'schema');
364
413
  const tbl = assertIdent(table, '表名');
@@ -52,6 +52,14 @@ function vendor() {
52
52
  /* ============================ 配置解析 ============================ */
53
53
  /** tedious 支持的 TDS 协议版本(默认 7_4) */
54
54
  const TDS_VERSIONS = new Set(['7_4', '7_3_A', '7_3_B', '7_2', '7_1']);
55
+ /**
56
+ * 可显式下探的最低 TLS 版本(默认不设置,沿用 Node 默认下限 TLSv1.2)。
57
+ *
58
+ * 刻意不含 TLSv1.3:tedious message-io.js 在调用方未给 maxVersion 时硬性补成 'TLSv1.2',
59
+ * 于是 minVersion=TLSv1.3 会变成 min>max,握手直接抛 ERR_SSL_NO_PROTOCOLS_AVAILABLE
60
+ * (已实测)——那是个 100% 失败的配置,放进白名单只会把用户引到另一条死路。
61
+ */
62
+ const TLS_MIN_VERSIONS = ['TLSv1', 'TLSv1.1', 'TLSv1.2'];
55
63
  /** URLSearchParams 取值(参数名大小写不敏感,URL 里常见 camelCase 与全小写混写) */
56
64
  function queryParam(sp, name) {
57
65
  const lower = name.toLowerCase();
@@ -73,6 +81,52 @@ function boolParam(sp, name, dflt) {
73
81
  return false;
74
82
  throw new Error(`sqlserver 连接 URL 的 ${name} 取值非法:${JSON.stringify(v)}(只接受 true/false)`);
75
83
  }
84
+ /** fields 分支的布尔取值:分字段表单以字符串提交,故与 URL 同口径归一;非法值同样 fail-closed */
85
+ function boolField(v, name, dflt) {
86
+ if (v === undefined || v === null || v === '')
87
+ return dflt;
88
+ if (typeof v === 'boolean')
89
+ return v;
90
+ const s = String(v).trim().toLowerCase();
91
+ if (s === 'true' || s === '1' || s === 'yes')
92
+ return true;
93
+ if (s === 'false' || s === '0' || s === 'no')
94
+ return false;
95
+ throw new Error(`sqlserver 连接配置的 ${name} 取值非法:${JSON.stringify(v)}(只接受 true/false)`);
96
+ }
97
+ /** TDS 版本白名单(URL 与 fields 共用,避免两套文案漂移) */
98
+ function applyTdsVersion(options, raw, source) {
99
+ if (raw === undefined || raw === null || String(raw) === '')
100
+ return;
101
+ const v = String(raw);
102
+ if (!TDS_VERSIONS.has(v)) {
103
+ throw new Error(`sqlserver 连接${source}的 tdsVersion 取值非法:${JSON.stringify(raw)}(可选 ${[...TDS_VERSIONS].join('/')},默认 7_4)`);
104
+ }
105
+ options.tdsVersion = v;
106
+ }
107
+ /**
108
+ * 最低 TLS 版本(URL ?tlsMinVersion= 与 fields.tlsMinVersion 同口径)。
109
+ *
110
+ * 只写 cryptoCredentialsDetails.minVersion,刻意不提供 ciphers / maxVersion 开关:
111
+ * - Electron 内置 Node 是 BoringSSL,OpenSSL 专有的 cipher 串(如 DEFAULT@SECLEVEL=0)会直接抛
112
+ * INVALID_COMMAND;而「连 2008/2008R2 只有 TLS 1.0」这个场景只需把下限从默认的 TLSv1.2 放开即可。
113
+ * - tedious message-io 自己会把 maxVersion 补成 TLSv1.2(未指定时),故上限天然封顶在 1.2;
114
+ * 这里再写 maxVersion 反而会削弱安全性,保持只设下限。
115
+ *
116
+ * 取值区分大小写、不做归一化(与 Node tls 官方 API 语义一致:'tlsv1' / 'TLSv1_0' 均会被底层拒绝)。
117
+ * 非法值 fail-closed:静默回退到 Node 默认下限会让用户以为已下探,实际握手仍按 TLS 1.2 起步,
118
+ * 白白多一轮失败排查。
119
+ */
120
+ function applyTlsMinVersion(options, raw, source) {
121
+ if (raw === undefined || raw === null || String(raw) === '')
122
+ return;
123
+ const v = String(raw);
124
+ const hit = TLS_MIN_VERSIONS.find((x) => x === v);
125
+ if (hit === undefined) {
126
+ throw new Error(`sqlserver 连接${source}的 tlsMinVersion 取值非法:${JSON.stringify(raw)}(可选 ${TLS_MIN_VERSIONS.join('/')},默认不设置=沿用 Node 默认 TLSv1.2;TLSv1.3 不可选:底层 tedious 把 TLS 上限封顶在 1.2,设 1.3 会导致最小版本大于最大版本而握手失败)`);
127
+ }
128
+ options.cryptoCredentialsDetails = { minVersion: hit };
129
+ }
76
130
  function decodeCred(raw) {
77
131
  try {
78
132
  return decodeURIComponent(raw);
@@ -142,13 +196,8 @@ function poolConfig(conn) {
142
196
  options.encrypt = boolParam(u.searchParams, 'encrypt', true);
143
197
  options.trustServerCertificate = boolParam(u.searchParams, 'trustServerCertificate', false);
144
198
  options.useUTC = boolParam(u.searchParams, 'useUTC', true);
145
- const tds = queryParam(u.searchParams, 'tdsVersion');
146
- if (tds !== undefined && tds !== '') {
147
- if (!TDS_VERSIONS.has(tds)) {
148
- throw new Error(`sqlserver 连接 URL 的 tdsVersion 取值非法:${JSON.stringify(tds)}(可选 ${[...TDS_VERSIONS].join('/')},默认 7_4)`);
149
- }
150
- options.tdsVersion = tds;
151
- }
199
+ applyTdsVersion(options, queryParam(u.searchParams, 'tdsVersion'), ' URL ');
200
+ applyTlsMinVersion(options, queryParam(u.searchParams, 'tlsMinVersion'), ' URL ');
152
201
  const instance = queryParam(u.searchParams, 'instanceName');
153
202
  // instanceName 与 port 互斥:mssql 层在建连前会 delete 掉 port(tedious 对两者共存直接抛
154
203
  // 互斥 Error),端口改由 SQL Server Browser(UDP 1434)按实例名解析
@@ -174,6 +223,14 @@ function poolConfig(conn) {
174
223
  cfg.database = String(f.database);
175
224
  if (f.instanceName != null && String(f.instanceName) !== '')
176
225
  options.instanceName = String(f.instanceName);
226
+ // 分字段表单此前完全读不到这几个键:用户在表单里关掉加密/信任证书、选 TDS 版本或下探
227
+ // TLS 下限都不生效(连接仍按 URL 默认值走),排障时会误以为参数没被实现。此处与 URL 分支
228
+ // 同口径补齐,默认值仍取自 options 初始值(encrypt=true / trustServerCertificate=false /
229
+ // tdsVersion=7_4 / useUTC=true)。
230
+ options.encrypt = boolField(f.encrypt, 'encrypt', options.encrypt);
231
+ options.trustServerCertificate = boolField(f.trustServerCertificate, 'trustServerCertificate', options.trustServerCertificate);
232
+ applyTdsVersion(options, f.tdsVersion, '配置');
233
+ applyTlsMinVersion(options, f.tlsMinVersion, '配置');
177
234
  }
178
235
  else {
179
236
  throw new Error('sqlserver 连接缺少 url 或 fields 配置');
@@ -378,19 +435,29 @@ function mssqlReason(e) {
378
435
  const num = typeof err.number === 'number' ? err.number : undefined;
379
436
  const sev = typeof err.severity === 'number' ? err.severity : undefined;
380
437
  const line = typeof err.lineNumber === 'number' ? err.lineNumber : undefined;
381
- // TLS/证书类失败(自签证书、链不可验证、协议版本不符)在 SQL Server 2008/2008 R2 上很常见。
382
- // 默认 encrypt=true + trustServerCertificate=false 与 mssql 官方一致(不改默认值),但必须:
383
- // 1) 不把「TCP 已连通、TLS 校验失败」误报成网络不可达(会把用户引向错误排查方向);
384
- // 2) 把「怎么连上」写清楚(URL 加 ?trustServerCertificate=true / ?encrypt=false)。
438
+ // TLS 失败在 SQL Server 2008/2008 R2 上很常见,但根因分两族、解法正交,必须分开给:
439
+ // - 证书族(自签/链不可验证):证书校验阶段失败 → trustServerCertificate=true / encrypt=false;
440
+ // - 协议族(无共同 TLS 版本):版本协商阶段就失败,证书校验根本没执行 → 只能升服务端 TLS 1.2
441
+ // 或让客户端下探 ?tlsMinVersion=。给它 trustServerCertificate 是开错药方(用户会白跑一轮)。
442
+ // 默认 encrypt=true + trustServerCertificate=false 与 mssql 官方一致(不改默认值),且无论哪族
443
+ // 都不能把「TCP 已连通、TLS 失败」误报成网络不可达(会把用户引向错误排查方向)。
385
444
  // tedious 建连期把底层 socket/TLS 错误用 ES cause 选项挂在 err.cause(originalError 是 mssql 事务
386
445
  // abort 专用,建连场景恒 undefined),message 已聚合底层原文但读 cause 更稳,一并纳入特征文本。
387
446
  const causeText = errText(err.cause);
388
447
  const tlsText = `${detail} ${errText(err.originalError)} ${causeText}`;
389
- // 特征族来源:OpenSSL/BoringSSL 原生错误串(SSL routines/OPENSSL_internal/UNSUPPORTED_PROTOCOL、
390
- // handshake_*.c)、Node ERR_SSL_NO_PROTOCOLS_AVAILABLE 的 message、服务器 alert 拒绝握手/版本。
391
- // 刻意不加 ECONNRESET/socket hang up/ENOTFOUND——与真实网络故障不可区分,误加会让 ECONNREFUSED
392
- // 场景被套上 TLS 指引。
393
- const tlsish = /self[ -]?signed|certificate|unable to verify|wrong version number|ERR_SSL|EPROTO|CERT_|UNSUPPORTED_PROTOCOL|SSL[ -_]?routines|OPENSSL_internal|handshake[ _-]?(failure|client|server)|TLSV1_ALERT|SSLV3_ALERT|alert protocol version|alert handshake failure|no protocols available/i.test(tlsText);
448
+ // 协议族:BoringSSL/OpenSSL 的「没有共同协议版本」家族。UNSUPPORTED_PROTOCOL = SSL_R_UNSUPPORTED_PROTOCOL,
449
+ // 在 ClientHello/ServerHello 版本协商阶段就返回,握手从未走到证书校验;`no protocols available` 是 Node
450
+ // 侧同一语义的表述(ERR_SSL_NO_PROTOCOLS_AVAILABLE 亦含此串)。SSL routines 单独出现不足以判族
451
+ // (证书错误也带该前缀),故要求同时含 UNSUPPORTED。
452
+ const tlsProtoish = /UNSUPPORTED_PROTOCOL|no protocols available|alert protocol version|(?:SSL[ -_]?routines|OPENSSL_internal)[\s\S]{0,80}?UNSUPPORTED/i.test(tlsText);
453
+ // 证书族:证书本身不可信(自签、链断、主机名不符、已过期)
454
+ const tlsCertish = /self[ -]?signed|certificate|unable to verify|CERT_/i.test(tlsText);
455
+ // 兜底「确实是 TLS 层失败」的通用前缀:只用于把 base 从「网络不可达」纠正为「TCP 已连通」,
456
+ // 不据此给任何具体药方(族未定就不该猜)。刻意不加 ECONNRESET/socket hang up/ENOTFOUND——
457
+ // 与真实网络故障不可区分,误加会让 ECONNREFUSED 场景被套上 TLS 指引。
458
+ const tlsish = tlsProtoish ||
459
+ tlsCertish ||
460
+ /ERR_SSL|EPROTO|SSL[ -_]?routines|OPENSSL_internal|handshake[ _-]?(failure|client|server)|TLSV1_ALERT|SSLV3_ALERT|alert handshake failure/i.test(tlsText);
394
461
  let base;
395
462
  switch (code) {
396
463
  case 'ELOGIN':
@@ -403,10 +470,13 @@ function mssqlReason(e) {
403
470
  : `请求超时:语句执行超过 requestTimeout,请拆小查询或调大超时(${detail})`;
404
471
  break;
405
472
  case 'ESOCKET':
406
- // 保留「网络不可达」字样(既有断言与用户检索习惯依赖),但 TLS 特征时先说明 TCP 已连通
407
- base = tlsish
408
- ? `TCP 已连通但 TLS 握手/证书校验失败(不是网络不可达:网络不可达指主机/端口不可达或连接被拒,此处 TCP 已建立)(${detail})`
409
- : `网络不可达:无法与服务器建立 TCP 连接,请检查主机名、端口与网络;端口确认可达仍反复出现时,也可能是服务器在 TLS 握手阶段主动断开(如老实例仅支持 TLS 1.0)(${detail})`;
473
+ // 保留「网络不可达」字样(既有断言与用户检索习惯依赖),但 TLS 特征时先说明 TCP 已连通。
474
+ // 协议族与证书族分开表述:前者是版本协商失败,与证书无关。
475
+ base = tlsProtoish
476
+ ? `TCP 已连通但 TLS 协议版本协商失败:客户端与服务端没有共同的 TLS 协议版本(UNSUPPORTED_PROTOCOL 出现在版本协商阶段,此时证书校验尚未执行,故与证书是否受信无关)(${detail})`
477
+ : tlsish
478
+ ? `TCP 已连通但 TLS 握手/证书校验失败(不是网络不可达:网络不可达指主机/端口不可达或连接被拒,此处 TCP 已建立)(${detail})`
479
+ : `网络不可达:无法与服务器建立 TCP 连接,请检查主机名、端口与网络;端口确认可达仍反复出现时,也可能是服务器在 TLS 握手阶段主动断开(如老实例仅支持 TLS 1.0)(${detail})`;
410
480
  break;
411
481
  // 服务器强制加密(SQL Server 2022 / Azure SQL 默认)而客户端 encrypt=false:tedious 抛英文原文,
412
482
  // 不映射则中文界面里只剩英文,故单列一条给出可直接照做的改法。
@@ -430,7 +500,15 @@ function mssqlReason(e) {
430
500
  base = detail;
431
501
  }
432
502
  const trail = [];
433
- if (tlsish) {
503
+ if (tlsProtoish) {
504
+ // 顺序即优先级:服务端升级是正解(一次性、对所有客户端生效),客户端下探是权宜(仍受限于
505
+ // 服务端实际支持的版本)。第③条显式点名「跳过证书校验」是先发制人——用户看到 TLS 报错的第一
506
+ // 反应通常就是去信任证书,明说无效比沉默更省一轮排查;措辞必须是否定式,不得写成可尝试项。
507
+ trail.push('修复优先级:① 首选在服务端启用 TLS 1.2(SQL Server 2008 R2 SP3 装 KB4057113、SQL Server 2008 SP4 装 KB4057114,之后重启实例)——这是根治,且对所有客户端生效');
508
+ trail.push('② 若暂时无法动服务端,可在连接 URL 追加 ?tlsMinVersion=TLSv1 让客户端把最低 TLS 版本下探到 1.0(本插件已支持;上限仍由 tedious 封顶在 TLS 1.2)');
509
+ trail.push('③ 注意:跳过证书校验(信任任意证书)对这类错误无效——它只影响证书校验阶段,而此处是协议版本协商失败,证书校验根本没执行');
510
+ }
511
+ else if (tlsCertish) {
434
512
  trail.push('若目标是 SQL Server 2008/2008 R2 或使用自签名证书的实例,可在连接 URL 追加 ?trustServerCertificate=true(或 ?encrypt=false,前提是服务器未强制加密)');
435
513
  }
436
514
  if (err.originalError !== undefined)
@@ -5,7 +5,9 @@
5
5
  * 调用 handleDbToolRequest——前端同源相对路径 fetch 即可达。
6
6
  *
7
7
  * trust 校验(等价移植 dsh-ssh-tunnel lib/shared/http-trust.js,自包含):
8
- * - Host 必须是 loopback(或 ctx.webRuntime.trustedHosts 中的条目);
8
+ * - Host 必须是 loopback(或受信条目):0.2.1-rc.3 起来自 ctx.webStartup.trustedHosts
9
+ * 加监听 bind 地址(Connection 的 isBindAddressAuthority 语义),旧宿主来自已删除的
10
+ * ctx.webRuntime.trustedHosts;
9
11
  * - 带 Origin/Referer 时其 host 必须是 loopback(且同端口)或受信条目。
10
12
  * - 0.0.0.0 不是 loopback。违者 403 FORBIDDEN。
11
13
  *
@@ -240,6 +242,10 @@ async function route(req, res, service, path, q, opts) {
240
242
  if (path === '/api/tables' && req.method === 'GET') {
241
243
  return sendOk(res, await service.tables(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
242
244
  }
245
+ if (path === '/api/search-tables' && req.method === 'GET') {
246
+ const limit = q.get('limit') !== null ? Number(q.get('limit')) : undefined;
247
+ return sendOk(res, await service.searchTables(projectOf(q), required(q, 'connId'), required(q, 'keyword'), q.get('database') ?? undefined, limit));
248
+ }
243
249
  if (path === '/api/schemas' && req.method === 'GET') {
244
250
  return sendOk(res, await service.schemas(projectOf(q), required(q, 'connId'), q.get('database') ?? undefined));
245
251
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * http-trust 本地校验(Host loopback / trustedHosts + Origin 同源校验)。
11
11
  * - ctx.effect 收尾:注销 HTTP 路由、关适配器、停 challenge 清理。
12
12
  */
13
+ import { isIP } from 'node:net';
13
14
  import { DbToolStore, normalizeProjectKey } from './store/index.js';
14
15
  import { DbToolService, handleToolAction } from './manager.js';
15
16
  import { handleDbToolRequest } from './http/index.js';
@@ -19,7 +20,9 @@ import { handleDbToolRequest } from './http/index.js';
19
20
  * 注意:不要把 webRuntime 写进来。宿主 @deepseek-ai/dsh-web-app 提供的是
20
21
  * webApp,从不注册 webRuntime;cordis 的 inject 是硬依赖,缺失服务会让 fiber
21
22
  * 永久 pending、apply() 不执行,HTTP 路由也就不会注册(前端表现为 HTTP 404)。
22
- * trustedHosts 改用 ctx.get('webRuntime', true) 可选读取。
23
+ * trustedHosts 改用 ctx.get('webStartup' | 'webRuntime', true) 可选读取:
24
+ * 0.2.1-rc.3 删除了 webRuntime 服务、信任权威改为 webStartup.trustedHosts,
25
+ * 旧宿主仍用 webRuntime,故按新优先、旧回退读取。
23
26
  */
24
27
  export const inject = ['webServer', 'sessions', 'tools', 'connection'];
25
28
  const TOOL_DESCRIPTION = `数据库管理工具(dsh-db-tool):在已配置的数据库连接上执行查询/写操作/脚本。
@@ -47,11 +50,37 @@ export function apply(ctx) {
47
50
  const store = new DbToolStore();
48
51
  const service = new DbToolService(store);
49
52
  // HTTP API:挂在 DSH webServer 的 /dsh-db-tool/api 前缀下(同源访问)
53
+ /**
54
+ * rc.3 的 Connection 在 trustedHosts 之外还单独接受监听 bind 地址
55
+ * (isBindAddressAuthority)。本地兜底对齐该语义,但只补具体单播字面量:
56
+ * wildcard(0.0.0.0/::/*)与 loopback 一律跳过,不放宽 DNS rebinding 攻击面。
57
+ */
58
+ const bindAuthorities = () => {
59
+ const host = ctx.webServer?.host;
60
+ if (typeof host !== 'string')
61
+ return [];
62
+ const h = host.trim();
63
+ if (h === '' || h === '*' || h === '0.0.0.0' || h === '::')
64
+ return [];
65
+ if (isIP(h) === 0)
66
+ return [];
67
+ if (h === '::1' || h === '::ffff:127.0.0.1' || /^127\./.test(h))
68
+ return [];
69
+ const port = ctx.webServer?.port;
70
+ if (typeof port !== 'number' || !(port > 0))
71
+ return [h];
72
+ return [h, `${h.includes(':') ? `[${h}]` : h}:${port}`];
73
+ };
50
74
  const trustedHosts = () => {
51
75
  try {
52
- // webRuntime 不在 inject 内:缺失服务会让插件永不激活,故可选读取
53
- const runtime = ctx.get?.('webRuntime', true);
54
- return runtime?.trustedHosts ?? [];
76
+ // webRuntime 不在 inject 内:缺失服务会让插件永不激活,故可选读取。
77
+ // 0.2.1-rc.3 删除了 webRuntime,改由 webStartup.trustedHosts 提供信任权威。
78
+ const startup = ctx.get?.('webStartup', true);
79
+ const declared = startup?.trustedHosts ??
80
+ ctx.get?.('webRuntime', true)?.trustedHosts ??
81
+ [];
82
+ // 仅 rc.3(webStartup 存在)补 bind 地址;旧宿主的 webRuntime 已含 bind 派生的 LAN 地址。
83
+ return startup === undefined ? declared : [...bindAuthorities(), ...declared];
55
84
  }
56
85
  catch {
57
86
  return [];
package/dist/manager.js CHANGED
@@ -27,6 +27,9 @@ function assertNonEmpty(v, label) {
27
27
  throw new DbToolError('INVALID_ARGUMENT', `缺少必填参数: ${label}`);
28
28
  return s;
29
29
  }
30
+ /** searchTables 回退白名单:listTables(database) 语义为「库名」的 kind(pg/gaussdb 有原生实现;
31
+ * redis/oracle/dmdb 语义不兼容,回退会错标来源或重复返回,见 manager.searchTables 注释) */
32
+ const SEARCH_FALLBACK_KINDS = new Set(['mysql', 'mongodb', 'sqlite']);
30
33
  /** 单连接并发控制台事务会话上限(每会话独占驱动池 + checkout 连接,防连接耗尽) */
31
34
  const MAX_CONSOLE_SESSIONS_PER_CONN = 4;
32
35
  /** 全局并发传输任务上限(每任务独立 reader/writer 连接,防连接耗尽) */
@@ -259,6 +262,56 @@ export class DbToolService {
259
262
  throw this.toDriverError(e);
260
263
  }
261
264
  }
265
+ /**
266
+ * 跨库表名模糊搜索(ro 即可)。优先用适配器原生实现(mysql 走 information_schema
267
+ * 一条 LIKE);未实现时回退「逐库 listTables + 内存过滤」(小并发防打满连接池),
268
+ * 单库语义(sqlite/redis)只搜默认库。limit 钳制 1..200,默认 100。
269
+ */
270
+ async searchTables(projectPath, connId, keyword, database, limit) {
271
+ const kw = assertNonEmpty(keyword, 'keyword').trim();
272
+ const cap = Math.max(1, Math.min(200, Math.floor(Number(limit) || 100)));
273
+ const { key, adapter } = await this.authorize(projectPath, connId, false);
274
+ const auditStmt = `search "${kw.length > 100 ? kw.slice(0, 100) + '…' : kw}"${database ? ` @ ${database}` : ''}`;
275
+ try {
276
+ let hits;
277
+ if (adapter.searchTables) {
278
+ hits = await adapter.searchTables(kw, database, cap);
279
+ }
280
+ else if (SEARCH_FALLBACK_KINDS.has(adapter.kind)) {
281
+ // 回退仅对「listTables(database) 语义 = 库名」的 kind 安全:
282
+ // pg/gaussdb 走原生 searchTables;redis listTables 忽略库名(逐库循环会重复返回同一批键)、
283
+ // oracle/dmdb database 语义为 owner,跨库回退会错标来源——这些 kind 不支持回退,返回空。
284
+ if (database) {
285
+ const list = await adapter.listTables(database);
286
+ hits = filterTables(list, kw, database);
287
+ }
288
+ else {
289
+ const dbs = await adapter.listDatabases();
290
+ hits = [];
291
+ for (let i = 0; i < dbs.length && hits.length < cap; i += 8) {
292
+ const batch = await Promise.all(dbs.slice(i, i + 8).map(async (db) => {
293
+ try {
294
+ return filterTables(await adapter.listTables(db), kw, db);
295
+ }
296
+ catch {
297
+ return []; // 个别库不可读(权限/系统库)不拖垮整体搜索
298
+ }
299
+ }));
300
+ hits = hits.concat(...batch);
301
+ }
302
+ }
303
+ }
304
+ else {
305
+ hits = [];
306
+ }
307
+ this.audit(key, connId, 'search_tables', auditStmt, 'none', false, true, undefined, hits.length);
308
+ return hits.slice(0, cap);
309
+ }
310
+ catch (e) {
311
+ this.audit(key, connId, 'search_tables', auditStmt, 'none', false, false, this.errText(e));
312
+ throw this.toDriverError(e);
313
+ }
314
+ }
262
315
  /** 库内 schema 清单(仅 PG/GaussDB 等三层语义适配器实现;ro 即可)。 */
263
316
  async schemas(projectPath, connId, database) {
264
317
  const { key, adapter } = await this.authorize(projectPath, connId, false);
@@ -538,12 +591,12 @@ export class DbToolService {
538
591
  if (this.disposed)
539
592
  throw new DbToolError('INVALID_ARGUMENT', '服务已关闭');
540
593
  if (!projectPath || projectPath.trim() === '') {
541
- throw new DbToolError('UNAUTHORIZED_PROJECT', '业务操作需要 projectPath(匿名项目仅可管理连接)');
594
+ throw new DbToolError('UNAUTHORIZED_PROJECT', '项目路径为空:请先在面板顶部填写当前项目路径(或重新打开侧边栏让插件自动解析),再进行数据浏览/查询');
542
595
  }
543
596
  const key = normalizeProjectKey(projectPath);
544
597
  const mode = this.store.grants.check(key, connId);
545
598
  if (!mode)
546
- throw new DbToolError('UNAUTHORIZED_PROJECT', `项目未授权该连接: ${connId}`);
599
+ throw new DbToolError('UNAUTHORIZED_PROJECT', `项目未授权该连接: ${connId}(请切到「项目授权」面板,为当前项目勾选该连接的 ro/rw 授权后重试)`);
547
600
  if (needWrite && mode === 'ro') {
548
601
  throw new DbToolError('READ_ONLY', '该连接对本项目为只读授权(ro),拒绝写操作');
549
602
  }
@@ -971,6 +1024,14 @@ export class DbToolService {
971
1024
  return new DbToolError('DRIVER_ERROR', this.errText(e));
972
1025
  }
973
1026
  }
1027
+ /** 表名/注释子串匹配(大小写不敏感,回退路径用;命中转 TableSearchHit) */
1028
+ function filterTables(list, keyword, database) {
1029
+ const kw = keyword.toLowerCase();
1030
+ return list
1031
+ .filter((t) => t.name.toLowerCase().includes(kw) ||
1032
+ (t.comment !== undefined && t.comment.toLowerCase().includes(kw)))
1033
+ .map((t) => ({ database, name: t.name, type: t.type, comment: t.comment }));
1034
+ }
974
1035
  function pick(args, snake) {
975
1036
  return args[snake] ?? (snake === 'conn_id' ? args.connId : args.challengeId);
976
1037
  }
@@ -163,7 +163,7 @@ function toMeta(rec, hasPassword = false) {
163
163
  if (hasPassword)
164
164
  meta.hasPassword = true;
165
165
  if (rec.fields) {
166
- const { host, port, user, database } = rec.fields;
166
+ const { host, port, user, database, tlsMinVersion } = rec.fields;
167
167
  if (typeof host === 'string')
168
168
  meta.host = host;
169
169
  if (typeof port === 'number')
@@ -172,6 +172,8 @@ function toMeta(rec, hasPassword = false) {
172
172
  meta.user = user;
173
173
  if (typeof database === 'string')
174
174
  meta.database = database;
175
+ if (typeof tlsMinVersion === 'string')
176
+ meta.tlsMinVersion = tlsMinVersion;
175
177
  }
176
178
  return meta;
177
179
  }