diff --git a/lib/letsencrypt.ts b/lib/letsencrypt.ts index e691d15..c3b12fc 100644 --- a/lib/letsencrypt.ts +++ b/lib/letsencrypt.ts @@ -43,7 +43,7 @@ function init(certPath: string, port: number, logger: pino.Logger 'localhost:port/example.com/' - createServer(function (req: IncomingMessage, res: ServerResponse) { + return createServer(function (req: IncomingMessage, res: ServerResponse) { if (req.method !== 'GET') { res.statusCode = 405; // Method Not Allowed res.end(); diff --git a/lib/proxy.ts b/lib/proxy.ts index 6b4cdae..bae348e 100755 --- a/lib/proxy.ts +++ b/lib/proxy.ts @@ -5,8 +5,9 @@ import path from 'path'; import { URL, parse as parseUrl } from 'url'; import cluster from 'cluster'; -import http, { Agent, ClientRequest, IncomingMessage, ServerResponse } from 'http'; +import http, { Agent, ClientRequest, IncomingMessage, Server, ServerResponse } from 'http'; import https from 'https'; +import http2, { Http2ServerRequest, Http2ServerResponse } from 'http2'; import fs from 'fs'; import tls from 'tls'; @@ -37,6 +38,9 @@ export class Redbird { routing: any = {}; resolvers: Resolver[] = []; certs: any; + lazyCerts: { + [key: string]: { email: string; production: boolean; renewWithin: number }; + } = {}; private _defaultResolver: any; private proxy: httpProxy; @@ -46,6 +50,7 @@ export class Redbird { private httpsServer: any; private letsencryptHost: string; + private letsencryptServer: Server; get defaultResolver() { return this._defaultResolver; @@ -291,12 +296,16 @@ export class Redbird { return server; } + /** + * Special resolver for handling Let's Encrypt ACME challenges. + * @param opts + */ setupLetsencrypt(opts: ProxyOptions) { if (!opts.letsencrypt.path) { throw Error('Missing certificate path for Lets Encrypt'); } const letsencryptPort = opts.letsencrypt.port || defaultLetsencryptPort; - letsencrypt.init(opts.letsencrypt.path, letsencryptPort, this.log); + this.letsencryptServer = letsencrypt.init(opts.letsencrypt.path, letsencryptPort, this.log); opts.resolvers = opts.resolvers || []; this.letsencryptHost = '127.0.0.1:' + letsencryptPort; @@ -322,11 +331,25 @@ export class Redbird { ca?: any; opts?: any; } = { - SNICallback: (hostname: string, cb: (err: any, ctx: any) => void) => { + SNICallback: async (hostname: string, cb: (err: any, ctx?: any) => void) => { + if (!certs[hostname] && this.lazyCerts[hostname]) { + try { + await this.updateCertificates( + hostname, + this.lazyCerts[hostname].email, + this.lazyCerts[hostname].production, + this.lazyCerts[hostname].renewWithin + ); + } catch (err) { + console.error('Error getting LetsEncrypt certificates', err); + return cb(err); + } + } else if (!certs[hostname]) { + return cb(new Error('No certs for hostname ' + hostname)); + } + if (cb) { cb(null, certs[hostname]); - } else { - return certs[hostname]; } }, // @@ -350,10 +373,12 @@ export class Redbird { } if (sslOpts.http2) { - httpsModule = sslOpts.serverModule || require('spdy'); - if (isObject(sslOpts.http2)) { - sslOpts.spdy = sslOpts.http2; - } + httpsModule = sslOpts.serverModule || { + createServer: ( + sslOpts: any, + cb: (req: Http2ServerRequest, res: Http2ServerResponse) => void + ) => http2.createSecureServer(sslOpts, cb), + }; } else { httpsModule = sslOpts.serverModule || https; } @@ -434,7 +459,16 @@ export class Redbird { @target {String|URL} A string or a url parsed by node url module. @opts {Object} Route options. */ - register(opts: { src: string | URL; target: string | URL; ssl: any }): Promise; + register(opts: { + src: string | URL; + target: string | URL; + ssl: { + key?: string; + cert?: string; + ca?: string; + letsencrypt?: { email: string; production: boolean; lazy?: boolean }; + }; + }): Promise; register(src: string, opts: any): Promise; register(src: string | URL, target: string | URL, opts: any): Promise; async register(src: any, target?: any, opts?: any): Promise { @@ -475,13 +509,23 @@ export class Redbird { console.error('Missing certificate path for Lets Encrypt'); return; } - this.log?.info('Getting Lets Encrypt certificates for %s', src.hostname); - await this.updateCertificates( - src.hostname, - ssl.letsencrypt.email, - ssl.letsencrypt.production, - this.opts.letsencrypt.renewWithin || ONE_MONTH - ); + + if (!ssl.letsencrypt.lazy) { + this.log?.info('Getting Lets Encrypt certificates for %s', src.hostname); + await this.updateCertificates( + src.hostname, + ssl.letsencrypt.email, + ssl.letsencrypt.production, + this.opts.letsencrypt.renewWithin || ONE_MONTH + ); + } else { + // We need to store the letsencrypt options for this domain somewhere + this.log?.info('Lazy loading Lets Encrypt certificates for %s', src.hostname); + this.lazyCerts[src.hostname] = { + ...ssl.letsencrypt, + renewWithin: this.opts.letsencrypt.renewWithin || ONE_MONTH, + }; + } } else { // Trigger the use of the default certificates. this.certs[src.hostname] = void 0; @@ -626,11 +670,11 @@ export class Redbird { url?: string, req?: IncomingMessage ): Promise { - host = host.toLowerCase(); + try { + host = host.toLowerCase(); - const promiseArray = this.resolvers.map((resolver) => resolver.fn.call(this, host, url, req)); + const promiseArray = this.resolvers.map((resolver) => resolver.fn.call(this, host, url, req)); - try { const resolverResults = await Promise.all(promiseArray); for (let i = 0; i < resolverResults.length; i++) { @@ -724,11 +768,24 @@ export class Redbird { } } - close() { + async close() { this.proxy.close(); this.agent && this.agent.destroy(); - return Promise.all( + // Clear any renewal timers + if (this.certs) { + Object.keys(this.certs).forEach((domain) => { + const cert = this.certs[domain]; + if (cert && cert.renewalTimeout) { + safe.clearTimeout(cert.renewalTimeout); + cert.renewalTimeout = null; + } + }); + } + + this.letsencryptServer?.close(); + + await Promise.all( [this.server, this.httpsServer] .filter((s) => s) .map((server) => new Promise((resolve) => server.close(resolve))) diff --git a/test/letsencrypt_certificates.spec.ts b/test/letsencrypt_certificates.spec.ts index 99b90b2..22f7753 100644 --- a/test/letsencrypt_certificates.spec.ts +++ b/test/letsencrypt_certificates.spec.ts @@ -7,7 +7,6 @@ import fs from 'fs'; import path from 'path'; import { certificate, key } from './fixtures'; -import pino from 'pino'; const ONE_DAY = 24 * 60 * 60 * 1000; @@ -86,6 +85,8 @@ function makeHttpsRequest(options) { }); } +const responseMessage = 'Hello from target server' + describe('Redbird Lets Encrypt SSL Certificate Generation', () => { let proxy: Redbird; let targetServer: Server; @@ -95,7 +96,7 @@ describe('Redbird Lets Encrypt SSL Certificate Generation', () => { // Start a simple HTTP server to act as the backend target targetServer = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('Hello from target server'); + res.end(responseMessage); }); await new Promise((resolve) => { @@ -109,9 +110,6 @@ describe('Redbird Lets Encrypt SSL Certificate Generation', () => { port: 8080, ssl: { port: 8443, - // Provide paths to your default SSL key and cert files - //key: path.join(__dirname, 'ssl', 'default.key'), // Replace with actual paths - //cert: path.join(__dirname, 'ssl', 'default.crt'), // Replace with actual paths }, letsencrypt: { path: path.join(__dirname, 'letsencrypt'), // Path to store Let's Encrypt certificates @@ -160,7 +158,7 @@ describe('Redbird Lets Encrypt SSL Certificate Generation', () => { const response = await makeHttpsRequest(options); expect(response.status).toBe(200); - expect(response.data).toBe('Hello from target server'); + expect(response.data).toBe(responseMessage); }); it('should renew SSL certificates that are halfway to expire', async () => { @@ -236,4 +234,58 @@ describe('Redbird Lets Encrypt SSL Certificate Generation', () => { // Restore real timers vi.useRealTimers(); }); + + it('should not request certificates immediately for lazy loaded domains', async () => { + // Reset mocks + getCertificatesMock.mockClear(); + + // Simulate registering a domain with lazy loading enabled + await proxy.register('https://lazy.example.com', `http://localhost:${TEST_PORT}`, { + ssl: { + letsencrypt: { + email: 'email@example.com', + production: false, + lazy: true, + }, + }, + }); + + // Check that certificates were not requested during registration + expect(getCertificatesMock).not.toHaveBeenCalled(); + }); + + it('should request and cache certificates on first HTTPS request for lazy certificates', async () => { + // Reset mocks + getCertificatesMock.mockClear(); + + // Make an HTTPS request to trigger lazy loading of certificates + const options = { + hostname: 'localhost', + port: 8443, + path: '/', + method: 'GET', + headers: { Host: 'lazy.example.com' }, // Required for virtual hosts + rejectUnauthorized: false, // Accept self-signed certificates + }; + + const response = await new Promise<{ statusCode: number; data: string }>((resolve, reject) => { + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + resolve({ statusCode: res.statusCode || 0, data }); + }); + }); + req.on('error', reject); + req.end(); + }); + + expect(response.statusCode).toBe(200); + expect(response.data).toBe(responseMessage); + + // Ensure that certificates are now loaded + expect(getCertificatesMock).toHaveBeenCalled(); + }); });