dsh-remote-panel 0.1.1 → 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 +43 -1
- package/deploy/templates/Caddyfile.tmpl +17 -3
- package/deploy/templates/install.sh.tmpl +4 -3
- package/lib/allowlist.js +2 -4
- package/lib/api.js +18 -0
- package/lib/assets/icon-180.png +0 -0
- package/lib/assets/icon-192.png +0 -0
- package/lib/assets/icon-512.png +0 -0
- package/lib/assets/icon-maskable-512.png +0 -0
- package/lib/client.js +978 -71
- package/lib/deploy/render.js +19 -2
- package/lib/index.js +332 -28
- package/lib/pair.js +4 -3
- package/lib/probe.js +258 -3
- package/package.json +1 -1
package/lib/deploy/render.js
CHANGED
|
@@ -110,6 +110,7 @@ export function normalizeInput(raw = {}) {
|
|
|
110
110
|
|
|
111
111
|
if (!home.startsWith('/')) throw new Error(`home 必须是绝对路径:${home}`)
|
|
112
112
|
|
|
113
|
+
const tls = raw.tls === true || raw.tls === 'true'
|
|
113
114
|
const devName = requireMatch(String(raw.devName ?? 'dshmesh0'), DEV, 'TUN 网卡名')
|
|
114
115
|
const peers = normalizeRelay(raw.relay)
|
|
115
116
|
const lanCidr = String(raw.lanCidr ?? '192.168.1.0/24')
|
|
@@ -117,16 +118,23 @@ export function normalizeInput(raw = {}) {
|
|
|
117
118
|
throw new Error(`局域网 CIDR 格式不合法:${lanCidr}`)
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// 组网(EasyTier)成员每次重连可能换 IP,按网段放行才不会一直踩 403;
|
|
122
|
+
// 默认按 /24 取虚拟 IP 所在网段,与 EasyTier 的默认掩码一致。
|
|
123
|
+
const meshCidr = `${virtualIp.split('.').slice(0, 3).join('.')}.0/24`
|
|
124
|
+
|
|
120
125
|
return {
|
|
121
126
|
user,
|
|
122
127
|
home,
|
|
123
128
|
virtualIp,
|
|
129
|
+
meshCidr,
|
|
124
130
|
port,
|
|
125
131
|
upstreamPort,
|
|
126
132
|
accessKey,
|
|
127
133
|
networkSecret,
|
|
128
134
|
networkName,
|
|
129
135
|
hostname: String(raw.hostname ?? 'dsh-remote').replace(/[^A-Za-z0-9._-]/g, ''),
|
|
136
|
+
tls,
|
|
137
|
+
scheme: tls ? 'https' : 'http',
|
|
130
138
|
lanCidr,
|
|
131
139
|
peers,
|
|
132
140
|
relayLabel: peers[0].replace(/^\w+:\/\//, ''),
|
|
@@ -164,13 +172,21 @@ export function renderDeploy(raw) {
|
|
|
164
172
|
{ allowLeftover: ['TCP_WHITELIST'] },
|
|
165
173
|
)
|
|
166
174
|
|
|
175
|
+
// TLS 时站点必须写成「IP:端口」:内部 CA 要按名字签证书,空 host 签不出来。
|
|
176
|
+
// 同时 auto_https 不能是 off —— off 会连证书自动化一起关掉,`tls internal` 就成了摆设
|
|
177
|
+
// (实测 2.6.2:off + tls internal = 握手 no certificate available)。
|
|
167
178
|
const caddy = fill(template('Caddyfile.tmpl'), {
|
|
168
179
|
VERSION: input.version,
|
|
169
180
|
PORT: input.port,
|
|
170
181
|
UPSTREAM_PORT: input.upstreamPort,
|
|
171
182
|
VIRTUAL_IP: input.virtualIp,
|
|
172
183
|
LAN_CIDR: input.lanCidr,
|
|
184
|
+
MESH_CIDR: input.meshCidr,
|
|
173
185
|
ACCESS_KEY: input.accessKey,
|
|
186
|
+
APP_DIR: `${input.home.replace(/\/$/, '')}/.dsh/remote-panel-app`,
|
|
187
|
+
SITE_ADDR: input.tls ? `${input.virtualIp}:${input.port}` : `:${input.port}`,
|
|
188
|
+
AUTO_HTTPS: input.tls ? 'disable_redirects' : 'off',
|
|
189
|
+
TLS_LINE: input.tls ? '\ttls internal' : '',
|
|
174
190
|
})
|
|
175
191
|
|
|
176
192
|
const proxyUnit = fill(template('dsh-proxy.service.tmpl'), { VERSION: input.version })
|
|
@@ -189,6 +205,7 @@ export function renderDeploy(raw) {
|
|
|
189
205
|
ET_SHA_X86_64: shaFor(input.etVersion, 'x86_64'),
|
|
190
206
|
ET_SHA_AARCH64: shaFor(input.etVersion, 'aarch64'),
|
|
191
207
|
ACCESS_KEY: input.accessKey,
|
|
208
|
+
SCHEME: input.scheme,
|
|
192
209
|
ENV_BODY: env.trimEnd(),
|
|
193
210
|
UNIT_BODY: unit.trimEnd(),
|
|
194
211
|
CADDY_BODY: caddy.trimEnd(),
|
|
@@ -207,10 +224,10 @@ export function renderDeploy(raw) {
|
|
|
207
224
|
install,
|
|
208
225
|
summary: {
|
|
209
226
|
urls: {
|
|
210
|
-
mesh:
|
|
227
|
+
mesh: `${input.scheme}://${input.virtualIp}:${input.port}/`,
|
|
211
228
|
loopback: `http://127.0.0.1:${input.port}/`,
|
|
212
229
|
},
|
|
213
|
-
gate:
|
|
230
|
+
gate: `${input.scheme}://${input.virtualIp}:${input.port}/?k=${input.accessKey}`,
|
|
214
231
|
peers: input.peers,
|
|
215
232
|
},
|
|
216
233
|
}
|
package/lib/index.js
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { timingSafeEqual } from 'node:crypto'
|
|
16
|
-
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
17
19
|
|
|
18
|
-
import { BadRequest, defaults, isPortFree, readJson, render } from './api.js'
|
|
20
|
+
import { BadRequest, defaults, detectTls, isPortFree, readJson, render } from './api.js'
|
|
19
21
|
import { runDoctor } from './doctor.js'
|
|
20
22
|
import { loadOrCreateToken } from './token.js'
|
|
21
|
-
import { probe, reloadProxy } from './probe.js'
|
|
23
|
+
import { probe, probeCertificate, certCheck, lastCertSnapshot, reloadProxy } from './probe.js'
|
|
22
24
|
import { caddyfilePath, mutateAllowlist, parseEntries } from './allowlist.js'
|
|
23
25
|
import { recentRejects } from './logs.js'
|
|
24
26
|
import { pingHost } from './netcheck.js'
|
|
@@ -35,6 +37,38 @@ export const VERSION = pkg.version
|
|
|
35
37
|
/** 控制面 API 前缀;前端与测试共用。 */
|
|
36
38
|
export const API_PREFIX = '/remote-panel/api'
|
|
37
39
|
|
|
40
|
+
/** 手机 App 化资源(manifest / 图标)的公开前缀:浏览器抓 manifest 时带不了自定义头。 */
|
|
41
|
+
export const APP_PREFIX = '/remote-panel/app'
|
|
42
|
+
|
|
43
|
+
/** 我们清单的路径(head 里注入一次 <link rel="manifest">)。 */
|
|
44
|
+
export const MANIFEST_PATH = `${APP_PREFIX}/manifest.webmanifest`
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 我们自己的 Web App Manifest:DSH 自带的那个用 favicon.svg 当图标、没有 short_name 之外的品牌信息,
|
|
48
|
+
* 这里换成黑底蓝鱼标 + 固定 start_url/scope,装到主屏后点开就是独立窗口。
|
|
49
|
+
*/
|
|
50
|
+
export function appManifest() {
|
|
51
|
+
return {
|
|
52
|
+
name: 'DeepSeek Harness',
|
|
53
|
+
short_name: 'DSH',
|
|
54
|
+
description: '手机上操作这台机器上的 DeepSeek Harness',
|
|
55
|
+
id: '/',
|
|
56
|
+
start_url: '/',
|
|
57
|
+
scope: '/',
|
|
58
|
+
display: 'fullscreen',
|
|
59
|
+
display_override: ['fullscreen', 'standalone'],
|
|
60
|
+
background_color: '#000000',
|
|
61
|
+
theme_color: '#000000',
|
|
62
|
+
icons: [
|
|
63
|
+
{ src: `${APP_PREFIX}/icon-192.png`, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
64
|
+
{ src: `${APP_PREFIX}/icon-512.png`, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
|
65
|
+
{ src: `${APP_PREFIX}/icon-maskable-512.png`, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
38
72
|
/** 注入 index 的全局名,前端从这里读取进程令牌。 */
|
|
39
73
|
export const TOKEN_GLOBAL = '__DSH_REMOTE_PANEL__'
|
|
40
74
|
|
|
@@ -51,6 +85,170 @@ function json(res, status, body) {
|
|
|
51
85
|
res.end(JSON.stringify(body))
|
|
52
86
|
}
|
|
53
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 把 PWA 资源落到 Caddy 能直接 file_server 的目录(默认 ~/.dsh/remote-panel-app)。
|
|
90
|
+
*
|
|
91
|
+
* 为什么要落盘:浏览器抓 manifest 按规范**不带 Cookie**,走门禁必然 401,
|
|
92
|
+
* Chrome/Edge 报 `manifest-parsing-or-network-error`,「安装应用」直接失败。
|
|
93
|
+
* 只有让 Caddy 在门禁之前直接发静态文件才装得上。启动时同步一次即可。
|
|
94
|
+
*
|
|
95
|
+
* @param {string} [dir]
|
|
96
|
+
* @returns {string} 实际写入的目录
|
|
97
|
+
*/
|
|
98
|
+
export function syncAppAssets(dir = join(homedir(), '.dsh', 'remote-panel-app')) {
|
|
99
|
+
mkdirSync(dir, { recursive: true })
|
|
100
|
+
const write = (name, bytes) => {
|
|
101
|
+
const target = join(dir, name)
|
|
102
|
+
if (existsSync(target) && Buffer.compare(readFileSync(target), bytes) === 0) return
|
|
103
|
+
writeFileSync(target, bytes)
|
|
104
|
+
}
|
|
105
|
+
write('manifest.webmanifest', Buffer.from(`${JSON.stringify(appManifest(), null, 2)}\n`))
|
|
106
|
+
for (const name of APP_ASSETS.keys()) {
|
|
107
|
+
try {
|
|
108
|
+
write(name, readFileSync(new URL(`./assets/${name}`, import.meta.url)))
|
|
109
|
+
} catch {
|
|
110
|
+
/* 缺某个尺寸不算致命 */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const ca = localCaPath()
|
|
114
|
+
if (ca !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
write('ca.crt', readFileSync(ca))
|
|
117
|
+
} catch {
|
|
118
|
+
/* 没有就算了 */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return dir
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 写 `/remote-panel/app/go.html`:WebView 壳的启动页。
|
|
126
|
+
*
|
|
127
|
+
* 壳里没有浏览器的 Cookie,启动时要先拿门禁 Cookie 再拿 DSH 令牌。
|
|
128
|
+
* 这一页由插件在**每次启动**时重写,所以令牌轮换后自动跟上。
|
|
129
|
+
*
|
|
130
|
+
* @param {string} dir 资源目录
|
|
131
|
+
* @param {string|undefined} token 当前进程的 DSH 令牌
|
|
132
|
+
* @param {string|undefined} accessKey 门禁口令
|
|
133
|
+
*/
|
|
134
|
+
export function writeGoPage(dir, token, accessKey) {
|
|
135
|
+
const target =
|
|
136
|
+
token === undefined || !accessKey
|
|
137
|
+
? '/'
|
|
138
|
+
: `/pair?k=${encodeURIComponent(accessKey)}&token=${encodeURIComponent(token)}`
|
|
139
|
+
const html =
|
|
140
|
+
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
141
|
+
`<title>DSH</title><meta http-equiv="refresh" content="0;url=${target}">` +
|
|
142
|
+
'<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
|
+
'<p>正在进入 DSH…</p>' +
|
|
144
|
+
`<script>location.replace(${JSON.stringify(target)})</script>`
|
|
145
|
+
writeFileSync(join(dir, 'go.html'), html)
|
|
146
|
+
return target
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 注入 index 的 head 行:主题色 + iOS 独立窗口 + 桌面图标 + 我们自己的 manifest。
|
|
151
|
+
*
|
|
152
|
+
* manifest 用黑底蓝鱼图标、`display: fullscreen`、`start_url/scope = /`;
|
|
153
|
+
* 它排在 DSH 自带那条之前,浏览器取第一条,所以主屏看到的是我们的图标与名字。
|
|
154
|
+
*/
|
|
155
|
+
export function appHeadInjections() {
|
|
156
|
+
const head = [
|
|
157
|
+
'<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">',
|
|
158
|
+
'<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">',
|
|
159
|
+
'<meta name="mobile-web-app-capable" content="yes">',
|
|
160
|
+
'<meta name="apple-mobile-web-app-capable" content="yes">',
|
|
161
|
+
'<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">',
|
|
162
|
+
'<meta name="apple-mobile-web-app-title" content="DSH">',
|
|
163
|
+
`<link rel="apple-touch-icon" href="${APP_PREFIX}/icon-180.png">`,
|
|
164
|
+
`<link rel="manifest" href="${MANIFEST_PATH}">`,
|
|
165
|
+
]
|
|
166
|
+
return head.map((html) => ({ kind: 'html', placement: 'head', html }))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 把 index 的 viewport 补齐:刘海屏安全区 + 键盘不遮输入框。 */
|
|
170
|
+
export function withAppViewport(html) {
|
|
171
|
+
return String(html).replace(
|
|
172
|
+
/(<meta\s+name="viewport"\s+content=")([^"]*)(")/i,
|
|
173
|
+
(all, head, content, tail) => {
|
|
174
|
+
if (content.includes('viewport-fit')) return all
|
|
175
|
+
return `${head}${content}, viewport-fit=cover, interactive-widget=resizes-content${tail}`
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const APP_ASSETS = new Map([
|
|
181
|
+
['icon-180.png', 'image/png'],
|
|
182
|
+
['icon-192.png', 'image/png'],
|
|
183
|
+
['icon-512.png', 'image/png'],
|
|
184
|
+
['icon-maskable-512.png', 'image/png'],
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Caddy 内部 CA 的根证书:手机要装的就是这张。
|
|
189
|
+
* 用户级 Caddy 在 ~/.local/share/caddy(或 XDG_DATA_HOME);系统级在 /var/lib/caddy。
|
|
190
|
+
*/
|
|
191
|
+
export function localCaPath(env = process.env) {
|
|
192
|
+
const base = env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), '.local', 'share')
|
|
193
|
+
const candidates = [
|
|
194
|
+
join(base, 'caddy', 'pki', 'authorities', 'local', 'root.crt'),
|
|
195
|
+
'/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt',
|
|
196
|
+
]
|
|
197
|
+
return candidates.find((candidate) => existsSync(candidate))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 公开资源:manifest 与图标(浏览器抓它们时不会带面板令牌)。 */
|
|
201
|
+
function handleAppAsset(req, res) {
|
|
202
|
+
const path = String(req.url ?? '/').split('?')[0]
|
|
203
|
+
const name = path.startsWith(`${APP_PREFIX}/`) ? path.slice(APP_PREFIX.length + 1) : ''
|
|
204
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
205
|
+
res.writeHead(405).end()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
if (name === 'manifest.webmanifest') {
|
|
209
|
+
const body = JSON.stringify(appManifest())
|
|
210
|
+
res.writeHead(200, {
|
|
211
|
+
'content-type': 'application/manifest+json; charset=utf-8',
|
|
212
|
+
'cache-control': 'no-cache',
|
|
213
|
+
'content-length': Buffer.byteLength(body),
|
|
214
|
+
})
|
|
215
|
+
res.end(body)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (name === 'ca.crt') {
|
|
219
|
+
const path = localCaPath()
|
|
220
|
+
if (path === undefined) {
|
|
221
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Caddy 根证书还没生成(先启用 HTTPS)')
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
const bytes = readFileSync(path)
|
|
225
|
+
res.writeHead(200, {
|
|
226
|
+
'content-type': 'application/x-x509-ca-cert',
|
|
227
|
+
'content-disposition': 'attachment; filename="dsh-caddy-root.crt"',
|
|
228
|
+
'cache-control': 'no-store',
|
|
229
|
+
'content-length': bytes.length,
|
|
230
|
+
})
|
|
231
|
+
res.end(bytes)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
const type = APP_ASSETS.get(name)
|
|
235
|
+
if (type === undefined) {
|
|
236
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found')
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const bytes = readFileSync(new URL(`./assets/${name}`, import.meta.url))
|
|
241
|
+
res.writeHead(200, {
|
|
242
|
+
'content-type': type,
|
|
243
|
+
'cache-control': 'public, max-age=86400',
|
|
244
|
+
'content-length': bytes.length,
|
|
245
|
+
})
|
|
246
|
+
res.end(bytes)
|
|
247
|
+
} catch {
|
|
248
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
54
252
|
function safeEqual(actual, expected) {
|
|
55
253
|
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
|
56
254
|
const a = Buffer.from(actual, 'utf8')
|
|
@@ -63,10 +261,34 @@ function headerValue(req, key) {
|
|
|
63
261
|
return Array.isArray(value) ? value[0] : value
|
|
64
262
|
}
|
|
65
263
|
|
|
264
|
+
/** 面板发起的请求走同源反代:优先用 Caddy 写的 X-Forwarded-Proto,兜底看 socket。 */
|
|
265
|
+
export function requestScheme(req) {
|
|
266
|
+
const forwarded = String(req?.headers?.['x-forwarded-proto'] ?? '').split(',')[0].trim().toLowerCase()
|
|
267
|
+
if (forwarded === 'https' || forwarded === 'http') return forwarded
|
|
268
|
+
return req?.socket?.encrypted === true ? 'https' : 'http'
|
|
269
|
+
}
|
|
270
|
+
|
|
66
271
|
function authorized(req, token) {
|
|
67
272
|
return safeEqual(headerValue(req, TOKEN_HEADER), token)
|
|
68
273
|
}
|
|
69
274
|
|
|
275
|
+
/**
|
|
276
|
+
* `/status` 里的轻量 TLS 字段。
|
|
277
|
+
*
|
|
278
|
+
* 只报「线上 Caddyfile 有没有启用 TLS」,外加最近一次 `/probe` 留下的证书缓存;
|
|
279
|
+
* 这里**不做网络探测**(`/status` 在页面启动路径上,必须秒回),证书详情走 `/probe`。
|
|
280
|
+
* @param {boolean} enabled
|
|
281
|
+
* @param {{expiresAt?: string|null, hoursLeft?: number|null} | undefined} snapshot
|
|
282
|
+
*/
|
|
283
|
+
export function tlsStatus(enabled, snapshot) {
|
|
284
|
+
const info = { enabled: enabled === true }
|
|
285
|
+
if (info.enabled && snapshot !== undefined && snapshot !== null) {
|
|
286
|
+
if (typeof snapshot.expiresAt === 'string') info.expiresAt = snapshot.expiresAt
|
|
287
|
+
if (typeof snapshot.hoursLeft === 'number') info.hoursLeft = snapshot.hoursLeft
|
|
288
|
+
}
|
|
289
|
+
return info
|
|
290
|
+
}
|
|
291
|
+
|
|
70
292
|
/**
|
|
71
293
|
* 路由表:`<METHOD> <suffix>` → handler(context)。
|
|
72
294
|
* 后续功能(状态、向导、配对、白名单)都挂在这张表上,保持分派逻辑只有一处。
|
|
@@ -94,7 +316,7 @@ routes.set('POST /state', async ({ req }) => {
|
|
|
94
316
|
}
|
|
95
317
|
})
|
|
96
318
|
|
|
97
|
-
routes.set('GET /pair', async () => {
|
|
319
|
+
routes.set('GET /pair', async ({ req }) => {
|
|
98
320
|
const state = readState()
|
|
99
321
|
if (state === null) throw new BadRequest('尚未部署:先完成部署并回填')
|
|
100
322
|
const upstream = Number.isInteger(Number(state.upstream)) ? Number(state.upstream) : 3080
|
|
@@ -109,7 +331,23 @@ routes.set('GET /pair', async () => {
|
|
|
109
331
|
}
|
|
110
332
|
// 全部验证失败也把最新的给出来,至少能让用户看到「令牌过期」而不是空白
|
|
111
333
|
if (token === undefined) token = candidates.at(-1)
|
|
112
|
-
|
|
334
|
+
// 协议:优先看这次请求(经 Caddy 的 https 会带 X-Forwarded-Proto),
|
|
335
|
+
// 但面板也可能被「直连 3080」打开(桌面客户端/回环书签),那种请求永远汇报 http;
|
|
336
|
+
// 这时按线上 Caddyfile 判断——配对链接必须指向真正对外的那一套。
|
|
337
|
+
const scheme = requestScheme(req) === 'https' || detectTls() ? 'https' : 'http'
|
|
338
|
+
try {
|
|
339
|
+
const dir = join(homedir(), '.dsh', 'remote-panel-app')
|
|
340
|
+
if (existsSync(dir)) writeGoPage(dir, token, state.accessKey)
|
|
341
|
+
} catch {
|
|
342
|
+
/* 目录还没建好就算了 */
|
|
343
|
+
}
|
|
344
|
+
const urls = pairingUrls({
|
|
345
|
+
ip: state.ip,
|
|
346
|
+
port: state.port,
|
|
347
|
+
accessKey: state.accessKey,
|
|
348
|
+
token,
|
|
349
|
+
scheme,
|
|
350
|
+
})
|
|
113
351
|
return {
|
|
114
352
|
ok: true,
|
|
115
353
|
found: token !== undefined,
|
|
@@ -157,23 +395,47 @@ routes.set('POST /allowlist', async ({ req }) => {
|
|
|
157
395
|
return { ok: true, ...result, reload }
|
|
158
396
|
})
|
|
159
397
|
|
|
160
|
-
routes.set('GET /doctor', async ({ query }) => {
|
|
398
|
+
routes.set('GET /doctor', async ({ query, certProbe = probeCertificate }) => {
|
|
161
399
|
const raw = Number(query.get('port') ?? 8710)
|
|
162
400
|
const port = Number.isInteger(raw) && raw > 0 && raw <= 65535 ? raw : 8710
|
|
163
401
|
const virtualIp = query.get('ip') ?? undefined
|
|
164
402
|
const devName = query.get('dev') ?? undefined
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
403
|
+
const state = readState()
|
|
404
|
+
const tlsEnabled = detectTls()
|
|
405
|
+
|
|
406
|
+
// 证书探测与静态体检并行:最多多等 2s(probeCertificate 自带超时)
|
|
407
|
+
let certPromise = Promise.resolve(undefined)
|
|
408
|
+
if (tlsEnabled) {
|
|
409
|
+
certPromise =
|
|
410
|
+
state === null
|
|
411
|
+
? Promise.resolve({ ok: false, hoursLeft: null, reason: '尚未部署' })
|
|
412
|
+
: Promise.resolve(certProbe({ host: state.ip, port: Number(state.port) || port }))
|
|
413
|
+
}
|
|
414
|
+
const [doctor, cert] = await Promise.all([
|
|
415
|
+
runDoctor({
|
|
416
|
+
port,
|
|
417
|
+
virtualIp: virtualIp === '' ? undefined : virtualIp,
|
|
418
|
+
devName: devName === '' ? undefined : devName,
|
|
419
|
+
isPortFree,
|
|
420
|
+
}),
|
|
421
|
+
certPromise,
|
|
422
|
+
])
|
|
423
|
+
if (!tlsEnabled) return doctor
|
|
424
|
+
return withCertCheck(doctor, cert, { deployed: state !== null })
|
|
171
425
|
})
|
|
172
426
|
|
|
173
|
-
|
|
427
|
+
/** 把证书自检追加到 doctor 结果末尾(doctor.js 本轮不在改动范围)。 */
|
|
428
|
+
function withCertCheck(doctor, cert, options) {
|
|
429
|
+
if (doctor === undefined || doctor === null || !Array.isArray(doctor.checks)) return doctor
|
|
430
|
+
return { ...doctor, checks: [...doctor.checks, certCheck(cert, options)] }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
routes.set('GET /diag', async ({ probe: runProbe = probe }) => {
|
|
174
434
|
const state = readState()
|
|
175
435
|
const safeState = state === null ? null : { ...state, accessKey: undefined, networkSecret: undefined }
|
|
176
|
-
const
|
|
436
|
+
const tlsEnabled = detectTls()
|
|
437
|
+
const result = await runProbe(state, { tls: tlsEnabled })
|
|
438
|
+
const doctor = await runDoctor({ isPortFree })
|
|
177
439
|
return {
|
|
178
440
|
ok: true,
|
|
179
441
|
generatedAt: new Date().toISOString(),
|
|
@@ -182,31 +444,38 @@ routes.set('GET /diag', async () => {
|
|
|
182
444
|
platform: `${process.platform}/${process.arch}`,
|
|
183
445
|
state: safeState,
|
|
184
446
|
probe: result,
|
|
185
|
-
doctor:
|
|
447
|
+
doctor: tlsEnabled ? withCertCheck(doctor, result.cert, { deployed: state !== null }) : doctor,
|
|
186
448
|
}
|
|
187
449
|
})
|
|
188
450
|
|
|
189
|
-
routes.set('GET /probe', async () => {
|
|
451
|
+
routes.set('GET /probe', async ({ probe: runProbe = probe }) => {
|
|
190
452
|
const state = readState()
|
|
191
|
-
return { ok: true, state, probe: await
|
|
453
|
+
return { ok: true, state, probe: await runProbe(state, { tls: detectTls() }) }
|
|
192
454
|
})
|
|
193
455
|
|
|
194
|
-
routes.set('GET /status', ({ query }) =>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
456
|
+
routes.set('GET /status', ({ query }) => {
|
|
457
|
+
const tlsEnabled = detectTls()
|
|
458
|
+
return {
|
|
459
|
+
ok: true,
|
|
460
|
+
version: VERSION,
|
|
461
|
+
startedAt: STARTED_AT,
|
|
462
|
+
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
463
|
+
deployed: readState() !== null,
|
|
464
|
+
// 面板展示用:对外的协议(直连 3080 时页面自己看到的只有 http)
|
|
465
|
+
scheme: tlsEnabled ? 'https' : 'http',
|
|
466
|
+
// 轻量证书信息:只读最近一次 /probe 的缓存,不在 status 里做网络探测
|
|
467
|
+
tls: tlsStatus(tlsEnabled, lastCertSnapshot()),
|
|
468
|
+
echo: query.get('echo') ?? null,
|
|
469
|
+
}
|
|
470
|
+
})
|
|
202
471
|
|
|
203
472
|
/**
|
|
204
473
|
* 处理一次控制面请求。
|
|
205
474
|
* @param {import('node:http').IncomingMessage} req
|
|
206
475
|
* @param {import('node:http').ServerResponse} res
|
|
207
|
-
* @param {{ token: string }} options
|
|
476
|
+
* @param {{ token: string, probe?: Function, certProbe?: Function }} [options]
|
|
208
477
|
*/
|
|
209
|
-
export async function handleRequest(req, res, { token }) {
|
|
478
|
+
export async function handleRequest(req, res, { token, probe: runProbe = probe, certProbe = probeCertificate } = {}) {
|
|
210
479
|
if (!authorized(req, token)) {
|
|
211
480
|
json(res, 403, {
|
|
212
481
|
ok: false,
|
|
@@ -225,7 +494,7 @@ export async function handleRequest(req, res, { token }) {
|
|
|
225
494
|
}
|
|
226
495
|
|
|
227
496
|
try {
|
|
228
|
-
const result = await handler({ req, res, url, query: url.searchParams, version: VERSION })
|
|
497
|
+
const result = await handler({ req, res, url, query: url.searchParams, version: VERSION, probe: runProbe, certProbe })
|
|
229
498
|
if (result !== undefined) json(res, 200, result)
|
|
230
499
|
} catch (error) {
|
|
231
500
|
const status = error instanceof BadRequest ? 400 : 500
|
|
@@ -240,12 +509,37 @@ export async function handleRequest(req, res, { token }) {
|
|
|
240
509
|
export function apply(ctx) {
|
|
241
510
|
// 持久化:DSH 重启后已打开的页面仍然可用
|
|
242
511
|
const token = loadOrCreateToken()
|
|
512
|
+
// PWA 资源给 Caddy 直接发:manifest 抓取不带 Cookie,不能走门禁
|
|
513
|
+
let appDir = null
|
|
514
|
+
try {
|
|
515
|
+
appDir = syncAppAssets()
|
|
516
|
+
} catch (error) {
|
|
517
|
+
console.warn('[remote-panel] sync app assets failed', error)
|
|
518
|
+
}
|
|
519
|
+
// WebView 壳的启动页:带上当前进程的令牌(异步找 journal,不阻塞启动)
|
|
520
|
+
if (appDir !== null) {
|
|
521
|
+
void (async () => {
|
|
522
|
+
try {
|
|
523
|
+
const state = readState()
|
|
524
|
+
const ports = [Number(state?.port) || 8710, Number(state?.upstream) || 3080]
|
|
525
|
+
const tokens = await findTokens({ ports })
|
|
526
|
+
writeGoPage(appDir, tokens.at(-1), state?.accessKey)
|
|
527
|
+
} catch (error) {
|
|
528
|
+
console.warn('[remote-panel] write go.html failed', error)
|
|
529
|
+
}
|
|
530
|
+
})()
|
|
531
|
+
}
|
|
243
532
|
|
|
244
533
|
ctx.inject(['webServer'], (webCtx) => {
|
|
245
534
|
webCtx.on('webserver/index-inject', (table) => {
|
|
246
535
|
table.push({ kind: 'global', name: TOKEN_GLOBAL, value: token })
|
|
536
|
+
table.push(...appHeadInjections())
|
|
247
537
|
})
|
|
248
538
|
|
|
539
|
+
// viewport-fit=cover 让「添加到主屏幕」后铺满刘海屏;
|
|
540
|
+
// interactive-widget=resizes-content 让软键盘改变布局而不是盖住输入条。
|
|
541
|
+
webCtx.effect(() => webCtx.webServer.tapIndex(withAppViewport), 'remote-panel: mobile viewport')
|
|
542
|
+
|
|
249
543
|
webCtx.effect(
|
|
250
544
|
() =>
|
|
251
545
|
webCtx.webServer.register({
|
|
@@ -255,5 +549,15 @@ export function apply(ctx) {
|
|
|
255
549
|
}),
|
|
256
550
|
'remote-panel: control plane api',
|
|
257
551
|
)
|
|
552
|
+
|
|
553
|
+
webCtx.effect(
|
|
554
|
+
() =>
|
|
555
|
+
webCtx.webServer.register({
|
|
556
|
+
kind: 'prefix',
|
|
557
|
+
path: APP_PREFIX,
|
|
558
|
+
handler: handleAppAsset,
|
|
559
|
+
}),
|
|
560
|
+
'remote-panel: app assets',
|
|
561
|
+
)
|
|
258
562
|
})
|
|
259
563
|
}
|
package/lib/pair.js
CHANGED
|
@@ -88,10 +88,11 @@ export async function findToken(options) {
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* 两条配对 URL。顺序有意义:先门禁,再令牌。
|
|
91
|
-
* @param {{ ip: string, port: number, accessKey?: string, token?: string }} input
|
|
91
|
+
* @param {{ ip: string, port: number, accessKey?: string, token?: string, scheme?: string }} input
|
|
92
92
|
*/
|
|
93
|
-
export function pairingUrls({ ip, port, accessKey, token }) {
|
|
94
|
-
|
|
93
|
+
export function pairingUrls({ ip, port, accessKey, token, scheme = 'http' }) {
|
|
94
|
+
// 走 TLS 时配对链接必须是 https,否则手机第一次跳转就落到明文端口上
|
|
95
|
+
const base = `${scheme}://${ip}:${port}`
|
|
95
96
|
const gate = accessKey === undefined ? undefined : `${base}/?k=${accessKey}`
|
|
96
97
|
const tokenUrl = token === undefined ? undefined : `${base}/?token=${token}`
|
|
97
98
|
return {
|