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.
- package/dist/{chunk-QURDG7SD.js → chunk-MD5CVZP7.js} +187 -20
- package/dist/esbuild.cjs +187 -20
- package/dist/esbuild.js +1 -1
- package/dist/index.cjs +187 -20
- package/dist/index.js +1 -1
- package/dist/nuxt.cjs +187 -20
- package/dist/nuxt.js +1 -1
- package/dist/rollup.cjs +187 -20
- package/dist/rollup.js +1 -1
- package/dist/rspack.cjs +187 -20
- package/dist/rspack.js +1 -1
- package/dist/vite.cjs +187 -20
- package/dist/vite.js +1 -1
- package/dist/webpack.cjs +187 -20
- package/dist/webpack.js +1 -1
- package/package.json +1 -1
- package/src/caddy/caddyfile.ts +1 -1
- package/src/caddy/index.ts +110 -21
- package/src/caddy/log.ts +16 -0
- package/src/caddy/tls-health.ts +142 -0
package/src/caddy/index.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
236
|
-
|
|
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.
|
|
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
|
-
|
|
425
|
+
void resolveWhenReady()
|
|
345
426
|
}
|
|
346
427
|
})
|
|
347
428
|
|
|
348
429
|
this._caddyChild!.stdout?.on('data', (_data) => {
|
|
349
430
|
consola.info(_data.toString())
|
|
350
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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.
|
|
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.
|
|
471
|
+
await this.stopCaddy()
|
|
383
472
|
try {
|
|
384
473
|
await lockfile.unlock(caddyLockFilePath).catch(() => { })
|
|
385
474
|
await unlink(caddyFilePath).catch(() => { })
|
package/src/caddy/log.ts
ADDED
|
@@ -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
|
+
}
|