unplugin-https-reverse-proxy 4.0.2 → 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.
@@ -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
+ }