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.
package/lib/probe.js CHANGED
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  import { execFile } from 'node:child_process'
9
+ import { request as httpsRequest } from 'node:https'
10
+ import { isIP } from 'node:net'
11
+ import { connect as tlsConnect } from 'node:tls'
9
12
  import { promisify } from 'node:util'
10
13
 
11
14
  const run = promisify(execFile)
@@ -30,8 +33,63 @@ async function systemdState(unit, { user = false } = {}, exec = run) {
30
33
  }
31
34
  }
32
35
 
36
+ /**
37
+ * 探测「这台机器上的反代」是 http 还是 https。
38
+ *
39
+ * 切了 TLS 之后 http 探活会拿到 400(TLS 端口不接受明文),面板会误报不健康;
40
+ * 所以先试 https(内部 CA 不在本机信任链里,只探活不校验证书),失败再退回 http。
41
+ *
42
+ * @param {number} port
43
+ * @param {number} [timeoutMs]
44
+ * @returns {Promise<{ok:boolean,status:number,body:string,scheme:'https'|'http'}>}
45
+ */
46
+ export async function probeHealth(port, { https = httpsHealth, http = httpHealth, timeoutMs, host } = {}) {
47
+ const secure = await https(port, { host, timeoutMs })
48
+ if (secure.ok) return { ...secure, scheme: 'https' }
49
+ const plain = await http(port, timeoutMs)
50
+ return plain.ok ? { ...plain, scheme: 'http' } : { ...plain, scheme: 'http', httpsAttempt: secure }
51
+ }
52
+
53
+ /**
54
+ * @param {number} port
55
+ * @param {number} [timeoutMs]
56
+ */
57
+ function httpsHealth(port, { host = '127.0.0.1', timeoutMs = 2000 } = {}) {
58
+ return new Promise((resolve) => {
59
+ // 连站点自己的地址(拿任务机 IP),不要绕 127.0.0.1:
60
+ // Caddy 按 SNI 选站点,绕回环时握手会拿不到证书(tlsv1 alert internal error)。
61
+ const req = httpsRequest(
62
+ {
63
+ host,
64
+ port,
65
+ path: '/healthz',
66
+ rejectUnauthorized: false,
67
+ timeout: timeoutMs,
68
+ },
69
+ (res) => {
70
+ let body = ''
71
+ res.setEncoding('utf8')
72
+ res.on('data', (chunk) => {
73
+ body += chunk
74
+ })
75
+ res.on('end', () =>
76
+ resolve({
77
+ ok: res.statusCode === 200 && body.trim() === 'ok',
78
+ status: res.statusCode ?? 0,
79
+ body: body.trim().slice(0, 64),
80
+ }),
81
+ )
82
+ },
83
+ )
84
+ req.on('timeout', () => req.destroy(new Error('timeout')))
85
+ req.on('error', (error) => resolve({ ok: false, status: 0, body: '', reason: String(error?.message ?? error) }))
86
+ req.end()
87
+ })
88
+ }
89
+
33
90
  /**
34
91
  * @param {number} port
92
+ * @param {number} [timeoutMs]
35
93
  */
36
94
  async function httpHealth(port, timeoutMs = 2000) {
37
95
  try {
@@ -62,13 +120,197 @@ async function upstreamCheck(port, timeoutMs = 2000) {
62
120
  }
63
121
  }
64
122
 
123
+ /**
124
+ * 证书有效期自检。
125
+ *
126
+ * 背景:Caddy 用内部 CA 给站点签 12 小时的叶子证书,到期前自动续。
127
+ * 续签失败(磁盘满 / admin 接口挂了 / 配置改坏)时手机端会整段失联;
128
+ * 而探活用的是 `rejectUnauthorized:false`,过期证书照样「握手成功」,面板因此毫无感知。
129
+ * 这里单独把「实际发出的那张证书」取回来,看 SAN 与剩余时间。
130
+ *
131
+ * 只读:不触发续签、不碰 Caddy admin API,只做一次 TLS 握手。
132
+ */
133
+
134
+ /** 剩余时间低于该阈值即视为不健康(小时)。 */
135
+ export const CERT_WARN_HOURS = 2
136
+
137
+ /** 最近一次 probe() 的证书结果;/status 只读这份缓存,不发网络请求。 */
138
+ let lastCert
139
+
140
+ /**
141
+ * 最近一次证书探测结果(副本)。没探测过返回 undefined。
142
+ * @returns {{ok:boolean, expiresAt:string|null, hoursLeft:number|null, subject:string, san:string, reason?:string}|undefined}
143
+ */
144
+ export function lastCertSnapshot() {
145
+ return lastCert === undefined ? undefined : { ...lastCert }
146
+ }
147
+
148
+ /** SAN 里一条 `IP Address:1.2.3.4` / `DNS:example.com` 是否覆盖 host。 */
149
+ export function sanCovers(san, host) {
150
+ const target = String(host ?? '').trim()
151
+ if (target === '') return false
152
+ const ipv = isIP(target) !== 0
153
+ for (const raw of String(san ?? '').split(',')) {
154
+ const entry = raw.trim()
155
+ if (entry === '') continue
156
+ if (ipv) {
157
+ // 证书里 IP 条目写成 `IP Address:<ip>`
158
+ if (entry === `IP Address:${target}`) return true
159
+ continue
160
+ }
161
+ if (!entry.startsWith('DNS:')) continue
162
+ const name = entry.slice(4)
163
+ if (name === target) return true
164
+ // 通配符只匹配一个标签:*.example.com 覆盖 a.example.com,不覆盖 a.b.example.com
165
+ if (name.startsWith('*.')) {
166
+ const suffix = name.slice(1)
167
+ const prefix = target.slice(0, target.length - suffix.length)
168
+ if (target.endsWith(suffix) && prefix !== '' && !prefix.includes('.')) return true
169
+ }
170
+ }
171
+ return false
172
+ }
173
+
174
+ /**
175
+ * 把对端证书折算成面板要的结论。
176
+ *
177
+ * ok = 剩余时间 > warnHours 且 SAN 覆盖 host(握手是否成功由调用方保证)。
178
+ * @param {{
179
+ * cert?: {valid_to?: string, subjectaltname?: string, subject?: {CN?: string}},
180
+ * host?: string,
181
+ * now?: number,
182
+ * warnHours?: number,
183
+ * }} [input]
184
+ */
185
+ export function evaluateCertificate({ cert, host, now = Date.now(), warnHours = CERT_WARN_HOURS } = {}) {
186
+ const subject = typeof cert?.subject?.CN === 'string' ? cert.subject.CN : ''
187
+ const san = typeof cert?.subjectaltname === 'string' ? cert.subjectaltname : ''
188
+ const expires = Date.parse(String(cert?.valid_to ?? ''))
189
+ if (!Number.isFinite(expires)) {
190
+ return { ok: false, expiresAt: null, hoursLeft: null, subject, san, reason: '证书缺少 valid_to' }
191
+ }
192
+ // 保留一位小数:面板只用来判断「还剩几小时」,不需要毫秒级
193
+ const hoursLeft = Math.round(((expires - now) / 3600000) * 10) / 10
194
+ const result = { expiresAt: new Date(expires).toISOString(), hoursLeft, subject, san }
195
+ if (hoursLeft <= 0) return { ok: false, ...result, reason: `证书已过期(${result.expiresAt})` }
196
+ if (!sanCovers(san, host)) return { ok: false, ...result, reason: `证书 SAN 不覆盖 ${host || '(空)'}` }
197
+ if (hoursLeft <= warnHours) {
198
+ return { ok: false, ...result, reason: `证书剩余时间低于 ${warnHours} 小时` }
199
+ }
200
+ return { ok: true, ...result }
201
+ }
202
+
203
+ /**
204
+ * 对 host:port 做一次 TLS 握手,拿对端证书。
205
+ *
206
+ * 必须连站点自己的地址并带 SNI:Caddy 按 SNI 选站点,
207
+ * 绕 127.0.0.1 或 SNI 写错都会拿到别的证书(甚至 tlsv1 alert internal error)。
208
+ * 内部 CA 不在本机信任链里,所以不校验证书链;有效期与 SAN 由 evaluateCertificate 自己判。
209
+ *
210
+ * @param {{ host: string, port: number, timeoutMs?: number }} options
211
+ */
212
+ function tlsPeerCertificate({ host, port, timeoutMs = 2000 } = {}) {
213
+ return new Promise((resolve, reject) => {
214
+ let settled = false
215
+ const finish = (error, cert) => {
216
+ if (settled) return
217
+ settled = true
218
+ if (error) reject(error)
219
+ else resolve(cert)
220
+ }
221
+ let socket
222
+ try {
223
+ socket = tlsConnect(
224
+ { host, port: Number(port), servername: host, rejectUnauthorized: false, timeout: timeoutMs },
225
+ () => {
226
+ try {
227
+ const cert = socket.getPeerCertificate()
228
+ finish(null, cert)
229
+ socket.end()
230
+ } catch (error) {
231
+ finish(error)
232
+ socket.destroy()
233
+ }
234
+ },
235
+ )
236
+ } catch (error) {
237
+ finish(error)
238
+ return
239
+ }
240
+ // 超时与错误都必须收敛成结果,不能把异常抛回 probe()
241
+ socket.on('timeout', () => {
242
+ finish(new Error(`TLS 握手超时(${timeoutMs}ms)`))
243
+ socket.destroy()
244
+ })
245
+ socket.on('error', (error) => finish(error))
246
+ })
247
+ }
248
+
249
+ /**
250
+ * 探测站点证书。任何失败都折算成结果对象,不抛异常。
251
+ *
252
+ * @param {{
253
+ * host?: string,
254
+ * port?: number,
255
+ * timeoutMs?: number,
256
+ * now?: number,
257
+ * warnHours?: number,
258
+ * peer?: typeof tlsPeerCertificate,
259
+ * }} [options]
260
+ * @returns {Promise<{ok:boolean, expiresAt:string|null, hoursLeft:number|null, subject:string, san:string, reason?:string}>}
261
+ */
262
+ export async function probeCertificate({
263
+ host,
264
+ port,
265
+ timeoutMs = 2000,
266
+ now = Date.now(),
267
+ warnHours = CERT_WARN_HOURS,
268
+ peer = tlsPeerCertificate,
269
+ } = {}) {
270
+ try {
271
+ const cert = await peer({ host, port, timeoutMs })
272
+ return evaluateCertificate({ cert, host, now, warnHours })
273
+ } catch (error) {
274
+ return {
275
+ ok: false,
276
+ expiresAt: null,
277
+ hoursLeft: null,
278
+ subject: '',
279
+ san: '',
280
+ reason: `TLS 握手失败:${String(error?.message ?? error)}`,
281
+ }
282
+ }
283
+ }
284
+
285
+ /** 没做握手时的占位结果(未部署 / 缺 IP / 未启用 TLS)。 */
286
+ function offlineCert(reason) {
287
+ return { ok: false, expiresAt: null, hoursLeft: null, subject: '', san: '', reason }
288
+ }
289
+
290
+ /**
291
+ * 把证书结果折成一条 doctor check(doctor.js 不在本次改动范围,由接口层追加)。
292
+ * @param {{ok?:boolean, expiresAt?:string|null, hoursLeft?:number|null, reason?:string}|null} [cert]
293
+ * @param {{ deployed?: boolean }} [options]
294
+ */
295
+ export function certCheck(cert, { deployed = true } = {}) {
296
+ const base = { id: 'tls.cert', label: '证书有效期' }
297
+ if (!deployed) return { ...base, state: 'unknown', detail: '尚未部署,没有证书可查' }
298
+ if (cert === undefined || cert === null) return { ...base, state: 'unknown', detail: '还没有证书探测结果' }
299
+ if (cert.ok === true) {
300
+ return { ...base, state: 'ok', detail: `剩余 ${cert.hoursLeft} 小时(${cert.expiresAt} 到期)` }
301
+ }
302
+ const hours = typeof cert.hoursLeft === 'number' ? `;剩余 ${cert.hoursLeft} 小时` : ''
303
+ return { ...base, state: 'warn', detail: `${cert.reason ?? '证书不可用'}${hours}` }
304
+ }
305
+
65
306
  /**
66
307
  * @param {{ port?: number } | null} state
67
- * @param {{ exec?: typeof run, health?: typeof httpHealth }} [deps]
308
+ * @param {{ exec?: typeof run, health?: typeof httpHealth, upstream?: Function, certProbe?: Function, tls?: boolean, certTimeoutMs?: number }} [deps]
68
309
  */
69
310
  export async function probe(state, deps = {}) {
70
311
  const exec = deps.exec ?? run
71
- const health = deps.health ?? httpHealth
312
+ const health = deps.health ?? probeHealth
313
+ const certProbe = deps.certProbe ?? probeCertificate
72
314
 
73
315
  const [easytier, caddy] = await Promise.all([
74
316
  systemdState(UNITS.easytier, {}, exec),
@@ -78,8 +320,20 @@ export async function probe(state, deps = {}) {
78
320
  const port = Number(state?.port)
79
321
  const upstreamPort = Number(state?.upstream ?? 3080)
80
322
  const deployed = Number.isInteger(port) && port > 0
81
- const healthz = deployed ? await health(port) : { ok: false, status: 0, reason: '未部署' }
323
+ const host = typeof state?.ip === 'string' ? state.ip.trim() : ''
324
+
325
+ // 证书探测与 healthz/上游并行,最多多等 2s;失败只影响 cert 字段
326
+ let certPromise
327
+ if (!deployed) certPromise = Promise.resolve(offlineCert('未部署'))
328
+ else if (host === '') certPromise = Promise.resolve(offlineCert('缺少站点 IP'))
329
+ else if (deps.tls === false) certPromise = Promise.resolve(offlineCert('未启用 TLS'))
330
+ else certPromise = Promise.resolve(certProbe({ host, port, timeoutMs: deps.certTimeoutMs ?? 2000 }))
331
+
332
+ const healthz = deployed ? await health(port, { host: state.ip }) : { ok: false, status: 0, reason: '未部署' }
82
333
  const upstream = await (deps.upstream ?? upstreamCheck)(upstreamPort)
334
+ const cert = await certPromise
335
+ // /status 只读这份缓存:未部署时清空,避免面板拿旧证书
336
+ lastCert = deployed ? cert : undefined
83
337
 
84
338
  return {
85
339
  ok: easytier.ok && caddy.ok && healthz.ok && upstream.ok,
@@ -87,6 +341,7 @@ export async function probe(state, deps = {}) {
87
341
  caddy,
88
342
  healthz,
89
343
  upstream,
344
+ cert,
90
345
  }
91
346
  }
92
347
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-panel",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "DSH 远程访问控制面:部署向导、状态面板与一键配对(EasyTier + Caddy),数据面保持进程外。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",