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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog — dsh-db-tool
2
2
 
3
+ ## 1.9.3
4
+
5
+ SQL Server 支持范围扩到 2005(9.0)→ 2025(17.0),修复前端无法配置加密参数导致的「怎么改都连不上」,并在面板上加插件版本号标签。
6
+
7
+ ### 新增
8
+
9
+ - **项目行最右侧显示插件版本号**:以 `v1.9.3` 胶囊标签钉在项目路径行行尾,只读态与编辑态共用同一枚标签(进入编辑不会消失)。版本号由新增的 `GET /api/version` 从 `package.json` 读取下发,而非前端写常量——发版时版本号已在三处同步,再维护一份前端常量必然漂移(最坏情况是界面显示 1.9.3 而实际装的是旧版,用户报障时给出错误版本)。读不到时返回 `unknown`、前端不渲染胶囊且不报错:版本展示属辅助信息,不能污染面板错误条,也不该变成后台反复失败请求。
10
+
11
+ ### 修复
12
+
13
+ - **登录阶段错误分诊原本完全失效(代码审查发现,已修)**:8 个错误号(17819/17898/17802/17835/17836/17828/17832/47072)此前只按 `err.number` 查表,但这些错误**全部发生在建连/登录阶段**,而 tedious 的 `Login7TokenHandler.onErrorMessage` 构造的是 `new ConnectionError(token.message, 'ELOGIN')`——`token.number` 只用于判断临时错误,**没有赋给 error 对象**(只有查询阶段的 `RequestError` 才有 number)。真实链路上 `number` 恒为 `undefined`,于是诊断全部退化成「用户名或密码错误」:17819(服务器只接受 strict 加密)会被误报成密码错,17835(服务器强制加密)不会提示开加密,反而叠加无效的「装 CA」建议。现补 `hintByText()`:`number` 缺失时按服务器原文特征反查(特征取自官方 `sys.messages` 最独特片段,宁可漏匹配退回通用文案,也不错匹配给错药方),并让 `ELOGIN` 分支在命中特征时优先给针对性诊断而非「用户名或密码错误」。
14
+ - **`caCert` 曾把信任范围从 137 张缩到 37 张(代码审查发现,已修)**:`applyCaCert` 原先以 `systemCa()` 打底、不含内置 bundle。而 Node 一旦收到 `ca` 选项就不再使用默认信任库,于是「为连 A 实例装一张自签 CA」会导致**所有走公网 CA 的实例全部连不上**(`SELF_SIGNED_CERT_IN_CHAIN`)——恰是 `applySystemCaDefault` 注释里警告过的失败模式。现三处统一走 `mergedCaWith()`:内置 bundle + 系统信任库 + 用户 CA 去重合并,并加模块级缓存(避免每次建连重复读证书与重复构造 SecureContext)。
15
+ - **单行 PEM 会静默失效(代码审查发现,已修)**:前端 `caCert` 是单行 `<input>`,粘贴多行 PEM 时换行被压平;而 `tls.createSecureContext({ca:[单行PEM]})` **不报错**(实测静默接受),握手时才失败并报 `DEPTH_ZERO_SELF_SIGNED_CERT`——用户看到的是"填了 CA 还是同样的错"。现 `normalizePem()` 自动按 64 列折行还原(已验证折行结果与原 PEM 内容等价且可被 `createSecureContext` 接受),残缺 PEM(有 BEGIN 无 END)则 fail-closed 报错而非静默接受。
16
+ - **`serverName` 未 trim**:`?serverName=%20`(一个空格)会被当成有效值注入,现与其它参数同口径先 trim 再判空。
17
+
18
+
19
+ - **前端补齐加密参数入口(用户可见的核心修复)**:分字段表单此前只暴露 `tlsMinVersion`,没有任何 `encrypt` / `trustServerCertificate` 入口——而 `tlsMinVersion` 恰好对「证书类」错误无效。用户连自签证书实例时唯一能动的开关是错的那个,于是陷入「换个 TLS 版本再试、还是同一个错」的死循环。现补齐三项控件:`encrypt`(`默认`/`强制加密`/`不主动加密`/`严格加密 TDS 8.0`)、`trustServerCertificate` 复选框、`serverName` 与 `caCert` 输入框,并在表单内直接给出「证书类 vs 协议版本类」的分流提示。
20
+ - **`tdsVersion` 从表单移除**:TDS 版本在协议层本就自动降级([MS-TDS](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac) 规定客户端声明高于服务器时服务器**必须**使用自身最高版本;微软官方 Go 驱动同样硬编码声明 7.4 且宣称支持 2005+)。把它摆在表单里等于把驱动已处理好的问题转嫁给用户。该参数仍保留为排障覆盖项。
21
+ - **编辑回填补全**:`ConnectionMeta` / `toMeta()` 增加 `encrypt` / `trustServerCertificate` / `serverName` / `caCert` 回填。此前这些字段即使存进 `connections.json` 也读不回来,编辑表单显示为空、保存即被静默清掉。`encrypt` 的三态(`true` / `false` / `'strict'`)按原语义透传,非法值不回填(fail-closed)。
22
+
23
+ ### 安全
24
+
25
+ - **`tedious` 20.3.3 → 20.3.8(GHSA-wj7m-ffj3-574h,高危)**:旧版本在 `encrypt=true` 且服务器 PRELOGIN 回 `OFF`/`NOT_SUP` 或缺失 `ENCRYPTION` 选项时,**不报错、不告警,直接以明文发送 LOGIN7(含账号密码)**,而应用侧仍以为连接已加密。`trustServerCertificate=false` 对此无缓解作用——根本没有 TLS 握手,证书校验无从发生。已在源码层核实:旧逻辑只在 `ON`/`REQ` 时启动 TLS,其余分支静默落空;20.3.8 改为 `encrypt===true` 且非 `ON`/`REQ` 时抛 `EENCRYPT`。`mssql@12.7.4` 的依赖范围(`^19.2.2 || ^20.0.0`)本就允许该版本,故无需改 `mssql`;同时新增 `overrides.tedious` 钉住下限防止 lock 回退。注意 `npm audit` 当前**不报**此条,不能靠它兜底。
26
+ - **参数校验 fail-closed**:`encrypt` 只接受 `true`/`false`/`strict`,拼错(如 `stric`)直接抛中文错误,不静默回退到 `true`——加密强度是安全承诺。`caCert` 路径读不到时抛错而非忽略:静默忽略会退回系统信任库校验,把真正原因掩盖掉。
27
+
28
+ ### 新增
29
+
30
+ - **`encrypt=strict`(TDS 8.0)**:支持 SQL Server 2022+ 的 Force Strict Encryption。此前该场景**必然连不上**且报错会被归到证书族,误导排查。需注意 strict 路径下驱动不传 `rejectUnauthorized`,**`trustServerCertificate` 完全失效**,证书强制校验——报错文案已明确这一点。
31
+ - **`serverName`**:证书签发给域名而实际用 IP / 转发地址连接时指定校验名,把该场景从「关掉证书校验」里分流出来。
32
+ - **`caCert`**:传 PEM 文件路径或证书文本,走官方推荐解法(保住加密与校验,只扩大信任根)。
33
+ - **系统信任库默认注入**:Node 默认只信任编译期内置的 bundle,**不读操作系统信任库**——这意味着「按官方推荐把自签证书导入 Windows 受信任根」这个最常见的正确做法,在不注入的情况下对连接毫无帮助(用户会以为"导入了却没用")。现无论是否填 `caCert`,都把 `tls.getCACertificates('default')` 与 `('system')` 合并后注入校验(实测:内置 118 张、系统库去重后 36 张、两者重叠 17 张,合并去重后 137 张;系统库独有 19 张已确认包含)。三条降级边界:① 无该 API 的老环境(Node < 22.15)不注入,保持原生默认,公网证书照常可用;② 有 API 但系统库为空时只注入 bundle,绝不把 `ca` 设成空数组(那会让校验彻底失败);③ 取证书异常全部吞掉,绝不用"取系统证书失败"打断建连。显式给了 `caCert` 时不覆盖用户选择。已在 DSH 实际运行环境(Electron 44.7 / Node 24.21)验证。
34
+ - **8 个服务器错误号的针对性分诊**:`17819`(只接受 strict → 改 `encrypt=strict`)、`17898`/`17802`(TDS 版本被拒 → 升级客户端库,**不**建议下调 `tdsVersion`)、`17835`(服务器强制加密 → 开 `encrypt`)、`17836`/`17828`/`17832`(包结构错配 → 查代理/中间层/端口,**不**归为版本问题)、`47072`(Azure 最低 TLS 门槛)。这些错误的服务器原文常含 `certificate`/`encryption` 字样,会被 TLS 族正则误命中并叠加「装 CA / 关校验」这类**无效**建议,故在命中已知错误号时抑制 TLS 族建议,保证每次只给出一个正确出口。
35
+ - **`EENCRYPT` 区分两种相反成因**:一是 `encrypt=false` 而服务器强制加密(药方是开),二是 `encrypt=true` 而服务器未协商加密(药方是关或修服务端),此前合并为一句必然把用户引反方向。
36
+
37
+ ### 文档
38
+
39
+ - README / README_EN:覆盖范围改为 2005 → 2025;补充版本 → TLS 1.2 → 所需补丁对照;写明官方推荐顺序(装 CA 优先于关校验);写明 `encrypt=false` 的真实行为——服务器未强制加密时**会跳过 TLS 握手并明文发送 LOGIN7**(已抓包实测),澄清「登录包始终加密」只适用于服务器强制加密或 `encrypt=true` 的场景;删除「不支持 TDS 8.0 / strict」的虚标;修正「连 2008 需显式 `tdsVersion=7_3_A`」这一无官方依据的经验说法。
40
+
41
+ ### 行为变更(升级须知)
42
+
43
+ - **`encrypt=true` 连不支持加密的服务器现在会显式失败**(`EENCRYPT`),而不是静默明文传输。这是上述安全修复的本意:这类实例请显式设 `encrypt=false`。
44
+ - 若某实例确实只支持旧 TLS(如 2005 / 未打补丁的 2008),仍会失败——但报错现在指向「服务端补丁缺失」与「客户端下探 TLS 下限」两条正确路径。
45
+ - **`poolConfig` 现在总会写入 `cryptoCredentialsDetails.ca`**(内置 bundle + 系统信任库合并),不再「未配置 TLS 选项就保持 options 形状」。这是为了让「按官方推荐把自签证书导入系统受信任根」真正生效(Node 默认不读系统信任库)。老环境(无 `tls.getCACertificates`)仍不注入、保持原生默认,公网证书不受影响。
46
+
3
47
  ## 1.9.1
4
48
 
5
49
  依赖安全与维护性升级(无代码改动,仅 `package-lock.json`)。
package/README.md CHANGED
@@ -145,27 +145,45 @@ Navicat 风格的右侧栏多标签编辑器(CodeMirror 6):
145
145
 
146
146
  ## SQL Server 连接说明
147
147
 
148
- 覆盖 SQL Server 2008 / 2008 R2 → 2022 与 Azure SQL Database。
148
+ 覆盖 SQL Server 2005(9.0)→ 2025(17.0)与 Azure SQL Database。
149
149
 
150
150
  **连接 URL 参数**(分字段表单同样支持):
151
151
 
152
152
  | 参数 | 取值 | 默认 |
153
153
  |---|---|---|
154
- | `encrypt` | `true` / `false` | `true` |
154
+ | `encrypt` | `true` / `false` / `strict` | `true` |
155
155
  | `trustServerCertificate` | `true` / `false` | `false` |
156
- | `tdsVersion` | `7_4` / `7_3_A` / `7_3_B` / `7_2` / `7_1` | `7_4` |
156
+ | `tdsVersion` | `7_4` / `7_3_A` / `7_3_B` / `7_2` / `7_1` | `7_4`(服务器自动降级,通常无需设置) |
157
157
  | `tlsMinVersion` | `TLSv1` / `TLSv1.1` / `TLSv1.2` | 不设置(Node 默认 TLSv1.2) |
158
+ | `serverName` | 证书校验用的服务器名 | 不设置(用连接主机名校验) |
159
+ | `caCert` | PEM 文件路径或证书文本 | 不设置(用系统信任库) |
158
160
  | `instanceName` | 命名实例名(与 port 互斥) | — |
159
161
  | `useUTC` | `true` / `false` | `true` |
160
162
 
161
- **连老实例时先分清两类 TLS 失败,解法不通用**:
163
+ **TDS 版本不需要手动选**:客户端固定声明 `7.4`,服务器会按自身能力自动降级(2005→7.2、2008→7.3.A、2008 R2→7.3.B)。这是 [MS-TDS](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac) 规定的行为(客户端声明高于服务器时,服务器**必须**使用自己最高的版本)。`tdsVersion` 仅作排障覆盖项保留。
162
164
 
163
- - **证书类**(`self signed certificate` / `unable to verify` / `CERT_`)→ 追加 `?trustServerCertificate=true`;服务器未强制加密时也可 `?encrypt=false`
164
- - **协议版本类**(`UNSUPPORTED_PROTOCOL` / `no protocols available` / `alert protocol version`)→ 客户端与服务端没有共同的 TLS 版本,握手在协商阶段就失败,**证书校验根本没执行**,`trustServerCertificate` 对它无效。根治靠服务端启用 TLS 1.2(2008 R2 SP3 装 KB4057113、2008 SP4 装 KB4057114);暂时动不了服务端时用 `?tlsMinVersion=TLSv1` 让客户端下探
165
+ **连老实例时先分清两类 TLS 失败,解法正交、不可互相替代**:
166
+
167
+ - **证书类**(`self signed certificate` / `unable to verify` / `CERT_`)→ 证书校验阶段失败。① 推荐用 `?caCert=` 安装受信 CA(校验照常生效);证书签发给域名而你在用 IP 连接时,再加 `?serverName=域名`。② 不得已才用 `?trustServerCertificate=true` 跳过校验,或 `?encrypt=false`(前提是服务器未强制加密)。
168
+ - **协议版本类**(`UNSUPPORTED_PROTOCOL` / `no protocols available` / `alert protocol version`)→ 客户端与服务端没有共同的 TLS 版本,握手在协商阶段就失败,**证书校验根本没执行**,`trustServerCertificate` 对它无效。根治靠服务端启用 TLS 1.2(2008 R2 SP3 装 KB4057113、2008 SP4 装 KB4057114、2012/2014 装对应 CU/GDR 与 KB3052404);暂时动不了服务端时用 `?tlsMinVersion=TLSv1` 让客户端下探。
169
+
170
+ 各版本对 TLS 1.2 的支持:2016+ 原生支持;2012 SP4 / 2014 SP2 及以后原生支持,更早的 SP 需打补丁;2008 / 2008 R2 需上述补丁;**SQL Server 2005 无任何补丁可支持 TLS 1.2**(官方明确「TLS 1.2 只支持 SQL Server 2008 及以后」),只能下探客户端 TLS 下限。详见 [KB3135244](https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/connect/tls-1-2-support-microsoft-sql-server)。
171
+
172
+ **`encrypt=false` 的真实语义(实测确认,勿误解为「只是不加密」)**:
173
+
174
+ - 服务器**未**强制加密(PRELOGIN 回 `OFF`/`NOT_SUP`)时,`encrypt=false` 会**完全跳过 TLS 握手,直接明文发送 LOGIN7(含账号密码)**。已用假 TDS 服务器抓包实测:客户端下一个包就是 `0x10`(LOGIN7),没有 TLS 握手。**这是明文传输,切勿在内网之外使用。**
175
+ - 服务器**强制**加密(`Force Encryption=Yes` 或 Azure SQL)时,`encrypt=false` 会被拒绝(提示「服务器要求加密连接」)——此时仍必须开加密。
176
+ - 微软文档中「登录包始终加密」指的是**服务器强制加密或客户端 `encrypt=true` 时**的语义,不能推广成「`encrypt=false` 也总有 TLS 握手」。
177
+
178
+ 因此默认 `encrypt=true` 才是安全档;`encrypt=false` 应视为明确的降级选择。
179
+
180
+ **`encrypt=strict`(TDS 8.0)**:仅 SQL Server 2022+ 且开启 Force Strict Encryption 的实例支持。该模式下 TLS 在登录包之前就完成握手,因此 **`trustServerCertificate` 完全不生效**(驱动不传 `rejectUnauthorized`,证书强制校验),只能靠 `caCert` 或 `serverName`。
165
181
 
166
182
  `tlsMinVersion` 不含 `TLSv1.3`:底层 tedious 会把 TLS 上限封顶在 1.2,设 1.3 会让最小版本大于最大版本,握手必然失败。
167
183
 
168
- **其他边界**:仅 SQL 认证(不支持 Windows 集成认证与 Azure AD 认证);不支持 SQL Server 2005 及更早;`tdsVersion=8_0`(TDS 8.0)与 `encrypt='strict'` 暂未支持。
184
+ **系统信任库**:插件会把操作系统信任库并入证书校验(`tls.getCACertificates('system')`,需 Node ≥ 22.15;DSH 桌面端内置 Node 24.21 满足)。因此按官方推荐把自签证书导入 Windows「受信任的根证书颁发机构」后,**无需填 `caCert` 即可连接**。注入的是「内置 bundle + 系统信任库」合并列表(实测 118 + 72 = 190 张),合并而非替换,避免只给系统库导致连公网证书反而失败。老环境没有该 API 时不注入,保持 Node 原生默认校验(公网证书照常可用)。
185
+
186
+ **其他边界**:仅 SQL 认证(不支持 Windows 集成认证与 Azure AD 认证);不支持 SQL Server 2000 及更早;不支持 `tdsVersion=8_0`(TDS 8.0 通过 `encrypt=strict` 启用)。
169
187
 
170
188
  ---
171
189
 
@@ -190,7 +208,7 @@ DSH 桌面端 0.2.0-rc.2+ 已捆绑 `dsh` 命令:
190
208
  2. 安装插件(npm 安装须钉精确版本,`@latest` 会因 release-age 校验回落):
191
209
 
192
210
  ```bash
193
- dsh plugin --profile desktop add dsh-db-tool@1.9.1 --registry=https://registry.npmjs.org/
211
+ dsh plugin --profile desktop add dsh-db-tool@1.9.3 --registry=https://registry.npmjs.org/
194
212
  ```
195
213
 
196
214
  3. **重启桌面端**生效
package/README_EN.md CHANGED
@@ -145,27 +145,45 @@ Both are contract behaviours of the official right sidebar and are easy to mista
145
145
 
146
146
  ## SQL Server connection notes
147
147
 
148
- Covers SQL Server 2008 / 2008 R2 through 2022 and Azure SQL Database.
148
+ Covers SQL Server 2005 (9.0) through 2025 (17.0) and Azure SQL Database.
149
149
 
150
150
  **Connection URL parameters** (also supported by the field-by-field form):
151
151
 
152
152
  | Parameter | Values | Default |
153
153
  |---|---|---|
154
- | `encrypt` | `true` / `false` | `true` |
154
+ | `encrypt` | `true` / `false` / `strict` | `true` |
155
155
  | `trustServerCertificate` | `true` / `false` | `false` |
156
- | `tdsVersion` | `7_4` / `7_3_A` / `7_3_B` / `7_2` / `7_1` | `7_4` |
156
+ | `tdsVersion` | `7_4` / `7_3_A` / `7_3_B` / `7_2` / `7_1` | `7_4` (negotiated down automatically; normally leave unset) |
157
157
  | `tlsMinVersion` | `TLSv1` / `TLSv1.1` / `TLSv1.2` | unset (Node default TLSv1.2) |
158
+ | `serverName` | Name to validate against the server certificate | unset (validate against the connected host) |
159
+ | `caCert` | PEM file path or certificate text | unset (use the system trust store) |
158
160
  | `instanceName` | Named instance (mutually exclusive with port) | — |
159
161
  | `useUTC` | `true` / `false` | `true` |
160
162
 
161
- **When connecting to older instances, tell the two TLS failure families apart — their remedies are not interchangeable**:
163
+ **You do not need to pick a TDS version**: the client always declares `7.4` and the server negotiates down to what it supports (2005→7.2, 2008→7.3.A, 2008 R2→7.3.B). This is mandated by [MS-TDS](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac) (when the client declares a higher version, the server **must** use the highest version it supports). `tdsVersion` remains only as a troubleshooting override.
162
164
 
163
- - **Certificate family** (`self signed certificate` / `unable to verify` / `CERT_`) → append `?trustServerCertificate=true`; use `?encrypt=false` when the server does not force encryption
164
- - **Protocol-version family** (`UNSUPPORTED_PROTOCOL` / `no protocols available` / `alert protocol version`) → client and server share no common TLS version, so the handshake fails during negotiation and **certificate validation never runs**; `trustServerCertificate` is useless here. The real fix is enabling TLS 1.2 on the server (KB4057113 for 2008 R2 SP3, KB4057114 for 2008 SP4); when the server cannot be touched, lower the client floor with `?tlsMinVersion=TLSv1`
165
+ **When connecting to older instances, tell the two TLS failure families apart — their remedies are orthogonal and not interchangeable**:
166
+
167
+ - **Certificate family** (`self signed certificate` / `unable to verify` / `CERT_`) → fails during certificate validation. ① Prefer installing the trusted CA via `?caCert=` (validation still applies); if the certificate names a host but you connect by IP, also set `?serverName=`. ② Only as a last resort use `?trustServerCertificate=true` to skip validation, or `?encrypt=false` when the server does not force encryption.
168
+ - **Protocol-version family** (`UNSUPPORTED_PROTOCOL` / `no protocols available` / `alert protocol version`) → client and server share no common TLS version, so the handshake fails during negotiation and **certificate validation never runs**; `trustServerCertificate` is useless here. The real fix is enabling TLS 1.2 on the server (KB4057113 for 2008 R2 SP3, KB4057114 for 2008 SP4, corresponding CU/GDR plus KB3052404 for 2012/2014); when the server cannot be touched, lower the client floor with `?tlsMinVersion=TLSv1`.
169
+
170
+ TLS 1.2 support by version: 2016+ has it natively; 2012 SP4 / 2014 SP2 and later have it natively, earlier service packs need patches; 2008 / 2008 R2 need the updates above; **SQL Server 2005 has no patch that adds TLS 1.2** (officially "TLS 1.2 support is offered only for SQL Server 2008 and later"), so lowering the client TLS floor is the only option. See [KB3135244](https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/connect/tls-1-2-support-microsoft-sql-server).
171
+
172
+ **What `encrypt=false` really does (verified by packet capture — do not read it as "just unencrypted data")**:
173
+
174
+ - When the server does **not** force encryption (PRELOGIN replies `OFF`/`NOT_SUP`), `encrypt=false` **skips the TLS handshake entirely and sends LOGIN7 — including the credentials — in cleartext**. Measured against a fake TDS server: the client's next packet is `0x10` (LOGIN7) with no handshake at all. **This is plaintext transmission; never use it outside a trusted network.**
175
+ - When the server **does** force encryption (`Force Encryption=Yes`, Azure SQL), `encrypt=false` is rejected ("server requires encryption"), so encryption is still mandatory there.
176
+ - Microsoft's "the login packet is always encrypted" statement applies **only when the server forces encryption or the client sets `encrypt=true`**; it must not be generalised into "`encrypt=false` still performs a TLS handshake".
177
+
178
+ The default `encrypt=true` is therefore the secure setting; treat `encrypt=false` as an explicit downgrade.
179
+
180
+ **`encrypt=strict` (TDS 8.0)**: only supported by SQL Server 2022+ instances with Force Strict Encryption enabled. In this mode TLS completes before the login packet, so **`trustServerCertificate` has no effect at all** (the driver omits `rejectUnauthorized` and validation is mandatory); use `caCert` or `serverName` instead.
165
181
 
166
182
  `tlsMinVersion` does not include `TLSv1.3`: the underlying tedious driver caps the TLS ceiling at 1.2, so setting 1.3 makes the minimum exceed the maximum and the handshake always fails.
167
183
 
168
- **Other boundaries**: SQL authentication only (Windows integrated authentication and Azure AD are not supported); SQL Server 2005 and earlier are not supported; `tdsVersion=8_0` (TDS 8.0) and `encrypt='strict'` are not yet supported.
184
+ **System trust store**: the plugin merges the OS trust store into certificate validation (`tls.getCACertificates('system')`, requires Node ≥ 22.15; the DSH desktop ships Node 24.21). Importing a self-signed certificate into the Windows "Trusted Root Certification Authorities" store is therefore enough — **no `caCert` needed**. The injected list is bundled + system merged (measured 118 + 72 = 190); merging rather than replacing avoids breaking public-CA connections. On older runtimes without that API nothing is injected and Node's native default applies.
185
+
186
+ **Other boundaries**: SQL authentication only (Windows integrated authentication and Azure AD are not supported); SQL Server 2000 and earlier are not supported; `tdsVersion=8_0` is not supported (TDS 8.0 is enabled via `encrypt=strict`).
169
187
 
170
188
  ---
171
189
 
@@ -190,7 +208,7 @@ DSH desktop 0.2.0-rc.2+ bundles the `dsh` command:
190
208
  2. Install the plugin (npm installs must pin an exact version — `@latest` falls back to an older release due to release-age checks):
191
209
 
192
210
  ```bash
193
- dsh plugin --profile desktop add dsh-db-tool@1.9.1 --registry=https://registry.npmjs.org/
211
+ dsh plugin --profile desktop add dsh-db-tool@1.9.3 --registry=https://registry.npmjs.org/
194
212
  ```
195
213
 
196
214
  3. **Restart the desktop app** to load it
package/client/client.js CHANGED
@@ -23,6 +23,7 @@ window.__ModuleLoader__.load({
23
23
  tabDesc: "多库连接管理、项目授权、数据浏览与 SQL 控制台",
24
24
  tabUnavailable: "页签尚未就绪,请切换页签后重试",
25
25
  projectLabel: "项目: {path}",
26
+ pluginVersion: "插件版本",
26
27
  projectUnbound: "未识别当前项目路径,请在下方填写工作区绝对路径",
27
28
  projectPathPlaceholder: "工作区绝对路径(如 E:\\Code\\my-app)",
28
29
  viewManage: "连接管理",
@@ -115,8 +116,17 @@ window.__ModuleLoader__.load({
115
116
  fieldsPassword: "密码",
116
117
  passwordSaved: "已保存(留空保持不变)",
117
118
  fieldsDatabase: "数据库名",
118
- fieldsTlsMinVersion: "最低 TLS 版本(连 SQL Server 2008/2008R2 报 UNSUPPORTED_PROTOCOL 时选 TLSv1)",
119
+ fieldsTlsMinVersion: "最低 TLS 版本(连老实例报协议族错误时才需要下探)",
119
120
  tlsMinDefault: "默认(TLSv1.2)",
121
+ fieldsEncrypt: "加密",
122
+ encryptDefault: "默认(请求加密)",
123
+ encryptOn: "强制加密(encrypt=true)",
124
+ encryptOff: "不主动加密(encrypt=false)",
125
+ encryptStrict: "严格加密 TDS 8.0(strict)",
126
+ fieldsTrustCert: "信任服务器证书(跳过证书校验)",
127
+ fieldsServerName: "证书校验用名称(用 IP 连接但证书签给域名时填)",
128
+ fieldsCaCert: "受信 CA(PEM 文件路径或证书文本,推荐)",
129
+ tlsHint: "证书类错误请装 CA(推荐)或勾选信任证书;协议版本类错误请下探最低 TLS 版本。两者解法不同,不能互相替代。",
120
130
  ssl: "启用 SSL",
121
131
  test: "测试",
122
132
  testing: "测试中…",
@@ -255,6 +265,7 @@ window.__ModuleLoader__.load({
255
265
  tabDesc: "Connection management, project grants, data browse and a SQL console",
256
266
  tabUnavailable: "This tab is not ready yet; switch tabs and try again",
257
267
  projectLabel: "Project: {path}",
268
+ pluginVersion: "Plugin version",
258
269
  projectUnbound: "Cannot detect current project path; enter the workspace absolute path below",
259
270
  projectPathPlaceholder: "workspace absolute path (e.g. /home/me/my-app)",
260
271
  viewManage: "Connections",
@@ -346,8 +357,17 @@ window.__ModuleLoader__.load({
346
357
  fieldsPassword: "Password",
347
358
  passwordSaved: "Saved (leave blank to keep)",
348
359
  fieldsDatabase: "Database",
349
- fieldsTlsMinVersion: "Minimum TLS version (pick TLSv1 when SQL Server 2008/2008R2 reports UNSUPPORTED_PROTOCOL)",
360
+ fieldsTlsMinVersion: "Minimum TLS version (lower it only when an old instance reports a protocol error)",
350
361
  tlsMinDefault: "Default (TLSv1.2)",
362
+ fieldsEncrypt: "Encryption",
363
+ encryptDefault: "Default (request encryption)",
364
+ encryptOn: "Mandatory (encrypt=true)",
365
+ encryptOff: "Optional (encrypt=false)",
366
+ encryptStrict: "Strict TDS 8.0 (strict)",
367
+ fieldsTrustCert: "Trust server certificate (skip certificate validation)",
368
+ fieldsServerName: "Name to validate (set when connecting by IP but the certificate names a host)",
369
+ fieldsCaCert: "Trusted CA (PEM file path or certificate text, recommended)",
370
+ tlsHint: "Certificate errors: install the CA (recommended) or trust the certificate. Protocol-version errors: lower the minimum TLS version. These are different fixes and cannot substitute for each other.",
351
371
  ssl: "Enable SSL",
352
372
  test: "Test",
353
373
  testing: "Testing…",
@@ -829,7 +849,7 @@ window.__ModuleLoader__.load({
829
849
  }
830
850
 
831
851
  /* ---------------- 连接管理 ---------------- */
832
- const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", tlsMinVersion: "", ssl: false };
852
+ const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", tlsMinVersion: "", encrypt: "", trustServerCertificate: false, serverName: "", caCert: "", ssl: false };
833
853
 
834
854
  // 口令弹窗(ManageView 局部复用,导出/导入共用):键盘契约对齐 DangerDialog
835
855
  // (Escape=取消、Tab 在对话框内循环锁定);差异:初始焦点在口令输入框,确认按钮主色。
@@ -1346,7 +1366,7 @@ window.__ModuleLoader__.load({
1346
1366
  const freshForm = () => withKindDefaults({ ...EMPTY_FORM, mode: "fields" });
1347
1367
 
1348
1368
  /** 编辑回填:从 meta 还原已存配置(密码不出库,留空=保留原密码)。
1349
- * url 方式 → 回填脱敏 url;分字段方式 → 回填 host/port/user/database/tlsMinVersion。 */
1369
+ * url 方式 → 回填脱敏 url;分字段方式 → 回填 host/port/user/database 与 TLS 相关项。 */
1350
1370
  function buildEditForm(c) {
1351
1371
  const mode = c.mode || (c.safeUrl ? "url" : "fields");
1352
1372
  return {
@@ -1357,6 +1377,11 @@ window.__ModuleLoader__.load({
1357
1377
  host: c.host || "", port: c.port != null ? String(c.port) : "",
1358
1378
  user: c.user || "", database: c.database || "",
1359
1379
  tlsMinVersion: c.tlsMinVersion || "",
1380
+ // encrypt 三态:布尔与 'strict' 语义不同,回填时保持原样;缺省留空=不注入(沿用适配器默认 true)
1381
+ encrypt: typeof c.encrypt === "boolean" ? String(c.encrypt) : (c.encrypt === "strict" ? "strict" : ""),
1382
+ trustServerCertificate: !!c.trustServerCertificate,
1383
+ serverName: c.serverName || "",
1384
+ caCert: c.caCert || "",
1360
1385
  ssl: !!c.ssl,
1361
1386
  };
1362
1387
  }
@@ -1434,9 +1459,22 @@ window.__ModuleLoader__.load({
1434
1459
  // 端口原样发字符串:Number("abc")=NaN 会被 JSON 序列化成 null,服务端按「未提供端口」静默回退默认值,配置错误被掩盖
1435
1460
  if (form.port) body.fields.port = form.port;
1436
1461
  if (form.password) body.fields.password = form.password;
1437
- // 最低 TLS 版本:空=不注入(沿用 Node 默认 TLSv1.2)。仅 sqlserver 有此字段,
1438
- // 其它库即使表单残留旧值也不发,避免无关库收到未知键。
1439
- if (form.kind === "sqlserver" && form.tlsMinVersion) body.fields.tlsMinVersion = form.tlsMinVersion;
1462
+ // TLS 相关项仅 sqlserver 有:其它库即使表单残留旧值也不发,避免无关库收到未知键。
1463
+ // 空值一律不注入 —— 留空语义是「沿用适配器默认」(encrypt=true / 信任证书=false /
1464
+ // 不改 TLS 下限),发空字符串反而会触发服务端「取值非法」的 fail-closed 校验。
1465
+ if (form.kind === "sqlserver") {
1466
+ if (form.tlsMinVersion) body.fields.tlsMinVersion = form.tlsMinVersion;
1467
+ // encrypt 三态:true / false / strict。这是证书族与协议族错误的正确出口,
1468
+ // 也是「服务器强制加密」与「TDS 8.0 严格加密」两条路径的开关。
1469
+ if (form.encrypt === "true") body.fields.encrypt = true;
1470
+ else if (form.encrypt === "false") body.fields.encrypt = false;
1471
+ else if (form.encrypt === "strict") body.fields.encrypt = "strict";
1472
+ // 信任自签证书:只影响证书校验,不影响加密协商;strict 下驱动不传
1473
+ // rejectUnauthorized,此开关会完全失效(UI 已就此提示)。
1474
+ if (form.trustServerCertificate) body.fields.trustServerCertificate = true;
1475
+ if (form.serverName) body.fields.serverName = form.serverName;
1476
+ if (form.caCert) body.fields.caCert = form.caCert;
1477
+ }
1440
1478
  // 从 url 方式切到分字段保存 → 服务端清除已存 url(否则连接仍走旧 url)
1441
1479
  if (props.initial && props.initial.mode === "url") body.clearUrl = true;
1442
1480
  }
@@ -1500,13 +1538,34 @@ window.__ModuleLoader__.load({
1500
1538
  React.createElement("div", { className: "dbt-row" },
1501
1539
  React.createElement("input", { placeholder: t("fieldsUser"), "aria-label": t("fieldsUser") + t("requiredMark"), "aria-required": "true", value: form.user, onChange: (e) => patch({ user: e.target.value }) }),
1502
1540
  React.createElement("input", { type: "password", placeholder: pwLabel, "aria-label": pwLabel, value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
1503
- // 最低 TLS 版本(仅 sqlserver):老实例只支持 TLS 1.0 时,客户端默认下限 TLSv1.2 会
1504
- // 报 UNSUPPORTED_PROTOCOL(协议版本协商失败,与证书无关,信任证书无效)。留空=不改默认。
1541
+ // TLS 相关项(仅 sqlserver)。设计原则:默认留空 = 沿用适配器默认
1542
+ // (encrypt=true + 校验证书 + Node 默认 TLS 下限),即「默认安全」;
1543
+ // 只有连老实例/自签证书失败时才需要动下面这些开关。
1544
+ // 常见误配:报 self signed certificate 是「证书族」,改 TLS 版本无效;
1545
+ // 报 UNSUPPORTED_PROTOCOL 是「协议族」,开关闭证书校验无效——两族解法正交。
1505
1546
  form.kind === "sqlserver"
1506
- ? React.createElement("div", { className: "dbt-row" },
1507
- React.createElement("select", { "aria-label": t("fieldsTlsMinVersion"), value: form.tlsMinVersion, onChange: (e) => patch({ tlsMinVersion: e.target.value }) },
1508
- React.createElement("option", { value: "" }, t("tlsMinDefault")),
1509
- ["TLSv1", "TLSv1.1", "TLSv1.2"].map((v) => React.createElement("option", { key: v, value: v }, v))))
1547
+ ? React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } },
1548
+ React.createElement("div", { className: "dbt-row" },
1549
+ // 加密模式:true=请求加密(默认)/ false=不主动要求加密(登录包仍加密,不能免掉 TLS 握手)/
1550
+ // strict=TDS 8.0 严格加密(仅 SQL Server 2022+ 开启 Force Strict Encryption 的实例)
1551
+ React.createElement("label", { className: "dbt-row", style: { flex: "none" } }, t("fieldsEncrypt"),
1552
+ React.createElement("select", { "aria-label": t("fieldsEncrypt"), value: form.encrypt, onChange: (e) => patch({ encrypt: e.target.value }) },
1553
+ React.createElement("option", { value: "" }, t("encryptDefault")),
1554
+ React.createElement("option", { value: "true" }, t("encryptOn")),
1555
+ React.createElement("option", { value: "false" }, t("encryptOff")),
1556
+ React.createElement("option", { value: "strict" }, t("encryptStrict")))),
1557
+ // 最低 TLS 版本:仅老实例(2005/2008/未打补丁的 2012·2014)报协议族错误时才需要下探
1558
+ React.createElement("label", { className: "dbt-row", style: { flex: "none" } }, t("fieldsTlsMinVersion"),
1559
+ React.createElement("select", { "aria-label": t("fieldsTlsMinVersion"), value: form.tlsMinVersion, onChange: (e) => patch({ tlsMinVersion: e.target.value }) },
1560
+ React.createElement("option", { value: "" }, t("tlsMinDefault")),
1561
+ ["TLSv1", "TLSv1.1", "TLSv1.2"].map((v) => React.createElement("option", { key: v, value: v }, v))))),
1562
+ React.createElement("label", { className: "dbt-row", style: { flex: "none" } },
1563
+ React.createElement("input", { type: "checkbox", checked: !!form.trustServerCertificate, "aria-label": t("fieldsTrustCert"), onChange: (e) => patch({ trustServerCertificate: e.target.checked }) }),
1564
+ t("fieldsTrustCert")),
1565
+ React.createElement("div", { className: "dbt-row" },
1566
+ React.createElement("input", { placeholder: t("fieldsServerName"), "aria-label": t("fieldsServerName"), value: form.serverName, onChange: (e) => patch({ serverName: e.target.value }) }),
1567
+ React.createElement("input", { placeholder: t("fieldsCaCert"), "aria-label": t("fieldsCaCert"), value: form.caCert, onChange: (e) => patch({ caCert: e.target.value }) })),
1568
+ React.createElement("span", { style: { fontSize: 12, opacity: 0.75 } }, t("tlsHint")))
1510
1569
  : null,
1511
1570
  ),
1512
1571
  // 分组 3:操作
@@ -3668,6 +3727,9 @@ window.__ModuleLoader__.load({
3668
3727
  const [message, setMessage] = React.useState("");
3669
3728
  const [confirmReq, setConfirmReq] = React.useState(null); // {statement,danger,reason,resolve}
3670
3729
  const [treeRefresh, setTreeRefresh] = React.useState(0); // 浏览树刷新信号(手动按钮 / 切回可见自动)
3730
+ // 插件版本号(显示在项目行最右侧):只拉一次并缓存,失败保持空串——版本展示属辅助信息,
3731
+ // 拿不到就不显示,绝不让它影响面板其它功能或弹错误。
3732
+ const [pluginVer, setPluginVer] = React.useState("");
3671
3733
  // 侧边栏切回可见时自动重拉已展开层:agents 等外部建库/建表后切回来即可见,无需手点刷新
3672
3734
  React.useEffect(() => {
3673
3735
  if (visible) setTreeRefresh((x) => x + 1);
@@ -3709,6 +3771,18 @@ window.__ModuleLoader__.load({
3709
3771
  reload().catch((e) => setError(String(e && e.message ? e.message : e)));
3710
3772
  }, [visible, reload]);
3711
3773
 
3774
+ // 版本号只请求一次(成功或失败都算尝试过):失败就静默不显示,不在每次切回面板时重试,
3775
+ // 避免把一个纯展示信息变成后台反复失败请求。用 ref 而非 state 记「已尝试」,
3776
+ // 否则 setPluginVer 触发重渲染又会把 effect 拉起来。
3777
+ const verTriedRef = React.useRef(false);
3778
+ React.useEffect(() => {
3779
+ if (!visible || verTriedRef.current) return;
3780
+ verTriedRef.current = true;
3781
+ api("version")
3782
+ .then((r) => setPluginVer(r && typeof r.version === "string" ? r.version : ""))
3783
+ .catch(() => { /* 拿不到就不显示版本,不影响面板 */ });
3784
+ }, [visible]);
3785
+
3712
3786
  function run(name, fn) {
3713
3787
  setBusy(name); setError(""); setMessage("");
3714
3788
  return fn()
@@ -3719,6 +3793,21 @@ window.__ModuleLoader__.load({
3719
3793
  function onError(e) { setError(String(e && e.message ? e.message : e)); }
3720
3794
 
3721
3795
  const shared = { conns, grants, projectPath, busy, run, reload, onError, askConfirm };
3796
+ // 版本号胶囊:钉在项目行最右侧。抽成局部变量让「只读态」与「编辑态」两种行都能复用,
3797
+ // 否则用户点了铅笔进入编辑后版本号就消失了。拿不到版本时不渲染,避免空白胶囊。
3798
+ const versionBadge = pluginVer
3799
+ ? React.createElement("span", {
3800
+ className: "dbt-ver",
3801
+ style: {
3802
+ marginLeft: "auto", flex: "none",
3803
+ display: "inline-block", padding: "0 6px", borderRadius: "9px",
3804
+ border: "1px solid currentColor", fontSize: "11px", lineHeight: "16px",
3805
+ opacity: 0.75, whiteSpace: "nowrap",
3806
+ },
3807
+ title: t("pluginVersion") + ": v" + pluginVer,
3808
+ "aria-label": t("pluginVersion") + ": v" + pluginVer,
3809
+ }, "v" + pluginVer)
3810
+ : null;
3722
3811
  // 四视图常驻元素:Panel 渲染期构建一次,切 tab 不重建(状态保留),ManageView/GrantsView/ConsoleView 复用 shared
3723
3812
  const viewEls = {
3724
3813
  manage: React.createElement(ManageView, shared),
@@ -3755,6 +3844,8 @@ window.__ModuleLoader__.load({
3755
3844
  if (!v || v === resolvedRef.current) setProjectEdited(false);
3756
3845
  },
3757
3846
  }),
3847
+ // 编辑态同样显示版本号:否则用户一点铅笔,版本标签就消失
3848
+ versionBadge,
3758
3849
  )
3759
3850
  : React.createElement("div", { className: "dbt-row" },
3760
3851
  React.createElement("span", { className: "dbt-muted" }, t("projectLabel", { path: projectPath })),
@@ -3778,6 +3869,8 @@ window.__ModuleLoader__.load({
3778
3869
  stroke: "currentColor", strokeWidth: 1.2, strokeLinecap: "round", strokeLinejoin: "round",
3779
3870
  })),
3780
3871
  )),
3872
+ // 版本号胶囊(与编辑态共用同一份 versionBadge,保证两种状态位置一致)
3873
+ versionBadge,
3781
3874
  error ? React.createElement("div", { className: "dbt-err", "aria-live": "polite" }, t("error") + ": " + error) : null,
3782
3875
  message ? React.createElement("div", { className: "dbt-msg", "aria-live": "polite" }, message) : null,
3783
3876
  // 视图常驻挂载 + hidden 切换:切 tab 保留各视图内部状态(控制台 SQL、浏览选中不丢);
@@ -3949,6 +4042,7 @@ window.__ModuleLoader__.load({
3949
4042
  classifyHead, ConsoleView,
3950
4043
  Panel, TransferView, TransferLogView,
3951
4044
  urlToFieldsPatch,
4045
+ ConnForm, buildEditForm, EMPTY_FORM,
3952
4046
  };
3953
4047
  return module.exports;
3954
4048
  },
@@ -67,7 +67,10 @@ function connOptions(conn) {
67
67
  }
68
68
  export async function createMysqlAdapter(conn, opts) {
69
69
  const baseOpts = connOptions(conn);
70
- const pool = mysql.createPool(baseOpts);
70
+ // 驱动注入与 sqlserver 对齐(opts?.driver ?? 真实模块):让 ro 钩子这类
71
+ // 「注册形态」契约能在离线测试里断言,不依赖真实 MySQL 实例。
72
+ const Driver = opts?.driver ?? mysql;
73
+ const pool = Driver.createPool(baseOpts);
71
74
  const readOnly = opts?.mode === 'ro';
72
75
  // 跨库(Navicat 式):按库名缓存独立池。mysql2 官方多库方案即多池管理——池上
73
76
  // changeUser/USE 只影响被调用的那条池内连接,池保持建池配置,切库不可靠。
@@ -104,7 +107,8 @@ export async function createMysqlAdapter(conn, opts) {
104
107
  let p = dbPools.get(db);
105
108
  if (!p) {
106
109
  // connOptions 已把 URL/fields 拆成纯字段,覆盖 database 即建出目标库的池
107
- p = mysql.createPool({ ...baseOpts, database: db });
110
+ // (用注入的 Driver,保证离线测试能覆盖跨库池的 ro 钩子注册)
111
+ p = Driver.createPool({ ...baseOpts, database: db });
108
112
  dbPools.set(db, p);
109
113
  attachReadOnly(p);
110
114
  }