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/client.js CHANGED
@@ -109,6 +109,11 @@ window.__ModuleLoader__.load({
109
109
  '.dra-err{margin:8px 0;padding:8px 10px;border-radius:6px;font-size:12px;',
110
110
  'border:1px solid var(--dsw-alias-state-error-primary);',
111
111
  'color:var(--dsw-alias-state-error-primary)}',
112
+ // 警告(黄):用于"策略性受阻"而非"出错"——例如 nodeToken 为空导致
113
+ // 独立监听绑回环。误用红色会让用户以为插件坏了(v0.9.3 的真实教训)。
114
+ '.dra-warn{margin:8px 0;padding:8px 10px;border-radius:6px;font-size:12px;',
115
+ 'border:1px solid var(--dsw-alias-state-warning-primary,rgba(230,162,60,.6));',
116
+ 'color:var(--dsw-alias-state-warning-primary,#e6a23c)}',
112
117
  '.dra-form{display:flex;flex-direction:column;gap:6px;margin:8px 0 4px}',
113
118
  '.dra-in,.dra-ta,.dra-sel{width:100%;box-sizing:border-box;border-radius:6px;',
114
119
  'border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);',
@@ -1052,8 +1057,12 @@ window.__ModuleLoader__.load({
1052
1057
  const setPairNote = pairNoteS[1]
1053
1058
 
1054
1059
  function loadPair() {
1055
- // 端口不用问服务端:面板本身就由 hub 的 webserver 服务,
1056
- // 浏览器地址栏的端口 = 节点拨入端口(同一服务)。
1060
+ // 2026-10-20(C1):**端口不再由前端决定**。
1061
+ // 原来传 window.location.port,理由是"面板与节点端点同端口"——
1062
+ // 0.9.3 把端点搬到独立监听(19387)后这个前提就不成立了,
1063
+ // 于是生成的每个配对码都指向 DSH 面板端口(且每次启动还随机变)。
1064
+ // 现在服务端从真实监听状态取端口;这里仍带 port 只是为了兼容
1065
+ // "独立监听没起来、退回老通道"的旧部署。
1057
1066
  const port = window.location.port
1058
1067
  ? Number(window.location.port)
1059
1068
  : (window.location.protocol === 'https:' ? 443 : 80)
@@ -1576,37 +1585,95 @@ window.__ModuleLoader__.load({
1576
1585
  !pair
1577
1586
  ? h('div', { className: 'dra-empty' }, pairNote || '读取中…(/pair-info 不可达时请刷新重试)')
1578
1587
  : h('div', null,
1588
+ // ── 独立监听状态(2026-10-20)──────────────────────────────
1589
+ // 0.9.3 起副机拨的是插件自己的端口,不是 DSH 面板的端口。
1590
+ // 这里必须如实告诉用户"在听哪个 host:port",否则拿到配对码也
1591
+ // 无从判断它指向哪里(这正是 C1 隐蔽的地方)。
1592
+ (function () {
1593
+ const L = pair.listener
1594
+ if (!L) return null
1595
+ if (L.state === 'listening') {
1596
+ return h('div', { className: 'dra-kv', style: { padding: '4px 9px' } },
1597
+ h('span', { className: 'dra-k' }, '节点端口'),
1598
+ h('span', { className: 'dra-mono' }, (L.host || '?') + ':' + (L.port || '?')),
1599
+ h('span', { className: 'dra-dim', style: { fontSize: '11px' } },
1600
+ '(独立监听' + (L.source ? ' · ' + L.source : '') + ')'),
1601
+ )
1602
+ }
1603
+ return h('div', {
1604
+ className: L.state === 'failed' ? 'dra-err' : 'dra-warn',
1605
+ style: { margin: '4px 9px 8px' },
1606
+ },
1607
+ h('div', { style: { fontWeight: '600' } },
1608
+ L.state === 'failed' ? '⚠ 独立监听启动失败 —— 下面这些码连不上'
1609
+ : (L.state === 'disabled' ? '⚠ 独立监听已关闭 —— 走 DSH 的 Web 服务通道' : '⏳ 独立监听尚未就绪')),
1610
+ L.hint ? h('div', { style: { marginTop: '2px' } }, L.hint) : null)
1611
+ })(),
1612
+ // ── 生成口令(2026-10-20,H3)──────────────────────────────
1613
+ // 面板此前**没有**任何生成 nodeToken 的入口:YAML 与 instance.json
1614
+ // 都不写它,所以走面板配的 hub 永远空口令 → 独立监听只能绑回环
1615
+ // → 副机连不上,而用户完全不知道"口令"这件事。
1616
+ h('div', { className: 'dra-kv', style: { padding: '4px 9px' } },
1617
+ h('span', { className: 'dra-k' }, '入网口令'),
1618
+ h('span', { className: 'dra-mono' }, pair.tokenSet ? '已设置 ✓' : '未设置(副机连不上)'),
1619
+ !pair.tokenSet
1620
+ ? h('button', {
1621
+ className: 'dra-btn',
1622
+ onClick: function () {
1623
+ const ok = window.confirm('生成一个新口令?\n\n生成后旧口令立即失效,'
1624
+ + '已配置旧口令的副机需要重新贴一次配对码。\n\nDSH 需要重启才生效。')
1625
+ if (!ok) return
1626
+ apiPost('/setup/rotate-token', {}).then(function (d) {
1627
+ if (d && d.token) {
1628
+ // 只显示这一次,之后服务端不再回显明文
1629
+ setPairNote('新口令(复制保存,只显示这一次):' + d.token)
1630
+ window.setTimeout(loadPair, 400)
1631
+ } else {
1632
+ setPairNote('生成失败:' + ((d && d.message) || '未知原因'))
1633
+ }
1634
+ }, function (e) { setPairNote('生成失败:' + (e && e.message ? e.message : e)) })
1635
+ },
1636
+ }, '生成口令')
1637
+ : null,
1638
+ ),
1579
1639
  pair.note ? h('div', { className: 'dra-dim', style: { padding: '2px 9px' } }, pair.note) : null,
1580
1640
  (pair.codes || []).length === 0
1581
1641
  ? h('div', { className: 'dra-empty' }, '本机没有可用的局域网地址。')
1582
1642
  : (pair.codes || []).map(function (c, i) {
1583
- return h('div', { key: c.address, style: { padding: '6px 9px' } },
1584
- h('div', { className: 'dra-kv' },
1585
- h('span', { className: 'dra-k' }, '地址 ' + (i + 1)),
1586
- h('span', { className: 'dra-mono' }, c.address + (c.private ? '' : '(公网)')),
1587
- ),
1588
- h('div', { className: 'dra-mono dra-ell', style: { fontSize: '11px', padding: '2px 9px' }, title: c.hubUrl }, c.hubUrl),
1589
- h('div', { className: 'dra-mono', style: {
1590
- fontSize: '11px', margin: '4px 9px', padding: '6px',
1591
- background: 'rgba(127,127,127,.12)', borderRadius: '4px',
1592
- wordBreak: 'break-all', userSelect: 'all',
1593
- } }, c.pairCode),
1594
- h('div', { className: 'dra-actions', style: { padding: '0 9px 4px' } },
1595
- h('button', {
1596
- className: 'dra-btn',
1597
- onClick: function () {
1598
- copyText("pair: '" + c.pairCode + "'").then(function () {
1599
- setPairNote('已复制 pair 配置行 —— 粘到副机 cordis.patch.yml 后重启副机 DSH')
1600
- }, function () {
1601
- setPairNote('复制失败 —— 请手动选中上面的配对码复制(整行,含 dshra1:// 开头)')
1602
- })
1603
- },
1604
- }, '复制 pair 配置行'),
1605
- h('button', {
1606
- className: 'dra-btn',
1607
- onClick: function () { copyText(c.pairCode).then(function () { setPairNote('已复制配对码') }, function () { setPairNote('复制失败 —— 请手动选中复制') }) },
1608
- }, '只复制码'),
1609
- ),
1643
+ return h('div', {
1644
+ key: c.address,
1645
+ // 只有真正绑定的那个地址能用;其余标灰并说明原因(C2)
1646
+ style: { padding: '6px 9px', opacity: c.usable === false ? 0.45 : 1 },
1647
+ },
1648
+ h('div', { className: 'dra-kv' },
1649
+ h('span', { className: 'dra-k' },
1650
+ '地址 ' + (i + 1) + (c.usable === false ? '(不是监听地址,连不上)' : '')),
1651
+ h('span', { className: 'dra-mono' }, c.address + (c.private ? '' : '(公网)')),
1652
+ ),
1653
+ h('div', { className: 'dra-mono dra-ell', style: { fontSize: '11px', padding: '2px 9px' }, title: c.hubUrl }, c.hubUrl),
1654
+ h('div', { className: 'dra-mono', style: {
1655
+ fontSize: '11px', margin: '4px 9px', padding: '6px',
1656
+ background: 'rgba(127,127,127,.12)', borderRadius: '4px',
1657
+ wordBreak: 'break-all', userSelect: 'all',
1658
+ } }, c.pairCode),
1659
+ h('div', { className: 'dra-actions', style: { padding: '0 9px 4px' } },
1660
+ h('button', {
1661
+ className: 'dra-btn',
1662
+ disabled: c.usable === false,
1663
+ onClick: function () {
1664
+ copyText("pair: '" + c.pairCode + "'").then(function () {
1665
+ setPairNote('已复制 pair 配置行 —— 粘到副机 cordis.patch.yml 后重启副机 DSH')
1666
+ }, function () {
1667
+ setPairNote('复制失败 —— 请手动选中上面的配对码复制(整行,含 dshra1:// 开头)')
1668
+ })
1669
+ },
1670
+ }, '复制 pair 配置行'),
1671
+ h('button', {
1672
+ className: 'dra-btn',
1673
+ disabled: c.usable === false,
1674
+ onClick: function () { copyText(c.pairCode).then(function () { setPairNote('已复制配对码') }, function () { setPairNote('复制失败 —— 请手动选中复制') }) },
1675
+ }, '只复制码'),
1676
+ ),
1610
1677
  )
1611
1678
  }),
1612
1679
  h('div', { className: 'dra-kv', style: { padding: '4px 9px' } },
@@ -1728,28 +1795,70 @@ window.__ModuleLoader__.load({
1728
1795
  // (托管块残留)、自己改错。
1729
1796
  //
1730
1797
  // 所以这里渲染成**红色横幅 + 具体修法**。日志用户不会看,面板会看。
1798
+ //
1799
+ // ═══════════════════════════════════════════════════════════════════════
1800
+ // 2026-10-20 重写(v0.9.3 审查 H5):横幅要按**独立监听**判断
1801
+ // ═══════════════════════════════════════════════════════════════════════
1802
+ //
1803
+ // 0.9.3 把节点端点搬到了插件自己的监听端口(默认 19387),不再跟 DSH 的
1804
+ // webserver 同端口。但这里的判断条件仍是 `reach.loopback === true` ——
1805
+ // 而 DSH 的 webserver 默认就绑 127.0.0.1。
1806
+ //
1807
+ // 结果:副机明明能从 19387 连上,面板却天天挂一条大红条说"连不上",
1808
+ // 还把用户引向"改 webserver 的 host"这个**已被证伪**的修法
1809
+ // (DSH 每次启动都重写 patch,改了就丢,而且跟新通道无关)。
1810
+ //
1811
+ // 现在分三种情况,只有真的连不上才红:
1812
+ // · 独立监听在听且绑非回环 → 不显示横幅(正常)
1813
+ // · 独立监听在听但绑回环 → 黄条:多半是 nodeToken 为空(安全策略)
1814
+ // · 独立监听没起来 → 红条:端口被占/地址不可用,给改法
1731
1815
  const reach = caps && caps.reachability ? caps.reachability : null
1732
- const reachBox = reach && reach.loopback === true
1733
- ? h('div', { className: 'dra-err', style: { marginBottom: '10px' } },
1734
- h('div', { style: { fontWeight: '600', marginBottom: '4px' } },
1735
- '⚠ 副机连不上:本机 Web 服务只监听 ' + (reach.bound || '127.0.0.1')),
1736
- h('div', null, '现在监听在 ', h('code', null, reach.origin || ''),
1737
- ',只有本机能访问。副机用任何地址都连不上。'),
1738
- h('div', { style: { marginTop: '6px' } }, '修法:在 profile 的 ',
1739
- h('code', null, 'cordis.patch.yml'), ' 里把 ', h('code', null, 'webserver'),
1740
- ' 的 ', h('code', null, 'host'), ' 改成一个具体的本机 IP,然后重启 DSH。'),
1741
- // ⚠ 修法**不能写 0.0.0.0**(2026-10-18 事故):新版 dsh-host-webserver
1742
- // 在 schema 校验阶段就拒绝通配地址,会让 **DSH 启动崩溃**
1743
- // (webserver 是 required 插件)。必须绑一个具体的本机接口地址。
1744
- h('div', { className: 'dra-dim', style: { marginTop: '2px' } },
1745
- '⚠ 不能用 0.0.0.0 —— 新版 DSH 会拒绝并导致启动失败。'
1746
- + '查地址:Tailscale 用 tailscale ip -4,局域网用 ipconfig。'
1747
- + '推荐绑 Tailscale IP(固定不变、跨网可达)。'),
1748
- reach.hint
1749
- ? h('div', { className: 'dra-dim', style: { marginTop: '4px' } }, reach.hint)
1750
- : null,
1751
- )
1752
- : null
1816
+ const lis = caps && caps.listener ? caps.listener : null
1817
+ const listenUp = lis && lis.ok === true
1818
+ const listenLoop = listenUp && reach ? reach.loopback === true : false
1819
+
1820
+ // 只有"确实没有可用通道"才用红色;回环是策略问题,用黄色提示
1821
+ const reachBox = (function () {
1822
+ if (listenUp && listenLoop !== true) return null // 主通道正常,什么都不显示
1823
+
1824
+ if (listenUp && listenLoop === true) {
1825
+ return h('div', { className: 'dra-warn', style: { marginBottom: '10px' } },
1826
+ h('div', { style: { fontWeight: '600', marginBottom: '4px' } },
1827
+ '⚠ 独立监听绑在回环(' + (reach && reach.origin ? reach.origin : lis.host) + '),副机还连不上'),
1828
+ h('div', null, '这是**安全策略**:nodeToken 为空时,端点不暴露到网络'
1829
+ + '(否则同网络里任何人都能无口令接入)。'),
1830
+ h('div', { style: { marginTop: '6px' } },
1831
+ '修法:点右上角「副机接入」→「生成口令」,然后按提示重启 DSH。'),
1832
+ )
1833
+ }
1834
+
1835
+ if (lis && lis.ok === false) {
1836
+ return h('div', { className: 'dra-err', style: { marginBottom: '10px' } },
1837
+ h('div', { style: { fontWeight: '600', marginBottom: '4px' } },
1838
+ '⚠ 独立监听启动失败:副机拨不进来'),
1839
+ h('div', null, '原因:', h('code', null, lis.error || '未知')),
1840
+ h('div', { style: { marginTop: '6px' } },
1841
+ '修法:多半是端口 ' + (lis.port || 19387) + ' 被占用,'
1842
+ + '或者 listenHost 填的本机 IP 在这台机器上不存在(换网卡/换网络后很常见)。'
1843
+ + '改一个空闲的 listenPort、或把 listenHost 留空让它自动选,然后重启 DSH。'
1844
+ + '注意 listenHost 不能用 0.0.0.0(新版 DSH 直接拒绝,必须填具体 IP)。'),
1845
+ )
1846
+ }
1847
+
1848
+ // 独立监听关闭/未就绪 → 退回老通道判断
1849
+ if (reach && reach.loopback === true) {
1850
+ return h('div', { className: 'dra-err', style: { marginBottom: '10px' } },
1851
+ h('div', { style: { fontWeight: '600', marginBottom: '4px' } },
1852
+ '⚠ 副机连不上:没有可用的节点端口'),
1853
+ h('div', null, '独立监听未启用',
1854
+ lis && lis.disabled ? '(' + lis.disabled + ')' : '',
1855
+ ',而 DSH 的 Web 服务只监听 ', h('code', null, reach.origin || '127.0.0.1'), '。'),
1856
+ h('div', { style: { marginTop: '6px' } },
1857
+ '修法:去掉配置里的 ', h('code', null, 'listenPort: 0'), ',让独立监听启用。'),
1858
+ )
1859
+ }
1860
+ return null
1861
+ })()
1753
1862
 
1754
1863
  const cards = h('div', { className: 'dra-cards' },
1755
1864
  h('div', { className: 'dra-card' },
package/lib/env-check.js CHANGED
@@ -57,6 +57,74 @@ function item(ok, id, label, { fix = '', detail = '', autoFix = false, severity
57
57
  // 凡是判定"这台机器是 hub 还是 worker"的地方都得过这一层。
58
58
  // (v0.9.0 审查 H1 的根因就是这里漏了 —— 直接拿原始 'auto' 跟 'hub' 比。)
59
59
  import { resolveRole } from './index.js'
60
+ // 回环判据**必须只有一份实现**(2026-10-20 收口):
61
+ // 独立监听、env-check、面板曾各写一份,C2 就是这类"两套判据"分叉的直接后果。
62
+ import { isLoopbackHost } from './listen-server.js'
63
+
64
+ /**
65
+ * 独立监听这一项的体检(2026-10-20 新增,v0.9.3 审查 M4)。
66
+ *
67
+ * 三种状态要分开报,因为**修法完全不同**:
68
+ * · listening → 看绑在哪(回环 = 副机连不上,多半是 nodeToken 为空)
69
+ * · disabled → 用户显式关了(listenPort: 0),走老通道,不算错
70
+ * · failed → 端口被占/地址不可用,改 listenPort 或 listenHost
71
+ * · 未就绪 → 刚启动,稍后刷新
72
+ *
73
+ * @param {object|null} listener { ok, disabled, host, port, source, error }
74
+ * @param {object} cfg 归一化后的配置(用于给出可粘贴的修法片段)
75
+ */
76
+ function listenerCheck(listener, cfg) {
77
+ const L = listener && typeof listener === 'object' ? listener : null
78
+ const host = L && typeof L.host === 'string' ? L.host : ''
79
+ const port = L && Number.isFinite(L.port) ? L.port : null
80
+ const source = L && typeof L.source === 'string' ? L.source : ''
81
+ const err = L && typeof L.error === 'string' ? L.error : ''
82
+ const disabled = L && typeof L.disabled === 'string' ? L.disabled : ''
83
+
84
+ if (!L || L.ok === null || L.ok === undefined) {
85
+ return item(true, 'listener', '独立监听(尚未就绪)', {
86
+ detail: '插件刚启动?稍后刷新体检',
87
+ severity: 'info',
88
+ })
89
+ }
90
+
91
+ if (L.ok === true) {
92
+ const loop = isLoopbackHost(host)
93
+ return item(!loop, 'listener', loop
94
+ ? '独立监听绑在回环(副机连不上)'
95
+ : '独立监听已就绪(副机从这里拨入)', {
96
+ detail: loop
97
+ ? `${host}:${port ?? '?'} —— 安全策略:nodeToken 为空时不暴露到网络`
98
+ : `${host}:${port ?? '?'}(来源 ${source || 'auto'})`,
99
+ fix: loop
100
+ ? '到面板「副机接入」点「生成口令」(写入 nodeToken),重启 DSH 后会改绑可达地址。\n'
101
+ + ' 或显式指定:yaml:\n'
102
+ + ' - id: remote-agent\n'
103
+ + ' config:\n'
104
+ + " listenHost: '<本机具体 IP,如 tailscale ip -4 的结果>'"
105
+ : '',
106
+ autoFix: true,
107
+ })
108
+ }
109
+
110
+ if (disabled !== '') {
111
+ return item(true, 'listener', '独立监听已关闭(走 DSH 的 Web 服务通道)', {
112
+ detail: `原因:${disabled}`,
113
+ severity: 'info',
114
+ })
115
+ }
116
+
117
+ return item(false, 'listener', '独立监听启动失败(副机拨不进来)', {
118
+ detail: err !== '' ? err : '未知原因',
119
+ fix: '端口多半被占用。换一个空闲端口后重启 DSH:\n'
120
+ + ' yaml:\n'
121
+ + ' - id: remote-agent\n'
122
+ + ' config:\n'
123
+ + ` listenPort: ${Number.isFinite(cfg?.listenPort) ? (cfg.listenPort + 1) : 19388}`,
124
+ autoFix: true,
125
+ })
126
+ }
127
+
60
128
 
61
129
  /**
62
130
  * 判定一个 origin/URL 是否只对本机可见。
@@ -82,7 +150,8 @@ export function isLoopbackOrigin(origin) {
82
150
  * @param {object} o
83
151
  * @param {'hub'|'worker'} o.role
84
152
  * @param {object} [o.config] 本行生效配置(只读快照)
85
- * @param {object|null} [o.reachability] hub 的可达性自检结论(applyHub 产出)
153
+ * @param {object|null} [o.reachability] 可达性结论(独立监听优先;见 applyHub 的 currentReachability)
154
+ * @param {object|null} [o.listener] 独立监听原始状态 { ok, disabled, host, port, source, error }
86
155
  * @param {number|null} [o.webPort] 实际监听的端口(探测结果)
87
156
  * @param {boolean|null} [o.portFree] 目标端口是否空闲(探测结果)
88
157
  * @param {boolean|null} [o.firewallOk] 防火墙是否放行(探测结果;null=未探测)
@@ -95,6 +164,7 @@ export function envChecks({
95
164
  role,
96
165
  config = {},
97
166
  reachability = null,
167
+ listener = null,
98
168
  webPort = null,
99
169
  portFree = null,
100
170
  firewallOk = null,
@@ -187,49 +257,67 @@ export function envChecks({
187
257
  ))
188
258
  }
189
259
 
190
- /* ── 3. hub 可达性(**最贵的一条**:绑 loopback 时副机永远连不上)── */
260
+ /* ── 3. 独立监听(0.9.3 起**副机拨入的主通道**)─────────────────
261
+ *
262
+ * ⚠ 这是 2026-10-20 补的(v0.9.3 审查 M4)。此前体检查了 10 项,
263
+ * **没有一项**观察独立监听到底起没起来 —— 而它才是副机真正连的那条路。
264
+ * 端口被占、绑错地址、因为 nodeToken 为空退回回环,体检全都看不见。
265
+ */
266
+ if (isHub) {
267
+ out.push(listenerCheck(listener, cfg))
268
+ }
269
+
270
+ /* ── 4. 节点端点可达性(两条通道联合判断)────────────────────────
271
+ *
272
+ * 2026-10-20 重写(v0.9.3 审查 H5):
273
+ * 原实现只看 `ctx.webServer.getOrigin()`。0.9.3 把端点搬到独立监听后,
274
+ * 这造成**假红** —— DSH 的 webserver 仍绑 127.0.0.1(它的默认行为),
275
+ * 于是面板天天报"副机用任何地址都连不上",而 19387 上正好好地服务着。
276
+ * 更糟的是修法让用户去改 webserver 的 host —— 那个改动会被 DSH 每次启动
277
+ * 重写冲掉,且与新通道无关。
278
+ *
279
+ * 现在:独立监听在听且绑非回环 → 直接判可达,并把实际地址报出来。
280
+ */
191
281
  if (isHub) {
282
+ const listenUp = listener !== null && typeof listener === 'object'
283
+ && listener.ok === true
284
+ && typeof listener.host === 'string' && listener.host !== ''
285
+ const listenLoop = listenUp ? isLoopbackHost(listener.host) : null
286
+
192
287
  const loop = reachability && typeof reachability.loopback === 'boolean'
193
288
  ? reachability.loopback
194
289
  : (reachability && typeof reachability.origin === 'string' ? isLoopbackOrigin(reachability.origin) : null)
195
- if (loop === null) {
196
- out.push(item(true, 'reachability', 'Web 服务可达性(无法判定,已跳过)', {
197
- detail: reachability && reachability.hint ? reachability.hint : '未拿到 webServer.getOrigin()',
290
+
291
+ if (listenUp && listenLoop === false) {
292
+ // 主通道好着 —— 不再看 webServer(它绑 loopback 也无所谓)
293
+ out.push(item(true, 'reachability', '节点端点可达(副机从独立监听拨入)', {
294
+ detail: `${listener.host}:${listener.port ?? '?'}(来源 ${listener.source || 'auto'})`,
295
+ }))
296
+ } else if (listenUp && listenLoop === true) {
297
+ out.push(item(false, 'reachability', '独立监听绑在回环 —— 副机连不上', {
298
+ fix: '这是安全策略:nodeToken 为空时不把端点暴露到网络。\n'
299
+ + ' 到面板「副机接入」点「生成口令」,重启 DSH 后会自动选可达地址;\n'
300
+ + ' 或显式配置 listenHost: \'<本机具体 IP>\' 并设置 nodeToken。',
301
+ detail: `当前绑 ${listener.host}:${listener.port ?? '?'}`,
302
+ autoFix: true,
303
+ }))
304
+ } else if (loop === null) {
305
+ out.push(item(true, 'reachability', '节点端点可达性(无法判定,已跳过)', {
306
+ detail: reachability && reachability.hint ? reachability.hint : '未拿到独立监听或 webServer 状态',
198
307
  severity: 'info',
199
308
  }))
200
309
  } else {
310
+ // 主通道没起来 → 只能靠 DSH webserver 那条老通道(绑具体 IP 才通)
201
311
  out.push(item(
202
312
  loop === false,
203
313
  'reachability',
204
- 'Web 服务绑在可达地址(副机才能连上)',
314
+ '节点端点可达(独立监听未启用,退回 DSH 的 Web 服务)',
205
315
  {
206
- // 修法给**可直接粘贴的片段**(v0.9.0 审查 L3:不假装有一键修复)。
207
- // 这是用户 profile 的配置,插件客观上不能静默代改 ——
208
- // 用户 profile 优先于包内 patch 是明确的设计。
209
- //
210
- // ⚠ **绝对不要建议 `0.0.0.0`**(2026-10-18 事故)。
211
- //
212
- // 新版 `dsh-host-webserver` 在 schema 校验阶段就硬拒绝通配地址:
213
- // host "0.0.0.0" is an unspecified (wildcard) address, which is not
214
- // supported: binding every interface would expose remote code
215
- // execution to the network; bind one concrete IPv4 or IPv6 address
216
- // of a local interface instead
217
- //
218
- // 后果是 **DSH 启动崩溃**(webserver 是 required 插件,它挂了
219
- // `webServer` 服务就缺失,至少 6 个插件排队等它)。v0.9.0 曾把这个
220
- // 建议写进包内 patch,实测产生 10 份 crash 日志。
221
- //
222
- // 正确做法:绑**一个具体的本机接口地址**。推荐 Tailscale IP ——
223
- // 它固定不变、跨网可达,正好是本插件的使用场景。
224
- fix: '把 profile 的 cordis.patch.yml 里 webserver 的 host 改成'
225
- + '**一个具体的本机 IP**(不能用 0.0.0.0 —— 新版 DSH 会拒绝并启动失败),'
226
- + '然后**重启 DSH**。\n'
227
- + ' 查地址:Tailscale 用 `tailscale ip -4`,局域网用 `ipconfig`。\n'
228
- + ' 推荐绑 Tailscale IP(固定不变、跨网可达):\n'
229
- + ' - id: webserver\n'
230
- + ' config:\n'
231
- + " host: '<你的具体 IP,例如 100.x.x.x>'\n"
232
- + ' port: 19387',
316
+ // ⚠ **绝对不要建议 `0.0.0.0`**(2026-10-18 事故,10 份 crash 日志)
317
+ fix: '优先让独立监听起来(改 listenPort 换个空闲端口,或检查 nodeToken)。\n'
318
+ + ' 若确实要走 DSH 的 Web 服务:把 profile 的 cordis.patch.yml 里 webserver 的 host\n'
319
+ + ' 改成**一个具体的本机 IP**(不能用 0.0.0.0),然后重启 DSH。\n'
320
+ + ' 查地址:Tailscale 用 `tailscale ip -4`,局域网用 `ipconfig`。',
233
321
  detail: loop
234
322
  ? `当前只监听 ${reachability.bound || '127.0.0.1'}(${reachability.origin || ''})—— 副机用任何地址都连不上`
235
323
  : `当前监听 ${reachability.origin || ''}`,