dsh-db-tool 1.9.1 → 1.9.3
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 +44 -0
- package/README.md +26 -8
- package/README_EN.md +26 -8
- package/client/client.js +107 -13
- package/dist/adapters/mysql/index.js +6 -2
- package/dist/adapters/sqlserver/index.js +342 -14
- package/dist/http/index.js +5 -0
- package/dist/http/version.js +33 -0
- package/dist/store/connections.js +10 -1
- package/package.json +5 -1
- package/skills/db-admin/SKILL.md +21 -6
- package/vendor/sqlserver-driver.cjs +84 -243
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
* 服务层拦 execute + 适配器侧 query 首词白名单(复用 guard 的 SQL_READ_HEADS)+
|
|
25
25
|
* 拒绝多语句(防 `SELECT 1; UPDATE ...` 堆叠写)。这里是应用层防线,不是服务器级保证。
|
|
26
26
|
*/
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
27
28
|
import { createRequire } from 'node:module';
|
|
29
|
+
import * as tls from 'node:tls';
|
|
28
30
|
import { SQL_READ_HEADS, sqlHead } from '../../guard/index.js';
|
|
29
31
|
import { assertIdent, clampLimit, clampOffset, humanize, normalizeCell } from '../sql-shared/common.js';
|
|
30
32
|
let vendorCache;
|
|
@@ -104,14 +106,232 @@ function applyTdsVersion(options, raw, source) {
|
|
|
104
106
|
}
|
|
105
107
|
options.tdsVersion = v;
|
|
106
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* 加密模式(URL ?encrypt= 与 fields.encrypt 同口径):true / false / strict 三态。
|
|
111
|
+
*
|
|
112
|
+
* strict 是 tedious 对 TDS 8.0 的支持:TLS 在登录包之前就完成握手(ALPN=tds/8.0),仅 SQL Server
|
|
113
|
+
* 2022+ 开启 Force Strict Encryption 的实例可用;老实例必然拒绝,故非法值必须 fail-closed 报清
|
|
114
|
+
* 三态,不能把拼错的 'stric' 之类静默当成 true(加密强度是安全承诺,连错不报是安全事故)。
|
|
115
|
+
*/
|
|
116
|
+
function applyEncrypt(options, raw, source) {
|
|
117
|
+
if (raw === undefined || raw === null || String(raw).trim() === '')
|
|
118
|
+
return;
|
|
119
|
+
if (typeof raw === 'boolean') {
|
|
120
|
+
options.encrypt = raw;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const s = String(raw).trim().toLowerCase();
|
|
124
|
+
if (s === 'true' || s === '1' || s === 'yes') {
|
|
125
|
+
options.encrypt = true;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (s === 'false' || s === '0' || s === 'no') {
|
|
129
|
+
options.encrypt = false;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (s === 'strict') {
|
|
133
|
+
options.encrypt = 'strict';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`sqlserver 连接${source}的 encrypt 取值非法:${JSON.stringify(raw)}(只接受 true/false/strict;strict 表示 TDS 8.0 强制加密,仅 SQL Server 2022 及以上并开启 Force Strict Encryption 的实例支持)`);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 证书校验用的服务器名(URL ?serverName= 与 fields.serverName 同口径)。
|
|
140
|
+
*
|
|
141
|
+
* 两个真实场景需要它:① 用 IP 直连但证书签发给域名;② 走端口转发/NAT 后主机名与证书不符。
|
|
142
|
+
* tedious 走 strict 时 servername 取 options.serverName(wrapWithTls),非 strict 时同样优先
|
|
143
|
+
* 用它(startTls),故一个参数覆盖两条路径。留空不注入,沿用「主机名作校验名」的默认语义。
|
|
144
|
+
*/
|
|
145
|
+
function applyServerName(options, raw) {
|
|
146
|
+
// 与其它 applyXxx 同口径先 trim 再判空:否则 ?serverName=%20(一个空格)会被当成有效值注入
|
|
147
|
+
if (raw === undefined || raw === null || String(raw).trim() === '')
|
|
148
|
+
return;
|
|
149
|
+
options.serverName = String(raw).trim();
|
|
150
|
+
}
|
|
151
|
+
/** 取(必要时创建)cryptoCredentialsDetails,供 minVersion 与 ca 合并写入而不互相覆盖 */
|
|
152
|
+
function credentialsOf(options) {
|
|
153
|
+
const cur = options.cryptoCredentialsDetails;
|
|
154
|
+
if (cur !== undefined && cur !== null && typeof cur === 'object')
|
|
155
|
+
return cur;
|
|
156
|
+
const fresh = {};
|
|
157
|
+
options.cryptoCredentialsDetails = fresh;
|
|
158
|
+
return fresh;
|
|
159
|
+
}
|
|
160
|
+
/** PEM 证书起始标记(用于区分「直接传证书文本」与「传文件路径」) */
|
|
161
|
+
const PEM_HEADER = '-----BEGIN';
|
|
162
|
+
/**
|
|
163
|
+
* 系统信任库中的 CA(Node >=22.15 的 tls.getCACertificates('system'))。
|
|
164
|
+
*
|
|
165
|
+
* 失败或不可用时返回空数组并静默回退:拿不到系统根证书不该阻断连接(用户显式给的 caCert 仍然
|
|
166
|
+
* 生效)。之所以要主动合并——Node tls 一旦收到 ca 选项就只用这份列表校验,默认的系统信任库不再
|
|
167
|
+
* 生效;只塞用户那张 CA 会让「装了自签 CA 之后连别的实例」全部失败。
|
|
168
|
+
*/
|
|
169
|
+
function systemCa() {
|
|
170
|
+
try {
|
|
171
|
+
const get = tls.getCACertificates;
|
|
172
|
+
if (typeof get !== 'function')
|
|
173
|
+
return [];
|
|
174
|
+
const list = get('system');
|
|
175
|
+
if (!Array.isArray(list))
|
|
176
|
+
return [];
|
|
177
|
+
return list.filter((x) => typeof x === 'string' && x !== '');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* 内置 bundle 的 CA(Node >=22.15 的 tls.getCACertificates('default'))。
|
|
185
|
+
* 取不到时返回空数组——调用方据此跳过注入,保持 Node 默认校验行为。
|
|
186
|
+
*/
|
|
187
|
+
function defaultCa() {
|
|
188
|
+
try {
|
|
189
|
+
const get = tls.getCACertificates;
|
|
190
|
+
if (typeof get !== 'function')
|
|
191
|
+
return [];
|
|
192
|
+
const list = get('default');
|
|
193
|
+
if (!Array.isArray(list))
|
|
194
|
+
return [];
|
|
195
|
+
return list.filter((x) => typeof x === 'string' && x !== '');
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 显式受信 CA(URL ?caCert= 与 fields.caCert 同口径):值可以是 PEM 文本,也可以是 PEM 文件路径。
|
|
203
|
+
*
|
|
204
|
+
* 两条硬约束:
|
|
205
|
+
* - 路径读不出来必须 fail-closed 抛中文错。若静默跳过,用户会以为「已装 CA 但还连不上」,实际
|
|
206
|
+
* 仍按系统信任库校验——问题只会更难定位。
|
|
207
|
+
* - 写入的 ca 必须同时并入**内置 bundle 与系统信任库**,否则等于缩小信任范围:Node 一旦收到
|
|
208
|
+
* ca 选项就不再使用默认信任库,只塞用户那张会让"为连 A 实例装一张自签 CA"变成"从此所有公网
|
|
209
|
+
* CA 实例全部连不上"(SELF_SIGNED_CERT_IN_CHAIN)。这里与 applySystemCaDefault 用同一套合并口径。
|
|
210
|
+
*/
|
|
211
|
+
function applyCaCert(options, raw, source) {
|
|
212
|
+
if (raw === undefined || raw === null || String(raw) === '')
|
|
213
|
+
return;
|
|
214
|
+
const text = String(raw);
|
|
215
|
+
// PEM 文本原样保留(换行是 PEM 结构的一部分);只有当路径时才去掉两端空白
|
|
216
|
+
const pem = text.includes(PEM_HEADER) ? normalizePem(text, source) : readCaFile(text.trim(), source);
|
|
217
|
+
const merged = mergedCaWith(pem);
|
|
218
|
+
credentialsOf(options).ca = merged;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 规范化 PEM 文本:把被压成一行的证书重新按 64 列折行。
|
|
222
|
+
*
|
|
223
|
+
* 为什么必须有这一步:前端 caCert 是单行 <input>,用户从证书文件粘贴多行 PEM 时换行会被
|
|
224
|
+
* 浏览器压平。而 `tls.createSecureContext({ca:[单行PEM]})` **不报错**(实测静默接受),
|
|
225
|
+
* 握手时才失败并报 DEPTH_ZERO_SELF_SIGNED_CERT——用户看到的是"我填了 CA 还是同样的错",
|
|
226
|
+
* 属最难排查的失败形态。这里主动还原折行,避免把格式问题留给用户去猜。
|
|
227
|
+
*/
|
|
228
|
+
function normalizePem(text, source) {
|
|
229
|
+
const trimmed = text.trim();
|
|
230
|
+
// 已含换行(正常 PEM 块)→ 原样返回,绝不打乱有效格式
|
|
231
|
+
if (/[\r\n]/.test(trimmed))
|
|
232
|
+
return trimmed;
|
|
233
|
+
// 单行形态:抽出所有完整证书块并重新折行
|
|
234
|
+
const blocks = trimmed.match(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g);
|
|
235
|
+
if (blocks !== null && blocks.length > 0) {
|
|
236
|
+
return blocks
|
|
237
|
+
.map((b) => {
|
|
238
|
+
const m = /-----BEGIN ([^-]+)-----([\s\S]*?)-----END \1-----/.exec(b);
|
|
239
|
+
if (m === null)
|
|
240
|
+
return b;
|
|
241
|
+
const body = (m[2] ?? '').replace(/\s+/g, '');
|
|
242
|
+
if (body === '')
|
|
243
|
+
return b;
|
|
244
|
+
const wrapped = body.match(/.{1,64}/g) ?? [];
|
|
245
|
+
return `-----BEGIN ${m[1]}-----\n${wrapped.join('\n')}\n-----END ${m[1]}-----`;
|
|
246
|
+
})
|
|
247
|
+
.join('\n');
|
|
248
|
+
}
|
|
249
|
+
// 含 BEGIN 但不是完整证书块(被截断/粘贴残缺)→ fail-closed 说清楚,不要静默接受
|
|
250
|
+
throw new Error(`sqlserver 连接${source}的 caCert 不是完整的 PEM 证书(缺少 -----END 结尾或内容被截断)。请给出完整的 -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- 块,或改用文件路径`);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 合并 CA 列表:内置 bundle + 系统信任库 + 额外 PEM(去重,保序)。
|
|
254
|
+
*
|
|
255
|
+
* 三处(默认注入 / 显式 caCert / 将来可能的扩展)必须共用同一口径,否则会出现
|
|
256
|
+
* "未给 caCert 时信任 154 张、给了反而只剩 37 张"这种自相矛盾的信任收窄。
|
|
257
|
+
* 列表在进程生命周期内不变,故做模块级缓存——createSecureContext 传入近百张证书的开销
|
|
258
|
+
* 是可测的(每次建连都要跑一遍 poolConfig)。
|
|
259
|
+
*/
|
|
260
|
+
let caCache;
|
|
261
|
+
function caPools() {
|
|
262
|
+
if (caCache === undefined)
|
|
263
|
+
caCache = { bundled: defaultCa(), system: systemCa() };
|
|
264
|
+
return caCache;
|
|
265
|
+
}
|
|
266
|
+
/** 合并成一份去重的 CA 列表(bundled 优先,其次 system,最后 extras) */
|
|
267
|
+
function mergedCaWith(...extras) {
|
|
268
|
+
const { bundled, system } = caPools();
|
|
269
|
+
const out = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
for (const pem of [...bundled, ...system, ...extras]) {
|
|
272
|
+
if (pem === '' || seen.has(pem))
|
|
273
|
+
continue;
|
|
274
|
+
seen.add(pem);
|
|
275
|
+
out.push(pem);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 把系统信任库并入默认校验(未显式给 caCert 时也生效)。
|
|
281
|
+
*
|
|
282
|
+
* 为什么需要:Node 默认的 ca 是编译期内置的 bundle,**不读操作系统信任库**(只有 --use-system-ca
|
|
283
|
+
* 或显式注入才读)。因此「用户按官方推荐把自签证书导入 Windows 受信任根」这一最常见的正确做法,
|
|
284
|
+
* 在不注入的情况下对连接毫无帮助,用户会以为"导入没用"。此处在未给 caCert 时注入 system 列表,
|
|
285
|
+
* 让该做法真正生效。
|
|
286
|
+
*
|
|
287
|
+
* 合并而非替换:注入系统库后 Node 不再使用内置 bundle,只给 system 会让"连公网/别家实例"反而失败,
|
|
288
|
+
* 故 default + system 一起给。
|
|
289
|
+
*
|
|
290
|
+
* 兼容性(老环境不能因此连不上,故逐级降级而不是一刀切):
|
|
291
|
+
* - 没有 tls.getCACertificates(Node < 22.15 / 非官方构建)→ defaultCa() 与 systemCa() 都返回 [],
|
|
292
|
+
* 本函数不注入任何东西,保持 Node 原生默认行为(仍可用内置 bundle 正常连接公网证书)。
|
|
293
|
+
* - 有 API 但系统库为空(部分 Linux 容器、精简镜像)→ 只注入 bundled(等价于默认),
|
|
294
|
+
* 不会把 ca 设成空数组(那会让校验彻底失败)。
|
|
295
|
+
* - 任何异常都被 defaultCa/systemCa 内部吞掉,绝不让"取系统证书失败"打断建连。
|
|
296
|
+
*/
|
|
297
|
+
function applySystemCaDefault(options) {
|
|
298
|
+
if (options.cryptoCredentialsDetails !== undefined) {
|
|
299
|
+
const cur = options.cryptoCredentialsDetails;
|
|
300
|
+
// 已经有人写过 ca(caCert 显式给过)就不再插手,避免覆盖用户的显式选择
|
|
301
|
+
if (cur !== null && typeof cur === 'object' && cur['ca'] !== undefined)
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// 合并口径统一走 mergedCaWith(与 applyCaCert 同一实现,避免两处漂移)
|
|
305
|
+
const merged = mergedCaWith();
|
|
306
|
+
// 两者都拿不到 → 不注入:保持 Node 默认(内置 bundle 依然生效)
|
|
307
|
+
if (merged.length === 0)
|
|
308
|
+
return;
|
|
309
|
+
credentialsOf(options).ca = merged;
|
|
310
|
+
}
|
|
311
|
+
/** 读取 PEM 文件;任何失败都转成带修复指引的中文错误(绝不静默忽略该配置) */
|
|
312
|
+
function readCaFile(path, source) {
|
|
313
|
+
let text;
|
|
314
|
+
try {
|
|
315
|
+
text = readFileSync(path, 'utf8');
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
throw new Error(`sqlserver 连接${source}的 caCert 无法读取:${path}(${errText(e)})。请给出可读的 PEM 证书文件路径,或直接传 PEM 文本;该配置不会被忽略——忽略等于换回系统信任库校验,真正的原因会被掩盖`);
|
|
319
|
+
}
|
|
320
|
+
if (!text.includes(PEM_HEADER)) {
|
|
321
|
+
throw new Error(`sqlserver 连接${source}的 caCert 指向的文件不含 PEM 证书:${path}(应形如 -----BEGIN CERTIFICATE----- 开头;若本来就想直接传文本,请确认文本未被截断)`);
|
|
322
|
+
}
|
|
323
|
+
return text;
|
|
324
|
+
}
|
|
107
325
|
/**
|
|
108
326
|
* 最低 TLS 版本(URL ?tlsMinVersion= 与 fields.tlsMinVersion 同口径)。
|
|
109
327
|
*
|
|
110
|
-
* 只写 cryptoCredentialsDetails.minVersion
|
|
328
|
+
* 只写 cryptoCredentialsDetails.minVersion,不提供 ciphers / maxVersion 开关:
|
|
111
329
|
* - Electron 内置 Node 是 BoringSSL,OpenSSL 专有的 cipher 串(如 DEFAULT@SECLEVEL=0)会直接抛
|
|
112
330
|
* INVALID_COMMAND;而「连 2008/2008R2 只有 TLS 1.0」这个场景只需把下限从默认的 TLSv1.2 放开即可。
|
|
113
|
-
* - tedious message-io
|
|
114
|
-
*
|
|
331
|
+
* - TLS 上限在非 strict 路径由 tedious 自己封顶:message-io.js 的 startTls 会把
|
|
332
|
+
* cryptoCredentialsDetails.maxVersion 就地改写为 'TLSv1.2'(只有在白名单 TLSv1/TLSv1.1/TLSv1.2
|
|
333
|
+
* 内才原样保留),所以这里写 maxVersion=TLSv1.2 等价于「什么都不做」而不是「加固」。写更低的上限
|
|
334
|
+
* 反而会真的降级到 1.0/1.1,属于削弱安全性——上限留给 tedious 定,本函数只管下限。
|
|
115
335
|
*
|
|
116
336
|
* 取值区分大小写、不做归一化(与 Node tls 官方 API 语义一致:'tlsv1' / 'TLSv1_0' 均会被底层拒绝)。
|
|
117
337
|
* 非法值 fail-closed:静默回退到 Node 默认下限会让用户以为已下探,实际握手仍按 TLS 1.2 起步,
|
|
@@ -125,7 +345,8 @@ function applyTlsMinVersion(options, raw, source) {
|
|
|
125
345
|
if (hit === undefined) {
|
|
126
346
|
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
347
|
}
|
|
128
|
-
|
|
348
|
+
// 与 caCert 合并进同一份 cryptoCredentialsDetails:两者都可能被用户同时设置,直接整体赋值会互相覆盖
|
|
349
|
+
credentialsOf(options).minVersion = hit;
|
|
129
350
|
}
|
|
130
351
|
function decodeCred(raw) {
|
|
131
352
|
try {
|
|
@@ -193,11 +414,14 @@ function poolConfig(conn) {
|
|
|
193
414
|
const path = u.pathname.replace(/^\//, '');
|
|
194
415
|
if (path)
|
|
195
416
|
cfg.database = decodeCred(path);
|
|
196
|
-
|
|
417
|
+
// encrypt 是三态(true/false/strict),故不走 boolParam:strict 会被布尔白名单判为非法
|
|
418
|
+
applyEncrypt(options, queryParam(u.searchParams, 'encrypt'), ' URL ');
|
|
197
419
|
options.trustServerCertificate = boolParam(u.searchParams, 'trustServerCertificate', false);
|
|
198
420
|
options.useUTC = boolParam(u.searchParams, 'useUTC', true);
|
|
199
421
|
applyTdsVersion(options, queryParam(u.searchParams, 'tdsVersion'), ' URL ');
|
|
200
422
|
applyTlsMinVersion(options, queryParam(u.searchParams, 'tlsMinVersion'), ' URL ');
|
|
423
|
+
applyServerName(options, queryParam(u.searchParams, 'serverName'));
|
|
424
|
+
applyCaCert(options, queryParam(u.searchParams, 'caCert'), ' URL ');
|
|
201
425
|
const instance = queryParam(u.searchParams, 'instanceName');
|
|
202
426
|
// instanceName 与 port 互斥:mssql 层在建连前会 delete 掉 port(tedious 对两者共存直接抛
|
|
203
427
|
// 互斥 Error),端口改由 SQL Server Browser(UDP 1434)按实例名解析
|
|
@@ -227,17 +451,24 @@ function poolConfig(conn) {
|
|
|
227
451
|
// TLS 下限都不生效(连接仍按 URL 默认值走),排障时会误以为参数没被实现。此处与 URL 分支
|
|
228
452
|
// 同口径补齐,默认值仍取自 options 初始值(encrypt=true / trustServerCertificate=false /
|
|
229
453
|
// tdsVersion=7_4 / useUTC=true)。
|
|
230
|
-
|
|
454
|
+
// encrypt 与 URL 分支同口径的三态校验(true/false/strict),缺省沿用 options 初始值 encrypt=true
|
|
455
|
+
applyEncrypt(options, f.encrypt, '配置');
|
|
231
456
|
options.trustServerCertificate = boolField(f.trustServerCertificate, 'trustServerCertificate', options.trustServerCertificate);
|
|
232
457
|
applyTdsVersion(options, f.tdsVersion, '配置');
|
|
233
458
|
applyTlsMinVersion(options, f.tlsMinVersion, '配置');
|
|
459
|
+
applyServerName(options, f.serverName);
|
|
460
|
+
applyCaCert(options, f.caCert, '配置');
|
|
234
461
|
}
|
|
235
462
|
else {
|
|
236
463
|
throw new Error('sqlserver 连接缺少 url 或 fields 配置');
|
|
237
464
|
}
|
|
238
|
-
// conn.ssl 是通用字段(mysql/pg
|
|
239
|
-
|
|
465
|
+
// conn.ssl 是通用字段(mysql/pg 适配器同样处理):语义上等价要求加密信道。
|
|
466
|
+
// 已显式选 strict(TDS 8.0)时不降级为 true——strict 是更强的加密承诺,降级会悄悄削弱安全要求。
|
|
467
|
+
if (conn.ssl && options.encrypt !== 'strict')
|
|
240
468
|
options.encrypt = true;
|
|
469
|
+
// 未显式给 caCert 时把系统信任库并入默认校验:Node 默认只信任内置 bundle,不读操作系统信任库,
|
|
470
|
+
// 因此"按官方推荐把自签证书导入 Windows 受信任根"若不注入就对连接毫无帮助(详见 applySystemCaDefault)。
|
|
471
|
+
applySystemCaDefault(options);
|
|
241
472
|
cfg.connectionTimeout = 15000;
|
|
242
473
|
cfg.requestTimeout = 30000;
|
|
243
474
|
cfg.pool = { max: 10, min: 0, idleTimeoutMillis: 30000 };
|
|
@@ -422,6 +653,70 @@ function assertReadOnlyQuery(sql, scan) {
|
|
|
422
653
|
function errText(e) {
|
|
423
654
|
return e instanceof Error ? e.message : String(e);
|
|
424
655
|
}
|
|
656
|
+
/**
|
|
657
|
+
* SQL Server 引擎错误号 → 针对性的中文诊断与出口(仅覆盖「光看英文 message 会走错方向」的那些)。
|
|
658
|
+
*
|
|
659
|
+
* 原则:错误号是服务器给的结构化事实,比 message 文本可靠;但药方必须与成因正交地给对——
|
|
660
|
+
* - 17819 只要 TDS 8.0,与证书是否受信无关,绝不能给 trustServerCertificate;
|
|
661
|
+
* - 17898/17802 是 TDS 协议版本被拒,官方协议规定服务器会自动降级,故正解是升级客户端库,
|
|
662
|
+
* 不是让用户去下调 tdsVersion(那会把问题从「连不上」变成「连上了但行为异常」);
|
|
663
|
+
* - 17835 是服务器强制加密,解法是开 encrypt,同样与证书无关;
|
|
664
|
+
* - 17836/17828/17832 是包结构错配(代理/分片/加密中间层/连错端口),不是版本问题,
|
|
665
|
+
* 给 tdsVersion 建议会把排查引向完全错误的方向;
|
|
666
|
+
* - 47072 是 Azure 的最低 TLS 版本门槛,只会由「版本太低」触发,不会由「证书不受信」触发。
|
|
667
|
+
*/
|
|
668
|
+
const EREQUEST_HINTS = {
|
|
669
|
+
17819: '服务器只接受加密连接(要求 TDS 8.0 严格加密):请在连接 URL 追加 ?encrypt=strict 后重试(strict 走 TDS 8.0,TLS 在登录包之前就完成握手,仅 SQL Server 2022 及以上且开启 Force Strict Encryption 的实例支持)',
|
|
670
|
+
17898: '客户端声明的 TDS 协议版本不被服务器接受:官方协议(MS-TDS LOGIN7)规定服务器应降级到自身最高版本,所以正确做法是升级客户端驱动/库的版本,而不是下调 tdsVersion',
|
|
671
|
+
17802: '服务器拒绝该 TDS 协议版本(中间 TDS 版本不受支持):请升级客户端驱动/库的版本;下调 tdsVersion 只会让协议降级、后续行为更不可预期',
|
|
672
|
+
17835: '服务器强制要求加密连接,而当前配置未启用加密:请在连接 URL 追加 ?encrypt=true 后重试(这是加密开关问题,与证书是否受信无关,不要用关闭证书校验的方式绕过)',
|
|
673
|
+
17836: '登录包长度与网络包声明不一致:通常是代理、端口转发、加密中间层或分片设备改写了 TDS 包,或连到了并非 SQL Server 的端口。请核对端口与链路中间设备',
|
|
674
|
+
17828: '预登录包结构非法(服务器认为客户端发来的预登录包不合法):通常是代理或加密中间层改写了包结构,请核对网络链路中的中间设备与端口',
|
|
675
|
+
17832: '服务器无法解析客户端登录包:可能是包被中间层改写、驱动版本与服务端加解密方式不匹配,或连到了非 SQL Server 端口。请核对链路中间设备',
|
|
676
|
+
47072: 'Azure SQL 要求的最低 TLS 版本未满足:请把客户端 TLS 版本提升到 1.2 及以上(不要下探 tlsMinVersion,也不要关闭证书校验——这个错误只由版本过低触发)',
|
|
677
|
+
};
|
|
678
|
+
/** 已知错误号对应的补充诊断(未收录返回 undefined,保持原有通用文案) */
|
|
679
|
+
function erequestHint(num) {
|
|
680
|
+
if (num === undefined)
|
|
681
|
+
return undefined;
|
|
682
|
+
return Object.prototype.hasOwnProperty.call(EREQUEST_HINTS, num) ? EREQUEST_HINTS[num] : undefined;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* 服务器原文 → 错误号特征表(number 缺失时的兜底)。
|
|
686
|
+
*
|
|
687
|
+
* 为什么必须有这条兜底:登录阶段 tedious 抛的是 `ConnectionError(message, 'ELOGIN')`,不带 number,
|
|
688
|
+
* 而 17819/17835/17898/17802/17836/17828/17832 全部发生在建连/登录阶段。只按 number 查表等于
|
|
689
|
+
* 对这些错误完全没有分诊,还会把 17819 误报成"用户名或密码错误"。
|
|
690
|
+
*
|
|
691
|
+
* 特征取官方 sys.messages 原文里最独特的片段,避免与其它错误串味;宁可漏匹配(退回通用文案)
|
|
692
|
+
* 也不要错匹配(给错药方比不给更糟)。
|
|
693
|
+
*/
|
|
694
|
+
const HINT_BY_TEXT = [
|
|
695
|
+
// "The SQL Server or the endpoint is configured to accept only strict (TDS 8.0 and above) connections"
|
|
696
|
+
{ num: 17819, re: /accept only strict|TDS 8\.0 and above/i },
|
|
697
|
+
// "Encryption is required to connect to this server but the client library does not support encryption"
|
|
698
|
+
{ num: 17835, re: /Encryption is required to connect to this server/i },
|
|
699
|
+
// "Please use the client library which supports TDS Version 7.4 or higher"
|
|
700
|
+
{ num: 17898, re: /supports TDS Version 7\.4 or higher/i },
|
|
701
|
+
// "The Tabular Data Stream \(TDS\) version 0x... is unsupported or unknown"
|
|
702
|
+
{ num: 17802, re: /TDS\)? version 0x[0-9a-fA-F]+[^.]*unsupported or unknown/i },
|
|
703
|
+
// "Length specified in network packet payload did not match number of bytes read"
|
|
704
|
+
{ num: 17836, re: /Length specified in network packet payload did not match/i },
|
|
705
|
+
// "The prelogin packet used to open the connection is structurally invalid"
|
|
706
|
+
{ num: 17828, re: /prelogin packet used to open the connection is structurally invalid/i },
|
|
707
|
+
// "The login packet used to open the connection is structurally invalid"
|
|
708
|
+
{ num: 17832, re: /login packet used to open the connection is structurally invalid/i },
|
|
709
|
+
// "Login failed with invalid TLS version"(Azure 最低 TLS 门槛)
|
|
710
|
+
{ num: 47072, re: /invalid TLS version/i },
|
|
711
|
+
];
|
|
712
|
+
/** 按服务器原文特征反查错误号诊断(number 缺失或未收录时使用) */
|
|
713
|
+
function hintByText(text) {
|
|
714
|
+
for (const { num, re } of HINT_BY_TEXT) {
|
|
715
|
+
if (re.test(text))
|
|
716
|
+
return EREQUEST_HINTS[num];
|
|
717
|
+
}
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
425
720
|
/**
|
|
426
721
|
* mssql 错误码 → 中文可读提示。ETIMEOUT 在 ConnectionError(建连超时)与 RequestError
|
|
427
722
|
* (语句执行超时)下语义不同,故结合 err.name 判断而非只看 code;originalError/precedingErrors
|
|
@@ -458,10 +753,22 @@ function mssqlReason(e) {
|
|
|
458
753
|
const tlsish = tlsProtoish ||
|
|
459
754
|
tlsCertish ||
|
|
460
755
|
/ERR_SSL|EPROTO|SSL[ -_]?routines|OPENSSL_internal|handshake[ _-]?(failure|client|server)|TLSV1_ALERT|SSLV3_ALERT|alert handshake failure/i.test(tlsText);
|
|
756
|
+
// 已知错误号对应的针对性诊断(未收录为 undefined):既用于 EREQUEST 文案,也用于抑制 TLS 族建议。
|
|
757
|
+
// 关键坑:这些错误几乎全部发生在**登录阶段**,而 tedious 的 Login7TokenHandler.onErrorMessage
|
|
758
|
+
// 构造的是 `new ConnectionError(token.message, 'ELOGIN')` —— token.number 只用于判断临时错误,
|
|
759
|
+
// **没有赋给 error 对象**(只有查询阶段的 RequestError 才有 number)。因此真实链路上
|
|
760
|
+
// err.number 恒为 undefined,只按 number 查表会让这 8 条诊断全部失效、退化成"用户名或密码错误"。
|
|
761
|
+
// 故 number 缺失时按服务器原文特征反查(见 hintByText)。
|
|
762
|
+
const knownHint = erequestHint(num) ?? hintByText(detail);
|
|
461
763
|
let base;
|
|
462
764
|
switch (code) {
|
|
463
765
|
case 'ELOGIN':
|
|
464
|
-
|
|
766
|
+
// 登录阶段也可能是「服务器用错误号拒绝连接」而非账号问题(17819/17835/17898/17802 等都发生在
|
|
767
|
+
// 这一步)。命中已知特征时优先给针对性诊断——否则会把"只接受 strict 加密"误报成密码错误,
|
|
768
|
+
// 用户会反复核对账号,方向完全错。
|
|
769
|
+
base = knownHint === undefined
|
|
770
|
+
? `登录失败:用户名或密码错误,或账号不允许从当前主机登录(${detail})`
|
|
771
|
+
: `登录失败:服务器在登录阶段拒绝了该连接(${knownHint})。服务器原文:${detail}`;
|
|
465
772
|
break;
|
|
466
773
|
case 'ETIMEOUT':
|
|
467
774
|
base =
|
|
@@ -480,8 +787,14 @@ function mssqlReason(e) {
|
|
|
480
787
|
break;
|
|
481
788
|
// 服务器强制加密(SQL Server 2022 / Azure SQL 默认)而客户端 encrypt=false:tedious 抛英文原文,
|
|
482
789
|
// 不映射则中文界面里只剩英文,故单列一条给出可直接照做的改法。
|
|
790
|
+
// 同一个 EENCRYPT 有两种相反成因,必须分开表述(tedious 20.3.7+ 新增了前者的校验):
|
|
791
|
+
// - encrypt=true 而服务器预登录没回 ON/REQ → 服务器未协商加密,握手本会退化成明文,驱动现在直接拒绝;
|
|
792
|
+
// - encrypt=false 而服务器要求加密 → 服务器侧强制。
|
|
793
|
+
// 两者的药方正好相反(一个要关、一个要开),合并成一句必然把用户引反方向。
|
|
483
794
|
case 'EENCRYPT':
|
|
484
|
-
base =
|
|
795
|
+
base = /did not negotiate encryption/i.test(detail)
|
|
796
|
+
? `服务器未协商加密:客户端要求加密(encrypt=true)但服务器在预登录响应里没有回 ON/REQ,驱动为避免明文降级直接拒绝连接。若该实例确实不支持加密,可改用 ?encrypt=false 明文连接(内网可接受时);若要求加密,请在服务器侧启用「强制加密」并配置有效证书(${detail})`
|
|
797
|
+
: `服务器要求加密连接:当前 encrypt=false 被服务器拒绝,请改用 ?encrypt=true 后重试(${detail})`;
|
|
485
798
|
break;
|
|
486
799
|
case 'EINSTLOOKUP':
|
|
487
800
|
base = `实例名解析失败:SQL Server Browser 服务可能未运行或 UDP 1434 不可达(命名实例可改用「主机,端口」直连,如 host,1433)(${detail})`;
|
|
@@ -493,14 +806,21 @@ function mssqlReason(e) {
|
|
|
493
806
|
case 'ECANCEL':
|
|
494
807
|
base = `请求已取消(${detail})`;
|
|
495
808
|
break;
|
|
496
|
-
case 'EREQUEST':
|
|
497
|
-
base = `SQL 执行错误${num !== undefined ? `(错误号 ${num}${sev !== undefined ? `、严重级 ${sev}` : ''}${line !== undefined ? `、第 ${line} 行` : ''})` : ''}:${detail}`;
|
|
809
|
+
case 'EREQUEST': {
|
|
810
|
+
base = `SQL 执行错误${num !== undefined ? `(错误号 ${num}${sev !== undefined ? `、严重级 ${sev}` : ''}${line !== undefined ? `、第 ${line} 行` : ''})` : ''}:${knownHint === undefined ? detail : `${knownHint}。服务器原文:${detail}`}`;
|
|
498
811
|
break;
|
|
812
|
+
}
|
|
499
813
|
default:
|
|
500
814
|
base = detail;
|
|
501
815
|
}
|
|
502
816
|
const trail = [];
|
|
503
|
-
|
|
817
|
+
// 已知错误号已经给出了针对性诊断时,抑制 TLS 族建议:这类错误的服务器原文常含 certificate /
|
|
818
|
+
// encryption 字样(如 17819 的「TLS certificate is not configured to accept strict」),会被下面的
|
|
819
|
+
// 正则命中;但真因是服务端配置/协议问题,客户端装 CA、关校验一律无效,叠加只会给出互相矛盾的药方。
|
|
820
|
+
if (knownHint !== undefined) {
|
|
821
|
+
// 已由 EREQUEST_HINTS 给出单一出口,此处不再追加任何族建议
|
|
822
|
+
}
|
|
823
|
+
else if (tlsProtoish) {
|
|
504
824
|
// 顺序即优先级:服务端升级是正解(一次性、对所有客户端生效),客户端下探是权宜(仍受限于
|
|
505
825
|
// 服务端实际支持的版本)。第③条显式点名「跳过证书校验」是先发制人——用户看到 TLS 报错的第一
|
|
506
826
|
// 反应通常就是去信任证书,明说无效比沉默更省一轮排查;措辞必须是否定式,不得写成可尝试项。
|
|
@@ -509,7 +829,14 @@ function mssqlReason(e) {
|
|
|
509
829
|
trail.push('③ 注意:跳过证书校验(信任任意证书)对这类错误无效——它只影响证书校验阶段,而此处是协议版本协商失败,证书校验根本没执行');
|
|
510
830
|
}
|
|
511
831
|
else if (tlsCertish) {
|
|
512
|
-
|
|
832
|
+
// 证书族(自签/链断/主机名不符/已过期):解法有强弱两档,必须把官方推荐摆在前面。
|
|
833
|
+
// ① 装 CA(?caCert= 给 PEM 或文件路径)——校验照旧生效,只把签发者加进信任列表,这是官方推荐做法;
|
|
834
|
+
// ② strict(TDS 8.0)下 trustServerCertificate 完全失效:tedious 的 wrapWithTls 调 tls.connect
|
|
835
|
+
// 时没有传 rejectUnauthorized,证书是强制校验的。必须说清,否则用户在 strict 下改这个开关
|
|
836
|
+
// 会「改完还是同样的错」,白排查一轮。
|
|
837
|
+
trail.push('官方推荐先在客户端装 CA 而不是关校验:把签发服务器证书的根 CA(自签场景就是该证书本身)经 ?caCert= 指向 PEM 路径或直接给 PEM 文本,让证书校验照常生效;若证书签发给域名而你是用 IP/转发地址连接的,再用 ?serverName=域名 指定用于校验的名字');
|
|
838
|
+
trail.push('② 不得已才关校验:?trustServerCertificate=true(跳过证书链与主机名校验,仅加密信道不保证对端身份),或 ?encrypt=false(前提是服务器未强制加密)——SQL Server 2008/2008 R2 与使用自签证书的实例常见此情形,但这两条都会削弱安全性,仅建议内网临时使用');
|
|
839
|
+
trail.push('③ 注意:若当前用的是 ?encrypt=strict(TDS 8.0),trustServerCertificate 完全不生效(驱动 strict 路径不传 rejectUnauthorized,证书强制校验),只能装 CA 或改用 ?encrypt=true');
|
|
513
840
|
}
|
|
514
841
|
if (err.originalError !== undefined)
|
|
515
842
|
trail.push(`原始错误: ${errText(err.originalError)}`);
|
|
@@ -667,6 +994,7 @@ const PRODUCT_NAMES = {
|
|
|
667
994
|
14: '2017',
|
|
668
995
|
15: '2019',
|
|
669
996
|
16: '2022',
|
|
997
|
+
17: '2025',
|
|
670
998
|
};
|
|
671
999
|
/** 版本串展示:'Microsoft SQL Server 2019 - 15.0.2000.5'(Azure SQL 按 EngineEdition=5 单独标注) */
|
|
672
1000
|
function serverInfo(version, major, engine) {
|
package/dist/http/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as http from 'node:http';
|
|
19
19
|
import { DbToolError } from '../manager.js';
|
|
20
|
+
import { pluginVersion } from './version.js';
|
|
20
21
|
export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
|
|
21
22
|
/* ---------------- trust 校验(自包含,逻辑等价 dsh-ssh-tunnel http-trust.js) ---------------- */
|
|
22
23
|
export function isLoopbackHostname(hostname) {
|
|
@@ -343,6 +344,10 @@ async function route(req, res, service, path, q, opts) {
|
|
|
343
344
|
if (path === '/api/state' && req.method === 'GET') {
|
|
344
345
|
return sendOk(res, service.state(q.get('project') ?? undefined));
|
|
345
346
|
}
|
|
347
|
+
// 插件版本号:前端用来在面板上显示当前装的是哪一版(辅助信息,读不到时给 unknown 而非报错)
|
|
348
|
+
if (path === '/api/version' && req.method === 'GET') {
|
|
349
|
+
return sendOk(res, { version: pluginVersion() });
|
|
350
|
+
}
|
|
346
351
|
send(res, 404, { ok: false, error: `未知路由: ${req.method} ${path}`, code: 'NOT_FOUND' });
|
|
347
352
|
}
|
|
348
353
|
export class DbToolHttpServer {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自身版本号(用于前端展示,让用户一眼看到当前装的是哪一版)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要从 package.json 读而不是写常量:发版时版本号已经在 package.json/lock/CHANGELOG
|
|
5
|
+
* 三处同步,再维护一份前端常量必然漂移(最坏情况是界面显示 1.9.2 而实际装的是 1.9.1,
|
|
6
|
+
* 用户报障时会给出错误版本,排障直接走偏)。
|
|
7
|
+
*
|
|
8
|
+
* 读取失败(打包形态差异、文件缺失)时返回 'unknown' 而不是抛错:版本展示是辅助信息,
|
|
9
|
+
* 绝不能因为它让整个 API 或面板失败。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
let cached;
|
|
15
|
+
/**
|
|
16
|
+
* 相对本模块定位包根 package.json:lib/http/version.ts 与 dist/http/version.js 都位于包根下
|
|
17
|
+
* 两级,故 '../../package.json' 对源码与构建产物同时成立(实测 dist/http 上溯两级可命中)。
|
|
18
|
+
*/
|
|
19
|
+
export function pluginVersion() {
|
|
20
|
+
if (cached !== undefined)
|
|
21
|
+
return cached;
|
|
22
|
+
try {
|
|
23
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const pkg = path.resolve(here, '../../package.json');
|
|
25
|
+
const raw = JSON.parse(readFileSync(pkg, 'utf8'));
|
|
26
|
+
const v = typeof raw.version === 'string' ? raw.version.trim() : '';
|
|
27
|
+
cached = v === '' ? 'unknown' : v;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
cached = 'unknown';
|
|
31
|
+
}
|
|
32
|
+
return cached;
|
|
33
|
+
}
|
|
@@ -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, tlsMinVersion } = rec.fields;
|
|
166
|
+
const { host, port, user, database, tlsMinVersion, encrypt, trustServerCertificate, serverName, caCert } = rec.fields;
|
|
167
167
|
if (typeof host === 'string')
|
|
168
168
|
meta.host = host;
|
|
169
169
|
if (typeof port === 'number')
|
|
@@ -174,6 +174,15 @@ function toMeta(rec, hasPassword = false) {
|
|
|
174
174
|
meta.database = database;
|
|
175
175
|
if (typeof tlsMinVersion === 'string')
|
|
176
176
|
meta.tlsMinVersion = tlsMinVersion;
|
|
177
|
+
// encrypt 三态:布尔按 boolean 透传,'strict' 按字面量透传(两者语义不同,不能互相折叠)
|
|
178
|
+
if (typeof encrypt === 'boolean' || encrypt === 'strict')
|
|
179
|
+
meta.encrypt = encrypt;
|
|
180
|
+
if (typeof trustServerCertificate === 'boolean')
|
|
181
|
+
meta.trustServerCertificate = trustServerCertificate;
|
|
182
|
+
if (typeof serverName === 'string')
|
|
183
|
+
meta.serverName = serverName;
|
|
184
|
+
if (typeof caCert === 'string')
|
|
185
|
+
meta.caCert = caCert;
|
|
177
186
|
}
|
|
178
187
|
return meta;
|
|
179
188
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-db-tool",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -59,6 +59,9 @@
|
|
|
59
59
|
"optionalDependencies": {
|
|
60
60
|
"better-sqlite3": "^13.0.3"
|
|
61
61
|
},
|
|
62
|
+
"overrides": {
|
|
63
|
+
"tedious": "^20.3.8"
|
|
64
|
+
},
|
|
62
65
|
"devDependencies": {
|
|
63
66
|
"@codemirror/autocomplete": "^6.20.3",
|
|
64
67
|
"@codemirror/commands": "^6.11.1",
|
|
@@ -76,6 +79,7 @@
|
|
|
76
79
|
"mongodb": "^7.6.0",
|
|
77
80
|
"mssql": "12.7.4",
|
|
78
81
|
"semver": "^7.7.0",
|
|
82
|
+
"tedious": "^20.3.8",
|
|
79
83
|
"sql-formatter": "^15.9.0",
|
|
80
84
|
"stryker-cli": "^1.1.0",
|
|
81
85
|
"typescript": "^5.9.3",
|
package/skills/db-admin/SKILL.md
CHANGED
|
@@ -180,20 +180,35 @@ DatabaseManager({ action: "run_script", conn_id: "mongo1", code: '{"find":"users
|
|
|
180
180
|
|
|
181
181
|
## SQL Server 覆盖范围与边界
|
|
182
182
|
|
|
183
|
-
- 覆盖 SQL Server
|
|
184
|
-
-
|
|
185
|
-
-
|
|
186
|
-
-
|
|
183
|
+
- 覆盖 SQL Server 2005(9.0)→ 2025(17.0)与 Azure SQL Database;不支持 SQL Server 2000 及更早。方言与元数据只使用 2005+ 通用写法(元数据走 `sys.*` catalog views)。
|
|
184
|
+
- **TDS 版本自动协商,无需用户选择**:客户端固定声明 `7_4`,服务器按自身能力自动降级(2005→`7_2`、2008→`7_3_A`、2008 R2→`7_3_B`、2012+→`7_4`)。这是 [MS-TDS](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac) 的 MUST 级规定(客户端声明高于服务器时,服务器**必须**用自己最高版本)。`tdsVersion` 仅作排障覆盖项;**不要**因「老版本」就下调它——那会把「连不上」变成「连上了但行为异常」。
|
|
185
|
+
- **认证**:仅 SQL 认证(user/password);**不支持 Windows 集成认证,也不支持 Azure AD 认证**——需要这两类认证时本工具不可用。注意:本工具**未实现** `IsIntegratedSecurityOnly` 探测,遇到「实例仅允许 Windows 认证」时只会表现为登录失败,不要声称能自动识别该状态。
|
|
186
|
+
- **连接形态**:`sqlserver://user:password@host:port/db`,支持 query 参数 `encrypt` / `trustServerCertificate` / `tdsVersion` / `tlsMinVersion` / `serverName` / `caCert` / `instanceName` / `useUTC`(参数名大小写不敏感);`instanceName` 与端口互斥(命名实例改由 SQL Server Browser 解析)。**不接受** ADO.NET 键值连接串(`Server=...;Database=...`)。
|
|
187
|
+
- **默认与覆盖**:`encrypt=true` + `trustServerCertificate=false` + `tdsVersion=7_4` + `useUTC=true`(同 mssql 官方默认,也是微软官方推荐的安全档)。URL query 与分字段表单**同口径**,两处都能覆盖上述各项;非法取值直接报错,不静默回退。
|
|
187
188
|
|
|
188
189
|
### TLS 握手失败:两类根因,解法互不通用
|
|
189
190
|
|
|
190
191
|
| 报错特征 | 根因 | 处置(按优先级) |
|
|
191
192
|
|---|---|---|
|
|
192
|
-
| `self signed certificate` / `unable to verify` / `CERT_` 家族 | 证书校验阶段失败(自签、链断、主机名不符、已过期) | ① `?trustServerCertificate=true
|
|
193
|
-
| `UNSUPPORTED_PROTOCOL` / `no protocols available` / `alert protocol version`(含 `SSL routines ... UNSUPPORTED`) | 协议版本协商阶段失败:客户端与服务端无共同 TLS 版本,**证书校验根本没执行** | ① 服务端启用 TLS 1.2(2008 R2 SP3 装 KB4057113、2008 SP4 装 KB4057114,重启实例);② 暂时不能动服务端时,客户端下探 `?tlsMinVersion=TLSv1` |
|
|
193
|
+
| `self signed certificate` / `unable to verify` / `CERT_` 家族 | 证书校验阶段失败(自签、链断、主机名不符、已过期) | ① `?caCert=` 装受信 CA(PEM 路径或文本,校验照常生效,**官方推荐**);证书签给域名而在用 IP 连接时再加 `?serverName=域名`;② 不得已才 `?trustServerCertificate=true` 或 `?encrypt=false`(后者需服务器未强制加密) |
|
|
194
|
+
| `UNSUPPORTED_PROTOCOL` / `no protocols available` / `alert protocol version`(含 `SSL routines ... UNSUPPORTED`) | 协议版本协商阶段失败:客户端与服务端无共同 TLS 版本,**证书校验根本没执行** | ① 服务端启用 TLS 1.2(2008 R2 SP3 装 KB4057113、2008 SP4 装 KB4057114、2012/2014 装对应 CU/GDR 与 KB3052404,重启实例);② 暂时不能动服务端时,客户端下探 `?tlsMinVersion=TLSv1` |
|
|
194
195
|
|
|
195
196
|
**不要拿证书类参数治协议版本类**:`trustServerCertificate=true` / `encrypt=false` 只影响证书校验,对 `UNSUPPORTED_PROTOCOL` 没有任何作用,试错只会白跑一轮。两类判据互斥,报错消息里已按族给出中文根因;看到 `TCP 已连通但 TLS ...` 说明网络与端口都没问题,不要再查主机/防火墙。
|
|
196
197
|
|
|
198
|
+
**关键事实:`encrypt=false` 的真实行为(已抓包实测)**。服务器**未**强制加密时,`encrypt=false` 会**完全跳过 TLS 握手并明文发送 LOGIN7(含账号密码)**——实测客户端下一个包就是 TDS `0x10`,没有 TLS 握手。这是明文传输风险,不是「只是不加密数据包」。服务器**强制**加密时(`Force Encryption=Yes` / Azure SQL),`encrypt=false` 会被拒(提示服务器要求加密)。官方那句「登录包始终加密」仅适用于「服务器强制加密或客户端 `encrypt=true`」的场景,**不可推广成 `encrypt=false` 也总有 TLS 握手**。故默认 `encrypt=true` 才是安全档。另注:连 2005 或未打补丁的 2008 时服务端可能只有 TLS 1.0,需下探 `?tlsMinVersion=TLSv1`。
|
|
199
|
+
|
|
200
|
+
### 版本与 TLS 1.2 支持
|
|
201
|
+
|
|
202
|
+
| 版本 | TLS 1.2 | 所需补丁 |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| 2005(9.0) | **不支持**(无任何补丁) | 无解,只能下探客户端 TLS 下限 + 信任证书 |
|
|
205
|
+
| 2008 SP4 / 2008 R2 SP3 | 需补丁 | KB4057114 / KB4057113 |
|
|
206
|
+
| 2012 SP4- / 2014 SP3- | 需补丁 | 对应 CU/GDR + KB3052404 |
|
|
207
|
+
| 2012 SP4 / 2014 SP2 / 2016+ | 原生支持 | — |
|
|
208
|
+
| 2022 / 2025 | 原生;TDS 8.0 下可 TLS 1.3 | — |
|
|
209
|
+
|
|
210
|
+
2016+ 实际使用哪个 TLS 版本由**操作系统的 Schannel** 决定,不由 SQL Server 决定——同一版本实例在不同机器上表现可能不同。
|
|
211
|
+
|
|
197
212
|
### `tlsMinVersion` 参数
|
|
198
213
|
|
|
199
214
|
| 取值 | 含义 |
|