dsh-remote-agent 0.9.2 → 0.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 +148 -0
- package/CONTRIBUTING.md +3 -1
- package/KNOWN-ISSUES.md +2 -0
- package/README.en.md +26 -7
- package/README.md +31 -18
- package/bin/dsh-remote-setup.js +109 -16
- package/lib/client.js +159 -50
- package/lib/env-check.js +121 -33
- package/lib/hub/index.js +335 -62
- package/lib/hub/ui-routes.js +114 -13
- package/lib/index.js +51 -0
- package/lib/instance-config.js +261 -0
- package/lib/listen-server.js +321 -0
- package/lib/setup-routes.js +186 -2
- package/lib/setup.js +172 -14
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +51 -3
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
|
-
//
|
|
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', {
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
h('
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
)
|
|
1752
|
-
|
|
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]
|
|
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.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 || ''}`,
|