dsh-remote-panel 0.3.0 → 0.3.2
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/README.md +27 -0
- package/deploy/templates/Caddyfile.tmpl +7 -1
- package/lib/client.js +124 -67
- package/lib/contract.js +73 -0
- package/lib/index.js +52 -9
- package/lib/pair.js +5 -5
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -64,6 +64,33 @@ Caddy 的 `pki/authorities/local/root.crt`)。
|
|
|
64
64
|
- Android:设置 → 安全 → 加密与凭据 → 安装证书 → CA 证书。
|
|
65
65
|
- iOS:下载后到 设置 → 通用 → 关于本机 → 证书信任设置 里打开信任。
|
|
66
66
|
|
|
67
|
+
## 已知限制(有意为之 / 待后续)
|
|
68
|
+
|
|
69
|
+
| 项 | 现状 | 说明 |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| APK 签名 | debug keystore | 自己侧载够用;要公开分发需换正式签名并保留密钥 |
|
|
72
|
+
| APK 更新 | 重新下载覆盖安装 | 面板「手机 App」卡里有下载入口;没有应用内自动更新 |
|
|
73
|
+
| APK 与口令 | 壳内置门禁口令 | 换了口令要重新构建壳(`./android/build.sh [server] [key]`) |
|
|
74
|
+
| DSH 升级 | hash 类名可能变 | 有契约自检(面板「外壳适配」灯),失配会显红而不是静默错位 |
|
|
75
|
+
| 崩溃上报 | 无 | 壳里失败会显示错误文本;日志只有控制台 |
|
|
76
|
+
| 无障碍 | 部分 | 抽屉没有焦点陷阱;aria 只覆盖了注入的按钮 |
|
|
77
|
+
|
|
78
|
+
## 安全模型:令牌只在门禁之后
|
|
79
|
+
|
|
80
|
+
手机端有两道门:Caddy 的口令门禁(`dsh_gate` Cookie)+ DSH 自己的会话令牌。两者的取用顺序是:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
扫码/点链接 → /pair?k=口令(种门禁 Cookie) → /remote-panel/login(要 Cookie,Caddy 直接发)
|
|
84
|
+
→ /?token=…(换取 DSH 会话)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- 二维码里**只有口令**,没有令牌;
|
|
88
|
+
- 带令牌的跳转页 `login.html` 放在门禁**之后**,没有 Cookie 拿不到(实测 401);
|
|
89
|
+
- 免鉴权的 `/remote-panel/app/*` 只放静态资源(manifest / 图标 / 根证书 / APK),不含任何凭据;
|
|
90
|
+
- 旧版 APK 的入口 `go.html` 已改成「请更新」提示页,不会再泄露令牌。
|
|
91
|
+
|
|
92
|
+
APK 里内置了门禁口令(启动即过 `/pair`),所以**换口令后需要重新构建壳**。
|
|
93
|
+
|
|
67
94
|
## 手机 App(WebView 壳)
|
|
68
95
|
|
|
69
96
|
PWA「安装应用」在国行 Android 上装不成:WebAPK 打包依赖 Google Play 服务,且打包服务器要能从公网
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
handle /pair {
|
|
26
26
|
header Set-Cookie "dsh_gate=@@ACCESS_KEY@@; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax"
|
|
27
27
|
header Content-Type "text/html; charset=utf-8"
|
|
28
|
-
respond `<!doctype html><meta charset="utf-8"><title>正在连接…</title><script>const t=new URLSearchParams(location.search).get("token");location.replace(t?"/?token="+encodeURIComponent(t):"/")</script>` 200
|
|
28
|
+
respond `<!doctype html><meta charset="utf-8"><title>正在连接…</title><script>const t=new URLSearchParams(location.search).get("token");location.replace(t?"/?token="+encodeURIComponent(t):"/remote-panel/login")</script>` 200
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
# 诊断用:不经过任何认证
|
|
@@ -62,6 +62,12 @@
|
|
|
62
62
|
# Caddy 默认没有 cookie 匹配器,用 Header 通配匹配替代
|
|
63
63
|
@paired header Cookie "*dsh_gate=@@ACCESS_KEY@@*"
|
|
64
64
|
handle @paired {
|
|
65
|
+
# 带令牌的跳转页:必须过了门禁才能拿
|
|
66
|
+
handle /remote-panel/login* {
|
|
67
|
+
root * @@APP_DIR@@
|
|
68
|
+
rewrite * /login.html
|
|
69
|
+
file_server
|
|
70
|
+
}
|
|
65
71
|
# 关键:不重写 Host/Origin。DSH 通过 --trusted-host 接受这个 authority,
|
|
66
72
|
# Cookie 因此天然同源,不需要任何注入或伪造。
|
|
67
73
|
reverse_proxy 127.0.0.1:@@UPSTREAM_PORT@@
|
package/lib/client.js
CHANGED
|
@@ -545,11 +545,14 @@ window.__ModuleLoader__.load({
|
|
|
545
545
|
const [probe, setProbe] = useState(null)
|
|
546
546
|
const [rejects, setRejects] = useState([])
|
|
547
547
|
const [busy, setBusy] = useState(false)
|
|
548
|
+
const [shell, setShell] = useState(null)
|
|
548
549
|
|
|
549
550
|
const refresh = useCallback(async () => {
|
|
550
551
|
setBusy(true)
|
|
551
552
|
try {
|
|
552
|
-
|
|
553
|
+
const [probed, status] = await Promise.all([callApi('/probe'), callApi('/status')])
|
|
554
|
+
setProbe(probed.probe)
|
|
555
|
+
setShell(status.shell ?? null)
|
|
553
556
|
setRejects((await callApi('/rejects')).entries)
|
|
554
557
|
} catch {
|
|
555
558
|
/* 状态探测失败时保留上一帧 */
|
|
@@ -572,6 +575,8 @@ window.__ModuleLoader__.load({
|
|
|
572
575
|
// 内部 CA 的叶子证书只有 12 小时,续签失败时探活照样握手成功(rejectUnauthorized:false),
|
|
573
576
|
// 所以单独看它:剩余不足 2 小时或 SAN 不匹配都算红灯
|
|
574
577
|
[certHours === null ? '证书' : `证书 ${certHours}h`, cert === undefined ? undefined : { ok: cert.ok === true }],
|
|
578
|
+
// 手机外壳依赖 DSH 的 hash 类名:DSH 升级后这里会红,提示需要更新插件
|
|
579
|
+
['外壳适配', shell === null ? undefined : { ok: shell.ok === true }],
|
|
575
580
|
]
|
|
576
581
|
|
|
577
582
|
return jsxs(Card, {
|
|
@@ -591,6 +596,12 @@ window.__ModuleLoader__.load({
|
|
|
591
596
|
}),
|
|
592
597
|
],
|
|
593
598
|
}),
|
|
599
|
+
shell !== null && shell.ok !== true
|
|
600
|
+
? jsx('p', {
|
|
601
|
+
style: { ...styles.hint, color: '#f2b8b5' },
|
|
602
|
+
children: `外壳挂点缺失:${(shell.missing ?? []).join(', ') || '未知'} —— DSH 升级后需要更新插件,否则手机样式会错位`,
|
|
603
|
+
})
|
|
604
|
+
: null,
|
|
594
605
|
cert !== undefined && cert.ok !== true
|
|
595
606
|
? jsx('p', {
|
|
596
607
|
style: { ...styles.hint, color: '#f2b8b5' },
|
|
@@ -1068,7 +1079,7 @@ window.__ModuleLoader__.load({
|
|
|
1068
1079
|
inset: 0 auto 0 0;
|
|
1069
1080
|
height: auto !important;
|
|
1070
1081
|
z-index: 900;
|
|
1071
|
-
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
1082
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px));
|
|
1072
1083
|
border-radius: 0 16px 16px 0 !important;
|
|
1073
1084
|
box-shadow: 0 0 28px rgba(0, 0, 0, .32) !important;
|
|
1074
1085
|
}
|
|
@@ -1076,7 +1087,10 @@ window.__ModuleLoader__.load({
|
|
|
1076
1087
|
body { overscroll-behavior: none; }
|
|
1077
1088
|
|
|
1078
1089
|
/* 顶部安全区:默认取 env();WebView 里由壳把真实切角高度写进 --dsh-safe-top */
|
|
1079
|
-
:root {
|
|
1090
|
+
:root {
|
|
1091
|
+
--dsh-safe-top: env(safe-area-inset-top, 0px);
|
|
1092
|
+
--dsh-safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
1093
|
+
}
|
|
1080
1094
|
|
|
1081
1095
|
/* 顶栏:52px;刘海屏/独立窗口把状态栏让出来 */
|
|
1082
1096
|
.wSkVaW_header {
|
|
@@ -1156,11 +1170,16 @@ window.__ModuleLoader__.load({
|
|
|
1156
1170
|
.pXSMma_previewBadge { display: none !important; }
|
|
1157
1171
|
/* 原生把「问候语 + 输入条」一起居中;手机按原型拆开:
|
|
1158
1172
|
问候语留在屏幕中间(原生居中),输入条钉到底部 */
|
|
1173
|
+
/* 会话里的输入条座位:底部要让开手势条(WebView 里 env() 常为 0,由壳注入) */
|
|
1174
|
+
.wSkVaW_composerSeat {
|
|
1175
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)) !important;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1159
1178
|
.wSkVaW_composerHero .uV2eYG_root {
|
|
1160
1179
|
position: fixed;
|
|
1161
1180
|
left: 0;
|
|
1162
1181
|
right: 0;
|
|
1163
|
-
bottom: max(18px, env(safe-area-inset-bottom, 0px));
|
|
1182
|
+
bottom: max(18px, max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)));
|
|
1164
1183
|
z-index: 55;
|
|
1165
1184
|
}
|
|
1166
1185
|
|
|
@@ -1436,7 +1455,7 @@ window.__ModuleLoader__.load({
|
|
|
1436
1455
|
overflow-y: auto;
|
|
1437
1456
|
-webkit-overflow-scrolling: touch;
|
|
1438
1457
|
overscroll-behavior: contain;
|
|
1439
|
-
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
|
|
1458
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), var(--dsh-safe-bottom, 0px)) !important;
|
|
1440
1459
|
}
|
|
1441
1460
|
|
|
1442
1461
|
/* 长会话滚动:只在窄屏做滚动容器层面的优化,桌面保持原样 */
|
|
@@ -1620,62 +1639,31 @@ window.__ModuleLoader__.load({
|
|
|
1620
1639
|
}
|
|
1621
1640
|
}
|
|
1622
1641
|
|
|
1642
|
+
|
|
1623
1643
|
/**
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
* DSH 打开工作区选择器时会先把焦点交给搜索框;关掉后焦点会回到输入条,
|
|
1627
|
-
* 手机上就是键盘自己弹出来。这里在选择器关闭、或 chip 文案变化后,
|
|
1628
|
-
* 把焦点从输入条上撤走(只撤输入条里的可编辑区,不动选择器自己的搜索框)。
|
|
1644
|
+
* 外壳挂点自检:DSH 升级后这些 hash 类名可能全变,样式就整体失配。
|
|
1645
|
+
* 启动后检查一遍 DOM,把结果报给宿主,面板里能直接看到「外壳适配」这盏灯。
|
|
1629
1646
|
*/
|
|
1630
|
-
function
|
|
1631
|
-
if (typeof document === 'undefined' || typeof
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
let
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
const watchLabel = () => {
|
|
1652
|
-
const label = document.querySelector('[class*="_workspaceLabel"]')
|
|
1653
|
-
if (label === null || label === observedLabel) return
|
|
1654
|
-
labelObserver?.disconnect()
|
|
1655
|
-
observedLabel = label
|
|
1656
|
-
lastLabel = label.textContent ?? ''
|
|
1657
|
-
labelObserver = new MutationObserver(() => {
|
|
1658
|
-
const now = label.textContent ?? ''
|
|
1659
|
-
if (now === lastLabel) return
|
|
1660
|
-
lastLabel = now
|
|
1661
|
-
scheduleRelease()
|
|
1662
|
-
})
|
|
1663
|
-
labelObserver.observe(label, { childList: true, characterData: true, subtree: true })
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
// 选择器关闭 = 里面那个搜索框失焦。用事件判断,别在每次 DOM 变动时全量 querySelector。
|
|
1667
|
-
const onFocusOut = (event) => {
|
|
1668
|
-
const target = event.target
|
|
1669
|
-
if (target instanceof Element && target.closest('[class*="wsc-"]') !== null) scheduleRelease()
|
|
1670
|
-
}
|
|
1671
|
-
document.addEventListener('focusout', onFocusOut, true)
|
|
1672
|
-
const ensureLabel = window.setInterval(watchLabel, 800)
|
|
1673
|
-
watchLabel()
|
|
1674
|
-
return () => {
|
|
1675
|
-
document.removeEventListener('focusout', onFocusOut, true)
|
|
1676
|
-
window.clearInterval(ensureLabel)
|
|
1677
|
-
labelObserver?.disconnect()
|
|
1678
|
-
}
|
|
1647
|
+
function installShellContractReport() {
|
|
1648
|
+
if (typeof document === 'undefined' || typeof window === 'undefined') return () => {}
|
|
1649
|
+
// 只查「任何页面都该存在」的挂点;抽屉里的 hHd-Xa_ 只在打开时才有,不参与判定
|
|
1650
|
+
const markers = ['wSkVaW_', 'uV2eYG_', 'pXSMma_', 'pI_x6G_']
|
|
1651
|
+
let timer = window.setTimeout(async () => {
|
|
1652
|
+
try {
|
|
1653
|
+
const missing = markers.filter((marker) => document.querySelector(`[class*="${marker}"]`) === null)
|
|
1654
|
+
if (missing.length > 0) {
|
|
1655
|
+
console.warn('[remote-panel] 外壳挂点缺失,手机样式可能失配:', missing.join(', '))
|
|
1656
|
+
}
|
|
1657
|
+
await callApi('/shell', {
|
|
1658
|
+
method: 'POST',
|
|
1659
|
+
headers: { 'content-type': 'application/json' },
|
|
1660
|
+
body: JSON.stringify({ missing }),
|
|
1661
|
+
})
|
|
1662
|
+
} catch {
|
|
1663
|
+
/* 上报失败不影响使用 */
|
|
1664
|
+
}
|
|
1665
|
+
}, 1500)
|
|
1666
|
+
return () => window.clearTimeout(timer)
|
|
1679
1667
|
}
|
|
1680
1668
|
|
|
1681
1669
|
/**
|
|
@@ -1961,30 +1949,97 @@ window.__ModuleLoader__.load({
|
|
|
1961
1949
|
* 做法:在 pointerdown 阶段记一个短窗口(900ms),窗口内如果输入框收到 focus 就立刻 blur 回去;
|
|
1962
1950
|
* 点别的地方会立刻清掉窗口,不会影响正常输入。
|
|
1963
1951
|
*/
|
|
1952
|
+
/**
|
|
1953
|
+
* 输入条焦点守卫:这几种操作都不该把键盘顶起来——
|
|
1954
|
+
* 切工作区、切会话、打开面板、点「+」菜单。
|
|
1955
|
+
*
|
|
1956
|
+
* DSH 会在操作之后把焦点塞回输入条,且时机在我们之后,
|
|
1957
|
+
* 所以除了撤焦点,还要开一个短暂的压制窗口(focusin 里再撤一次)。
|
|
1958
|
+
* 只针对输入条里的可编辑区,不拦用户自己点输入条。
|
|
1959
|
+
*/
|
|
1964
1960
|
function installComposerFocusGuard() {
|
|
1965
1961
|
if (typeof document === 'undefined') return () => {}
|
|
1966
|
-
let
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1962
|
+
let suppressUntil = 0
|
|
1963
|
+
let labelObserver = null
|
|
1964
|
+
let observedLabel = null
|
|
1965
|
+
let lastLabel = ''
|
|
1966
|
+
let labelTimer = 0
|
|
1967
|
+
|
|
1968
|
+
const isComposer = (el) =>
|
|
1969
|
+
el instanceof Element &&
|
|
1970
|
+
el !== document.body &&
|
|
1971
|
+
(el.isContentEditable === true || el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') &&
|
|
1972
|
+
(el.closest('.uV2eYG_root') !== null || el.closest('[class*="_composer"]') !== null)
|
|
1973
|
+
|
|
1974
|
+
const release = () => {
|
|
1975
|
+
const el = document.activeElement
|
|
1976
|
+
if (isComposer(el)) el.blur()
|
|
1977
|
+
}
|
|
1978
|
+
// 分几拍撤一次,并留一段压制窗口覆盖 DSH 自己的 focus
|
|
1979
|
+
const scheduleRelease = (windowMs = 700) => {
|
|
1980
|
+
suppressUntil = Math.max(suppressUntil, Date.now() + windowMs)
|
|
1981
|
+
for (const delay of [0, 80, 200, 400, 700]) window.setTimeout(release, delay)
|
|
1971
1982
|
}
|
|
1983
|
+
|
|
1972
1984
|
const onFocusIn = (event) => {
|
|
1973
|
-
if (Date.now() >
|
|
1985
|
+
if (Date.now() > suppressUntil) return
|
|
1974
1986
|
const el = event.target
|
|
1975
|
-
if (!
|
|
1987
|
+
if (!isComposer(el)) return
|
|
1976
1988
|
const drop = () => {
|
|
1977
|
-
if (Date.now() <=
|
|
1989
|
+
if (Date.now() <= suppressUntil && document.activeElement === el) el.blur?.()
|
|
1978
1990
|
}
|
|
1979
1991
|
drop()
|
|
1980
1992
|
// 有的实现会在下一帧再 focus 一次,补一刀
|
|
1981
1993
|
requestAnimationFrame(drop)
|
|
1982
1994
|
}
|
|
1995
|
+
|
|
1996
|
+
// 「+」按钮弹菜单,别顺手把焦点交给输入条
|
|
1997
|
+
const onPointerDown = (event) => {
|
|
1998
|
+
const target = event.target
|
|
1999
|
+
if (target instanceof Element && target.closest('.uV2eYG_add') !== null) suppressUntil = Date.now() + 900
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// 抽屉里选会话 / 打开面板:切完别把键盘顶起来
|
|
2003
|
+
const onClick = (event) => {
|
|
2004
|
+
const target = event.target
|
|
2005
|
+
if (!(target instanceof Element)) return
|
|
2006
|
+
if (target.closest('[class*="_sessionRow"], [class*="_panelRow"]') !== null) scheduleRelease(900)
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// 工作区选择器关闭 = 里面那个搜索框失焦
|
|
2010
|
+
const onFocusOut = (event) => {
|
|
2011
|
+
const target = event.target
|
|
2012
|
+
if (target instanceof Element && target.closest('[class*="wsc-"]') !== null) scheduleRelease()
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const watchLabel = () => {
|
|
2016
|
+
const label = document.querySelector('[class*="_workspaceLabel"]')
|
|
2017
|
+
if (label === null || label === observedLabel) return
|
|
2018
|
+
labelObserver?.disconnect()
|
|
2019
|
+
observedLabel = label
|
|
2020
|
+
lastLabel = label.textContent ?? ''
|
|
2021
|
+
labelObserver = new MutationObserver(() => {
|
|
2022
|
+
const now = label.textContent ?? ''
|
|
2023
|
+
if (now === lastLabel) return
|
|
2024
|
+
lastLabel = now
|
|
2025
|
+
scheduleRelease()
|
|
2026
|
+
})
|
|
2027
|
+
labelObserver.observe(label, { childList: true, characterData: true, subtree: true })
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1983
2030
|
document.addEventListener('pointerdown', onPointerDown, true)
|
|
1984
2031
|
document.addEventListener('focusin', onFocusIn, true)
|
|
2032
|
+
document.addEventListener('click', onClick, true)
|
|
2033
|
+
document.addEventListener('focusout', onFocusOut, true)
|
|
2034
|
+
labelTimer = window.setInterval(watchLabel, 800)
|
|
2035
|
+
watchLabel()
|
|
1985
2036
|
return () => {
|
|
1986
2037
|
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
1987
2038
|
document.removeEventListener('focusin', onFocusIn, true)
|
|
2039
|
+
document.removeEventListener('click', onClick, true)
|
|
2040
|
+
document.removeEventListener('focusout', onFocusOut, true)
|
|
2041
|
+
window.clearInterval(labelTimer)
|
|
2042
|
+
labelObserver?.disconnect()
|
|
1988
2043
|
}
|
|
1989
2044
|
}
|
|
1990
2045
|
|
|
@@ -2225,6 +2280,7 @@ window.__ModuleLoader__.load({
|
|
|
2225
2280
|
const cleanupNet = installConnectivity()
|
|
2226
2281
|
const cleanupFocus = installComposerFocusGuard()
|
|
2227
2282
|
const cleanupFps = installFpsMeter()
|
|
2283
|
+
const cleanupContract = installShellContractReport()
|
|
2228
2284
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupClear, 'remote-panel: workspace clear')
|
|
2229
2285
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupBack, 'remote-panel: back gesture')
|
|
2230
2286
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupAttention, 'remote-panel: attention')
|
|
@@ -2232,6 +2288,7 @@ window.__ModuleLoader__.load({
|
|
|
2232
2288
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupNet, 'remote-panel: connectivity')
|
|
2233
2289
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupFocus, 'remote-panel: composer focus guard')
|
|
2234
2290
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupFps, 'remote-panel: fps meter')
|
|
2291
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanupContract, 'remote-panel: shell contract report')
|
|
2235
2292
|
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup, 'remote-panel: mobile shell css')
|
|
2236
2293
|
|
|
2237
2294
|
ctx.slots.inject('shell.overlay', () =>
|
package/lib/contract.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外壳契约检查:确认 DSH 前端还在用我们依赖的那套 hash 类名。
|
|
3
|
+
*
|
|
4
|
+
* 手机外壳的样式全靠 DSH 构建产物里的私有类名(`wSkVaW_header` 这种),
|
|
5
|
+
* 它们随每次构建变化。DSH 一升级,样式可能整体失配——所以在宿主侧做一次
|
|
6
|
+
* 「挂点还在不在」的体检,失配时面板显红,而不是等用户发现界面错位。
|
|
7
|
+
*
|
|
8
|
+
* 只读:拉一次 index.html 和它引用的 CSS,做纯字符串匹配。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { request } from 'node:http'
|
|
12
|
+
|
|
13
|
+
/** 外壳依赖的类名前缀(对应 lib/client.js 里的选择器)。 */
|
|
14
|
+
export const SHELL_MARKERS = ['wSkVaW_', 'hHd-Xa_', 'uV2eYG_', 'pXSMma_', 'pI_x6G_']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ port: number, token: string, host?: string, timeoutMs?: number }} options
|
|
18
|
+
* @returns {Promise<{ ok: boolean, missing: string[], asset?: string, reason?: string, checkedAt: number }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function checkShellContract({ port, token, host = '127.0.0.1', timeoutMs = 4000 }) {
|
|
21
|
+
const checkedAt = Date.now()
|
|
22
|
+
const base = `http://${host}:${port}`
|
|
23
|
+
// DSH 的令牌是「先 303 种 Cookie,再访问首页」两步走,这里手工跟一次
|
|
24
|
+
const login = await requestText(`${base}/?token=${encodeURIComponent(token)}`, { timeoutMs })
|
|
25
|
+
const cookie = login.cookie
|
|
26
|
+
if (cookie === undefined) return { ok: false, missing: [...SHELL_MARKERS], reason: 'login-failed', checkedAt }
|
|
27
|
+
const html = await requestText(`${base}/`, { timeoutMs, cookie })
|
|
28
|
+
if (html.body === undefined) return { ok: false, missing: [...SHELL_MARKERS], reason: 'index-unreachable', checkedAt }
|
|
29
|
+
// 页面通常引多个 CSS(vendor + 业务),挂点类名在业务那个里,所以全取回来一起看
|
|
30
|
+
const assets = [...html.body.matchAll(/href="\.?\/?(assets\/[^"']+\.css)"/g)].map((m) => m[1])
|
|
31
|
+
if (assets.length === 0) return { ok: false, missing: [...SHELL_MARKERS], reason: 'no-css-asset', checkedAt }
|
|
32
|
+
let css = ''
|
|
33
|
+
let matched
|
|
34
|
+
for (const asset of assets) {
|
|
35
|
+
const body = await requestText(`${base}/${asset}`, { timeoutMs, cookie })
|
|
36
|
+
if (body.body === undefined) continue
|
|
37
|
+
css += body.body
|
|
38
|
+
if (matched === undefined && SHELL_MARKERS.some((marker) => body.body.includes(marker))) matched = asset
|
|
39
|
+
}
|
|
40
|
+
if (css === '') return { ok: false, missing: [...SHELL_MARKERS], reason: 'css-unreachable', checkedAt }
|
|
41
|
+
const missing = SHELL_MARKERS.filter((marker) => !css.includes(marker))
|
|
42
|
+
return { ok: missing.length === 0, missing, asset: matched ?? assets[0], assets, checkedAt }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 简单 GET:返回 { body, cookie },超时/失败给 undefined。
|
|
47
|
+
* 体检不该把状态接口带崩,所以这里不抛异常。
|
|
48
|
+
*/
|
|
49
|
+
function requestText(url, { timeoutMs, cookie } = {}) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const headers = cookie === undefined ? {} : { cookie }
|
|
52
|
+
const req = request(url, { method: 'GET', headers, timeout: timeoutMs }, (res) => {
|
|
53
|
+
const jar = (res.headers['set-cookie'] ?? []).map((entry) => entry.split(';')[0]).join('; ')
|
|
54
|
+
if ((res.statusCode ?? 500) >= 400) {
|
|
55
|
+
res.resume()
|
|
56
|
+
resolve({ cookie: jar === '' ? undefined : jar })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
let body = ''
|
|
60
|
+
res.setEncoding('utf8')
|
|
61
|
+
res.on('data', (chunk) => {
|
|
62
|
+
body += chunk
|
|
63
|
+
})
|
|
64
|
+
res.on('end', () => resolve({ body, cookie: jar === '' ? undefined : jar }))
|
|
65
|
+
})
|
|
66
|
+
req.on('timeout', () => {
|
|
67
|
+
req.destroy()
|
|
68
|
+
resolve({})
|
|
69
|
+
})
|
|
70
|
+
req.on('error', () => resolve({}))
|
|
71
|
+
req.end()
|
|
72
|
+
})
|
|
73
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -110,6 +110,7 @@ export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app'))
|
|
|
110
110
|
/* 缺某个尺寸不算致命 */
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
+
writeLandingPage(dir)
|
|
113
114
|
const ca = localCaPath()
|
|
114
115
|
if (ca !== undefined) {
|
|
115
116
|
try {
|
|
@@ -131,23 +132,52 @@ export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app'))
|
|
|
131
132
|
* @param {string|undefined} token 当前进程的 DSH 令牌
|
|
132
133
|
* @param {string|undefined} accessKey 门禁口令
|
|
133
134
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
/**
|
|
136
|
+
* 写 `/remote-panel/login` 背后的跳转页(由 Caddy 放在门禁之后发送)。
|
|
137
|
+
*
|
|
138
|
+
* 之前这个页面放在免鉴权的 `/remote-panel/app/go.html` 里,等于把当前进程的令牌
|
|
139
|
+
* 暴露给网内任何人——口令门禁被绕过。现在令牌只在通过门禁之后才能拿到:
|
|
140
|
+
*
|
|
141
|
+
* 扫码 / 点链接 → /pair?k=口令(种 Cookie) → /remote-panel/login(要 Cookie) → /?token=
|
|
142
|
+
*
|
|
143
|
+
* @param {string} dir 资源目录
|
|
144
|
+
* @param {string|undefined} token 当前进程的 DSH 令牌
|
|
145
|
+
* @returns {string} 跳转目标
|
|
146
|
+
*/
|
|
147
|
+
export function writeLoginPage(dir, token) {
|
|
148
|
+
const target = token === undefined ? '/' : `/?token=${encodeURIComponent(token)}`
|
|
139
149
|
const html =
|
|
140
150
|
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
141
151
|
`<title>DSH</title><meta http-equiv="refresh" content="0;url=${target}">` +
|
|
142
152
|
'<style>html,body{height:100%;margin:0;background:#000;color:#888;font:14px system-ui;display:flex;align-items:center;justify-content:center}</style>' +
|
|
143
153
|
'<p>正在进入 DSH…</p>' +
|
|
144
|
-
// ?fps=1 时把开关记进 sessionStorage
|
|
154
|
+
// ?fps=1 时把开关记进 sessionStorage,跳转后由客户端插件读取(真机性能排查用)
|
|
145
155
|
'<script>try{if(/[?&]fps=1/.test(location.search))sessionStorage.setItem("dsh-fps","1")}catch(e){}' +
|
|
146
156
|
`location.replace(${JSON.stringify(target)})</script>`
|
|
147
|
-
writeFileSync(join(dir, '
|
|
157
|
+
writeFileSync(join(dir, 'login.html'), html)
|
|
148
158
|
return target
|
|
149
159
|
}
|
|
150
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 旧版手机壳的入口页:不再带任何凭据,只提示升级。
|
|
163
|
+
*
|
|
164
|
+
* v0.2.x 的 APK 启动地址是这里;那些版本会把令牌写在本页里(安全缺陷),
|
|
165
|
+
* 所以直接停用,让页面告诉用户装新版壳。
|
|
166
|
+
*/
|
|
167
|
+
export function writeLandingPage(dir) {
|
|
168
|
+
const html =
|
|
169
|
+
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
170
|
+
'<title>DSH</title>' +
|
|
171
|
+
'<style>html,body{height:100%;margin:0;background:#000;color:#ddd;font:14px system-ui;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center}</style>' +
|
|
172
|
+
'<div><p style="font-size:16px;font-weight:600;margin:0 0 8px">手机壳需要更新</p>' +
|
|
173
|
+
'<p style="color:#888;margin:0">旧版入口已停用(安全升级)。<br>请在浏览器打开面板重新下载 DSH 壳。</p></div>'
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(join(dir, 'go.html'), html)
|
|
176
|
+
} catch {
|
|
177
|
+
/* 目录不可写就算了 */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
/**
|
|
152
182
|
* 注入 index 的 head 行:主题色 + iOS 独立窗口 + 桌面图标 + 我们自己的 manifest。
|
|
153
183
|
*
|
|
@@ -339,7 +369,7 @@ routes.set('GET /pair', async ({ req }) => {
|
|
|
339
369
|
const scheme = requestScheme(req) === 'https' || detectTls() ? 'https' : 'http'
|
|
340
370
|
try {
|
|
341
371
|
const dir = join(homedir(), '.dsh', 'remote-panel-app')
|
|
342
|
-
if (existsSync(dir))
|
|
372
|
+
if (existsSync(dir)) writeLoginPage(dir, token)
|
|
343
373
|
} catch {
|
|
344
374
|
/* 目录还没建好就算了 */
|
|
345
375
|
}
|
|
@@ -455,6 +485,17 @@ routes.set('GET /probe', async ({ probe: runProbe = probe }) => {
|
|
|
455
485
|
return { ok: true, state, probe: await runProbe(state, { tls: detectTls() }) }
|
|
456
486
|
})
|
|
457
487
|
|
|
488
|
+
/** 客户端上报的外壳挂点自检结果(内存里保留最近一次)。 */
|
|
489
|
+
let lastShellReport = null
|
|
490
|
+
|
|
491
|
+
routes.set('POST /shell', async ({ req }) => {
|
|
492
|
+
const body = await readJson(req)
|
|
493
|
+
const missing = Array.isArray(body?.missing) ? body.missing.filter((item) => typeof item === 'string') : null
|
|
494
|
+
if (missing === null) throw new BadRequest('missing 必须是字符串数组')
|
|
495
|
+
lastShellReport = { ok: missing.length === 0, missing, checkedAt: Date.now() }
|
|
496
|
+
return { ok: true, ...lastShellReport }
|
|
497
|
+
})
|
|
498
|
+
|
|
458
499
|
routes.set('GET /status', ({ query }) => {
|
|
459
500
|
const tlsEnabled = detectTls()
|
|
460
501
|
return {
|
|
@@ -467,6 +508,8 @@ routes.set('GET /status', ({ query }) => {
|
|
|
467
508
|
scheme: tlsEnabled ? 'https' : 'http',
|
|
468
509
|
// 轻量证书信息:只读最近一次 /probe 的缓存,不在 status 里做网络探测
|
|
469
510
|
tls: tlsStatus(tlsEnabled, lastCertSnapshot()),
|
|
511
|
+
// 手机外壳的挂点自检:客户端启动后上报一次,用来说明「DSH 升级后样式还对不对得上」
|
|
512
|
+
shell: lastShellReport,
|
|
470
513
|
echo: query.get('echo') ?? null,
|
|
471
514
|
}
|
|
472
515
|
})
|
|
@@ -525,7 +568,7 @@ export function apply(ctx) {
|
|
|
525
568
|
const state = readState()
|
|
526
569
|
const ports = [Number(state?.port) || 8710, Number(state?.upstream) || 3080]
|
|
527
570
|
const tokens = await findTokens({ ports })
|
|
528
|
-
|
|
571
|
+
writeLoginPage(appDir, tokens.at(-1))
|
|
529
572
|
} catch (error) {
|
|
530
573
|
console.warn('[remote-panel] write go.html failed', error)
|
|
531
574
|
}
|
package/lib/pair.js
CHANGED
|
@@ -98,13 +98,13 @@ export function pairingUrls({ ip, port, accessKey, token, scheme = 'http' }) {
|
|
|
98
98
|
return {
|
|
99
99
|
gate,
|
|
100
100
|
token: tokenUrl,
|
|
101
|
-
//
|
|
101
|
+
// 登录入口:受门禁保护,Caddy 直接发一份带当前令牌的跳转页。
|
|
102
|
+
// 二维码只带门禁口令,口令换来的 Cookie 才能打开它——令牌不落在二维码/链接里。
|
|
103
|
+
login: `${base}/remote-panel/login`,
|
|
104
|
+
// 一张二维码走完两步:先种门禁 Cookie,再由 Caddy 转发到登录入口
|
|
102
105
|
// 走专用路径:Caddy 会回一个同源页面,由它发起跳转——
|
|
103
106
|
// 跨站导航不会携带 SameSite=Strict 的 DSH Cookie,非要同源页面自己发起才行
|
|
104
|
-
combo:
|
|
105
|
-
accessKey !== undefined && token !== undefined
|
|
106
|
-
? `${base}/pair?k=${accessKey}&token=${token}`
|
|
107
|
-
: undefined,
|
|
107
|
+
combo: accessKey !== undefined ? `${base}/pair?k=${accessKey}` : undefined,
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-panel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "DSH 远程访问控制面:部署向导、状态面板与一键配对(EasyTier + Caddy),数据面保持进程外。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"dsh": ">=0.2.1-rc.3"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"test": "node --test \"test/*.test.js\""
|
|
36
|
+
"test": "node --test \"test/*.test.js\"",
|
|
37
|
+
"e2e": "node test/e2e/shell.mjs"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"qrcode": "^1.5.4"
|