diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2fdad7..c419835 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: lts/* + node-version: 22.x registry-url: 'https://registry.npmjs.org' - run: npm i -g npm@latest @@ -30,10 +30,10 @@ jobs: run: npm i -g pnpm@9 - name: Install Dependencies - run: pnpm i - - - name: PNPM build - run: pnpm run build + run: pnpm i --frozen-lockfile - name: Publish to NPM - run: pnpm publish --access public --no-git-checks + run: | + pnpm run build + TARBALL=$(pnpm pack --pack-destination "$RUNNER_TEMP" | tail -n 1) + npm publish "$TARBALL" --access public diff --git a/package.json b/package.json index 3bb628f..467a7f6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.0.2", + "version": "4.0.3", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", diff --git a/src/caddy/caddyfile.ts b/src/caddy/caddyfile.ts index e0ea76e..7693edf 100644 --- a/src/caddy/caddyfile.ts +++ b/src/caddy/caddyfile.ts @@ -25,7 +25,7 @@ export function validateTemplateContext(ctx: unknown): typeof CaddyContext.infer export const caddyTemplate = `{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { diff --git a/src/caddy/index.ts b/src/caddy/index.ts index 86b343c..8e7eb9c 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -14,6 +14,8 @@ import { chmodRecursive, consola, once } from '../utils' import { genCaddyfile, validateTemplateContext } from './caddyfile' import { useCaddyConfig } from './config' import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants' +import { isCaddyReloadCancellation } from './log' +import { formatTlsHealthFailure, waitForLocalTls } from './tls-health' import { kill, logProgress, logProgressOver, tryPort } from './utils' export async function download(): Promise { @@ -187,6 +189,43 @@ export class CaddyInstant { this.inited = true } + private async ensureTlsReady(targetHost: string): Promise { + const health = await waitForLocalTls(targetHost) + if (!health.healthy) + throw new Error(formatTlsHealthFailure(targetHost, health)) + } + + private async updateCaddyfile(): Promise { + const { configJsonRef } = getCaddyConfig() + const proxies = Object.values(configJsonRef() || {}) + const ctx = validateTemplateContext({ + caddy_root: this.caddyRoot, + proxies, + }) + + this.caddyfile = await genCaddyfile(ctx) + await this.writeCaddyfile() + } + + private async stopCaddy(): Promise { + try { + await got.post('http://127.0.0.1:2019/stop', { + headers: { + Origin: 'http://localhost:2019', + }, + retry: { + limit: 0, + }, + timeout: { + request: 2000, + }, + }) + } + catch {} + + this._caddyChild?.kill() + } + /** * @param source like `127.0.0.1:8080` * @param target like `test.abc.com`. And if you provide a port ,it will be ignore. @@ -239,17 +278,17 @@ export class CaddyInstant { const { writeCaddyConfig } = getCaddyConfig() writeCaddyConfig(proxies) - this._stopEffects.push( - effect(async () => { - const { configJsonRef } = getCaddyConfig() - const proxies = Object.values(configJsonRef() || {}) - const ctx = validateTemplateContext({ - caddy_root: this.caddyRoot, - proxies, - }) + await this.updateCaddyfile() - this.caddyfile = await genCaddyfile(ctx) - await this.writeCaddyfile() + let skipInitialCaddyfileEffect = true + this._stopEffects.push( + effect(() => { + getCaddyConfig().configJsonRef() + if (skipInitialCaddyfileEffect) { + skipInitialCaddyfileEffect = false + return + } + void this.updateCaddyfile() }), ) @@ -264,6 +303,25 @@ export class CaddyInstant { } return new Promise((resolve, reject) => { + let startupCheckStarted = false + + const resolveWhenReady = async (): Promise => { + if (startupCheckStarted) + return + startupCheckStarted = true + + try { + if (https) + await this.ensureTlsReady(targetHost) + resolve() + } + catch (error) { + if (!this.locked) + await this.baseCleanup(restore) + reject(error) + } + } + const setupCleanup = (): void => { process.on('SIGINT', async () => { if (this.stoped) @@ -340,6 +398,10 @@ export class CaddyInstant { // caddy log // eslint-disable-next-line no-console showCaddyLog && line && console.info(line) + if (isCaddyReloadCancellation(line)) { + consola.info('Caddy restarted before TLS issuance completed; retrying.\n') + continue + } try { if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { if (line.includes('parsing caddyfile tokens')) { @@ -360,13 +422,13 @@ export class CaddyInstant { consola.success('Detect added a new reverse proxy config, restarted.\n') if (line.includes('caddy proxying') || line.includes('serving initial')) - return resolve() + void resolveWhenReady() } }) this._caddyChild!.stdout?.on('data', (_data) => { consola.info(_data.toString()) - resolve() + void resolveWhenReady() }) setupCleanup() @@ -374,9 +436,17 @@ export class CaddyInstant { else { consola.info('Caddy is running in another process.') - setupCleanup() - - return resolve() + void (async () => { + try { + if (https) + await this.ensureTlsReady(targetHost) + setupCleanup() + resolve() + } + catch (error) { + reject(error) + } + })() } }) } @@ -398,7 +468,7 @@ export class CaddyInstant { consola.fail('Restore host failed.\n') } if (!this.locked && !this.stoped) { - this._caddyChild?.kill() + await this.stopCaddy() try { await lockfile.unlock(caddyLockFilePath).catch(() => { }) await unlink(caddyFilePath).catch(() => { }) diff --git a/src/caddy/log.ts b/src/caddy/log.ts new file mode 100644 index 0000000..a1184ab --- /dev/null +++ b/src/caddy/log.ts @@ -0,0 +1,16 @@ +interface CaddyLogEntry { + error?: unknown + logger?: unknown +} + +export function isCaddyReloadCancellation(line: string): boolean { + try { + const entry = JSON.parse(line) as CaddyLogEntry + return entry.logger === 'tls' + && typeof entry.error === 'string' + && entry.error.endsWith('context canceled') + } + catch { + return false + } +} diff --git a/src/caddy/tls-health.ts b/src/caddy/tls-health.ts new file mode 100644 index 0000000..36cf4c6 --- /dev/null +++ b/src/caddy/tls-health.ts @@ -0,0 +1,142 @@ +import type { ConnectionOptions, PeerCertificate, TLSSocket } from 'node:tls' +import { checkServerIdentity, connect } from 'node:tls' + +export type TlsHealthReason = + | 'certificate_expired' + | 'certificate_invalid' + | 'certificate_missing' + | 'connection_failed' + | 'hostname_mismatch' + +export type TlsHealthResult = + | { + healthy: true + expiresAt: Date + } + | { + healthy: false + reason: TlsHealthReason + expiresAt?: Date + } + +export type HostnameVerifier = (hostname: string, certificate: PeerCertificate) => Error | undefined + +type TlsConnector = (options: ConnectionOptions) => TLSSocket +type TlsProbe = (hostname: string, options: ProbeLocalTlsOptions) => Promise +type Sleep = (ms: number) => Promise + +export function assessTlsCertificate( + certificate: PeerCertificate, + hostname: string, + now = Date.now(), + verifyHostname: HostnameVerifier = checkServerIdentity, +): TlsHealthResult { + if (!certificate.valid_to) + return { healthy: false, reason: 'certificate_missing' } + + const expiresAt = new Date(certificate.valid_to) + if (Number.isNaN(expiresAt.getTime())) + return { healthy: false, reason: 'certificate_invalid' } + + if (expiresAt.getTime() <= now) + return { healthy: false, reason: 'certificate_expired', expiresAt } + + try { + if (verifyHostname(hostname, certificate)) + return { healthy: false, reason: 'hostname_mismatch' } + } + catch { + return { healthy: false, reason: 'certificate_invalid' } + } + + return { healthy: true, expiresAt } +} + +interface ProbeLocalTlsOptions { + connect?: TlsConnector + port?: number + timeoutMs?: number +} + +export function probeLocalTls(hostname: string, options: ProbeLocalTlsOptions = {}): Promise { + const { + connect: openTlsConnection = connect, + port = 443, + timeoutMs = 1000, + } = options + + return new Promise((resolve) => { + let settled = false + let timeout: ReturnType | undefined + const socket = openTlsConnection({ + host: '127.0.0.1', + port, + servername: hostname, + rejectUnauthorized: false, + }) + + const finish = (result: TlsHealthResult): void => { + if (settled) + return + settled = true + if (timeout) + clearTimeout(timeout) + socket.destroy() + resolve(result) + } + + timeout = setTimeout(() => { + finish({ healthy: false, reason: 'connection_failed' }) + }, timeoutMs) + + socket.once('secureConnect', () => { + finish(assessTlsCertificate(socket.getPeerCertificate(), hostname)) + }) + socket.once('error', () => { + finish({ healthy: false, reason: 'connection_failed' }) + }) + }) +} + +interface WaitForLocalTlsOptions extends ProbeLocalTlsOptions { + probe?: TlsProbe + retryIntervalMs?: number + retryWindowMs?: number + sleep?: Sleep +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function waitForLocalTls(hostname: string, options: WaitForLocalTlsOptions = {}): Promise { + const { + probe: checkTls = probeLocalTls, + retryIntervalMs = 250, + retryWindowMs = 30_000, + sleep: wait = sleep, + ...probeOptions + } = options + const deadline = Date.now() + retryWindowMs + let result: TlsHealthResult = { healthy: false, reason: 'connection_failed' } + + do { + result = await checkTls(hostname, probeOptions) + if (result.healthy) + return result + + const remaining = deadline - Date.now() + if (remaining > 0) + await wait(Math.min(retryIntervalMs, remaining)) + } while (Date.now() < deadline) + + return result +} + +export function formatTlsHealthFailure(hostname: string, result: Exclude): string { + const reason = result.reason === 'certificate_expired' && result.expiresAt + ? `the certificate expired at ${result.expiresAt.toISOString()}` + : result.reason.replaceAll('_', ' ') + + 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.` +} diff --git a/test/caddy/index.test.ts b/test/caddy/index.test.ts index 54ed0ae..2fa3062 100644 --- a/test/caddy/index.test.ts +++ b/test/caddy/index.test.ts @@ -45,7 +45,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -117,7 +117,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -186,7 +186,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -256,7 +256,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { @@ -340,7 +340,7 @@ describe('caddy', () => { expect(caddyfile).toMatchInlineSnapshot(` "{ debug - auto_https disable_redirects + auto_https disable_redirects ignore_loaded_certs } :7601 { diff --git a/test/caddy/log.test.ts b/test/caddy/log.test.ts new file mode 100644 index 0000000..b55753c --- /dev/null +++ b/test/caddy/log.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { isCaddyReloadCancellation } from '../../src/caddy/log' + +describe('isCaddyReloadCancellation', () => { + it('ignores TLS issuance canceled by a Caddy config reload', () => { + expect(isCaddyReloadCancellation('{"level":"error","logger":"tls","msg":"job failed","error":"local.doc.suanshubang.com: obtaining certificate: unable to acquire lock \'issue_cert_local.doc.suanshubang.com\': context canceled"}')).toBe(true) + }) + + it('keeps other TLS errors actionable', () => { + expect(isCaddyReloadCancellation('{"level":"error","logger":"tls","msg":"job failed","error":"dial tcp: connection refused"}')).toBe(false) + }) + + it('does not ignore non-JSON log lines', () => { + expect(isCaddyReloadCancellation('Error: parsing caddyfile tokens')).toBe(false) + }) +}) diff --git a/test/caddy/tls-health.test.ts b/test/caddy/tls-health.test.ts new file mode 100644 index 0000000..2e6a8c6 --- /dev/null +++ b/test/caddy/tls-health.test.ts @@ -0,0 +1,86 @@ +import type { PeerCertificate } from 'node:tls' +import type { TlsHealthResult } from '../../src/caddy/tls-health' +import { describe, expect, it } from 'vitest' +import { assessTlsCertificate, waitForLocalTls } from '../../src/caddy/tls-health' + +function certificate(validTo: string): PeerCertificate { + return { + valid_to: validTo, + } as PeerCertificate +} + +describe('assessTlsCertificate', () => { + const now = Date.UTC(2026, 8, 4, 14, 0, 0) + + it('accepts a certificate that is valid for the requested hostname', () => { + const result = assessTlsCertificate(certificate('Sep 5 14:00:00 2026 GMT'), 'local.doc.suanshubang.com', now, () => undefined) + + expect(result).toMatchInlineSnapshot(` + { + "expiresAt": 2026-09-05T14:00:00.000Z, + "healthy": true, + } + `) + }) + + it('reports an expired certificate instead of treating the proxy as ready', () => { + const result = assessTlsCertificate(certificate('Aug 14 10:49:33 2026 GMT'), 'local.doc.suanshubang.com', now, () => undefined) + + expect(result).toMatchInlineSnapshot(` + { + "expiresAt": 2026-08-14T10:49:33.000Z, + "healthy": false, + "reason": "certificate_expired", + } + `) + }) + + it('reports a hostname mismatch', () => { + const result = assessTlsCertificate(certificate('Sep 5 14:00:00 2026 GMT'), 'local.doc.suanshubang.com', now, () => new Error('hostname mismatch')) + + expect(result).toMatchInlineSnapshot(` + { + "healthy": false, + "reason": "hostname_mismatch", + } + `) + }) + + it('reports certificates with an invalid validity date', () => { + const result = assessTlsCertificate(certificate('not-a-date'), 'local.doc.suanshubang.com', now, () => undefined) + + expect(result).toMatchInlineSnapshot(` + { + "healthy": false, + "reason": "certificate_invalid", + } + `) + }) +}) + +describe('waitForLocalTls', () => { + it('waits for Caddy to replace an expired certificate', async () => { + const results: TlsHealthResult[] = [ + { healthy: false, reason: 'certificate_expired', expiresAt: new Date('2026-08-14T10:49:33.000Z') }, + { healthy: true, expiresAt: new Date('2026-09-05T14:00:00.000Z') }, + ] + let attempts = 0 + + const result = await waitForLocalTls('local.doc.suanshubang.com', { + retryIntervalMs: 1, + retryWindowMs: 30_000, + probe: async () => results[attempts++]!, + sleep: async () => {}, + }) + + expect({ attempts, result }).toMatchInlineSnapshot(` + { + "attempts": 2, + "result": { + "expiresAt": 2026-09-05T14:00:00.000Z, + "healthy": true, + }, + } + `) + }) +}) diff --git a/test/caddyfile.test.ts b/test/caddyfile.test.ts index bc4ac29..9da4e5a 100644 --- a/test/caddyfile.test.ts +++ b/test/caddyfile.test.ts @@ -34,6 +34,7 @@ describe('caddyfile host grouping and route ordering', async () => { }) const caddyfile = await genCaddyfile(ctx) + expect(caddyfile).toContain('auto_https disable_redirects ignore_loaded_certs') expect(caddyfile).toContain('example.com:80 {') expect(caddyfile).toContain('another.com:80 {')