dsh-db-tool 1.9.2 → 1.9.4

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,11 +1,43 @@
1
1
  # Changelog — dsh-db-tool
2
2
 
3
- ## 1.9.2
3
+ ## 1.9.4
4
4
 
5
- SQL Server 支持范围扩到 2005(9.0)→ 2025(17.0),并修复前端无法配置加密参数导致的「怎么改都连不上」。
5
+ SQL Server 连接改为「开箱即连」:表单只留主机/端口/账号/密码,加密参数收进「高级选项」,新建连接默认 `encrypt=auto`——不加密优先,服务器强制加密时自动升级并信任其证书。
6
+
7
+ ### 新增
8
+
9
+ - **前端「高级选项」折叠区**:`trustServerCertificate` / `serverName` / `caCert` / `tlsMinVersion` / `tdsVersion` 等参数从主表单移入默认收起的折叠区,主表单只留主机、端口、账号、密码与库名。这些控件本身及其校验、回填逻辑不变,只是换了入口——常规连接不再需要理解加密参数,排障时展开即可。
10
+ - **`encrypt=auto`(新建连接默认值)**:建连先不带加密;若服务器**强制加密**(如 `Force Encryption=Yes` 或 Azure SQL,表现为 `EENCRYPT` / 「服务器要求加密连接」),自动改用 `encrypt=true` + `trustServerCertificate=true` 重试一次。服务器未强制加密的实例(内网自建居多)因此不再被自签证书问题挡住,这是「像 Navicat 一样开箱即连」的核心。
11
+
12
+ ### 修复
13
+
14
+ - **不再因为默认值而必须先读懂 TLS 才能连库**:此前默认 `encrypt=true` + `trustServerCertificate=false`,连自签证书实例必然失败并报证书错误,用户必须先展开并理解 `caCert` / `serverName` 才连得上。现在这条路径由默认策略自己消化,证书参数退化为纯排障工具。这是默认值调整带来的连带效果,并非独立缺陷修复。
15
+
16
+ ### 行为变更(升级须知)
17
+
18
+ - **默认 `encrypt` 从 `true` 改为 `auto`**,新建连接的行为随之改变,详见上文。
19
+ - **自动重试的安全边界(有意收窄,不要当作无限兜底)**:
20
+ - **仅重试一次**,不做重试链;失败即把原始错误抛给用户。
21
+ - **仅针对「服务器强制加密」这一种原因**。证书校验失败(`self signed certificate` / `CERT_` 家族)、TLS 版本协商失败(`UNSUPPORTED_PROTOCOL`)、登录失败等其他原因**不触发重试**——盲目重试会覆盖真实错因,把排障引向错误方向。
22
+ - **显式指定 `encrypt` 时不做任何重试**(`true` / `false` / `strict` 皆是)。显式值即最终策略,工具不替用户改主意,行为完全可预期。
23
+ - **`auto` 不是「自动加密」**:在未强制加密的服务器上,`auto` 的行为与 `encrypt=false` 相同,即**明文发送 LOGIN7(含账号密码)**。默认值的定位是「可信内网开箱即连」,跨公网或不可信网段请显式设 `encrypt=true`。README / README_EN 与两份 SKILL.md 均已同步这条风险说明。
24
+
25
+ ## 1.9.3
26
+
27
+ SQL Server 支持范围扩到 2005(9.0)→ 2025(17.0),修复前端无法配置加密参数导致的「怎么改都连不上」,并在面板上加插件版本号标签。
28
+
29
+ ### 新增
30
+
31
+ - **项目行最右侧显示插件版本号**:以 `v1.9.3` 胶囊标签钉在项目路径行行尾,只读态与编辑态共用同一枚标签(进入编辑不会消失)。版本号由新增的 `GET /api/version` 从 `package.json` 读取下发,而非前端写常量——发版时版本号已在三处同步,再维护一份前端常量必然漂移(最坏情况是界面显示 1.9.3 而实际装的是旧版,用户报障时给出错误版本)。读不到时返回 `unknown`、前端不渲染胶囊且不报错:版本展示属辅助信息,不能污染面板错误条,也不该变成后台反复失败请求。
6
32
 
7
33
  ### 修复
8
34
 
35
+ - **登录阶段错误分诊原本完全失效(代码审查发现,已修)**: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` 分支在命中特征时优先给针对性诊断而非「用户名或密码错误」。
36
+ - **`caCert` 曾把信任范围从 137 张缩到 37 张(代码审查发现,已修)**:`applyCaCert` 原先以 `systemCa()` 打底、不含内置 bundle。而 Node 一旦收到 `ca` 选项就不再使用默认信任库,于是「为连 A 实例装一张自签 CA」会导致**所有走公网 CA 的实例全部连不上**(`SELF_SIGNED_CERT_IN_CHAIN`)——恰是 `applySystemCaDefault` 注释里警告过的失败模式。现三处统一走 `mergedCaWith()`:内置 bundle + 系统信任库 + 用户 CA 去重合并,并加模块级缓存(避免每次建连重复读证书与重复构造 SecureContext)。
37
+ - **单行 PEM 会静默失效(代码审查发现,已修)**:前端 `caCert` 是单行 `<input>`,粘贴多行 PEM 时换行被压平;而 `tls.createSecureContext({ca:[单行PEM]})` **不报错**(实测静默接受),握手时才失败并报 `DEPTH_ZERO_SELF_SIGNED_CERT`——用户看到的是"填了 CA 还是同样的错"。现 `normalizePem()` 自动按 64 列折行还原(已验证折行结果与原 PEM 内容等价且可被 `createSecureContext` 接受),残缺 PEM(有 BEGIN 无 END)则 fail-closed 报错而非静默接受。
38
+ - **`serverName` 未 trim**:`?serverName=%20`(一个空格)会被当成有效值注入,现与其它参数同口径先 trim 再判空。
39
+
40
+
9
41
  - **前端补齐加密参数入口(用户可见的核心修复)**:分字段表单此前只暴露 `tlsMinVersion`,没有任何 `encrypt` / `trustServerCertificate` 入口——而 `tlsMinVersion` 恰好对「证书类」错误无效。用户连自签证书实例时唯一能动的开关是错的那个,于是陷入「换个 TLS 版本再试、还是同一个错」的死循环。现补齐三项控件:`encrypt`(`默认`/`强制加密`/`不主动加密`/`严格加密 TDS 8.0`)、`trustServerCertificate` 复选框、`serverName` 与 `caCert` 输入框,并在表单内直接给出「证书类 vs 协议版本类」的分流提示。
10
42
  - **`tdsVersion` 从表单移除**:TDS 版本在协议层本就自动降级([MS-TDS](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/773a62b6-ee89-4c02-9e5e-344882630aac) 规定客户端声明高于服务器时服务器**必须**使用自身最高版本;微软官方 Go 驱动同样硬编码声明 7.4 且宣称支持 2005+)。把它摆在表单里等于把驱动已处理好的问题转嫁给用户。该参数仍保留为排障覆盖项。
11
43
  - **编辑回填补全**:`ConnectionMeta` / `toMeta()` 增加 `encrypt` / `trustServerCertificate` / `serverName` / `caCert` 回填。此前这些字段即使存进 `connections.json` 也读不回来,编辑表单显示为空、保存即被静默清掉。`encrypt` 的三态(`true` / `false` / `'strict'`)按原语义透传,非法值不回填(fail-closed)。
@@ -20,18 +52,19 @@ SQL Server 支持范围扩到 2005(9.0)→ 2025(17.0),并修复前端
20
52
  - **`encrypt=strict`(TDS 8.0)**:支持 SQL Server 2022+ 的 Force Strict Encryption。此前该场景**必然连不上**且报错会被归到证书族,误导排查。需注意 strict 路径下驱动不传 `rejectUnauthorized`,**`trustServerCertificate` 完全失效**,证书强制校验——报错文案已明确这一点。
21
53
  - **`serverName`**:证书签发给域名而实际用 IP / 转发地址连接时指定校验名,把该场景从「关掉证书校验」里分流出来。
22
54
  - **`caCert`**:传 PEM 文件路径或证书文本,走官方推荐解法(保住加密与校验,只扩大信任根)。
23
- - **系统信任库默认注入**:Node 默认只信任编译期内置的 bundle,**不读操作系统信任库**——这意味着「按官方推荐把自签证书导入 Windows 受信任根」这个最常见的正确做法,在不注入的情况下对连接毫无帮助(用户会以为"导入了却没用")。现无论是否填 `caCert`,都把 `tls.getCACertificates('default')` 与 `('system')` 合并后注入校验(实测 118 + 72 = 190 张;系统库独有的 38 张已确认包含在内)。三条降级边界:① 无该 API 的老环境(Node < 22.15)不注入,保持原生默认,公网证书照常可用;② 有 API 但系统库为空时只注入 bundle,绝不把 `ca` 设成空数组(那会让校验彻底失败);③ 取证书异常全部吞掉,绝不用"取系统证书失败"打断建连。显式给了 `caCert` 时不覆盖用户选择。已在 DSH 实际运行环境(Electron 44.7 / Node 24.21)验证。
55
+ - **系统信任库默认注入**: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)验证。
24
56
  - **8 个服务器错误号的针对性分诊**:`17819`(只接受 strict → 改 `encrypt=strict`)、`17898`/`17802`(TDS 版本被拒 → 升级客户端库,**不**建议下调 `tdsVersion`)、`17835`(服务器强制加密 → 开 `encrypt`)、`17836`/`17828`/`17832`(包结构错配 → 查代理/中间层/端口,**不**归为版本问题)、`47072`(Azure 最低 TLS 门槛)。这些错误的服务器原文常含 `certificate`/`encryption` 字样,会被 TLS 族正则误命中并叠加「装 CA / 关校验」这类**无效**建议,故在命中已知错误号时抑制 TLS 族建议,保证每次只给出一个正确出口。
25
57
  - **`EENCRYPT` 区分两种相反成因**:一是 `encrypt=false` 而服务器强制加密(药方是开),二是 `encrypt=true` 而服务器未协商加密(药方是关或修服务端),此前合并为一句必然把用户引反方向。
26
58
 
27
59
  ### 文档
28
60
 
29
- - README / README_EN:覆盖范围改为 2005 → 2025;补充版本 → TLS 1.2 → 所需补丁对照;写明官方推荐顺序(装 CA 优先于关校验);补充「`encrypt=false` 不能跳过 TLS 握手」这一常被误解的事实(登录包始终加密);删除「不支持 TDS 8.0 / strict」的虚标;修正「连 2008 需显式 `tdsVersion=7_3_A`」这一无官方依据的经验说法。
61
+ - README / README_EN:覆盖范围改为 2005 → 2025;补充版本 → TLS 1.2 → 所需补丁对照;写明官方推荐顺序(装 CA 优先于关校验);写明 `encrypt=false` 的真实行为——服务器未强制加密时**会跳过 TLS 握手并明文发送 LOGIN7**(已抓包实测),澄清「登录包始终加密」只适用于服务器强制加密或 `encrypt=true` 的场景;删除「不支持 TDS 8.0 / strict」的虚标;修正「连 2008 需显式 `tdsVersion=7_3_A`」这一无官方依据的经验说法。
30
62
 
31
63
  ### 行为变更(升级须知)
32
64
 
33
65
  - **`encrypt=true` 连不支持加密的服务器现在会显式失败**(`EENCRYPT`),而不是静默明文传输。这是上述安全修复的本意:这类实例请显式设 `encrypt=false`。
34
66
  - 若某实例确实只支持旧 TLS(如 2005 / 未打补丁的 2008),仍会失败——但报错现在指向「服务端补丁缺失」与「客户端下探 TLS 下限」两条正确路径。
67
+ - **`poolConfig` 现在总会写入 `cryptoCredentialsDetails.ca`**(内置 bundle + 系统信任库合并),不再「未配置 TLS 选项就保持 options 形状」。这是为了让「按官方推荐把自签证书导入系统受信任根」真正生效(Node 默认不读系统信任库)。老环境(无 `tls.getCACertificates`)仍不注入、保持原生默认,公网证书不受影响。
35
68
 
36
69
  ## 1.9.1
37
70
 
package/README.md CHANGED
@@ -147,11 +147,28 @@ Navicat 风格的右侧栏多标签编辑器(CodeMirror 6):
147
147
 
148
148
  覆盖 SQL Server 2005(9.0)→ 2025(17.0)与 Azure SQL Database。
149
149
 
150
- **连接 URL 参数**(分字段表单同样支持):
150
+ **常规使用只需要填主机、端口、账号、密码**(外加库名)就能连上,加密与证书参数一律不用管。表单默认只显示这四个必填项,其余参数收在「高级选项」折叠区里,需要排障时再展开。
151
+
152
+ ### 默认加密策略:不加密优先,被拒后自动升级
153
+
154
+ 新建连接默认 `encrypt=auto`(表单加密一栏的默认档):
155
+
156
+ 1. 先**不带加密**建连——服务器没强制加密的实例(内网自建居多)直接连上,不再因为自签证书或多一层 TLS 卡住。
157
+ 2. 若服务器**强制加密**(报 `EENCRYPT` / 提示「服务器要求加密连接」,如开启 `Force Encryption=Yes` 或 Azure SQL),自动改用 `encrypt=true` + `trustServerCertificate=true` **重试一次**,用户无感。
158
+
159
+ **自动重试的边界(有意收窄,不要理解成「无限自动兜底」)**:
160
+
161
+ - **只重试一次**,不会连环重试,失败就把原始错误报给用户。
162
+ - **只针对「服务器强制加密」这一种原因**。证书校验失败、TLS 版本协商失败、账号密码错误等其他失败**不会**触发重试——盲目重试只会把这些错误的真实原因盖掉,把排障引向错误方向。
163
+ - **显式写 `encrypt=true` / `false` / `strict` 时不做任何重试**。你指定的策略就是最终策略,工具不替你改主意。
164
+
165
+ **`auto` 在未强制加密的服务器上等同于 `encrypt=false`,也就是明文传输**——这是默认策略的代价,风险说明见下文「`encrypt=false` 的真实语义」,别跳过。
166
+
167
+ **连接 URL 参数**(高级用户与排障时用,日常连接**无需**填这些;分字段表单同样支持):
151
168
 
152
169
  | 参数 | 取值 | 默认 |
153
170
  |---|---|---|
154
- | `encrypt` | `true` / `false` / `strict` | `true` |
171
+ | `encrypt` | `auto` / `true` / `false` / `strict` | `auto`(先不加密,服务器强制加密时自动升级并信任其证书,仅重试一次) |
155
172
  | `trustServerCertificate` | `true` / `false` | `false` |
156
173
  | `tdsVersion` | `7_4` / `7_3_A` / `7_3_B` / `7_2` / `7_1` | `7_4`(服务器自动降级,通常无需设置) |
157
174
  | `tlsMinVersion` | `TLSv1` / `TLSv1.1` / `TLSv1.2` | 不设置(Node 默认 TLSv1.2) |
@@ -160,6 +177,8 @@ Navicat 风格的右侧栏多标签编辑器(CodeMirror 6):
160
177
  | `instanceName` | 命名实例名(与 port 互斥) | — |
161
178
  | `useUTC` | `true` / `false` | `true` |
162
179
 
180
+ `caCert` / `serverName` / `trustServerCertificate` / `tlsMinVersion` / `tdsVersion` 都在表单的**「高级选项」**折叠区里(默认收起),专供证书与老版本实例排障;正常连接不需要展开它。
181
+
163
182
  **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` 仅作排障覆盖项保留。
164
183
 
165
184
  **连老实例时先分清两类 TLS 失败,解法正交、不可互相替代**:
@@ -169,7 +188,17 @@ Navicat 风格的右侧栏多标签编辑器(CodeMirror 6):
169
188
 
170
189
  各版本对 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
190
 
172
- **`encrypt=false` 不能跳过 TLS 握手**:SQL Server 的登录包(Login7,含账号密码)**即使在未配置加密时也始终加密**,因此仍要完成一次 TLS 握手。协议版本无交集时,`encrypt=false` 一样连不上——这是「改了加密参数却仍报同一个错」的常见原因。
191
+ **`encrypt=false` 的真实语义(实测确认,勿误解为「只是不加密」)**:
192
+
193
+ - 服务器**未**强制加密(PRELOGIN 回 `OFF`/`NOT_SUP`)时,`encrypt=false` 会**完全跳过 TLS 握手,直接明文发送 LOGIN7(含账号密码)**。已用假 TDS 服务器抓包实测:客户端下一个包就是 `0x10`(LOGIN7),没有 TLS 握手。**这是明文传输,切勿在内网之外使用。**
194
+ - 服务器**强制**加密(`Force Encryption=Yes` 或 Azure SQL)时,`encrypt=false` 会被拒绝(提示「服务器要求加密连接」)——此时仍必须开加密。
195
+ - 微软文档中「登录包始终加密」指的是**服务器强制加密或客户端 `encrypt=true` 时**的语义,不能推广成「`encrypt=false` 也总有 TLS 握手」。
196
+
197
+ **这段也是默认 `encrypt=auto` 的安全含义**:`auto` 的第一步就是「不加密建连」,落到未强制加密的服务器上时,行为与 `encrypt=false` **完全相同**——明文传账号密码。`auto` 只在服务器拒绝明文时才升级到 TLS,它不是「自动帮你加密」,而是「先按不加密试,被拒才加密」。因此:
198
+
199
+ - 在**可信内网**用默认值没问题;跨公网、跨不可信网段连 SQL Server 时,请显式设 `encrypt=true`(并在需要时用 `caCert` / `serverName` 解决证书问题),不要依赖 `auto`。
200
+ - 需要强制安全档时显式写 `encrypt=true`:显式值不做任何自动重试,行为完全可预期。
201
+ - `encrypt=false` 与 `encrypt=auto` 都应视为**明确的降级选择**,不是「只是没开高级选项」。
173
202
 
174
203
  **`encrypt=strict`(TDS 8.0)**:仅 SQL Server 2022+ 且开启 Force Strict Encryption 的实例支持。该模式下 TLS 在登录包之前就完成握手,因此 **`trustServerCertificate` 完全不生效**(驱动不传 `rejectUnauthorized`,证书强制校验),只能靠 `caCert` 或 `serverName`。
175
204
 
@@ -202,7 +231,7 @@ DSH 桌面端 0.2.0-rc.2+ 已捆绑 `dsh` 命令:
202
231
  2. 安装插件(npm 安装须钉精确版本,`@latest` 会因 release-age 校验回落):
203
232
 
204
233
  ```bash
205
- dsh plugin --profile desktop add dsh-db-tool@1.9.2 --registry=https://registry.npmjs.org/
234
+ dsh plugin --profile desktop add dsh-db-tool@1.9.4 --registry=https://registry.npmjs.org/
206
235
  ```
207
236
 
208
237
  3. **重启桌面端**生效
package/README_EN.md CHANGED
@@ -147,11 +147,28 @@ Both are contract behaviours of the official right sidebar and are easy to mista
147
147
 
148
148
  Covers SQL Server 2005 (9.0) through 2025 (17.0) and Azure SQL Database.
149
149
 
150
- **Connection URL parameters** (also supported by the field-by-field form):
150
+ **For normal use you only fill in host, port, user and password** (plus the database). Encryption and certificate parameters are not your problem. The form shows only those four required fields; everything else lives in a collapsed **Advanced options** section you open only when troubleshooting.
151
+
152
+ ### Default encryption policy: prefer plaintext, upgrade automatically when the server insists
153
+
154
+ New connections default to `encrypt=auto` (the default option in the form's encryption field):
155
+
156
+ 1. Connect **without encryption** first — instances that do not force encryption (typically self-hosted, on an intranet) connect right away, no longer blocked by a self-signed certificate or an extra TLS layer.
157
+ 2. If the server **forces encryption** (`EENCRYPT` / "server requires encryption", e.g. `Force Encryption=Yes` or Azure SQL), retry **once** with `encrypt=true` + `trustServerCertificate=true`. The user does not see this.
158
+
159
+ **Boundaries of the automatic retry (deliberately narrow — not an unlimited safety net)**:
160
+
161
+ - **Exactly one retry.** No retry chains; on failure the original error is surfaced.
162
+ - **Only for the "server forces encryption" cause.** Certificate-validation failures, TLS-version negotiation failures, bad credentials and everything else do **not** trigger a retry — blind retrying would bury the real cause and send troubleshooting in the wrong direction.
163
+ - **No retry at all when `encrypt` is set explicitly** to `true` / `false` / `strict`. The policy you specify is final; the tool does not second-guess it.
164
+
165
+ **On a server that does not force encryption, `auto` behaves exactly like `encrypt=false`, i.e. cleartext transmission** — that is the cost of this default; see "What `encrypt=false` really does" below. Do not skip it.
166
+
167
+ **Connection URL parameters** (for advanced users and troubleshooting — **not needed** for everyday connections; the field-by-field form supports the same set):
151
168
 
152
169
  | Parameter | Values | Default |
153
170
  |---|---|---|
154
- | `encrypt` | `true` / `false` / `strict` | `true` |
171
+ | `encrypt` | `auto` / `true` / `false` / `strict` | `auto` (unencrypted first; upgrade with certificate trust when the server forces encryption — one retry only) |
155
172
  | `trustServerCertificate` | `true` / `false` | `false` |
156
173
  | `tdsVersion` | `7_4` / `7_3_A` / `7_3_B` / `7_2` / `7_1` | `7_4` (negotiated down automatically; normally leave unset) |
157
174
  | `tlsMinVersion` | `TLSv1` / `TLSv1.1` / `TLSv1.2` | unset (Node default TLSv1.2) |
@@ -160,6 +177,8 @@ Covers SQL Server 2005 (9.0) through 2025 (17.0) and Azure SQL Database.
160
177
  | `instanceName` | Named instance (mutually exclusive with port) | — |
161
178
  | `useUTC` | `true` / `false` | `true` |
162
179
 
180
+ `caCert` / `serverName` / `trustServerCertificate` / `tlsMinVersion` / `tdsVersion` all live in the **Advanced options** section of the form (collapsed by default); they exist for certificate and legacy-instance troubleshooting and are not needed for normal connections.
181
+
163
182
  **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.
164
183
 
165
184
  **When connecting to older instances, tell the two TLS failure families apart — their remedies are orthogonal and not interchangeable**:
@@ -169,7 +188,19 @@ Covers SQL Server 2005 (9.0) through 2025 (17.0) and Azure SQL Database.
169
188
 
170
189
  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
190
 
172
- **`encrypt=false` does not skip the TLS handshake**: SQL Server always encrypts the login packet (Login7, which carries the credentials) *even when encryption is not configured*, so a TLS handshake still happens. When the protocol versions do not overlap, `encrypt=false` fails just the same — a common reason why "changing the encryption setting" does not fix the error.
191
+ **What `encrypt=false` really does (verified by packet capture — do not read it as "just unencrypted data")**:
192
+
193
+ - 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.**
194
+ - When the server **does** force encryption (`Force Encryption=Yes`, Azure SQL), `encrypt=false` is rejected ("server requires encryption"), so encryption is still mandatory there.
195
+ - 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".
196
+
197
+ Setting `encrypt=true` explicitly is therefore the secure setting; treat `encrypt=false` as an explicit downgrade.
198
+
199
+ **The same paragraph defines what the default `encrypt=auto` means for security**: the first step of `auto` is an unencrypted connection, so against a server that does not force encryption it behaves **exactly** like `encrypt=false` — credentials in cleartext. `auto` only upgrades to TLS when the server refuses plaintext; it is not "automatically encrypt for you", it is "try unencrypted first, encrypt only when rejected". Therefore:
200
+
201
+ - The default is fine on a **trusted intranet**; when the SQL Server is reached over the public internet or an untrusted segment, set `encrypt=true` explicitly (and use `caCert` / `serverName` for certificate issues) instead of relying on `auto`.
202
+ - Set `encrypt=true` explicitly when you need a guaranteed secure setting: explicit values never retry, so behaviour is fully predictable.
203
+ - Treat both `encrypt=false` and `encrypt=auto` as an **explicit downgrade**, not as "just left the advanced options closed".
173
204
 
174
205
  **`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.
175
206
 
@@ -202,7 +233,7 @@ DSH desktop 0.2.0-rc.2+ bundles the `dsh` command:
202
233
  2. Install the plugin (npm installs must pin an exact version — `@latest` falls back to an older release due to release-age checks):
203
234
 
204
235
  ```bash
205
- dsh plugin --profile desktop add dsh-db-tool@1.9.2 --registry=https://registry.npmjs.org/
236
+ dsh plugin --profile desktop add dsh-db-tool@1.9.4 --registry=https://registry.npmjs.org/
206
237
  ```
207
238
 
208
239
  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: "连接管理",
@@ -118,14 +119,16 @@ window.__ModuleLoader__.load({
118
119
  fieldsTlsMinVersion: "最低 TLS 版本(连老实例报协议族错误时才需要下探)",
119
120
  tlsMinDefault: "默认(TLSv1.2)",
120
121
  fieldsEncrypt: "加密",
121
- encryptDefault: "默认(请求加密)",
122
- encryptOn: "强制加密(encrypt=true)",
122
+ encryptAuto: "自动(推荐)",
123
+ // 原「默认(请求加密)」空值项已由 auto 取代;本键现在是 value=true 的文案(强制加密)
124
+ encryptDefault: "强制加密",
123
125
  encryptOff: "不主动加密(encrypt=false)",
124
126
  encryptStrict: "严格加密 TDS 8.0(strict)",
125
127
  fieldsTrustCert: "信任服务器证书(跳过证书校验)",
126
128
  fieldsServerName: "证书校验用名称(用 IP 连接但证书签给域名时填)",
127
129
  fieldsCaCert: "受信 CA(PEM 文件路径或证书文本,推荐)",
128
130
  tlsHint: "证书类错误请装 CA(推荐)或勾选信任证书;协议版本类错误请下探最低 TLS 版本。两者解法不同,不能互相替代。",
131
+ advancedTls: "高级选项",
129
132
  ssl: "启用 SSL",
130
133
  test: "测试",
131
134
  testing: "测试中…",
@@ -264,6 +267,7 @@ window.__ModuleLoader__.load({
264
267
  tabDesc: "Connection management, project grants, data browse and a SQL console",
265
268
  tabUnavailable: "This tab is not ready yet; switch tabs and try again",
266
269
  projectLabel: "Project: {path}",
270
+ pluginVersion: "Plugin version",
267
271
  projectUnbound: "Cannot detect current project path; enter the workspace absolute path below",
268
272
  projectPathPlaceholder: "workspace absolute path (e.g. /home/me/my-app)",
269
273
  viewManage: "Connections",
@@ -358,14 +362,15 @@ window.__ModuleLoader__.load({
358
362
  fieldsTlsMinVersion: "Minimum TLS version (lower it only when an old instance reports a protocol error)",
359
363
  tlsMinDefault: "Default (TLSv1.2)",
360
364
  fieldsEncrypt: "Encryption",
361
- encryptDefault: "Default (request encryption)",
362
- encryptOn: "Mandatory (encrypt=true)",
365
+ encryptAuto: "Automatic (recommended)",
366
+ encryptDefault: "Mandatory encryption",
363
367
  encryptOff: "Optional (encrypt=false)",
364
368
  encryptStrict: "Strict TDS 8.0 (strict)",
365
369
  fieldsTrustCert: "Trust server certificate (skip certificate validation)",
366
370
  fieldsServerName: "Name to validate (set when connecting by IP but the certificate names a host)",
367
371
  fieldsCaCert: "Trusted CA (PEM file path or certificate text, recommended)",
368
372
  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.",
373
+ advancedTls: "Advanced options",
369
374
  ssl: "Enable SSL",
370
375
  test: "Test",
371
376
  testing: "Testing…",
@@ -847,7 +852,19 @@ window.__ModuleLoader__.load({
847
852
  }
848
853
 
849
854
  /* ---------------- 连接管理 ---------------- */
850
- 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 };
855
+ // encrypt 默认 'auto':对齐 Navicat 的「自动」策略(适配器按服务端能力协商),
856
+ // 而不是把「强制加密」当默认,避免老实例连不上时用户被迫先找开关。
857
+ const EMPTY_FORM = { id: "", kind: "mysql", name: "", mode: "url", url: "", urlUser: "", urlPassword: "", host: "", port: "", user: "", password: "", database: "", tlsMinVersion: "", encrypt: "auto", trustServerCertificate: false, serverName: "", caCert: "", ssl: false };
858
+
859
+ /** 是否有任一高级 TLS 字段被存过(编辑时据此默认展开高级区,避免用户看不到自己存过的配置) */
860
+ function hasAdvancedTls(meta) {
861
+ if (!meta) return false;
862
+ return !!(meta.tlsMinVersion || meta.serverName || meta.caCert || meta.trustServerCertificate
863
+ || (meta.encrypt !== undefined && meta.encrypt !== null && meta.encrypt !== "" && meta.encrypt !== "auto"));
864
+ }
865
+
866
+ // 高级 TLS 折叠区容器 id(aria-controls 指过来;单表单实例,固定值足够)
867
+ const TLS_ADVANCED_ID = "dbt-tls-advanced";
851
868
 
852
869
  // 口令弹窗(ManageView 局部复用,导出/导入共用):键盘契约对齐 DangerDialog
853
870
  // (Escape=取消、Tab 在对话框内循环锁定);差异:初始焦点在口令输入框,确认按钮主色。
@@ -1375,8 +1392,11 @@ window.__ModuleLoader__.load({
1375
1392
  host: c.host || "", port: c.port != null ? String(c.port) : "",
1376
1393
  user: c.user || "", database: c.database || "",
1377
1394
  tlsMinVersion: c.tlsMinVersion || "",
1378
- // encrypt 三态:布尔与 'strict' 语义不同,回填时保持原样;缺省留空=不注入(沿用适配器默认 true)
1379
- encrypt: typeof c.encrypt === "boolean" ? String(c.encrypt) : (c.encrypt === "strict" ? "strict" : ""),
1395
+ // encrypt 四态:'auto'(推荐,交适配器协商)/ true / false / 'strict',回填时保持原样。
1396
+ // 缺省(老记录没有该键)回落到 EMPTY_FORM 的 'auto':下拉已无空值项,
1397
+ // 回填空串会让 select 显示为空白选中项。
1398
+ encrypt: typeof c.encrypt === "boolean" ? String(c.encrypt)
1399
+ : (c.encrypt === "strict" || c.encrypt === "auto" ? c.encrypt : EMPTY_FORM.encrypt),
1380
1400
  trustServerCertificate: !!c.trustServerCertificate,
1381
1401
  serverName: c.serverName || "",
1382
1402
  caCert: c.caCert || "",
@@ -1407,6 +1427,9 @@ window.__ModuleLoader__.load({
1407
1427
  const [form, setForm] = React.useState(() => props.initial || freshForm());
1408
1428
  const [dirty, setDirty] = React.useState(() => new Set()); // 用户手改过的字段(切 kind 时保留)
1409
1429
  const [draftTest, setDraftTest] = React.useState(null); // null | {ok, msg}
1430
+ // 高级 TLS 区折叠态:默认收起(对齐 Navicat 的简洁界面);编辑已有连接且存过任一高级字段时默认展开。
1431
+ // 折叠只影响「是否渲染」,form 里的值不受影响 —— 收起状态照样回填与提交,绝不因隐藏丢配置。
1432
+ const [advancedOpen, setAdvancedOpen] = React.useState(() => hasAdvancedTls(props.initial));
1410
1433
  // 无障碍:URL/分字段切换(及表单挂载)后焦点引导进该模式首个输入,避免键盘/SR 用户滞留切换按钮
1411
1434
  const firstInputRef = React.useRef(null);
1412
1435
  React.useEffect(() => { if (firstInputRef.current) firstInputRef.current.focus(); }, [form.mode]);
@@ -1462,9 +1485,11 @@ window.__ModuleLoader__.load({
1462
1485
  // 不改 TLS 下限),发空字符串反而会触发服务端「取值非法」的 fail-closed 校验。
1463
1486
  if (form.kind === "sqlserver") {
1464
1487
  if (form.tlsMinVersion) body.fields.tlsMinVersion = form.tlsMinVersion;
1465
- // encrypt 三态:true / false / strict。这是证书族与协议族错误的正确出口,
1488
+ // encrypt 四态:'auto' / true / false / 'strict'。这是证书族与协议族错误的正确出口,
1466
1489
  // 也是「服务器强制加密」与「TDS 8.0 严格加密」两条路径的开关。
1467
- if (form.encrypt === "true") body.fields.encrypt = true;
1490
+ // 'auto' 必须原样发出字符串,不得退化成布尔(退化成 true 就丢了协商语义)。
1491
+ if (form.encrypt === "auto") body.fields.encrypt = "auto";
1492
+ else if (form.encrypt === "true") body.fields.encrypt = true;
1468
1493
  else if (form.encrypt === "false") body.fields.encrypt = false;
1469
1494
  else if (form.encrypt === "strict") body.fields.encrypt = "strict";
1470
1495
  // 信任自签证书:只影响证书校验,不影响加密协商;strict 下驱动不传
@@ -1537,33 +1562,50 @@ window.__ModuleLoader__.load({
1537
1562
  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 }) }),
1538
1563
  React.createElement("input", { type: "password", placeholder: pwLabel, "aria-label": pwLabel, value: form.password, onChange: (e) => patch({ password: e.target.value }) })),
1539
1564
  // TLS 相关项(仅 sqlserver)。设计原则:默认留空 = 沿用适配器默认
1540
- // (encrypt=true + 校验证书 + Node 默认 TLS 下限),即「默认安全」;
1565
+ // (encrypt='auto' 交适配器协商 + 校验证书 + Node 默认 TLS 下限),即「默认安全」;
1541
1566
  // 只有连老实例/自签证书失败时才需要动下面这些开关。
1542
1567
  // 常见误配:报 self signed certificate 是「证书族」,改 TLS 版本无效;
1543
1568
  // 报 UNSUPPORTED_PROTOCOL 是「协议族」,开关闭证书校验无效——两族解法正交。
1569
+ // 交互:这些项默认藏在「高级选项」折叠区内(对齐 Navicat 的简洁界面),
1570
+ // 收起时只留入口按钮;折叠态不影响 form 值,收起也照样回填与提交。
1544
1571
  form.kind === "sqlserver"
1545
1572
  ? React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } },
1546
1573
  React.createElement("div", { className: "dbt-row" },
1547
- // 加密模式:true=请求加密(默认)/ false=不主动要求加密(登录包仍加密,不能免掉 TLS 握手)/
1548
- // strict=TDS 8.0 严格加密(仅 SQL Server 2022+ 开启 Force Strict Encryption 的实例)
1549
- React.createElement("label", { className: "dbt-row", style: { flex: "none" } }, t("fieldsEncrypt"),
1550
- React.createElement("select", { "aria-label": t("fieldsEncrypt"), value: form.encrypt, onChange: (e) => patch({ encrypt: e.target.value }) },
1551
- React.createElement("option", { value: "" }, t("encryptDefault")),
1552
- React.createElement("option", { value: "true" }, t("encryptOn")),
1553
- React.createElement("option", { value: "false" }, t("encryptOff")),
1554
- React.createElement("option", { value: "strict" }, t("encryptStrict")))),
1555
- // 最低 TLS 版本:仅老实例(2005/2008/未打补丁的 2012·2014)报协议族错误时才需要下探
1556
- React.createElement("label", { className: "dbt-row", style: { flex: "none" } }, t("fieldsTlsMinVersion"),
1557
- React.createElement("select", { "aria-label": t("fieldsTlsMinVersion"), value: form.tlsMinVersion, onChange: (e) => patch({ tlsMinVersion: e.target.value }) },
1558
- React.createElement("option", { value: "" }, t("tlsMinDefault")),
1559
- ["TLSv1", "TLSv1.1", "TLSv1.2"].map((v) => React.createElement("option", { key: v, value: v }, v))))),
1560
- React.createElement("label", { className: "dbt-row", style: { flex: "none" } },
1561
- React.createElement("input", { type: "checkbox", checked: !!form.trustServerCertificate, "aria-label": t("fieldsTrustCert"), onChange: (e) => patch({ trustServerCertificate: e.target.checked }) }),
1562
- t("fieldsTrustCert")),
1563
- React.createElement("div", { className: "dbt-row" },
1564
- React.createElement("input", { placeholder: t("fieldsServerName"), "aria-label": t("fieldsServerName"), value: form.serverName, onChange: (e) => patch({ serverName: e.target.value }) }),
1565
- React.createElement("input", { placeholder: t("fieldsCaCert"), "aria-label": t("fieldsCaCert"), value: form.caCert, onChange: (e) => patch({ caCert: e.target.value }) })),
1566
- React.createElement("span", { style: { fontSize: 12, opacity: 0.75 } }, t("tlsHint")))
1574
+ React.createElement("button", {
1575
+ className: "dbt-btn",
1576
+ type: "button",
1577
+ "aria-label": t("advancedTls"),
1578
+ "aria-expanded": advancedOpen,
1579
+ // 收起时容器不渲染 → 不下发 aria-controls,避免指向不存在的节点
1580
+ "aria-controls": advancedOpen ? TLS_ADVANCED_ID : undefined,
1581
+ onClick: () => setAdvancedOpen(!advancedOpen),
1582
+ ...focusRingProps(),
1583
+ }, (advancedOpen ? "▾ " : "▸ ") + t("advancedTls"))),
1584
+ advancedOpen
1585
+ ? React.createElement("div", { id: TLS_ADVANCED_ID, style: { display: "flex", flexDirection: "column", gap: 6 } },
1586
+ React.createElement("div", { className: "dbt-row" },
1587
+ // 加密模式:auto=交适配器协商(推荐,对齐 Navicat)/
1588
+ // true=强制加密(握手失败即报错)/ false=不主动要求加密(登录包仍加密)/
1589
+ // strict=TDS 8.0 严格加密(仅 SQL Server 2022+ 开启 Force Strict Encryption 的实例)
1590
+ React.createElement("label", { className: "dbt-row", style: { flex: "none" } }, t("fieldsEncrypt"),
1591
+ React.createElement("select", { "aria-label": t("fieldsEncrypt"), value: form.encrypt, onChange: (e) => patch({ encrypt: e.target.value }) },
1592
+ React.createElement("option", { value: "auto" }, t("encryptAuto")),
1593
+ React.createElement("option", { value: "true" }, t("encryptDefault")),
1594
+ React.createElement("option", { value: "false" }, t("encryptOff")),
1595
+ React.createElement("option", { value: "strict" }, t("encryptStrict")))),
1596
+ // 最低 TLS 版本:仅老实例(2005/2008/未打补丁的 2012·2014)报协议族错误时才需要下探
1597
+ React.createElement("label", { className: "dbt-row", style: { flex: "none" } }, t("fieldsTlsMinVersion"),
1598
+ React.createElement("select", { "aria-label": t("fieldsTlsMinVersion"), value: form.tlsMinVersion, onChange: (e) => patch({ tlsMinVersion: e.target.value }) },
1599
+ React.createElement("option", { value: "" }, t("tlsMinDefault")),
1600
+ ["TLSv1", "TLSv1.1", "TLSv1.2"].map((v) => React.createElement("option", { key: v, value: v }, v))))),
1601
+ React.createElement("label", { className: "dbt-row", style: { flex: "none" } },
1602
+ React.createElement("input", { type: "checkbox", checked: !!form.trustServerCertificate, "aria-label": t("fieldsTrustCert"), onChange: (e) => patch({ trustServerCertificate: e.target.checked }) }),
1603
+ t("fieldsTrustCert")),
1604
+ React.createElement("div", { className: "dbt-row" },
1605
+ React.createElement("input", { placeholder: t("fieldsServerName"), "aria-label": t("fieldsServerName"), value: form.serverName, onChange: (e) => patch({ serverName: e.target.value }) }),
1606
+ React.createElement("input", { placeholder: t("fieldsCaCert"), "aria-label": t("fieldsCaCert"), value: form.caCert, onChange: (e) => patch({ caCert: e.target.value }) })),
1607
+ React.createElement("span", { style: { fontSize: 12, opacity: 0.75 } }, t("tlsHint")))
1608
+ : null)
1567
1609
  : null,
1568
1610
  ),
1569
1611
  // 分组 3:操作
@@ -3725,6 +3767,9 @@ window.__ModuleLoader__.load({
3725
3767
  const [message, setMessage] = React.useState("");
3726
3768
  const [confirmReq, setConfirmReq] = React.useState(null); // {statement,danger,reason,resolve}
3727
3769
  const [treeRefresh, setTreeRefresh] = React.useState(0); // 浏览树刷新信号(手动按钮 / 切回可见自动)
3770
+ // 插件版本号(显示在项目行最右侧):只拉一次并缓存,失败保持空串——版本展示属辅助信息,
3771
+ // 拿不到就不显示,绝不让它影响面板其它功能或弹错误。
3772
+ const [pluginVer, setPluginVer] = React.useState("");
3728
3773
  // 侧边栏切回可见时自动重拉已展开层:agents 等外部建库/建表后切回来即可见,无需手点刷新
3729
3774
  React.useEffect(() => {
3730
3775
  if (visible) setTreeRefresh((x) => x + 1);
@@ -3766,6 +3811,18 @@ window.__ModuleLoader__.load({
3766
3811
  reload().catch((e) => setError(String(e && e.message ? e.message : e)));
3767
3812
  }, [visible, reload]);
3768
3813
 
3814
+ // 版本号只请求一次(成功或失败都算尝试过):失败就静默不显示,不在每次切回面板时重试,
3815
+ // 避免把一个纯展示信息变成后台反复失败请求。用 ref 而非 state 记「已尝试」,
3816
+ // 否则 setPluginVer 触发重渲染又会把 effect 拉起来。
3817
+ const verTriedRef = React.useRef(false);
3818
+ React.useEffect(() => {
3819
+ if (!visible || verTriedRef.current) return;
3820
+ verTriedRef.current = true;
3821
+ api("version")
3822
+ .then((r) => setPluginVer(r && typeof r.version === "string" ? r.version : ""))
3823
+ .catch(() => { /* 拿不到就不显示版本,不影响面板 */ });
3824
+ }, [visible]);
3825
+
3769
3826
  function run(name, fn) {
3770
3827
  setBusy(name); setError(""); setMessage("");
3771
3828
  return fn()
@@ -3776,6 +3833,21 @@ window.__ModuleLoader__.load({
3776
3833
  function onError(e) { setError(String(e && e.message ? e.message : e)); }
3777
3834
 
3778
3835
  const shared = { conns, grants, projectPath, busy, run, reload, onError, askConfirm };
3836
+ // 版本号胶囊:钉在项目行最右侧。抽成局部变量让「只读态」与「编辑态」两种行都能复用,
3837
+ // 否则用户点了铅笔进入编辑后版本号就消失了。拿不到版本时不渲染,避免空白胶囊。
3838
+ const versionBadge = pluginVer
3839
+ ? React.createElement("span", {
3840
+ className: "dbt-ver",
3841
+ style: {
3842
+ marginLeft: "auto", flex: "none",
3843
+ display: "inline-block", padding: "0 6px", borderRadius: "9px",
3844
+ border: "1px solid currentColor", fontSize: "11px", lineHeight: "16px",
3845
+ opacity: 0.75, whiteSpace: "nowrap",
3846
+ },
3847
+ title: t("pluginVersion") + ": v" + pluginVer,
3848
+ "aria-label": t("pluginVersion") + ": v" + pluginVer,
3849
+ }, "v" + pluginVer)
3850
+ : null;
3779
3851
  // 四视图常驻元素:Panel 渲染期构建一次,切 tab 不重建(状态保留),ManageView/GrantsView/ConsoleView 复用 shared
3780
3852
  const viewEls = {
3781
3853
  manage: React.createElement(ManageView, shared),
@@ -3812,6 +3884,8 @@ window.__ModuleLoader__.load({
3812
3884
  if (!v || v === resolvedRef.current) setProjectEdited(false);
3813
3885
  },
3814
3886
  }),
3887
+ // 编辑态同样显示版本号:否则用户一点铅笔,版本标签就消失
3888
+ versionBadge,
3815
3889
  )
3816
3890
  : React.createElement("div", { className: "dbt-row" },
3817
3891
  React.createElement("span", { className: "dbt-muted" }, t("projectLabel", { path: projectPath })),
@@ -3835,6 +3909,8 @@ window.__ModuleLoader__.load({
3835
3909
  stroke: "currentColor", strokeWidth: 1.2, strokeLinecap: "round", strokeLinejoin: "round",
3836
3910
  })),
3837
3911
  )),
3912
+ // 版本号胶囊(与编辑态共用同一份 versionBadge,保证两种状态位置一致)
3913
+ versionBadge,
3838
3914
  error ? React.createElement("div", { className: "dbt-err", "aria-live": "polite" }, t("error") + ": " + error) : null,
3839
3915
  message ? React.createElement("div", { className: "dbt-msg", "aria-live": "polite" }, message) : null,
3840
3916
  // 视图常驻挂载 + hidden 切换:切 tab 保留各视图内部状态(控制台 SQL、浏览选中不丢);
@@ -107,11 +107,15 @@ function applyTdsVersion(options, raw, source) {
107
107
  options.tdsVersion = v;
108
108
  }
109
109
  /**
110
- * 加密模式(URL ?encrypt= 与 fields.encrypt 同口径):true / false / strict 三态。
110
+ * 加密模式(URL ?encrypt= 与 fields.encrypt 同口径):true / false / strict / auto 四态。
111
111
  *
112
112
  * strict 是 tedious 对 TDS 8.0 的支持:TLS 在登录包之前就完成握手(ALPN=tds/8.0),仅 SQL Server
113
113
  * 2022+ 开启 Force Strict Encryption 的实例可用;老实例必然拒绝,故非法值必须 fail-closed 报清
114
- * 三态,不能把拼错的 'stric' 之类静默当成 true(加密强度是安全承诺,连错不报是安全事故)。
114
+ * 四态,不能把拼错的 'stric' 之类静默当成 true(加密强度是安全承诺,连错不报是安全事故)。
115
+ *
116
+ * auto 是 Navicat 式「只填地址账号密码就能连」:先按 encrypt=false 连(等同 Navicat 不开加密),
117
+ * 只有服务器在预登录回 ON/REQ(tedious 抛 EENCRYPT「Server requires encryption」)时才自动以
118
+ * encrypt=true + trustServerCertificate=true 重试一次,见 makePoolBox。显式三态绝不触发重试。
115
119
  */
116
120
  function applyEncrypt(options, raw, source) {
117
121
  if (raw === undefined || raw === null || String(raw).trim() === '')
@@ -133,7 +137,11 @@ function applyEncrypt(options, raw, source) {
133
137
  options.encrypt = 'strict';
134
138
  return;
135
139
  }
136
- throw new Error(`sqlserver 连接${source}的 encrypt 取值非法:${JSON.stringify(raw)}(只接受 true/false/strict;strict 表示 TDS 8.0 强制加密,仅 SQL Server 2022 及以上并开启 Force Strict Encryption 的实例支持)`);
140
+ if (s === 'auto') {
141
+ options.encrypt = 'auto';
142
+ return;
143
+ }
144
+ throw new Error(`sqlserver 连接${source}的 encrypt 取值非法:${JSON.stringify(raw)}(只接受 true/false/strict/auto;strict 表示 TDS 8.0 强制加密,仅 SQL Server 2022 及以上并开启 Force Strict Encryption 的实例支持;auto 表示先按明文连接,服务器强制加密时自动以 encrypt=true 并信任服务器证书重试一次)`);
137
145
  }
138
146
  /**
139
147
  * 证书校验用的服务器名(URL ?serverName= 与 fields.serverName 同口径)。
@@ -143,9 +151,10 @@ function applyEncrypt(options, raw, source) {
143
151
  * 用它(startTls),故一个参数覆盖两条路径。留空不注入,沿用「主机名作校验名」的默认语义。
144
152
  */
145
153
  function applyServerName(options, raw) {
146
- if (raw === undefined || raw === null || String(raw) === '')
154
+ // 与其它 applyXxx 同口径先 trim 再判空:否则 ?serverName=%20(一个空格)会被当成有效值注入
155
+ if (raw === undefined || raw === null || String(raw).trim() === '')
147
156
  return;
148
- options.serverName = String(raw);
157
+ options.serverName = String(raw).trim();
149
158
  }
150
159
  /** 取(必要时创建)cryptoCredentialsDetails,供 minVersion 与 ca 合并写入而不互相覆盖 */
151
160
  function credentialsOf(options) {
@@ -203,19 +212,78 @@ function defaultCa() {
203
212
  * 两条硬约束:
204
213
  * - 路径读不出来必须 fail-closed 抛中文错。若静默跳过,用户会以为「已装 CA 但还连不上」,实际
205
214
  * 仍按系统信任库校验——问题只会更难定位。
206
- * - 写入的 ca 必须与系统信任库合并(见 systemCa 的注释),否则等于缩小信任范围。
215
+ * - 写入的 ca 必须同时并入**内置 bundle 与系统信任库**,否则等于缩小信任范围:Node 一旦收到
216
+ * ca 选项就不再使用默认信任库,只塞用户那张会让"为连 A 实例装一张自签 CA"变成"从此所有公网
217
+ * CA 实例全部连不上"(SELF_SIGNED_CERT_IN_CHAIN)。这里与 applySystemCaDefault 用同一套合并口径。
207
218
  */
208
219
  function applyCaCert(options, raw, source) {
209
220
  if (raw === undefined || raw === null || String(raw) === '')
210
221
  return;
211
222
  const text = String(raw);
212
223
  // PEM 文本原样保留(换行是 PEM 结构的一部分);只有当路径时才去掉两端空白
213
- const pem = text.includes(PEM_HEADER) ? text : readCaFile(text.trim(), source);
214
- const merged = systemCa().slice();
215
- if (!merged.includes(pem))
216
- merged.push(pem);
224
+ const pem = text.includes(PEM_HEADER) ? normalizePem(text, source) : readCaFile(text.trim(), source);
225
+ const merged = mergedCaWith(pem);
217
226
  credentialsOf(options).ca = merged;
218
227
  }
228
+ /**
229
+ * 规范化 PEM 文本:把被压成一行的证书重新按 64 列折行。
230
+ *
231
+ * 为什么必须有这一步:前端 caCert 是单行 <input>,用户从证书文件粘贴多行 PEM 时换行会被
232
+ * 浏览器压平。而 `tls.createSecureContext({ca:[单行PEM]})` **不报错**(实测静默接受),
233
+ * 握手时才失败并报 DEPTH_ZERO_SELF_SIGNED_CERT——用户看到的是"我填了 CA 还是同样的错",
234
+ * 属最难排查的失败形态。这里主动还原折行,避免把格式问题留给用户去猜。
235
+ */
236
+ function normalizePem(text, source) {
237
+ const trimmed = text.trim();
238
+ // 已含换行(正常 PEM 块)→ 原样返回,绝不打乱有效格式
239
+ if (/[\r\n]/.test(trimmed))
240
+ return trimmed;
241
+ // 单行形态:抽出所有完整证书块并重新折行
242
+ const blocks = trimmed.match(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g);
243
+ if (blocks !== null && blocks.length > 0) {
244
+ return blocks
245
+ .map((b) => {
246
+ const m = /-----BEGIN ([^-]+)-----([\s\S]*?)-----END \1-----/.exec(b);
247
+ if (m === null)
248
+ return b;
249
+ const body = (m[2] ?? '').replace(/\s+/g, '');
250
+ if (body === '')
251
+ return b;
252
+ const wrapped = body.match(/.{1,64}/g) ?? [];
253
+ return `-----BEGIN ${m[1]}-----\n${wrapped.join('\n')}\n-----END ${m[1]}-----`;
254
+ })
255
+ .join('\n');
256
+ }
257
+ // 含 BEGIN 但不是完整证书块(被截断/粘贴残缺)→ fail-closed 说清楚,不要静默接受
258
+ throw new Error(`sqlserver 连接${source}的 caCert 不是完整的 PEM 证书(缺少 -----END 结尾或内容被截断)。请给出完整的 -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- 块,或改用文件路径`);
259
+ }
260
+ /**
261
+ * 合并 CA 列表:内置 bundle + 系统信任库 + 额外 PEM(去重,保序)。
262
+ *
263
+ * 三处(默认注入 / 显式 caCert / 将来可能的扩展)必须共用同一口径,否则会出现
264
+ * "未给 caCert 时信任 154 张、给了反而只剩 37 张"这种自相矛盾的信任收窄。
265
+ * 列表在进程生命周期内不变,故做模块级缓存——createSecureContext 传入近百张证书的开销
266
+ * 是可测的(每次建连都要跑一遍 poolConfig)。
267
+ */
268
+ let caCache;
269
+ function caPools() {
270
+ if (caCache === undefined)
271
+ caCache = { bundled: defaultCa(), system: systemCa() };
272
+ return caCache;
273
+ }
274
+ /** 合并成一份去重的 CA 列表(bundled 优先,其次 system,最后 extras) */
275
+ function mergedCaWith(...extras) {
276
+ const { bundled, system } = caPools();
277
+ const out = [];
278
+ const seen = new Set();
279
+ for (const pem of [...bundled, ...system, ...extras]) {
280
+ if (pem === '' || seen.has(pem))
281
+ continue;
282
+ seen.add(pem);
283
+ out.push(pem);
284
+ }
285
+ return out;
286
+ }
219
287
  /**
220
288
  * 把系统信任库并入默认校验(未显式给 caCert 时也生效)。
221
289
  *
@@ -241,15 +309,9 @@ function applySystemCaDefault(options) {
241
309
  if (cur !== null && typeof cur === 'object' && cur['ca'] !== undefined)
242
310
  return;
243
311
  }
244
- const bundled = defaultCa();
245
- const system = systemCa();
312
+ // 合并口径统一走 mergedCaWith(与 applyCaCert 同一实现,避免两处漂移)
313
+ const merged = mergedCaWith();
246
314
  // 两者都拿不到 → 不注入:保持 Node 默认(内置 bundle 依然生效)
247
- if (bundled.length === 0 && system.length === 0)
248
- return;
249
- const merged = bundled.slice();
250
- for (const pem of system)
251
- if (!merged.includes(pem))
252
- merged.push(pem);
253
315
  if (merged.length === 0)
254
316
  return;
255
317
  credentialsOf(options).ca = merged;
@@ -410,6 +472,7 @@ function poolConfig(conn) {
410
472
  }
411
473
  // conn.ssl 是通用字段(mysql/pg 适配器同样处理):语义上等价要求加密信道。
412
474
  // 已显式选 strict(TDS 8.0)时不降级为 true——strict 是更强的加密承诺,降级会悄悄削弱安全要求。
475
+ // auto 也要让位:ssl=true 是用户显式要求加密,不能因为 auto 的首阶段是明文就退回明文。
413
476
  if (conn.ssl && options.encrypt !== 'strict')
414
477
  options.encrypt = true;
415
478
  // 未显式给 caCert 时把系统信任库并入默认校验:Node 默认只信任内置 bundle,不读操作系统信任库,
@@ -627,6 +690,42 @@ function erequestHint(num) {
627
690
  return undefined;
628
691
  return Object.prototype.hasOwnProperty.call(EREQUEST_HINTS, num) ? EREQUEST_HINTS[num] : undefined;
629
692
  }
693
+ /**
694
+ * 服务器原文 → 错误号特征表(number 缺失时的兜底)。
695
+ *
696
+ * 为什么必须有这条兜底:登录阶段 tedious 抛的是 `ConnectionError(message, 'ELOGIN')`,不带 number,
697
+ * 而 17819/17835/17898/17802/17836/17828/17832 全部发生在建连/登录阶段。只按 number 查表等于
698
+ * 对这些错误完全没有分诊,还会把 17819 误报成"用户名或密码错误"。
699
+ *
700
+ * 特征取官方 sys.messages 原文里最独特的片段,避免与其它错误串味;宁可漏匹配(退回通用文案)
701
+ * 也不要错匹配(给错药方比不给更糟)。
702
+ */
703
+ const HINT_BY_TEXT = [
704
+ // "The SQL Server or the endpoint is configured to accept only strict (TDS 8.0 and above) connections"
705
+ { num: 17819, re: /accept only strict|TDS 8\.0 and above/i },
706
+ // "Encryption is required to connect to this server but the client library does not support encryption"
707
+ { num: 17835, re: /Encryption is required to connect to this server/i },
708
+ // "Please use the client library which supports TDS Version 7.4 or higher"
709
+ { num: 17898, re: /supports TDS Version 7\.4 or higher/i },
710
+ // "The Tabular Data Stream \(TDS\) version 0x... is unsupported or unknown"
711
+ { num: 17802, re: /TDS\)? version 0x[0-9a-fA-F]+[^.]*unsupported or unknown/i },
712
+ // "Length specified in network packet payload did not match number of bytes read"
713
+ { num: 17836, re: /Length specified in network packet payload did not match/i },
714
+ // "The prelogin packet used to open the connection is structurally invalid"
715
+ { num: 17828, re: /prelogin packet used to open the connection is structurally invalid/i },
716
+ // "The login packet used to open the connection is structurally invalid"
717
+ { num: 17832, re: /login packet used to open the connection is structurally invalid/i },
718
+ // "Login failed with invalid TLS version"(Azure 最低 TLS 门槛)
719
+ { num: 47072, re: /invalid TLS version/i },
720
+ ];
721
+ /** 按服务器原文特征反查错误号诊断(number 缺失或未收录时使用) */
722
+ function hintByText(text) {
723
+ for (const { num, re } of HINT_BY_TEXT) {
724
+ if (re.test(text))
725
+ return EREQUEST_HINTS[num];
726
+ }
727
+ return undefined;
728
+ }
630
729
  /**
631
730
  * mssql 错误码 → 中文可读提示。ETIMEOUT 在 ConnectionError(建连超时)与 RequestError
632
731
  * (语句执行超时)下语义不同,故结合 err.name 判断而非只看 code;originalError/precedingErrors
@@ -663,12 +762,22 @@ function mssqlReason(e) {
663
762
  const tlsish = tlsProtoish ||
664
763
  tlsCertish ||
665
764
  /ERR_SSL|EPROTO|SSL[ -_]?routines|OPENSSL_internal|handshake[ _-]?(failure|client|server)|TLSV1_ALERT|SSLV3_ALERT|alert handshake failure/i.test(tlsText);
666
- // 已知错误号对应的针对性诊断(未收录为 undefined):既用于 EREQUEST 文案,也用于抑制 TLS 族建议
667
- const knownHint = erequestHint(num);
765
+ // 已知错误号对应的针对性诊断(未收录为 undefined):既用于 EREQUEST 文案,也用于抑制 TLS 族建议。
766
+ // 关键坑:这些错误几乎全部发生在**登录阶段**,而 tedious 的 Login7TokenHandler.onErrorMessage
767
+ // 构造的是 `new ConnectionError(token.message, 'ELOGIN')` —— token.number 只用于判断临时错误,
768
+ // **没有赋给 error 对象**(只有查询阶段的 RequestError 才有 number)。因此真实链路上
769
+ // err.number 恒为 undefined,只按 number 查表会让这 8 条诊断全部失效、退化成"用户名或密码错误"。
770
+ // 故 number 缺失时按服务器原文特征反查(见 hintByText)。
771
+ const knownHint = erequestHint(num) ?? hintByText(detail);
668
772
  let base;
669
773
  switch (code) {
670
774
  case 'ELOGIN':
671
- base = `登录失败:用户名或密码错误,或账号不允许从当前主机登录(${detail})`;
775
+ // 登录阶段也可能是「服务器用错误号拒绝连接」而非账号问题(17819/17835/17898/17802 等都发生在
776
+ // 这一步)。命中已知特征时优先给针对性诊断——否则会把"只接受 strict 加密"误报成密码错误,
777
+ // 用户会反复核对账号,方向完全错。
778
+ base = knownHint === undefined
779
+ ? `登录失败:用户名或密码错误,或账号不允许从当前主机登录(${detail})`
780
+ : `登录失败:服务器在登录阶段拒绝了该连接(${knownHint})。服务器原文:${detail}`;
672
781
  break;
673
782
  case 'ETIMEOUT':
674
783
  base =
@@ -706,6 +815,9 @@ function mssqlReason(e) {
706
815
  case 'ECANCEL':
707
816
  base = `请求已取消(${detail})`;
708
817
  break;
818
+ // encrypt=auto 的合并错误:文案本身已含「该实例强制加密」与两条原始原因,原样透出即可。
819
+ // 不做任何族建议——重试已带 trustServerCertificate=true,而原始原因②常含 self signed
820
+ // certificate / TLS 字样,让证书族建议跟上来会把人引向「再关一次校验」,那是错误方向。
709
821
  case 'EREQUEST': {
710
822
  base = `SQL 执行错误${num !== undefined ? `(错误号 ${num}${sev !== undefined ? `、严重级 ${sev}` : ''}${line !== undefined ? `、第 ${line} 行` : ''})` : ''}:${knownHint === undefined ? detail : `${knownHint}。服务器原文:${detail}`}`;
711
823
  break;
@@ -717,8 +829,10 @@ function mssqlReason(e) {
717
829
  // 已知错误号已经给出了针对性诊断时,抑制 TLS 族建议:这类错误的服务器原文常含 certificate /
718
830
  // encryption 字样(如 17819 的「TLS certificate is not configured to accept strict」),会被下面的
719
831
  // 正则命中;但真因是服务端配置/协议问题,客户端装 CA、关校验一律无效,叠加只会给出互相矛盾的药方。
720
- if (knownHint !== undefined) {
721
- // 已由 EREQUEST_HINTS 给出单一出口,此处不再追加任何族建议
832
+ // EAUTOENCRYPT(encrypt=auto 两次都失败)同理:原始原因②常带 self signed certificate / TLS
833
+ // 字样,而重试已经带了 trustServerCertificate=true,再追加证书族建议等于让人重复做同一件事。
834
+ if (knownHint !== undefined || code === 'EAUTOENCRYPT') {
835
+ // 已由 EREQUEST_HINTS / EAUTOENCRYPT 给出单一出口,此处不再追加任何族建议
722
836
  }
723
837
  else if (tlsProtoish) {
724
838
  // 顺序即优先级:服务端升级是正解(一次性、对所有客户端生效),客户端下探是权宜(仍受限于
@@ -820,17 +934,100 @@ function parseMajor(row) {
820
934
  function rowsOf(res) {
821
935
  return (res.recordset ?? res.recordsets?.[0] ?? []);
822
936
  }
937
+ /**
938
+ * 建连失败是否「服务器强制加密」(encrypt=auto 的重试判据)。
939
+ *
940
+ * tedious 在 PRELOGIN 响应为 ON/REQ 而客户端 encrypt=false 时抛
941
+ * `EENCRYPT: Server requires encryption, set 'encrypt' config option to true.`。
942
+ * 同一个 EENCRYPT 还有反方向的一种成因(客户端要加密但服务器回了 OFF/NOT_SUP,文案是
943
+ * "Server did not negotiate encryption")——那种情形重试加密毫无意义,必须排除,否则会把
944
+ * 「服务器不支持加密」误判成「服务器强制加密」而白跑一次。
945
+ */
946
+ function isServerRequiresEncryption(e) {
947
+ const err = (e ?? {});
948
+ const text = `${errText(e)} ${errText(err.originalError)} ${errText(err.cause)}`;
949
+ if (/did not negotiate encryption/i.test(text))
950
+ return false;
951
+ // code 存在时必须是 EENCRYPT;缺失时(被上层重新包装过)按服务器原文特征判定
952
+ const code = typeof err.code === 'string' ? err.code : '';
953
+ if (code !== '' && code !== 'EENCRYPT')
954
+ return false;
955
+ return /Server requires encryption/i.test(text);
956
+ }
957
+ /**
958
+ * encrypt=auto 两次都失败时的合并错误。
959
+ *
960
+ * 必须保留两条原始原因:用户要判断的是「服务器确实强制加密」还是「加密路径本身有别的问题」,
961
+ * 只留最后一条会把明文侧的拒绝原因吞掉。code 标成未被 tedious 使用的 EAUTOENCRYPT,让
962
+ * mssqlReason 走专门分支直接输出本条完整文案,不做族建议——重试已经带了
963
+ * trustServerCertificate=true,再往证书方向引(原始原因里常有 "self signed certificate")
964
+ * 只会让人白排查一轮。
965
+ */
966
+ function autoEncryptFailure(first, second) {
967
+ const err = new Error(`该实例强制加密:encrypt=auto 先按明文连接被服务器拒绝(服务器要求加密),已自动改用 encrypt=true 并信任服务器证书重试一次,仍然失败。` +
968
+ `原始原因①(明文连接):${errText(first)};原始原因②(加密连接):${errText(second)}`);
969
+ Object.assign(err, { code: 'EAUTOENCRYPT' });
970
+ return err;
971
+ }
823
972
  function makePoolBox(Driver, cfg) {
824
- const pool = new Driver.ConnectionPool(cfg);
973
+ // encrypt=auto 只在本层展开,绝不把字面量 'auto' 交给 tedious(它对未知取值没有定义行为)。
974
+ // 池配置是浅拷贝共享的(跨库池 { ...cfg, database } 的 options 仍指向同一对象),故这里必须
975
+ // 先拷一层 options 再改 encrypt,否则会把「第一阶段用明文」写回共享配置、污染后续池。
976
+ const baseOptions = (cfg.options ?? {});
977
+ const autoEncrypt = baseOptions['encrypt'] === 'auto';
978
+ const initialCfg = autoEncrypt
979
+ ? { ...cfg, options: { ...baseOptions, encrypt: false } }
980
+ : cfg;
981
+ // 第二阶段配置(仅 auto 存在):encrypt=true + 信任服务器证书,等价 Navicat 勾「加密+信任证书」
982
+ const retryCfg = autoEncrypt
983
+ ? { ...cfg, options: { ...baseOptions, encrypt: true, trustServerCertificate: true } }
984
+ : undefined;
825
985
  // 无条件兜底:mssql 的 ConnectionPool 在 acquire 失败(空闲连接被服务器断开等)时会
826
986
  // emit('error', err),EventEmitter 无监听器时直接抛 → 崩掉宿主进程。这里只留痕不中断请求。
827
- pool.on('error', (e) => {
828
- console.error('[sqlserver] 连接池错误(不影响后续请求,连接将按需重建):', e instanceof Error ? e.message : e);
829
- });
987
+ const attachErrorTrap = (p) => {
988
+ p.on('error', (e) => {
989
+ console.error('[sqlserver] 连接池错误(不影响后续请求,连接将按需重建):', e instanceof Error ? e.message : e);
990
+ });
991
+ };
992
+ // auto 重试会换池,故 pool 必须以 getter 暴露当前有效池:调用方(query/close)拿到的永远是
993
+ // 正在用的那个,不需要知道发生过重试。
994
+ let pool = new Driver.ConnectionPool(initialCfg);
995
+ attachErrorTrap(pool);
996
+ /**
997
+ * 单次建连;auto 模式下服务器强制加密时换一次池并重试,绝不循环。
998
+ * 顺序即语义:先明文(等同 Navicat 不开加密)→ 仅「服务器明确要求加密」才升级到加密+信任证书。
999
+ */
1000
+ const connectOnce = async () => {
1001
+ try {
1002
+ await pool.connect();
1003
+ return;
1004
+ }
1005
+ catch (e) {
1006
+ if (retryCfg === undefined || !isServerRequiresEncryption(e))
1007
+ throw e;
1008
+ const first = e;
1009
+ // 明文池已废:不关会留下半死连接(其 socket 监听仍在),失败也不得掩盖真正的成因
1010
+ try {
1011
+ await pool.close();
1012
+ }
1013
+ catch {
1014
+ /* 关闭失败不影响重试判据 */
1015
+ }
1016
+ const next = new Driver.ConnectionPool(retryCfg);
1017
+ attachErrorTrap(next);
1018
+ pool = next;
1019
+ try {
1020
+ await pool.connect();
1021
+ }
1022
+ catch (second) {
1023
+ throw autoEncryptFailure(first, second);
1024
+ }
1025
+ }
1026
+ };
830
1027
  let connecting;
831
1028
  const ready = () => {
832
1029
  if (connecting === undefined) {
833
- connecting = Promise.resolve(pool.connect()).then(() => undefined, (e) => {
1030
+ connecting = connectOnce().then(() => undefined, (e) => {
834
1031
  connecting = undefined; // 失败不缓存:允许下次重试
835
1032
  throw e;
836
1033
  });
@@ -855,7 +1052,13 @@ function makePoolBox(Driver, cfg) {
855
1052
  }
856
1053
  return majorPromise;
857
1054
  };
858
- return { pool, ready, major };
1055
+ return {
1056
+ get pool() {
1057
+ return pool;
1058
+ },
1059
+ ready,
1060
+ major,
1061
+ };
859
1062
  }
860
1063
  /* ============================ 适配器 ============================ */
861
1064
  const LIST_DATABASES_SQL = `SELECT name FROM sys.databases WHERE database_id > 4 ORDER BY name`;
@@ -1164,11 +1367,22 @@ export const factory = (conn) => createSqlserverAdapter(conn);
1164
1367
  /**
1165
1368
  * 数据传输端复用的独立原始池(绕过展示化转换,保真搬运)。
1166
1369
  * 库名与主连接一致(跨库由调用方在 conn 上覆盖 database)。
1370
+ *
1371
+ * pool 是 getter:encrypt=auto 的重试会换掉底层池,而传输端是在 ready() 之前就抓住 pool 的
1372
+ * (createSqlserverTransferReaderFromConn 先取 raw.pool 再 await raw.ready()),
1373
+ * 固定字段会把重试后的新池挡在外面、继续用那个已关闭的明文池。
1167
1374
  */
1168
1375
  export async function openSqlserverRaw(conn, driver) {
1169
1376
  const Driver = driver ?? vendor();
1170
1377
  const cfg = poolConfig(conn);
1171
1378
  const box = makePoolBox(Driver, cfg);
1172
1379
  const database = typeof cfg.database === 'string' ? cfg.database : '';
1173
- return { pool: box.pool, ready: box.ready, database, driver: Driver };
1380
+ return {
1381
+ get pool() {
1382
+ return box.pool;
1383
+ },
1384
+ ready: box.ready,
1385
+ database,
1386
+ driver: Driver,
1387
+ };
1174
1388
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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",
@@ -182,9 +182,10 @@ DatabaseManager({ action: "run_script", conn_id: "mongo1", code: '{"find":"users
182
182
 
183
183
  - 覆盖 SQL Server 2005(9.0)→ 2025(17.0)与 Azure SQL Database;不支持 SQL Server 2000 及更早。方言与元数据只使用 2005+ 通用写法(元数据走 `sys.*` catalog views)。
184
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 认证**——需要这两类认证时本工具不可用。SQL Server 全版本另支持 FEDAUTH 之外的 `IsIntegratedSecurityOnly` 判定:为 1 表示实例仅允许 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 与分字段表单**同口径**,两处都能覆盖上述各项;非法取值直接报错,不静默回退。
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=...`)。**常规连接只需主机/端口/账号/密码**;除 `useUTC` / `instanceName` 外,前述加密与证书参数在界面里都收在「高级选项」折叠区(默认收起),属排障用,不要默认要求用户去填。
187
+ - **默认与覆盖**:`encrypt=auto`(新建连接默认)+ `trustServerCertificate=false` + `tdsVersion=7_4` + `useUTC=true`。URL query 与分字段表单**同口径**,两处都能覆盖上述各项;非法取值直接报错,不静默回退。
188
+ - **`encrypt=auto` 的语义(默认策略,勿按「必须手动开加密」描述)**:先不带加密建连;若服务器**强制加密**(`EENCRYPT` / 「服务器要求加密连接」,如 `Force Encryption=Yes` 或 Azure SQL),自动以 `encrypt=true` + `trustServerCertificate=true` **重试一次**。边界严格:**仅重试一次**;**仅针对「服务器强制加密」这一种原因**(证书类、协议版本类、登录失败等一律不重试);**显式指定 `encrypt`(`true`/`false`/`strict`)时不做任何重试**。**`auto` 不等于「自动加密」**:在未强制加密的服务器上它等价于 `encrypt=false`,即明文传输——见下条风险说明。
188
189
 
189
190
  ### TLS 握手失败:两类根因,解法互不通用
190
191
 
@@ -195,7 +196,9 @@ DatabaseManager({ action: "run_script", conn_id: "mongo1", code: '{"find":"users
195
196
 
196
197
  **不要拿证书类参数治协议版本类**:`trustServerCertificate=true` / `encrypt=false` 只影响证书校验,对 `UNSUPPORTED_PROTOCOL` 没有任何作用,试错只会白跑一轮。两类判据互斥,报错消息里已按族给出中文根因;看到 `TCP 已连通但 TLS ...` 说明网络与端口都没问题,不要再查主机/防火墙。
197
198
 
198
- **关键事实:`encrypt=false` 不能跳过 TLS 握手**。SQL Server 的登录包(Login7,含账号密码)**即使在未配置加密时也始终加密**(官方原文:"even when you don't configure encryption, the credentials (in the login packet) ... are always encrypted")。所以协议版本无交集时 `encrypt=false` 一样连不上——这是「改了加密参数却仍报同一个错」的根因。另注:连 2005 或未打补丁的 2008 时,服务端可能只有 TLS 1.0,此时必须下探 `?tlsMinVersion=TLSv1`。
199
+ **默认 `auto` 不会替你兜住证书类错误**:`auto` 的自动重试只认「服务器强制加密」这一种原因,证书校验失败仍会原样报错。也就是说自签证书实例在默认值下照样可能连不上(取决于服务器是否强制加密),此时按上表证书族路径处理——把 CA 导入系统受信任根通常就能连(插件已并入系统信任库,无需填 `caCert`)。
200
+
201
+ **关键事实:`encrypt=false` 与默认 `encrypt=auto` 的真实行为(已抓包实测)**。服务器**未**强制加密时,`encrypt=false` 会**完全跳过 TLS 握手并明文发送 LOGIN7(含账号密码)**——实测客户端下一个包就是 TDS `0x10`,没有 TLS 握手。这是明文传输风险,不是「只是不加密数据包」。**默认的 `auto` 第一步正是这条路径,因此在未强制加密的服务器上 `auto` 与 `encrypt=false` 行为完全相同**——`auto` 只在服务器拒绝明文时才升级到 TLS,它不是「自动帮你加密」。服务器**强制**加密时(`Force Encryption=Yes` / Azure SQL),`encrypt=false` 会被拒(提示服务器要求加密)。官方那句「登录包始终加密」仅适用于「服务器强制加密或客户端 `encrypt=true`」的场景,**不可推广成 `encrypt=false` 也总有 TLS 握手**。故:可信内网可用默认值;跨公网或不可信网段连接时**显式设 `encrypt=true`**(显式值不触发任何自动重试,行为可预期),证书问题再用 `caCert` / `serverName` 解决。另注:连 2005 或未打补丁的 2008 时服务端可能只有 TLS 1.0,需下探 `?tlsMinVersion=TLSv1`。
199
202
 
200
203
  ### 版本与 TLS 1.2 支持
201
204