dsh-remote-panel 0.1.1 → 0.3.0

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.
@@ -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: `http://${input.virtualIp}:${input.port}/`,
227
+ mesh: `${input.scheme}://${input.virtualIp}:${input.port}/`,
211
228
  loopback: `http://127.0.0.1:${input.port}/`,
212
229
  },
213
- gate: `http://${input.virtualIp}:${input.port}/?k=${input.accessKey}`,
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,172 @@ 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
+ // ?fps=1 时把开关记进 sessionStorage,跳转后由客户端插件读取(用于真机性能排查)
145
+ '<script>try{if(/[?&]fps=1/.test(location.search))sessionStorage.setItem("dsh-fps","1")}catch(e){}' +
146
+ `location.replace(${JSON.stringify(target)})</script>`
147
+ writeFileSync(join(dir, 'go.html'), html)
148
+ return target
149
+ }
150
+
151
+ /**
152
+ * 注入 index 的 head 行:主题色 + iOS 独立窗口 + 桌面图标 + 我们自己的 manifest。
153
+ *
154
+ * manifest 用黑底蓝鱼图标、`display: fullscreen`、`start_url/scope = /`;
155
+ * 它排在 DSH 自带那条之前,浏览器取第一条,所以主屏看到的是我们的图标与名字。
156
+ */
157
+ export function appHeadInjections() {
158
+ const head = [
159
+ '<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000">',
160
+ '<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">',
161
+ '<meta name="mobile-web-app-capable" content="yes">',
162
+ '<meta name="apple-mobile-web-app-capable" content="yes">',
163
+ '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">',
164
+ '<meta name="apple-mobile-web-app-title" content="DSH">',
165
+ `<link rel="apple-touch-icon" href="${APP_PREFIX}/icon-180.png">`,
166
+ `<link rel="manifest" href="${MANIFEST_PATH}">`,
167
+ ]
168
+ return head.map((html) => ({ kind: 'html', placement: 'head', html }))
169
+ }
170
+
171
+ /** 把 index 的 viewport 补齐:刘海屏安全区 + 键盘不遮输入框。 */
172
+ export function withAppViewport(html) {
173
+ return String(html).replace(
174
+ /(<meta\s+name="viewport"\s+content=")([^"]*)(")/i,
175
+ (all, head, content, tail) => {
176
+ if (content.includes('viewport-fit')) return all
177
+ return `${head}${content}, viewport-fit=cover, interactive-widget=resizes-content${tail}`
178
+ },
179
+ )
180
+ }
181
+
182
+ const APP_ASSETS = new Map([
183
+ ['icon-180.png', 'image/png'],
184
+ ['icon-192.png', 'image/png'],
185
+ ['icon-512.png', 'image/png'],
186
+ ['icon-maskable-512.png', 'image/png'],
187
+ ])
188
+
189
+ /**
190
+ * Caddy 内部 CA 的根证书:手机要装的就是这张。
191
+ * 用户级 Caddy 在 ~/.local/share/caddy(或 XDG_DATA_HOME);系统级在 /var/lib/caddy。
192
+ */
193
+ export function localCaPath(env = process.env) {
194
+ const base = env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), '.local', 'share')
195
+ const candidates = [
196
+ join(base, 'caddy', 'pki', 'authorities', 'local', 'root.crt'),
197
+ '/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt',
198
+ ]
199
+ return candidates.find((candidate) => existsSync(candidate))
200
+ }
201
+
202
+ /** 公开资源:manifest 与图标(浏览器抓它们时不会带面板令牌)。 */
203
+ function handleAppAsset(req, res) {
204
+ const path = String(req.url ?? '/').split('?')[0]
205
+ const name = path.startsWith(`${APP_PREFIX}/`) ? path.slice(APP_PREFIX.length + 1) : ''
206
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
207
+ res.writeHead(405).end()
208
+ return
209
+ }
210
+ if (name === 'manifest.webmanifest') {
211
+ const body = JSON.stringify(appManifest())
212
+ res.writeHead(200, {
213
+ 'content-type': 'application/manifest+json; charset=utf-8',
214
+ 'cache-control': 'no-cache',
215
+ 'content-length': Buffer.byteLength(body),
216
+ })
217
+ res.end(body)
218
+ return
219
+ }
220
+ if (name === 'ca.crt') {
221
+ const path = localCaPath()
222
+ if (path === undefined) {
223
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Caddy 根证书还没生成(先启用 HTTPS)')
224
+ return
225
+ }
226
+ const bytes = readFileSync(path)
227
+ res.writeHead(200, {
228
+ 'content-type': 'application/x-x509-ca-cert',
229
+ 'content-disposition': 'attachment; filename="dsh-caddy-root.crt"',
230
+ 'cache-control': 'no-store',
231
+ 'content-length': bytes.length,
232
+ })
233
+ res.end(bytes)
234
+ return
235
+ }
236
+ const type = APP_ASSETS.get(name)
237
+ if (type === undefined) {
238
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found')
239
+ return
240
+ }
241
+ try {
242
+ const bytes = readFileSync(new URL(`./assets/${name}`, import.meta.url))
243
+ res.writeHead(200, {
244
+ 'content-type': type,
245
+ 'cache-control': 'public, max-age=86400',
246
+ 'content-length': bytes.length,
247
+ })
248
+ res.end(bytes)
249
+ } catch {
250
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found')
251
+ }
252
+ }
253
+
54
254
  function safeEqual(actual, expected) {
55
255
  if (typeof actual !== 'string' || typeof expected !== 'string') return false
56
256
  const a = Buffer.from(actual, 'utf8')
@@ -63,10 +263,34 @@ function headerValue(req, key) {
63
263
  return Array.isArray(value) ? value[0] : value
64
264
  }
65
265
 
266
+ /** 面板发起的请求走同源反代:优先用 Caddy 写的 X-Forwarded-Proto,兜底看 socket。 */
267
+ export function requestScheme(req) {
268
+ const forwarded = String(req?.headers?.['x-forwarded-proto'] ?? '').split(',')[0].trim().toLowerCase()
269
+ if (forwarded === 'https' || forwarded === 'http') return forwarded
270
+ return req?.socket?.encrypted === true ? 'https' : 'http'
271
+ }
272
+
66
273
  function authorized(req, token) {
67
274
  return safeEqual(headerValue(req, TOKEN_HEADER), token)
68
275
  }
69
276
 
277
+ /**
278
+ * `/status` 里的轻量 TLS 字段。
279
+ *
280
+ * 只报「线上 Caddyfile 有没有启用 TLS」,外加最近一次 `/probe` 留下的证书缓存;
281
+ * 这里**不做网络探测**(`/status` 在页面启动路径上,必须秒回),证书详情走 `/probe`。
282
+ * @param {boolean} enabled
283
+ * @param {{expiresAt?: string|null, hoursLeft?: number|null} | undefined} snapshot
284
+ */
285
+ export function tlsStatus(enabled, snapshot) {
286
+ const info = { enabled: enabled === true }
287
+ if (info.enabled && snapshot !== undefined && snapshot !== null) {
288
+ if (typeof snapshot.expiresAt === 'string') info.expiresAt = snapshot.expiresAt
289
+ if (typeof snapshot.hoursLeft === 'number') info.hoursLeft = snapshot.hoursLeft
290
+ }
291
+ return info
292
+ }
293
+
70
294
  /**
71
295
  * 路由表:`<METHOD> <suffix>` → handler(context)。
72
296
  * 后续功能(状态、向导、配对、白名单)都挂在这张表上,保持分派逻辑只有一处。
@@ -94,7 +318,7 @@ routes.set('POST /state', async ({ req }) => {
94
318
  }
95
319
  })
96
320
 
97
- routes.set('GET /pair', async () => {
321
+ routes.set('GET /pair', async ({ req }) => {
98
322
  const state = readState()
99
323
  if (state === null) throw new BadRequest('尚未部署:先完成部署并回填')
100
324
  const upstream = Number.isInteger(Number(state.upstream)) ? Number(state.upstream) : 3080
@@ -109,7 +333,23 @@ routes.set('GET /pair', async () => {
109
333
  }
110
334
  // 全部验证失败也把最新的给出来,至少能让用户看到「令牌过期」而不是空白
111
335
  if (token === undefined) token = candidates.at(-1)
112
- const urls = pairingUrls({ ip: state.ip, port: state.port, accessKey: state.accessKey, token })
336
+ // 协议:优先看这次请求(经 Caddy 的 https 会带 X-Forwarded-Proto),
337
+ // 但面板也可能被「直连 3080」打开(桌面客户端/回环书签),那种请求永远汇报 http;
338
+ // 这时按线上 Caddyfile 判断——配对链接必须指向真正对外的那一套。
339
+ const scheme = requestScheme(req) === 'https' || detectTls() ? 'https' : 'http'
340
+ try {
341
+ const dir = join(homedir(), '.dsh', 'remote-panel-app')
342
+ if (existsSync(dir)) writeGoPage(dir, token, state.accessKey)
343
+ } catch {
344
+ /* 目录还没建好就算了 */
345
+ }
346
+ const urls = pairingUrls({
347
+ ip: state.ip,
348
+ port: state.port,
349
+ accessKey: state.accessKey,
350
+ token,
351
+ scheme,
352
+ })
113
353
  return {
114
354
  ok: true,
115
355
  found: token !== undefined,
@@ -157,23 +397,47 @@ routes.set('POST /allowlist', async ({ req }) => {
157
397
  return { ok: true, ...result, reload }
158
398
  })
159
399
 
160
- routes.set('GET /doctor', async ({ query }) => {
400
+ routes.set('GET /doctor', async ({ query, certProbe = probeCertificate }) => {
161
401
  const raw = Number(query.get('port') ?? 8710)
162
402
  const port = Number.isInteger(raw) && raw > 0 && raw <= 65535 ? raw : 8710
163
403
  const virtualIp = query.get('ip') ?? undefined
164
404
  const devName = query.get('dev') ?? undefined
165
- return runDoctor({
166
- port,
167
- virtualIp: virtualIp === '' ? undefined : virtualIp,
168
- devName: devName === '' ? undefined : devName,
169
- isPortFree,
170
- })
405
+ const state = readState()
406
+ const tlsEnabled = detectTls()
407
+
408
+ // 证书探测与静态体检并行:最多多等 2s(probeCertificate 自带超时)
409
+ let certPromise = Promise.resolve(undefined)
410
+ if (tlsEnabled) {
411
+ certPromise =
412
+ state === null
413
+ ? Promise.resolve({ ok: false, hoursLeft: null, reason: '尚未部署' })
414
+ : Promise.resolve(certProbe({ host: state.ip, port: Number(state.port) || port }))
415
+ }
416
+ const [doctor, cert] = await Promise.all([
417
+ runDoctor({
418
+ port,
419
+ virtualIp: virtualIp === '' ? undefined : virtualIp,
420
+ devName: devName === '' ? undefined : devName,
421
+ isPortFree,
422
+ }),
423
+ certPromise,
424
+ ])
425
+ if (!tlsEnabled) return doctor
426
+ return withCertCheck(doctor, cert, { deployed: state !== null })
171
427
  })
172
428
 
173
- routes.set('GET /diag', async () => {
429
+ /** 把证书自检追加到 doctor 结果末尾(doctor.js 本轮不在改动范围)。 */
430
+ function withCertCheck(doctor, cert, options) {
431
+ if (doctor === undefined || doctor === null || !Array.isArray(doctor.checks)) return doctor
432
+ return { ...doctor, checks: [...doctor.checks, certCheck(cert, options)] }
433
+ }
434
+
435
+ routes.set('GET /diag', async ({ probe: runProbe = probe }) => {
174
436
  const state = readState()
175
437
  const safeState = state === null ? null : { ...state, accessKey: undefined, networkSecret: undefined }
176
- const result = await probe(state)
438
+ const tlsEnabled = detectTls()
439
+ const result = await runProbe(state, { tls: tlsEnabled })
440
+ const doctor = await runDoctor({ isPortFree })
177
441
  return {
178
442
  ok: true,
179
443
  generatedAt: new Date().toISOString(),
@@ -182,31 +446,38 @@ routes.set('GET /diag', async () => {
182
446
  platform: `${process.platform}/${process.arch}`,
183
447
  state: safeState,
184
448
  probe: result,
185
- doctor: await runDoctor({ isPortFree }),
449
+ doctor: tlsEnabled ? withCertCheck(doctor, result.cert, { deployed: state !== null }) : doctor,
186
450
  }
187
451
  })
188
452
 
189
- routes.set('GET /probe', async () => {
453
+ routes.set('GET /probe', async ({ probe: runProbe = probe }) => {
190
454
  const state = readState()
191
- return { ok: true, state, probe: await probe(state) }
455
+ return { ok: true, state, probe: await runProbe(state, { tls: detectTls() }) }
192
456
  })
193
457
 
194
- routes.set('GET /status', ({ query }) => ({
195
- ok: true,
196
- version: VERSION,
197
- startedAt: STARTED_AT,
198
- uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
199
- deployed: readState() !== null,
200
- echo: query.get('echo') ?? null,
201
- }))
458
+ routes.set('GET /status', ({ query }) => {
459
+ const tlsEnabled = detectTls()
460
+ return {
461
+ ok: true,
462
+ version: VERSION,
463
+ startedAt: STARTED_AT,
464
+ uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
465
+ deployed: readState() !== null,
466
+ // 面板展示用:对外的协议(直连 3080 时页面自己看到的只有 http)
467
+ scheme: tlsEnabled ? 'https' : 'http',
468
+ // 轻量证书信息:只读最近一次 /probe 的缓存,不在 status 里做网络探测
469
+ tls: tlsStatus(tlsEnabled, lastCertSnapshot()),
470
+ echo: query.get('echo') ?? null,
471
+ }
472
+ })
202
473
 
203
474
  /**
204
475
  * 处理一次控制面请求。
205
476
  * @param {import('node:http').IncomingMessage} req
206
477
  * @param {import('node:http').ServerResponse} res
207
- * @param {{ token: string }} options
478
+ * @param {{ token: string, probe?: Function, certProbe?: Function }} [options]
208
479
  */
209
- export async function handleRequest(req, res, { token }) {
480
+ export async function handleRequest(req, res, { token, probe: runProbe = probe, certProbe = probeCertificate } = {}) {
210
481
  if (!authorized(req, token)) {
211
482
  json(res, 403, {
212
483
  ok: false,
@@ -225,7 +496,7 @@ export async function handleRequest(req, res, { token }) {
225
496
  }
226
497
 
227
498
  try {
228
- const result = await handler({ req, res, url, query: url.searchParams, version: VERSION })
499
+ const result = await handler({ req, res, url, query: url.searchParams, version: VERSION, probe: runProbe, certProbe })
229
500
  if (result !== undefined) json(res, 200, result)
230
501
  } catch (error) {
231
502
  const status = error instanceof BadRequest ? 400 : 500
@@ -240,12 +511,37 @@ export async function handleRequest(req, res, { token }) {
240
511
  export function apply(ctx) {
241
512
  // 持久化:DSH 重启后已打开的页面仍然可用
242
513
  const token = loadOrCreateToken()
514
+ // PWA 资源给 Caddy 直接发:manifest 抓取不带 Cookie,不能走门禁
515
+ let appDir = null
516
+ try {
517
+ appDir = syncAppAssets()
518
+ } catch (error) {
519
+ console.warn('[remote-panel] sync app assets failed', error)
520
+ }
521
+ // WebView 壳的启动页:带上当前进程的令牌(异步找 journal,不阻塞启动)
522
+ if (appDir !== null) {
523
+ void (async () => {
524
+ try {
525
+ const state = readState()
526
+ const ports = [Number(state?.port) || 8710, Number(state?.upstream) || 3080]
527
+ const tokens = await findTokens({ ports })
528
+ writeGoPage(appDir, tokens.at(-1), state?.accessKey)
529
+ } catch (error) {
530
+ console.warn('[remote-panel] write go.html failed', error)
531
+ }
532
+ })()
533
+ }
243
534
 
244
535
  ctx.inject(['webServer'], (webCtx) => {
245
536
  webCtx.on('webserver/index-inject', (table) => {
246
537
  table.push({ kind: 'global', name: TOKEN_GLOBAL, value: token })
538
+ table.push(...appHeadInjections())
247
539
  })
248
540
 
541
+ // viewport-fit=cover 让「添加到主屏幕」后铺满刘海屏;
542
+ // interactive-widget=resizes-content 让软键盘改变布局而不是盖住输入条。
543
+ webCtx.effect(() => webCtx.webServer.tapIndex(withAppViewport), 'remote-panel: mobile viewport')
544
+
249
545
  webCtx.effect(
250
546
  () =>
251
547
  webCtx.webServer.register({
@@ -255,5 +551,15 @@ export function apply(ctx) {
255
551
  }),
256
552
  'remote-panel: control plane api',
257
553
  )
554
+
555
+ webCtx.effect(
556
+ () =>
557
+ webCtx.webServer.register({
558
+ kind: 'prefix',
559
+ path: APP_PREFIX,
560
+ handler: handleAppAsset,
561
+ }),
562
+ 'remote-panel: app assets',
563
+ )
258
564
  })
259
565
  }
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
- const base = `http://${ip}:${port}`
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 {