unplugin-https-reverse-proxy 4.0.1 → 4.0.3

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.
@@ -14,6 +14,8 @@ import { chmodRecursive, consola, once } from '../utils'
14
14
  import { genCaddyfile, validateTemplateContext } from './caddyfile'
15
15
  import { useCaddyConfig } from './config'
16
16
  import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
17
+ import { isCaddyReloadCancellation } from './log'
18
+ import { formatTlsHealthFailure, waitForLocalTls } from './tls-health'
17
19
  import { kill, logProgress, logProgressOver, tryPort } from './utils'
18
20
 
19
21
  export async function download(): Promise<string> {
@@ -114,6 +116,19 @@ function getCaddyConfig(): ReturnType<typeof useCaddyConfig> {
114
116
  return caddyConfig
115
117
  }
116
118
 
119
+ /** Split `host:port` (IPv4 or hostname); supports `[::1]:port`. */
120
+ function splitHostPort(addr: string): { host: string, port: string } {
121
+ const lastColon = addr.lastIndexOf(':')
122
+ if (lastColon === -1)
123
+ return { host: addr, port: '' }
124
+ if (addr.startsWith('[')) {
125
+ const end = addr.indexOf(']')
126
+ if (end !== -1 && addr[end + 1] === ':')
127
+ return { host: addr.slice(1, end), port: addr.slice(end + 2) }
128
+ }
129
+ return { host: addr.slice(0, lastColon), port: addr.slice(lastColon + 1) }
130
+ }
131
+
117
132
  export class CaddyInstant {
118
133
  private inited = false
119
134
  private stoped = false
@@ -174,12 +189,52 @@ export class CaddyInstant {
174
189
  this.inited = true
175
190
  }
176
191
 
192
+ private async ensureTlsReady(targetHost: string): Promise<void> {
193
+ const health = await waitForLocalTls(targetHost)
194
+ if (!health.healthy)
195
+ throw new Error(formatTlsHealthFailure(targetHost, health))
196
+ }
197
+
198
+ private async updateCaddyfile(): Promise<void> {
199
+ const { configJsonRef } = getCaddyConfig()
200
+ const proxies = Object.values(configJsonRef() || {})
201
+ const ctx = validateTemplateContext({
202
+ caddy_root: this.caddyRoot,
203
+ proxies,
204
+ })
205
+
206
+ this.caddyfile = await genCaddyfile(ctx)
207
+ await this.writeCaddyfile()
208
+ }
209
+
210
+ private async stopCaddy(): Promise<void> {
211
+ try {
212
+ await got.post('http://127.0.0.1:2019/stop', {
213
+ headers: {
214
+ Origin: 'http://localhost:2019',
215
+ },
216
+ retry: {
217
+ limit: 0,
218
+ },
219
+ timeout: {
220
+ request: 2000,
221
+ },
222
+ })
223
+ }
224
+ catch {}
225
+
226
+ this._caddyChild?.kill()
227
+ }
228
+
177
229
  /**
178
230
  * @param source like `127.0.0.1:8080`
179
231
  * @param target like `test.abc.com`. And if you provide a port ,it will be ignore.
180
232
  */
181
233
  async run(source: string, target: string, options?: Partial<RunOptions>): Promise<void> {
182
234
  source = source.replace('0.0.0.0', '127.0.0.1')
235
+ // Caddy dials the upstream host via DNS; `localhost` can hit lookup timeouts (VPN/DNS).
236
+ // Use loopback IP so the proxy never depends on resolving "localhost".
237
+ source = source.replace(/^localhost(?=:)/i, '127.0.0.1')
183
238
  const {
184
239
  restore = true,
185
240
  base = '/',
@@ -194,11 +249,11 @@ export class CaddyInstant {
194
249
  if (!this.inited)
195
250
  await this.init()
196
251
 
197
- if (!await addHost(source.split(':')[0], target.split(':')[0]))
252
+ const sourceHost = splitHostPort(source).host
253
+ const targetHost = splitHostPort(target).host
254
+ if (!await addHost(sourceHost, targetHost))
198
255
  throw new Error('update host failed')
199
256
  consola.success('update host success\n')
200
-
201
- const targetHost = target.split(':')[0]
202
257
  const normalizeBase = (b: string): string => {
203
258
  let out = b || '/'
204
259
  if (!out.startsWith('/'))
@@ -223,17 +278,17 @@ export class CaddyInstant {
223
278
  const { writeCaddyConfig } = getCaddyConfig()
224
279
  writeCaddyConfig(proxies)
225
280
 
226
- this._stopEffects.push(
227
- effect(async () => {
228
- const { configJsonRef } = getCaddyConfig()
229
- const proxies = Object.values(configJsonRef() || {})
230
- const ctx = validateTemplateContext({
231
- caddy_root: this.caddyRoot,
232
- proxies,
233
- })
281
+ await this.updateCaddyfile()
234
282
 
235
- this.caddyfile = await genCaddyfile(ctx)
236
- await this.writeCaddyfile()
283
+ let skipInitialCaddyfileEffect = true
284
+ this._stopEffects.push(
285
+ effect(() => {
286
+ getCaddyConfig().configJsonRef()
287
+ if (skipInitialCaddyfileEffect) {
288
+ skipInitialCaddyfileEffect = false
289
+ return
290
+ }
291
+ void this.updateCaddyfile()
237
292
  }),
238
293
  )
239
294
 
@@ -248,6 +303,25 @@ export class CaddyInstant {
248
303
  }
249
304
 
250
305
  return new Promise<void>((resolve, reject) => {
306
+ let startupCheckStarted = false
307
+
308
+ const resolveWhenReady = async (): Promise<void> => {
309
+ if (startupCheckStarted)
310
+ return
311
+ startupCheckStarted = true
312
+
313
+ try {
314
+ if (https)
315
+ await this.ensureTlsReady(targetHost)
316
+ resolve()
317
+ }
318
+ catch (error) {
319
+ if (!this.locked)
320
+ await this.baseCleanup(restore)
321
+ reject(error)
322
+ }
323
+ }
324
+
251
325
  const setupCleanup = (): void => {
252
326
  process.on('SIGINT', async () => {
253
327
  if (this.stoped)
@@ -270,7 +344,7 @@ export class CaddyInstant {
270
344
 
271
345
  // @ts-expect-error override
272
346
  process.exit = async (code?: number) => {
273
- const port = Number(source.split(':')[1])
347
+ const port = Number(splitHostPort(source).port)
274
348
  if (!Number.isNaN(port) && await tryPort(port))
275
349
  await kill(port)
276
350
 
@@ -279,6 +353,9 @@ export class CaddyInstant {
279
353
  const viteCacheDir = `${pwd}/node_modules/.vite`
280
354
  if (existsSync(viteCacheDir))
281
355
  await chmodRecursive(viteCacheDir, 0o777).catch(() => { })
356
+ const viteTempCacheDir = `${pwd}/node_modules/.vite-temp`
357
+ if (existsSync(viteTempCacheDir))
358
+ await chmodRecursive(viteTempCacheDir, 0o777).catch(() => { })
282
359
  const nuxtCacheDir = `${pwd}/.nuxt`
283
360
  if (existsSync(nuxtCacheDir))
284
361
  await chmodRecursive(nuxtCacheDir, 0o777).catch(() => { })
@@ -321,6 +398,10 @@ export class CaddyInstant {
321
398
  // caddy log
322
399
  // eslint-disable-next-line no-console
323
400
  showCaddyLog && line && console.info(line)
401
+ if (isCaddyReloadCancellation(line)) {
402
+ consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
403
+ continue
404
+ }
324
405
  try {
325
406
  if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
326
407
  if (line.includes('parsing caddyfile tokens')) {
@@ -341,13 +422,13 @@ export class CaddyInstant {
341
422
  consola.success('Detect added a new reverse proxy config, restarted.\n')
342
423
 
343
424
  if (line.includes('caddy proxying') || line.includes('serving initial'))
344
- return resolve()
425
+ void resolveWhenReady()
345
426
  }
346
427
  })
347
428
 
348
429
  this._caddyChild!.stdout?.on('data', (_data) => {
349
430
  consola.info(_data.toString())
350
- resolve()
431
+ void resolveWhenReady()
351
432
  })
352
433
 
353
434
  setupCleanup()
@@ -355,9 +436,17 @@ export class CaddyInstant {
355
436
  else {
356
437
  consola.info('Caddy is running in another process.')
357
438
 
358
- setupCleanup()
359
-
360
- return resolve()
439
+ void (async () => {
440
+ try {
441
+ if (https)
442
+ await this.ensureTlsReady(targetHost)
443
+ setupCleanup()
444
+ resolve()
445
+ }
446
+ catch (error) {
447
+ reject(error)
448
+ }
449
+ })()
361
450
  }
362
451
  })
363
452
  }
@@ -367,7 +456,7 @@ export class CaddyInstant {
367
456
 
368
457
  try {
369
458
  if (restore && this._source && this._target) {
370
- if (await removeHost(this._source.split(':')[0], this._target.split(':')[0]))
459
+ if (await removeHost(splitHostPort(this._source).host, splitHostPort(this._target).host))
371
460
  consola.success('Restore host success.\n')
372
461
 
373
462
  else
@@ -379,7 +468,7 @@ export class CaddyInstant {
379
468
  consola.fail('Restore host failed.\n')
380
469
  }
381
470
  if (!this.locked && !this.stoped) {
382
- this._caddyChild?.kill()
471
+ await this.stopCaddy()
383
472
  try {
384
473
  await lockfile.unlock(caddyLockFilePath).catch(() => { })
385
474
  await unlink(caddyFilePath).catch(() => { })
@@ -0,0 +1,16 @@
1
+ interface CaddyLogEntry {
2
+ error?: unknown
3
+ logger?: unknown
4
+ }
5
+
6
+ export function isCaddyReloadCancellation(line: string): boolean {
7
+ try {
8
+ const entry = JSON.parse(line) as CaddyLogEntry
9
+ return entry.logger === 'tls'
10
+ && typeof entry.error === 'string'
11
+ && entry.error.endsWith('context canceled')
12
+ }
13
+ catch {
14
+ return false
15
+ }
16
+ }
@@ -0,0 +1,142 @@
1
+ import type { ConnectionOptions, PeerCertificate, TLSSocket } from 'node:tls'
2
+ import { checkServerIdentity, connect } from 'node:tls'
3
+
4
+ export type TlsHealthReason =
5
+ | 'certificate_expired'
6
+ | 'certificate_invalid'
7
+ | 'certificate_missing'
8
+ | 'connection_failed'
9
+ | 'hostname_mismatch'
10
+
11
+ export type TlsHealthResult =
12
+ | {
13
+ healthy: true
14
+ expiresAt: Date
15
+ }
16
+ | {
17
+ healthy: false
18
+ reason: TlsHealthReason
19
+ expiresAt?: Date
20
+ }
21
+
22
+ export type HostnameVerifier = (hostname: string, certificate: PeerCertificate) => Error | undefined
23
+
24
+ type TlsConnector = (options: ConnectionOptions) => TLSSocket
25
+ type TlsProbe = (hostname: string, options: ProbeLocalTlsOptions) => Promise<TlsHealthResult>
26
+ type Sleep = (ms: number) => Promise<void>
27
+
28
+ export function assessTlsCertificate(
29
+ certificate: PeerCertificate,
30
+ hostname: string,
31
+ now = Date.now(),
32
+ verifyHostname: HostnameVerifier = checkServerIdentity,
33
+ ): TlsHealthResult {
34
+ if (!certificate.valid_to)
35
+ return { healthy: false, reason: 'certificate_missing' }
36
+
37
+ const expiresAt = new Date(certificate.valid_to)
38
+ if (Number.isNaN(expiresAt.getTime()))
39
+ return { healthy: false, reason: 'certificate_invalid' }
40
+
41
+ if (expiresAt.getTime() <= now)
42
+ return { healthy: false, reason: 'certificate_expired', expiresAt }
43
+
44
+ try {
45
+ if (verifyHostname(hostname, certificate))
46
+ return { healthy: false, reason: 'hostname_mismatch' }
47
+ }
48
+ catch {
49
+ return { healthy: false, reason: 'certificate_invalid' }
50
+ }
51
+
52
+ return { healthy: true, expiresAt }
53
+ }
54
+
55
+ interface ProbeLocalTlsOptions {
56
+ connect?: TlsConnector
57
+ port?: number
58
+ timeoutMs?: number
59
+ }
60
+
61
+ export function probeLocalTls(hostname: string, options: ProbeLocalTlsOptions = {}): Promise<TlsHealthResult> {
62
+ const {
63
+ connect: openTlsConnection = connect,
64
+ port = 443,
65
+ timeoutMs = 1000,
66
+ } = options
67
+
68
+ return new Promise((resolve) => {
69
+ let settled = false
70
+ let timeout: ReturnType<typeof setTimeout> | undefined
71
+ const socket = openTlsConnection({
72
+ host: '127.0.0.1',
73
+ port,
74
+ servername: hostname,
75
+ rejectUnauthorized: false,
76
+ })
77
+
78
+ const finish = (result: TlsHealthResult): void => {
79
+ if (settled)
80
+ return
81
+ settled = true
82
+ if (timeout)
83
+ clearTimeout(timeout)
84
+ socket.destroy()
85
+ resolve(result)
86
+ }
87
+
88
+ timeout = setTimeout(() => {
89
+ finish({ healthy: false, reason: 'connection_failed' })
90
+ }, timeoutMs)
91
+
92
+ socket.once('secureConnect', () => {
93
+ finish(assessTlsCertificate(socket.getPeerCertificate(), hostname))
94
+ })
95
+ socket.once('error', () => {
96
+ finish({ healthy: false, reason: 'connection_failed' })
97
+ })
98
+ })
99
+ }
100
+
101
+ interface WaitForLocalTlsOptions extends ProbeLocalTlsOptions {
102
+ probe?: TlsProbe
103
+ retryIntervalMs?: number
104
+ retryWindowMs?: number
105
+ sleep?: Sleep
106
+ }
107
+
108
+ function sleep(ms: number): Promise<void> {
109
+ return new Promise(resolve => setTimeout(resolve, ms))
110
+ }
111
+
112
+ export async function waitForLocalTls(hostname: string, options: WaitForLocalTlsOptions = {}): Promise<TlsHealthResult> {
113
+ const {
114
+ probe: checkTls = probeLocalTls,
115
+ retryIntervalMs = 250,
116
+ retryWindowMs = 30_000,
117
+ sleep: wait = sleep,
118
+ ...probeOptions
119
+ } = options
120
+ const deadline = Date.now() + retryWindowMs
121
+ let result: TlsHealthResult = { healthy: false, reason: 'connection_failed' }
122
+
123
+ do {
124
+ result = await checkTls(hostname, probeOptions)
125
+ if (result.healthy)
126
+ return result
127
+
128
+ const remaining = deadline - Date.now()
129
+ if (remaining > 0)
130
+ await wait(Math.min(retryIntervalMs, remaining))
131
+ } while (Date.now() < deadline)
132
+
133
+ return result
134
+ }
135
+
136
+ export function formatTlsHealthFailure(hostname: string, result: Exclude<TlsHealthResult, { healthy: true }>): string {
137
+ const reason = result.reason === 'certificate_expired' && result.expiresAt
138
+ ? `the certificate expired at ${result.expiresAt.toISOString()}`
139
+ : result.reason.replaceAll('_', ' ')
140
+
141
+ return `[unplugin-https-reverse-proxy] Caddy cannot serve valid HTTPS for ${hostname}: ${reason}. Restart Caddy to retry its automatic certificate renewal. Do not remove Caddy PKI data automatically because that replaces the trusted local root certificate.`
142
+ }