dsh-remote-panel 0.1.0 → 0.2.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 CHANGED
@@ -47,6 +47,48 @@ dsh plugin --profile web add dsh-remote-panel
47
47
 
48
48
  ---
49
49
 
50
+ ## 可选 HTTPS(内部 CA)
51
+
52
+ 默认是明文 HTTP。要装成 Android 独立应用,勾上向导里的「启用 HTTPS」重新部署即可,其余不变:
53
+
54
+ - Caddyfile 会变成 `10.140.140.223:8710 { tls internal … }`,`auto_https` 从 `off` 改成
55
+ `disable_redirects`——**`off` 会把证书自动化一起关掉**,`tls internal` 就签不出证书(实测握手
56
+ `no certificate available`)。
57
+ - 站点地址必须带 IP:内部 CA 按名字签,空 host 的 `:8710` 签不出来;签出来的是
58
+ `IP Address:10.140.140.223` 的证书。
59
+ - 配对二维码/链接、安装脚本打印的地址、面板里的地址都会跟着变成 `https://`。
60
+
61
+ 手机第一次要信任根证书:面板「远程访问 → 手机全屏」里有 **下载根证书**(`/remote-panel/app/ca.crt`,就是
62
+ Caddy 的 `pki/authorities/local/root.crt`)。
63
+
64
+ - Android:设置 → 安全 → 加密与凭据 → 安装证书 → CA 证书。
65
+ - iOS:下载后到 设置 → 通用 → 关于本机 → 证书信任设置 里打开信任。
66
+
67
+ ## 手机 App(WebView 壳)
68
+
69
+ PWA「安装应用」在国行 Android 上装不成:WebAPK 打包依赖 Google Play 服务,且打包服务器要能从公网
70
+ 访问源站(我们是内网 IP)。所以仓库里带了一个极简 WebView 壳:
71
+
72
+ ```bash
73
+ ./android/build.sh # 需要 ANDROID_HOME(build-tools 34 + platform 34)与 JDK 17
74
+ # 产物:android/dsh-pocket.apk
75
+ ```
76
+
77
+ - 启动地址是 `/remote-panel/app/go.html`:插件每次启动时重写这一页,写入当前令牌,
78
+ 先种门禁 Cookie 再跳首页,所以壳里不用登录;
79
+ - `network_security_config` 显式信任 **user CA**,否则 WebView 不认装在用户凭据里的 Caddy 根证书;
80
+ - 装好后的 APK 放在 `~/.dsh/remote-panel-app/dsh.apk`,手机直接访问
81
+ `https://<ip>:8710/remote-panel/app/dsh.apk` 下载安装即可。
82
+
83
+ ## 手机 App 化
84
+
85
+ - **iPhone / iPad**:浏览器「分享 → 添加到主屏幕」,从桌面图标打开就是无地址栏的全屏。
86
+ (iOS 不认 manifest 的 display,靠注入的 `apple-mobile-web-app-capable` + `apple-touch-icon`。)
87
+ - **Android**:真正「安装应用」需要 HTTPS;没有 HTTPS 时用 **设置 → 远程访问 → 手机全屏 → 进入全屏**
88
+ (Fullscreen API,隐藏浏览器 UI)。
89
+ - 配套优化:`viewport-fit=cover` + 安全区(刘海 / Home 指示条)、`interactive-widget=resizes-content`
90
+ (键盘挤压布局而不是盖住输入条)、`theme-color`(深浅两套)、`overscroll-behavior: none`(无下拉刷新)。
91
+
50
92
  ## 安全模型
51
93
 
52
94
  三层,从外到内:
@@ -70,7 +112,7 @@ dsh plugin --profile web add dsh-remote-panel
70
112
  | 现象 | 含义 | 处置 |
71
113
  |---|---|---|
72
114
  | 连接被拒绝 | 网络层或 Caddy 没起 | `systemctl --user status dsh-proxy`、`easytier-cli peer` |
73
- | `403 source not allowed` | 来源不在白名单 | 面板「来源白名单」加设备,或在家连 Wi-Fi |
115
+ | `403 source not allowed` | 来源不在白名单 | 面板「来源白名单」加设备;若手机组网地址变过(.2 → .3),确认名单里有组网网段 `10.x.0.0/24` |
74
116
  | `401 access key required` | 缺门禁 Cookie | 用 `?k=` 那条链接重开一次 |
75
117
  | `401 dsh web authentication required` | 已到 DSH,缺令牌 | 用 `?token=` 那条链接重开一次 |
76
118
  | `/healthz` 返回 200 | 链路本身健康 | 问题在认证层,不在网络层 |
@@ -2,10 +2,12 @@
2
2
  # 生成者:dsh-remote-panel @@VERSION@@
3
3
  {
4
4
  # admin API 保持默认(仅本机 2019):白名单热重载依赖 `caddy reload`
5
- auto_https off
5
+ # 走 TLS 时不能用 off(会把证书自动化一起关掉),改成 disable_redirects
6
+ auto_https @@AUTO_HTTPS@@
6
7
  }
7
8
 
8
- :@@PORT@@ {
9
+ @@SITE_ADDR@@ {
10
+ @@TLS_LINE@@
9
11
  # 访问日志进 journal,供面板「最近拒绝」使用
10
12
  log {
11
13
  output stderr
@@ -14,21 +16,47 @@
14
16
  }
15
17
 
16
18
  # 第一层:来源白名单
17
- @lan remote_ip 127.0.0.1 ::1 @@LAN_CIDR@@ @@VIRTUAL_IP@@
19
+ # 最后一条是组网网段:EasyTier 成员每次重连可能换地址(.2 → .3),
20
+ # 只钉单个 IP 会反复 403;按网段放行,边界仍由「入网需要网络密钥」保证。
21
+ @lan remote_ip 127.0.0.1 ::1 @@LAN_CIDR@@ @@VIRTUAL_IP@@ @@MESH_CIDR@@
18
22
  handle @lan {
23
+ # 配对入口:路径匹配(可靠),返回同源页面做一次「同站跳转」。
24
+ # 跨站导航不会带 SameSite=Strict 的 DSH Cookie,必须由同源页面自己发起。
25
+ handle /pair {
26
+ header Set-Cookie "dsh_gate=@@ACCESS_KEY@@; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax"
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
29
+ }
30
+
19
31
  # 诊断用:不经过任何认证
20
32
  handle /healthz {
21
33
  respond "ok" 200
22
34
  }
23
35
 
36
+ # PWA 资源必须免 Cookie:浏览器抓 manifest 时按规范不带凭据,走门禁会 401,
37
+ # Chrome/Edge 会报 manifest-parsing-or-network-error,于是「安装应用」失败。
38
+ # 文件由插件启动时同步到 @@APP_DIR@@(manifest / 图标 / 根证书)。
39
+ handle_path /remote-panel/app/* {
40
+ root * @@APP_DIR@@
41
+ @manifest path *.webmanifest
42
+ header @manifest Content-Type application/manifest+json
43
+ file_server
44
+ }
45
+
24
46
  # 第二层:口令门禁
25
47
  @key query k=@@ACCESS_KEY@@
26
48
  handle @key {
27
49
  # Cookie 值就是口令本身(24 位随机),而不是常量:常量能被手工伪造
28
50
  header Set-Cookie "dsh_gate=@@ACCESS_KEY@@; Max-Age=31536000; Path=/; HttpOnly; SameSite=Lax"
29
- # 显式写 * 匹配器:否则开头的 / 会被当成路径匹配器,
30
- # 变成 Location: 303 的怪响应
31
- redir * / 303
51
+
52
+ # 一张二维码走完两步:带 token 时转发到令牌地址,不带则回首页
53
+ @withToken query token=*
54
+ handle @withToken {
55
+ redir * /?token={query.token} 303
56
+ }
57
+ handle {
58
+ redir * / 303
59
+ }
32
60
  }
33
61
 
34
62
  # Caddy 默认没有 cookie 匹配器,用 Header 通配匹配替代
@@ -167,7 +167,8 @@ for _ in $(seq 1 30); do
167
167
  done
168
168
  ss -ltn 2>/dev/null | grep -q ":${PROXY_PORT} " || die "反代未监听 :${PROXY_PORT}"
169
169
 
170
- health="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PROXY_PORT}/healthz" || true)"
170
+ # TLS 时本地没有信任链,用 -k 只探活,不校验证书
171
+ health="$(curl -sk -o /dev/null -w '%{http_code}' "@@SCHEME@@://127.0.0.1:${PROXY_PORT}/healthz" || true)"
171
172
  [ "$health" = "200" ] || die "healthz 未返回 200(实际 ${health})"
172
173
 
173
174
  PAYLOAD="$(printf '{"version":"%s","port":%s,"ip":"%s","upstream":%s,"health":"%s","key":"%s"}' \
@@ -177,8 +178,8 @@ cat <<EOF
177
178
 
178
179
  部署完成。
179
180
 
180
- 手机 / 平板: http://${VIRTUAL_IP}:${PROXY_PORT}/
181
- 家里局域网: http://<本机局域网IP>:${PROXY_PORT}/
181
+ 手机 / 平板: @@SCHEME@@://${VIRTUAL_IP}:${PROXY_PORT}/
182
+ 家里局域网: @@SCHEME@@://<本机局域网IP>:${PROXY_PORT}/
182
183
 
183
184
  回填码(复制到「远程访问」面板):
184
185
  DSH_REMOTE_OK ${PAYLOAD}
package/lib/allowlist.js CHANGED
@@ -65,10 +65,8 @@ export function withEntry(text, entry, mode) {
65
65
 
66
66
  if (next.length === 0) throw new Error('白名单不能为空:至少保留本机 127.0.0.1')
67
67
 
68
- // 每行最多 6 条,避免 Caddyfile 出现超长行
69
- const chunks = []
70
- for (let i = 0; i < next.length; i += 6) chunks.push(next.slice(i, i + 6))
71
- lines[index] = chunks.map((chunk) => `${indent}@lan remote_ip ${chunk.join(' ')}`).join('\n')
68
+ // 必须保持一行:同名 matcher 定义两次会被 Caddy 拒绝(matcher is defined more than once: @lan)
69
+ lines[index] = `${indent}@lan remote_ip ${next.join(' ')}`
72
70
  return lines.join('\n')
73
71
  }
74
72
 
package/lib/api.js CHANGED
@@ -4,9 +4,11 @@
4
4
  */
5
5
 
6
6
  import { randomBytes } from 'node:crypto'
7
+ import { readFileSync } from 'node:fs'
7
8
  import { createServer } from 'node:net'
8
9
  import { homedir, networkInterfaces, userInfo } from 'node:os'
9
10
 
11
+ import { caddyfilePath } from './allowlist.js'
10
12
  import { renderDeploy } from './deploy/render.js'
11
13
 
12
14
  /** 客户端输入有问题(回 400,而不是 500)。 */
@@ -76,6 +78,20 @@ export function suggestToken(length = 24) {
76
78
  }
77
79
 
78
80
  /** 向导打开时的默认值。 */
81
+ /**
82
+ * 读线上 Caddyfile 判断当前是不是 TLS 部署。
83
+ * 重新部署时向导按它预填「启用 HTTPS」,避免点一下又掉回明文。
84
+ * @param {string} [home]
85
+ */
86
+ export function detectTls(home) {
87
+ try {
88
+ const text = readFileSync(caddyfilePath(home), 'utf8')
89
+ return /^\s*tls\s+internal\b/m.test(text)
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
79
95
  export async function defaults(version) {
80
96
  return {
81
97
  ok: true,
@@ -89,6 +105,8 @@ export async function defaults(version) {
89
105
  accessKey: suggestToken(24),
90
106
  networkSecret: suggestToken(32),
91
107
  networkName: 'dsh-remote',
108
+ // 默认沿用线上现状:已经在跑 TLS 就别让重新部署把它改回明文
109
+ tls: detectTls(),
92
110
  }
93
111
  }
94
112
 
Binary file
Binary file
Binary file
Binary file