dsh-db-tool 1.9.2 → 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 +15 -4
- package/README.md +8 -2
- package/README_EN.md +8 -2
- package/client/client.js +36 -0
- package/dist/adapters/sqlserver/index.js +118 -18
- package/dist/http/index.js +5 -0
- package/dist/http/version.js +33 -0
- package/package.json +1 -1
- package/skills/db-admin/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# Changelog — dsh-db-tool
|
|
2
2
|
|
|
3
|
-
## 1.9.
|
|
3
|
+
## 1.9.3
|
|
4
4
|
|
|
5
|
-
SQL Server 支持范围扩到 2005(9.0)→ 2025(17.0
|
|
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`、前端不渲染胶囊且不报错:版本展示属辅助信息,不能污染面板错误条,也不该变成后台反复失败请求。
|
|
6
10
|
|
|
7
11
|
### 修复
|
|
8
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
|
+
|
|
9
19
|
- **前端补齐加密参数入口(用户可见的核心修复)**:分字段表单此前只暴露 `tlsMinVersion`,没有任何 `encrypt` / `trustServerCertificate` 入口——而 `tlsMinVersion` 恰好对「证书类」错误无效。用户连自签证书实例时唯一能动的开关是错的那个,于是陷入「换个 TLS 版本再试、还是同一个错」的死循环。现补齐三项控件:`encrypt`(`默认`/`强制加密`/`不主动加密`/`严格加密 TDS 8.0`)、`trustServerCertificate` 复选框、`serverName` 与 `caCert` 输入框,并在表单内直接给出「证书类 vs 协议版本类」的分流提示。
|
|
10
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+)。把它摆在表单里等于把驱动已处理好的问题转嫁给用户。该参数仍保留为排障覆盖项。
|
|
11
21
|
- **编辑回填补全**:`ConnectionMeta` / `toMeta()` 增加 `encrypt` / `trustServerCertificate` / `serverName` / `caCert` 回填。此前这些字段即使存进 `connections.json` 也读不回来,编辑表单显示为空、保存即被静默清掉。`encrypt` 的三态(`true` / `false` / `'strict'`)按原语义透传,非法值不回填(fail-closed)。
|
|
@@ -20,18 +30,19 @@ SQL Server 支持范围扩到 2005(9.0)→ 2025(17.0),并修复前端
|
|
|
20
30
|
- **`encrypt=strict`(TDS 8.0)**:支持 SQL Server 2022+ 的 Force Strict Encryption。此前该场景**必然连不上**且报错会被归到证书族,误导排查。需注意 strict 路径下驱动不传 `rejectUnauthorized`,**`trustServerCertificate` 完全失效**,证书强制校验——报错文案已明确这一点。
|
|
21
31
|
- **`serverName`**:证书签发给域名而实际用 IP / 转发地址连接时指定校验名,把该场景从「关掉证书校验」里分流出来。
|
|
22
32
|
- **`caCert`**:传 PEM 文件路径或证书文本,走官方推荐解法(保住加密与校验,只扩大信任根)。
|
|
23
|
-
- **系统信任库默认注入**:Node 默认只信任编译期内置的 bundle,**不读操作系统信任库**——这意味着「按官方推荐把自签证书导入 Windows 受信任根」这个最常见的正确做法,在不注入的情况下对连接毫无帮助(用户会以为"导入了却没用")。现无论是否填 `caCert`,都把 `tls.getCACertificates('default')` 与 `('system')`
|
|
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)验证。
|
|
24
34
|
- **8 个服务器错误号的针对性分诊**:`17819`(只接受 strict → 改 `encrypt=strict`)、`17898`/`17802`(TDS 版本被拒 → 升级客户端库,**不**建议下调 `tdsVersion`)、`17835`(服务器强制加密 → 开 `encrypt`)、`17836`/`17828`/`17832`(包结构错配 → 查代理/中间层/端口,**不**归为版本问题)、`47072`(Azure 最低 TLS 门槛)。这些错误的服务器原文常含 `certificate`/`encryption` 字样,会被 TLS 族正则误命中并叠加「装 CA / 关校验」这类**无效**建议,故在命中已知错误号时抑制 TLS 族建议,保证每次只给出一个正确出口。
|
|
25
35
|
- **`EENCRYPT` 区分两种相反成因**:一是 `encrypt=false` 而服务器强制加密(药方是开),二是 `encrypt=true` 而服务器未协商加密(药方是关或修服务端),此前合并为一句必然把用户引反方向。
|
|
26
36
|
|
|
27
37
|
### 文档
|
|
28
38
|
|
|
29
|
-
- README / README_EN:覆盖范围改为 2005 → 2025;补充版本 → TLS 1.2 → 所需补丁对照;写明官方推荐顺序(装 CA
|
|
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`」这一无官方依据的经验说法。
|
|
30
40
|
|
|
31
41
|
### 行为变更(升级须知)
|
|
32
42
|
|
|
33
43
|
- **`encrypt=true` 连不支持加密的服务器现在会显式失败**(`EENCRYPT`),而不是静默明文传输。这是上述安全修复的本意:这类实例请显式设 `encrypt=false`。
|
|
34
44
|
- 若某实例确实只支持旧 TLS(如 2005 / 未打补丁的 2008),仍会失败——但报错现在指向「服务端补丁缺失」与「客户端下探 TLS 下限」两条正确路径。
|
|
45
|
+
- **`poolConfig` 现在总会写入 `cryptoCredentialsDetails.ca`**(内置 bundle + 系统信任库合并),不再「未配置 TLS 选项就保持 options 形状」。这是为了让「按官方推荐把自签证书导入系统受信任根」真正生效(Node 默认不读系统信任库)。老环境(无 `tls.getCACertificates`)仍不注入、保持原生默认,公网证书不受影响。
|
|
35
46
|
|
|
36
47
|
## 1.9.1
|
|
37
48
|
|
package/README.md
CHANGED
|
@@ -169,7 +169,13 @@ Navicat 风格的右侧栏多标签编辑器(CodeMirror 6):
|
|
|
169
169
|
|
|
170
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
171
|
|
|
172
|
-
**`encrypt=false`
|
|
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` 应视为明确的降级选择。
|
|
173
179
|
|
|
174
180
|
**`encrypt=strict`(TDS 8.0)**:仅 SQL Server 2022+ 且开启 Force Strict Encryption 的实例支持。该模式下 TLS 在登录包之前就完成握手,因此 **`trustServerCertificate` 完全不生效**(驱动不传 `rejectUnauthorized`,证书强制校验),只能靠 `caCert` 或 `serverName`。
|
|
175
181
|
|
|
@@ -202,7 +208,7 @@ DSH 桌面端 0.2.0-rc.2+ 已捆绑 `dsh` 命令:
|
|
|
202
208
|
2. 安装插件(npm 安装须钉精确版本,`@latest` 会因 release-age 校验回落):
|
|
203
209
|
|
|
204
210
|
```bash
|
|
205
|
-
dsh plugin --profile desktop add dsh-db-tool@1.9.
|
|
211
|
+
dsh plugin --profile desktop add dsh-db-tool@1.9.3 --registry=https://registry.npmjs.org/
|
|
206
212
|
```
|
|
207
213
|
|
|
208
214
|
3. **重启桌面端**生效
|
package/README_EN.md
CHANGED
|
@@ -169,7 +169,13 @@ Covers SQL Server 2005 (9.0) through 2025 (17.0) and Azure SQL Database.
|
|
|
169
169
|
|
|
170
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
171
|
|
|
172
|
-
|
|
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.
|
|
173
179
|
|
|
174
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.
|
|
175
181
|
|
|
@@ -202,7 +208,7 @@ DSH desktop 0.2.0-rc.2+ bundles the `dsh` command:
|
|
|
202
208
|
2. Install the plugin (npm installs must pin an exact version — `@latest` falls back to an older release due to release-age checks):
|
|
203
209
|
|
|
204
210
|
```bash
|
|
205
|
-
dsh plugin --profile desktop add dsh-db-tool@1.9.
|
|
211
|
+
dsh plugin --profile desktop add dsh-db-tool@1.9.3 --registry=https://registry.npmjs.org/
|
|
206
212
|
```
|
|
207
213
|
|
|
208
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: "连接管理",
|
|
@@ -264,6 +265,7 @@ window.__ModuleLoader__.load({
|
|
|
264
265
|
tabDesc: "Connection management, project grants, data browse and a SQL console",
|
|
265
266
|
tabUnavailable: "This tab is not ready yet; switch tabs and try again",
|
|
266
267
|
projectLabel: "Project: {path}",
|
|
268
|
+
pluginVersion: "Plugin version",
|
|
267
269
|
projectUnbound: "Cannot detect current project path; enter the workspace absolute path below",
|
|
268
270
|
projectPathPlaceholder: "workspace absolute path (e.g. /home/me/my-app)",
|
|
269
271
|
viewManage: "Connections",
|
|
@@ -3725,6 +3727,9 @@ window.__ModuleLoader__.load({
|
|
|
3725
3727
|
const [message, setMessage] = React.useState("");
|
|
3726
3728
|
const [confirmReq, setConfirmReq] = React.useState(null); // {statement,danger,reason,resolve}
|
|
3727
3729
|
const [treeRefresh, setTreeRefresh] = React.useState(0); // 浏览树刷新信号(手动按钮 / 切回可见自动)
|
|
3730
|
+
// 插件版本号(显示在项目行最右侧):只拉一次并缓存,失败保持空串——版本展示属辅助信息,
|
|
3731
|
+
// 拿不到就不显示,绝不让它影响面板其它功能或弹错误。
|
|
3732
|
+
const [pluginVer, setPluginVer] = React.useState("");
|
|
3728
3733
|
// 侧边栏切回可见时自动重拉已展开层:agents 等外部建库/建表后切回来即可见,无需手点刷新
|
|
3729
3734
|
React.useEffect(() => {
|
|
3730
3735
|
if (visible) setTreeRefresh((x) => x + 1);
|
|
@@ -3766,6 +3771,18 @@ window.__ModuleLoader__.load({
|
|
|
3766
3771
|
reload().catch((e) => setError(String(e && e.message ? e.message : e)));
|
|
3767
3772
|
}, [visible, reload]);
|
|
3768
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
|
+
|
|
3769
3786
|
function run(name, fn) {
|
|
3770
3787
|
setBusy(name); setError(""); setMessage("");
|
|
3771
3788
|
return fn()
|
|
@@ -3776,6 +3793,21 @@ window.__ModuleLoader__.load({
|
|
|
3776
3793
|
function onError(e) { setError(String(e && e.message ? e.message : e)); }
|
|
3777
3794
|
|
|
3778
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;
|
|
3779
3811
|
// 四视图常驻元素:Panel 渲染期构建一次,切 tab 不重建(状态保留),ManageView/GrantsView/ConsoleView 复用 shared
|
|
3780
3812
|
const viewEls = {
|
|
3781
3813
|
manage: React.createElement(ManageView, shared),
|
|
@@ -3812,6 +3844,8 @@ window.__ModuleLoader__.load({
|
|
|
3812
3844
|
if (!v || v === resolvedRef.current) setProjectEdited(false);
|
|
3813
3845
|
},
|
|
3814
3846
|
}),
|
|
3847
|
+
// 编辑态同样显示版本号:否则用户一点铅笔,版本标签就消失
|
|
3848
|
+
versionBadge,
|
|
3815
3849
|
)
|
|
3816
3850
|
: React.createElement("div", { className: "dbt-row" },
|
|
3817
3851
|
React.createElement("span", { className: "dbt-muted" }, t("projectLabel", { path: projectPath })),
|
|
@@ -3835,6 +3869,8 @@ window.__ModuleLoader__.load({
|
|
|
3835
3869
|
stroke: "currentColor", strokeWidth: 1.2, strokeLinecap: "round", strokeLinejoin: "round",
|
|
3836
3870
|
})),
|
|
3837
3871
|
)),
|
|
3872
|
+
// 版本号胶囊(与编辑态共用同一份 versionBadge,保证两种状态位置一致)
|
|
3873
|
+
versionBadge,
|
|
3838
3874
|
error ? React.createElement("div", { className: "dbt-err", "aria-live": "polite" }, t("error") + ": " + error) : null,
|
|
3839
3875
|
message ? React.createElement("div", { className: "dbt-msg", "aria-live": "polite" }, message) : null,
|
|
3840
3876
|
// 视图常驻挂载 + hidden 切换:切 tab 保留各视图内部状态(控制台 SQL、浏览选中不丢);
|
|
@@ -143,9 +143,10 @@ function applyEncrypt(options, raw, source) {
|
|
|
143
143
|
* 用它(startTls),故一个参数覆盖两条路径。留空不注入,沿用「主机名作校验名」的默认语义。
|
|
144
144
|
*/
|
|
145
145
|
function applyServerName(options, raw) {
|
|
146
|
-
|
|
146
|
+
// 与其它 applyXxx 同口径先 trim 再判空:否则 ?serverName=%20(一个空格)会被当成有效值注入
|
|
147
|
+
if (raw === undefined || raw === null || String(raw).trim() === '')
|
|
147
148
|
return;
|
|
148
|
-
options.serverName = String(raw);
|
|
149
|
+
options.serverName = String(raw).trim();
|
|
149
150
|
}
|
|
150
151
|
/** 取(必要时创建)cryptoCredentialsDetails,供 minVersion 与 ca 合并写入而不互相覆盖 */
|
|
151
152
|
function credentialsOf(options) {
|
|
@@ -203,19 +204,78 @@ function defaultCa() {
|
|
|
203
204
|
* 两条硬约束:
|
|
204
205
|
* - 路径读不出来必须 fail-closed 抛中文错。若静默跳过,用户会以为「已装 CA 但还连不上」,实际
|
|
205
206
|
* 仍按系统信任库校验——问题只会更难定位。
|
|
206
|
-
* - 写入的 ca
|
|
207
|
+
* - 写入的 ca 必须同时并入**内置 bundle 与系统信任库**,否则等于缩小信任范围:Node 一旦收到
|
|
208
|
+
* ca 选项就不再使用默认信任库,只塞用户那张会让"为连 A 实例装一张自签 CA"变成"从此所有公网
|
|
209
|
+
* CA 实例全部连不上"(SELF_SIGNED_CERT_IN_CHAIN)。这里与 applySystemCaDefault 用同一套合并口径。
|
|
207
210
|
*/
|
|
208
211
|
function applyCaCert(options, raw, source) {
|
|
209
212
|
if (raw === undefined || raw === null || String(raw) === '')
|
|
210
213
|
return;
|
|
211
214
|
const text = String(raw);
|
|
212
215
|
// PEM 文本原样保留(换行是 PEM 结构的一部分);只有当路径时才去掉两端空白
|
|
213
|
-
const pem = text.includes(PEM_HEADER) ? text : readCaFile(text.trim(), source);
|
|
214
|
-
const merged =
|
|
215
|
-
if (!merged.includes(pem))
|
|
216
|
-
merged.push(pem);
|
|
216
|
+
const pem = text.includes(PEM_HEADER) ? normalizePem(text, source) : readCaFile(text.trim(), source);
|
|
217
|
+
const merged = mergedCaWith(pem);
|
|
217
218
|
credentialsOf(options).ca = merged;
|
|
218
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
|
+
}
|
|
219
279
|
/**
|
|
220
280
|
* 把系统信任库并入默认校验(未显式给 caCert 时也生效)。
|
|
221
281
|
*
|
|
@@ -241,15 +301,9 @@ function applySystemCaDefault(options) {
|
|
|
241
301
|
if (cur !== null && typeof cur === 'object' && cur['ca'] !== undefined)
|
|
242
302
|
return;
|
|
243
303
|
}
|
|
244
|
-
|
|
245
|
-
const
|
|
304
|
+
// 合并口径统一走 mergedCaWith(与 applyCaCert 同一实现,避免两处漂移)
|
|
305
|
+
const merged = mergedCaWith();
|
|
246
306
|
// 两者都拿不到 → 不注入:保持 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
307
|
if (merged.length === 0)
|
|
254
308
|
return;
|
|
255
309
|
credentialsOf(options).ca = merged;
|
|
@@ -627,6 +681,42 @@ function erequestHint(num) {
|
|
|
627
681
|
return undefined;
|
|
628
682
|
return Object.prototype.hasOwnProperty.call(EREQUEST_HINTS, num) ? EREQUEST_HINTS[num] : undefined;
|
|
629
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
|
+
}
|
|
630
720
|
/**
|
|
631
721
|
* mssql 错误码 → 中文可读提示。ETIMEOUT 在 ConnectionError(建连超时)与 RequestError
|
|
632
722
|
* (语句执行超时)下语义不同,故结合 err.name 判断而非只看 code;originalError/precedingErrors
|
|
@@ -663,12 +753,22 @@ function mssqlReason(e) {
|
|
|
663
753
|
const tlsish = tlsProtoish ||
|
|
664
754
|
tlsCertish ||
|
|
665
755
|
/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
|
-
|
|
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);
|
|
668
763
|
let base;
|
|
669
764
|
switch (code) {
|
|
670
765
|
case 'ELOGIN':
|
|
671
|
-
|
|
766
|
+
// 登录阶段也可能是「服务器用错误号拒绝连接」而非账号问题(17819/17835/17898/17802 等都发生在
|
|
767
|
+
// 这一步)。命中已知特征时优先给针对性诊断——否则会把"只接受 strict 加密"误报成密码错误,
|
|
768
|
+
// 用户会反复核对账号,方向完全错。
|
|
769
|
+
base = knownHint === undefined
|
|
770
|
+
? `登录失败:用户名或密码错误,或账号不允许从当前主机登录(${detail})`
|
|
771
|
+
: `登录失败:服务器在登录阶段拒绝了该连接(${knownHint})。服务器原文:${detail}`;
|
|
672
772
|
break;
|
|
673
773
|
case 'ETIMEOUT':
|
|
674
774
|
base =
|
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
|
+
}
|
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",
|
package/skills/db-admin/SKILL.md
CHANGED
|
@@ -182,7 +182,7 @@ 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
|
|
185
|
+
- **认证**:仅 SQL 认证(user/password);**不支持 Windows 集成认证,也不支持 Azure AD 认证**——需要这两类认证时本工具不可用。注意:本工具**未实现** `IsIntegratedSecurityOnly` 探测,遇到「实例仅允许 Windows 认证」时只会表现为登录失败,不要声称能自动识别该状态。
|
|
186
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
187
|
- **默认与覆盖**:`encrypt=true` + `trustServerCertificate=false` + `tdsVersion=7_4` + `useUTC=true`(同 mssql 官方默认,也是微软官方推荐的安全档)。URL query 与分字段表单**同口径**,两处都能覆盖上述各项;非法取值直接报错,不静默回退。
|
|
188
188
|
|
|
@@ -195,7 +195,7 @@ DatabaseManager({ action: "run_script", conn_id: "mongo1", code: '{"find":"users
|
|
|
195
195
|
|
|
196
196
|
**不要拿证书类参数治协议版本类**:`trustServerCertificate=true` / `encrypt=false` 只影响证书校验,对 `UNSUPPORTED_PROTOCOL` 没有任何作用,试错只会白跑一轮。两类判据互斥,报错消息里已按族给出中文根因;看到 `TCP 已连通但 TLS ...` 说明网络与端口都没问题,不要再查主机/防火墙。
|
|
197
197
|
|
|
198
|
-
**关键事实:`encrypt=false`
|
|
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
199
|
|
|
200
200
|
### 版本与 TLS 1.2 支持
|
|
201
201
|
|