dsh-remote-agent 0.9.3 → 0.9.5

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/lib/setup.js CHANGED
@@ -15,6 +15,10 @@ import fs from 'node:fs'
15
15
  import net from 'node:net'
16
16
  import { randomBytes } from 'node:crypto'
17
17
  import { expandPairConfig } from './pairing.js'
18
+ // 地址枚举**必须只有一份实现**(2026-10-20 收口):
19
+ // 此前 detectLanIps(私网优先)与 resolveListenHost(Tailscale 优先)各写一套,
20
+ // 排序方向相反 → 面板生成的配对码第一条必定不是实际绑定地址(C2)。
21
+ import { listCandidateAddresses } from './listen-server.js'
18
22
 
19
23
  /** 与 hub 侧 newNodeToken 完全同构(nt_ 前缀 + 24 字节 base64url)。 */
20
24
  export function newNodeToken() {
@@ -26,23 +30,38 @@ export const NODE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
26
30
 
27
31
  /**
28
32
  * 枚举本机局域网 IPv4(排除 loopback / 链路本地 / 虚拟接口)。
29
- * 返回 [{ name, address, private }],private = RFC1918。
33
+ * 返回 [{ name, address, private }],private = RFC1918 或 Tailscale(可信内网)。
34
+ *
35
+ * ═══════════════════════════════════════════════════════════════════════════
36
+ * 2026-10-20 改为复用 `listCandidateAddresses()`(v0.9.3 审查 C2)
37
+ * ═══════════════════════════════════════════════════════════════════════════
38
+ *
39
+ * 原来这里自己实现了"枚举 + 私网优先排序",而 `lib/listen-server.js` 的
40
+ * `resolveListenHost()` 实现了"Tailscale 优先"。两套判据方向**相反**,于是:
41
+ *
42
+ * 独立监听实际绑 → 100.86.48.54(Tailscale)
43
+ * 面板配对码第一条 → ws://10.239.22.41:56594/...(WLAN,私网优先排第一)
44
+ *
45
+ * 用户复制第一条 → 连不上,而两端都不报错。
46
+ *
47
+ * 现在**一份实现**:排序 / 私网判定 / APIPA 过滤全部交给 listen-server,
48
+ * 这里只做字段名适配(它返回 iface,历史调用方要 name)。
49
+ *
50
+ * @returns {Array<{name:string, address:string, private:boolean, score:number}>}
30
51
  */
31
52
  export function detectLanIps() {
32
- const out = []
33
- const ifaces = os.networkInterfaces()
34
- for (const [name, addrs] of Object.entries(ifaces)) {
35
- for (const a of addrs ?? []) {
36
- if (a.family !== 'IPv4' || a.internal) continue
37
- if (a.address.startsWith('169.254.')) continue // 链路本地:没拿到 DHCP 的假地址
38
- const [o1, o2] = a.address.split('.').map(Number)
39
- const priv = o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)
40
- out.push({ name, address: a.address, private: priv })
41
- }
53
+ try {
54
+ return listCandidateAddresses().map((c) => ({
55
+ name: c.iface,
56
+ address: c.address,
57
+ // Tailscale(100.64/10)在语义上属于"可信内网",与 RFC1918 同等看待:
58
+ // 面板用它区分"要不要提示公网风险",把它算公网会给出错误警告。
59
+ private: c.score <= 1,
60
+ score: c.score,
61
+ }))
62
+ } catch {
63
+ return []
42
64
  }
43
- // 私网优先,稳定的地址排前面
44
- out.sort((x, y) => (x.private === y.private ? 0 : x.private ? -1 : 1))
45
- return out
46
65
  }
47
66
 
48
67
  /**
@@ -455,6 +474,145 @@ export function hasUnmanagedRemoteAgent(fileText) {
455
474
  return RA_ENTRY_RE.test(src.slice(0, i) + src.slice(j + MANAGED_END.length))
456
475
  }
457
476
 
477
+ /**
478
+ * 面板 setup 表单**管辖**的顶层 config 键。
479
+ *
480
+ * 只有这些字段允许被"整块重写"覆盖;其余(`nodeToken` / `uiPullRoot` /
481
+ * `listenHost` / `listenPort` / `relay` / `pair` / `token` …)一律原样保留。
482
+ *
483
+ * 放在这里而不是 setup-routes.js:它是「哪些字段归表单管」这个**领域事实**,
484
+ * CLI 向导与测试也要按同一份清单判断(两处各写一份就会分叉 —— 本版审查里
485
+ * C2/L2 就是这么来的)。
486
+ */
487
+ export const FORM_OWNED_KEYS = Object.freeze([
488
+ 'role',
489
+ 'nodeId',
490
+ 'transportMode',
491
+ 'hubUrl',
492
+ 'relayUrl',
493
+ 'roots',
494
+ ])
495
+
496
+ /**
497
+ * 从**现有托管块**里抽出"本次表单不管辖"的 config 字段,原样返回(含缩进)。
498
+ *
499
+ * ═══════════════════════════════════════════════════════════════════════════
500
+ * 为什么需要(2026-10-20,v0.9.3 审查 H2)
501
+ * ═══════════════════════════════════════════════════════════════════════════
502
+ *
503
+ * `upsertManagedBlock` 是**块级整体替换**:新块进来,旧块整段消失。
504
+ * 而面板的 setup 表单只提交 role/nodeId/transportMode/hubUrl/relayUrl/roots。
505
+ *
506
+ * 于是用户在面板点一次「保存」,托管块里的 `nodeToken`、`uiPullRoot`、
507
+ * `listenHost`、`listenPort`、`relay` **全部被静默删掉**(已实证)。
508
+ * 而 instance.json 那边也没存这些 —— 等于"点一次保存,凭证和设置就没了"。
509
+ *
510
+ * 修法不是让表单管更多字段(字段会一直加),而是**只重写自己管辖的那几个**,
511
+ * 其余原样搬回来。
512
+ *
513
+ * 实现刻意走**文本级切分**而不是 YAML 解析:
514
+ * · 不需要引入解析器(本包零运行时依赖是硬约束)
515
+ * · 保留用户的原始格式(注释、引号风格、键顺序)—— 解析再序列化会全部丢失
516
+ *
517
+ * @param {string} blockText 托管块正文(`MANAGED_BEGIN/END` 之间的内容)
518
+ * @param {Set<string>|string[]} ownedKeys 本次由表单管辖的顶层 config 键
519
+ * @returns {string[]} 需要原样保留的行
520
+ */
521
+ export function extractPreservedConfig(blockText, ownedKeys) {
522
+ const src = typeof blockText === 'string' ? blockText : ''
523
+ const owned = ownedKeys instanceof Set ? ownedKeys : new Set(Array.isArray(ownedKeys) ? ownedKeys : [])
524
+ const lines = src.split('\n')
525
+
526
+ // 定位 ` config:`(两个空格缩进 —— loader 条目的标准形态)
527
+ const start = lines.findIndex((l) => /^\s{2}config:\s*$/.test(l))
528
+ if (start === -1) return []
529
+
530
+ const groups = []
531
+ let cur = null
532
+ for (let i = start + 1; i < lines.length; i += 1) {
533
+ const line = lines[i]
534
+ if (line.trim() === '') {
535
+ // 空行跟着当前组(保留段落感),组还没开始就忽略
536
+ if (cur) cur.lines.push(line)
537
+ continue
538
+ }
539
+ // 顶层字段:4 个空格 + key:
540
+ const m = /^ {4}([A-Za-z0-9_]+):/.exec(line)
541
+ if (m) {
542
+ cur = { key: m[1], lines: [line] }
543
+ groups.push(cur)
544
+ continue
545
+ }
546
+ // 更深缩进(嵌套对象 / 列表项)→ 属于当前组
547
+ if (cur && /^ {5,}\S/.test(line)) {
548
+ cur.lines.push(line)
549
+ continue
550
+ }
551
+ // 缩进 ≤3 或新条目 —— config 段结束
552
+ break
553
+ }
554
+
555
+ const kept = []
556
+ for (const g of groups) {
557
+ if (owned.has(g.key)) continue
558
+ // 去掉尾部多余空行,避免拼回去时积累空行
559
+ const ls = g.lines.slice()
560
+ while (ls.length > 0 && ls[ls.length - 1].trim() === '') ls.pop()
561
+ kept.push(...ls)
562
+ }
563
+ return kept
564
+ }
565
+
566
+ /**
567
+ * 读取文件里现有托管块的正文(不含标记行)。没有托管块时返回 ''。
568
+ * @param {string} fileText
569
+ * @returns {string}
570
+ */
571
+ export function readManagedBlockBody(fileText) {
572
+ const src = typeof fileText === 'string' ? fileText : ''
573
+ const i = src.indexOf(MANAGED_BEGIN)
574
+ if (i === -1) return ''
575
+ const j = src.indexOf(MANAGED_END, i)
576
+ if (j === -1) return ''
577
+ return src.slice(i + MANAGED_BEGIN.length, j)
578
+ }
579
+
580
+ /**
581
+ * 在托管块正文里设置 `nodeToken`(有则替换,无则插入),返回**新的块正文**。
582
+ *
583
+ * 为什么不是"重新生成整个块":那会把用户手写的其它字段一起冲掉(H2)。
584
+ * 这里只动 token 这一行,其余字节保持原样。
585
+ *
586
+ * @param {string} blockText 托管块正文(不含标记)
587
+ * @param {string} token 新口令
588
+ * @returns {string}
589
+ */
590
+ export function upsertTokenInBlock(blockText, token) {
591
+ const body = typeof blockText === 'string' ? blockText : ''
592
+ const t = typeof token === 'string' ? token.trim() : ''
593
+ if (t === '') return body
594
+ // 单引号包裹 + 转义:token 是 nt_ + base64url,理论上不含引号,但不赌
595
+ const line = ` nodeToken: '${t.replace(/'/g, "''")}'`
596
+ const lines = body.split('\n')
597
+
598
+ const i = lines.findIndex((l) => /^ {4}nodeToken:/.test(l))
599
+ if (i !== -1) {
600
+ lines[i] = line
601
+ return lines.join('\n')
602
+ }
603
+
604
+ // 没有 → 插到 ` config:` 之后(保持它作为 config 的第一个子键,可读性好)
605
+ const ci = lines.findIndex((l) => /^\s{2}config:\s*$/.test(l))
606
+ if (ci !== -1) {
607
+ lines.splice(ci + 1, 0, line)
608
+ return lines.join('\n')
609
+ }
610
+
611
+ // 连 config 行都没有(极早期手写块)→ 追加一个 config 段
612
+ const trimmed = body.replace(/\s+$/, '')
613
+ return `${trimmed}${trimmed === '' ? '' : '\n'} config:\n${line}\n`
614
+ }
615
+
458
616
  /**
459
617
  * 写盘主流程:备份 → 合并 → 写入 → 校验,失败自动回滚。
460
618
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",
@@ -1113,7 +1113,7 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
1113
1113
  })(),
1114
1114
  'lib/hub/ui-routes.js(role: resolveRole(config) 而非原始 config.role)')
1115
1115
 
1116
- check('85', 'SMB 面板发真实 dir + CLI doctor 有 loopback 检查(H2 / M1)',
1116
+ check('85', 'SMB 面板发真实 dir + CLI doctor 走独立监听判可达性(H2 / M1 / H5)',
1117
1117
  // H2:不得写死 dir: ''
1118
1118
  (() => {
1119
1119
  const i = clientSrc5.indexOf('function doPlan()')
@@ -1125,8 +1125,17 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
1125
1125
  && /nodeRoots/.test(clientSrc5)
1126
1126
  // M1:CLI 必须调 envChecks(否则没有 loopback 诊断)
1127
1127
  && /envChecks/.test(setupBin)
1128
- && /isLoopbackOrigin/.test(setupBin),
1129
- 'lib/client.js(dir: shareDir + nodeRoots UI)+ bin/dsh-remote-setup.js(envChecks)')
1128
+ // H5:回环判据必须来自 listen-server(一份实现),不是 CLI 自己拼 origin
1129
+ && /isLoopbackHost/.test(setupBin)
1130
+ // H5 负向守卫:CLI **不得**再从 patch 里找 webserver 段判可达性。
1131
+ // 0.9.3 起端点由插件自己的独立监听承载,webserver 的 host 与副机接入
1132
+ // 毫无关系 —— 读了会把人引向"改 webserver"这个改了也会被冲掉的死路。
1133
+ // ⚠ 扫**剥注释后**的代码:注释里必须保留事故说明(含 `- id: webserver`
1134
+ // 字样),那是给后来者的警示,不该被这条断言判违规。
1135
+ && !/- id:\s*webserver/.test(stripComments(setupBin))
1136
+ // 生效配置以 instance.json 为准(否则体检报告的是已失效的 patch 值)
1137
+ && /readInstanceConfig/.test(setupBin),
1138
+ 'lib/client.js(dir: shareDir + nodeRoots UI)+ bin/dsh-remote-setup.js(envChecks + listen-server 判据)')
1130
1139
  }
1131
1140
 
1132
1141
  /* ── 86~87. 独立监听端口(2026-10-18,架构级修复)──