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 e637475..543f7d7 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; @@ -296,12 +301,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; @@ -327,11 +336,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]; } }, // @@ -355,10 +378,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; } @@ -439,7 +464,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 { @@ -480,13 +514,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; @@ -631,11 +675,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++) { @@ -729,15 +773,26 @@ export class Redbird { } } - close() { - this.proxy.close(); - this.agent && this.agent.destroy(); + async close() { + // 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(); - return Promise.all( - [this.server, this.httpsServer] + await Promise.all( + [this.proxy, this.server, this.httpsServer] .filter((s) => s) .map((server) => new Promise((resolve) => server.close(resolve))) ); + this.agent && this.agent.destroy(); } // diff --git a/test/letsencrypt_certificates.spec.ts b/test/letsencrypt_certificates.spec.ts index 99b90b2..b103201 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; @@ -109,9 +108,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 diff --git a/test/letsencrypt_lazy_certificates.spec.ts b/test/letsencrypt_lazy_certificates.spec.ts new file mode 100644 index 0000000..db2d0d1 --- /dev/null +++ b/test/letsencrypt_lazy_certificates.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http, { createServer } from 'http'; +import { Redbird } from '../lib/proxy'; // Adjust the path as necessary +import https from 'https'; +import { certificate, key } from './fixtures'; + +const TEST_PORT = 3030; +// Mock the letsencrypt module +vi.mock('../lib/letsencrypt', () => ({ + getCertificates: vi.fn().mockImplementation(async () => ({ + privkey: key, + cert: certificate, + chain: 'chain', + expiresAt: Date.now() + 90 * 24 * 3600 * 1000, // Certificate valid for 90 days + })), + init: vi.fn(), +})); + +// Import the mocked getCertificates function +import { getCertificates } from '../lib/letsencrypt'; // Path should match the module being mocked +const mockedGetCertificates = vi.mocked(getCertificates); + +// Setup and teardown the proxy and HTTP server +describe('Lazy SSL Certificate Handling', () => { + let server: http.Server; + let proxy: Redbird; + + beforeAll(async () => { + // Create an HTTP server that the proxy will use + server = createServer((req, res) => { + res.writeHead(200); + res.end('Hello, world!'); + }); + await new Promise((resolve) => server.listen(TEST_PORT, resolve)); + + // Setup Redbird proxy + proxy = new Redbird({ + port: 8080, + ssl: { + port: 8443, // This is the SSL port the proxy will use for HTTPS + }, + letsencrypt: { + path: '/path/to/certs', // Ensure this is configured as expected + port: 9999, // ACME challenges port + }, + }); + }); + + afterAll(async () => { + await proxy.close(); + console.log('Closing server'); + await new Promise((resolve) => server.close(() => resolve())); + console.log('Server closed'); + vi.restoreAllMocks(); + }); + + it('should not request certificates immediately for lazy loaded domains', async () => { + // Reset mocks + mockedGetCertificates.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(mockedGetCertificates).not.toHaveBeenCalled(); + }); + + it('should request and cache certificates on first HTTPS request', async () => { + // Reset mocks + mockedGetCertificates.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('Hello, world!'); + + // Ensure that certificates are now loaded + expect(mockedGetCertificates).toHaveBeenCalled(); + }); +});