dsh-server-connector 0.2.0 → 0.2.1
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 +40 -0
- package/README.md +2 -1
- package/lib/registry.js +123 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-11-06
|
|
4
|
+
|
|
5
|
+
### 修复(严重:导致 DSH 无法启动)
|
|
6
|
+
|
|
7
|
+
- **密码凭证引用格式非法,会让整个 DSH 启动失败**。此前密码认证生成
|
|
8
|
+
`server-connector:<id>:password`,其中含冒号与连字符,而
|
|
9
|
+
`@deepseek-ai/dsh-credentials` 要求所有凭证引用匹配 `/^[A-Za-z_][A-Za-z0-9_]*$/`。
|
|
10
|
+
这条非法引用一旦写入 `servers.json`,下次启动时 `credentials` 插件解析失败;
|
|
11
|
+
由于它是必需插件,整个 Host 起不来——**而且禁用本插件也没用,因为坏值已经在磁盘上**。
|
|
12
|
+
|
|
13
|
+
三处修复,缺一不可:
|
|
14
|
+
- 改为生成 `DSH_SERVER_CONNECTOR_<ID>_PASSWORD`,并校验调用方传入的引用,
|
|
15
|
+
使本插件再也无法写入非法值;
|
|
16
|
+
- 加载时迁移**旧格式**,让已经落盘的记录继续可用,而不是把「Host 崩溃」换成
|
|
17
|
+
「插件加载失败」;
|
|
18
|
+
- 迁移后**回写文件**,否则之后复制的文件副本仍带着非法值。已经合法的文件不会被重写。
|
|
19
|
+
|
|
20
|
+
只迁移本插件曾经写出的那一种格式;其他非法引用仍然拒绝,因为静默改写可能让记录
|
|
21
|
+
指向另一个密钥。
|
|
22
|
+
|
|
23
|
+
- 同时修正 README 与设计文档中展示的引用格式(它们此前在教读者写出非法值)。
|
|
24
|
+
|
|
25
|
+
### 升级说明
|
|
26
|
+
|
|
27
|
+
升级到 0.2.1 后**无需手动改文件**:插件加载时会自动把旧引用迁移成合法值并回写。
|
|
28
|
+
|
|
29
|
+
若 DSH 当前因该问题无法启动,先做下面任一操作再启动:
|
|
30
|
+
|
|
31
|
+
- 编辑 `~/.dsh/server-connector/servers.json`,把
|
|
32
|
+
`"server-connector:srv-01:password"` 改成 `"DSH_SERVER_CONNECTOR_SRV_01_PASSWORD"`
|
|
33
|
+
(`srv-02` 同理);或
|
|
34
|
+
- 直接删除该文件中 `auth.kind` 为 `password` 的服务器记录。
|
|
35
|
+
|
|
36
|
+
然后安装 0.2.1。
|
|
37
|
+
|
|
38
|
+
> 迁移只改引用名,**不搬运密钥值**。升级后原密码可能需要重新输入一次
|
|
39
|
+
> (取决于该密钥此前是否成功写入 `~/.dsh/.credentials.yaml`)。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
3
43
|
## 0.2.0 — 2026-11-05
|
|
4
44
|
|
|
5
45
|
### 新增
|
package/README.md
CHANGED
|
@@ -506,7 +506,8 @@ $DSH_HOME/server-connector/
|
|
|
506
506
|
|
|
507
507
|
### 凭据
|
|
508
508
|
|
|
509
|
-
- 密码交给 DSH 凭据服务(`$DSH_HOME/.credentials.yaml`),`servers.json` 只存引用,如 `
|
|
509
|
+
- 密码交给 DSH 凭据服务(`$DSH_HOME/.credentials.yaml`),`servers.json` 只存引用,如 `DSH_SERVER_CONNECTOR_SRV_03_PASSWORD`
|
|
510
|
+
(必须是 POSIX 标识符,不能含冒号或连字符);
|
|
510
511
|
- 面板只显示「已设置 / 未设置」,不会回显明文;接口也不会返回密钥;
|
|
511
512
|
- 没有凭据服务时,密码认证会报 `NO_CREDENTIAL`,改用私钥认证即可;
|
|
512
513
|
- 私钥认证只记录**路径**,密钥内容由 `ssh2` 自己读,插件不缓存。
|
package/lib/registry.js
CHANGED
|
@@ -336,13 +336,92 @@ function normalizeOptionalTimestamp(value, field, errorCode) {
|
|
|
336
336
|
return normalizeTimestamp(value, field, errorCode)
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* The grammar every credential ref must satisfy: a POSIX shell identifier.
|
|
341
|
+
*
|
|
342
|
+
* This is @deepseek-ai/dsh-credentials' own contract (`REF_PATTERN`), not an
|
|
343
|
+
* invention of this plugin. Keeping a local copy is deliberate: a ref this
|
|
344
|
+
* plugin persists is later parsed by that package, and a mismatch is not a
|
|
345
|
+
* local error — it prevents `credentials` from activating, which fails the whole
|
|
346
|
+
* Host startup.
|
|
347
|
+
*/
|
|
348
|
+
const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The ref this plugin derives for a server's password (doc §6.3).
|
|
352
|
+
*
|
|
353
|
+
* `server-connector:<id>:password` was the old shape and it was illegal: the
|
|
354
|
+
* colon and the dash fall outside the grammar above, so persisting it broke the
|
|
355
|
+
* next startup. The id is folded into a single identifier instead
|
|
356
|
+
* (`DSH_SERVER_CONNECTOR_SRV_01_PASSWORD`).
|
|
357
|
+
* @param {string} serverId - e.g. `srv-01`
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
function deriveCredentialRef(serverId) {
|
|
361
|
+
const normalised = String(serverId)
|
|
362
|
+
.toUpperCase()
|
|
363
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
364
|
+
.replace(/^_+|_+$/g, '')
|
|
365
|
+
const safe = normalised === '' ? 'SERVER' : normalised
|
|
366
|
+
return `DSH_SERVER_CONNECTOR_${safe}_PASSWORD`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Validate a caller-supplied credential ref against the same grammar.
|
|
371
|
+
*
|
|
372
|
+
* A caller may pass its own ref (an environment-variable name, typically), but
|
|
373
|
+
* an illegal one must be refused here rather than stored: storing it would
|
|
374
|
+
* break the next startup instead of this request.
|
|
375
|
+
* @param {unknown} value
|
|
376
|
+
* @param {RegistryErrorCode} errorCode
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
function assertCredentialRef(value, errorCode) {
|
|
380
|
+
const ref = requiredString(value, 'auth.credentialRef', errorCode)
|
|
381
|
+
if (!CREDENTIAL_REF_PATTERN.test(ref)) {
|
|
382
|
+
throw new RegistryError(
|
|
383
|
+
errorCode,
|
|
384
|
+
`auth.credentialRef ${JSON.stringify(ref)} must be a POSIX identifier ` +
|
|
385
|
+
`(letters, digits and underscores, not starting with a digit), because DSH credentials rejects ` +
|
|
386
|
+
`any other ref and the rejection fails the whole startup.`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
return ref
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Read-time recovery for a ref an older build persisted (doc §6.3).
|
|
394
|
+
*
|
|
395
|
+
* Before 0.2.1 this plugin derived `server-connector:<id>:password`, which is
|
|
396
|
+
* outside the grammar. Those records exist on disk in the wild, and refusing
|
|
397
|
+
* them at load would leave the plugin unable to start — trading a Host crash for
|
|
398
|
+
* a plugin that never loads. Instead the illegal legacy shape is mapped onto the
|
|
399
|
+
* derived one, so the record keeps working and the next write persists the fixed
|
|
400
|
+
* ref.
|
|
401
|
+
*
|
|
402
|
+
* Only the exact legacy shape is migrated. Any other illegal ref is still
|
|
403
|
+
* refused: silently rewriting a ref this plugin never wrote could point at a
|
|
404
|
+
* different secret.
|
|
405
|
+
* @param {unknown} value
|
|
406
|
+
* @param {string} serverId
|
|
407
|
+
* @param {RegistryErrorCode} errorCode
|
|
408
|
+
* @returns {string}
|
|
409
|
+
*/
|
|
410
|
+
function migrateCredentialRef(value, serverId, errorCode) {
|
|
411
|
+
const ref = requiredString(value, 'auth.credentialRef', errorCode)
|
|
412
|
+
if (CREDENTIAL_REF_PATTERN.test(ref)) return ref
|
|
413
|
+
if (ref === `server-connector:${serverId}:password`) return deriveCredentialRef(serverId)
|
|
414
|
+
return assertCredentialRef(ref, errorCode)
|
|
415
|
+
}
|
|
416
|
+
|
|
339
417
|
/**
|
|
340
418
|
* @param {unknown} value
|
|
341
419
|
* @param {string} serverId
|
|
342
420
|
* @param {RegistryErrorCode} errorCode
|
|
421
|
+
* @param {{migrate?: boolean}} [options] - `migrate` is set only by the load path.
|
|
343
422
|
* @returns {{kind:'key', privateKeyPath:string}|{kind:'password', credentialRef:string}|{kind:'agent'}}
|
|
344
423
|
*/
|
|
345
|
-
function normalizeAuth(value, serverId, errorCode) {
|
|
424
|
+
function normalizeAuth(value, serverId, errorCode, options = {}) {
|
|
346
425
|
if (value === undefined) return { kind: 'agent' }
|
|
347
426
|
if (!isPlainObject(value)) throw new RegistryError(errorCode, 'auth must be an object.')
|
|
348
427
|
const kind = value.kind
|
|
@@ -352,12 +431,20 @@ function normalizeAuth(value, serverId, errorCode) {
|
|
|
352
431
|
}
|
|
353
432
|
if (kind === 'password') {
|
|
354
433
|
assertKnownFields(value, AUTH_FIELDS.password, errorCode, 'auth')
|
|
355
|
-
// Doc §6.3: the record keeps a reference, never the password itself.
|
|
356
|
-
//
|
|
434
|
+
// Doc §6.3: the record keeps a reference, never the password itself.
|
|
435
|
+
//
|
|
436
|
+
// The ref MUST be a POSIX shell identifier: @deepseek-ai/dsh-credentials
|
|
437
|
+
// validates every stored ref with /^[A-Za-z_][A-Za-z0-9_]*$/ while loading
|
|
438
|
+
// credentials.yaml, and a ref outside that grammar throws during
|
|
439
|
+
// `credentials` activation. Because `credentials` is a required plugin, that
|
|
440
|
+
// takes the entire Host down at startup — a persisted bad ref bricks DSH
|
|
441
|
+
// until the file is edited by hand. Derive and validate accordingly.
|
|
357
442
|
const credentialRef =
|
|
358
443
|
value.credentialRef === undefined
|
|
359
|
-
?
|
|
360
|
-
:
|
|
444
|
+
? deriveCredentialRef(serverId)
|
|
445
|
+
: options.migrate === true
|
|
446
|
+
? migrateCredentialRef(value.credentialRef, serverId, errorCode)
|
|
447
|
+
: assertCredentialRef(value.credentialRef, errorCode)
|
|
361
448
|
return { kind: 'password', credentialRef }
|
|
362
449
|
}
|
|
363
450
|
if (kind === 'agent') {
|
|
@@ -455,6 +542,7 @@ function normalizeCustomRules(value, errorCode) {
|
|
|
455
542
|
* errorCode: RegistryErrorCode,
|
|
456
543
|
* label: string,
|
|
457
544
|
* createdAt?: string,
|
|
545
|
+
* migrate?: boolean,
|
|
458
546
|
* }} options
|
|
459
547
|
* @returns {ServerRecord}
|
|
460
548
|
*/
|
|
@@ -472,7 +560,7 @@ function normalizeRecord(source, options) {
|
|
|
472
560
|
host: normalizeHost(source.host, errorCode),
|
|
473
561
|
port: normalizePort(source.port, errorCode),
|
|
474
562
|
username: requiredString(source.username, 'username', errorCode),
|
|
475
|
-
auth: normalizeAuth(source.auth, id, errorCode),
|
|
563
|
+
auth: normalizeAuth(source.auth, id, errorCode, { migrate: options.migrate === true }),
|
|
476
564
|
hostKeyMode: normalizeHostKeyMode(source.hostKeyMode, errorCode),
|
|
477
565
|
defaultWorkDir: normalizeWorkDir(source.defaultWorkDir, errorCode),
|
|
478
566
|
enabled: normalizeEnabled(source.enabled, errorCode),
|
|
@@ -786,6 +874,9 @@ export class Registry {
|
|
|
786
874
|
id: recordId,
|
|
787
875
|
errorCode: 'INVALID_REGISTRY',
|
|
788
876
|
label: `servers[${index}]`,
|
|
877
|
+
// Loading is the one path allowed to repair a legacy credential ref;
|
|
878
|
+
// writes are strict so this plugin can never persist a bad one again.
|
|
879
|
+
migrate: true,
|
|
789
880
|
})
|
|
790
881
|
if (seenIds.has(record.id)) {
|
|
791
882
|
throw new RegistryError('INVALID_REGISTRY', `${this.#file}: duplicate server id "${record.id}".`)
|
|
@@ -798,7 +889,32 @@ export class Registry {
|
|
|
798
889
|
seenNames.add(nameKey)
|
|
799
890
|
servers.push(record)
|
|
800
891
|
}
|
|
801
|
-
|
|
892
|
+
const document = { version: REGISTRY_VERSION, servers }
|
|
893
|
+
// Persist a repaired legacy ref immediately. Without this the migration
|
|
894
|
+
// lives only in memory: every later read re-runs it, and a copy of the file
|
|
895
|
+
// taken before the first write would still carry the illegal ref that breaks
|
|
896
|
+
// the next startup. The rewrite is conditional so a clean file is never
|
|
897
|
+
// touched (its mtime stays put).
|
|
898
|
+
if (this.#migrated(text, document)) await this.#save(document)
|
|
899
|
+
return document
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Whether the loaded document differs from what is on disk, i.e. whether the
|
|
904
|
+
* legacy-ref migration actually changed a record and the file must be rewritten.
|
|
905
|
+
* @param {string} text - the raw file text that was parsed.
|
|
906
|
+
* @param {RegistryDocument} document - the normalised document.
|
|
907
|
+
* @returns {boolean}
|
|
908
|
+
*/
|
|
909
|
+
#migrated(text, document) {
|
|
910
|
+
for (const record of document.servers) {
|
|
911
|
+
if (record.auth.kind !== 'password') continue
|
|
912
|
+
const ref = record.auth.credentialRef
|
|
913
|
+
// The normalised ref is legal by construction; if the raw text does not
|
|
914
|
+
// contain it, this record was migrated.
|
|
915
|
+
if (!text.includes(JSON.stringify(ref))) return true
|
|
916
|
+
}
|
|
917
|
+
return false
|
|
802
918
|
}
|
|
803
919
|
|
|
804
920
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-server-connector",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "DSH plugin: SSH execution for servers configured on this machine, with a built-in high-risk command policy (block / confirm / allow).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|