Skip to content

Commit

Permalink
feat: add support for lazy let's encrypt certificates
Browse files Browse the repository at this point in the history
  • Loading branch information
manast committed Sep 18, 2024
1 parent 96a6ff2 commit 28aaa40
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 30 deletions.
2 changes: 1 addition & 1 deletion lib/letsencrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function init(certPath: string, port: number, logger: pino.Logger<never, boolean
};

// we need to proxy for example: 'example.com/.well-known/acme-challenge' -> '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();
Expand Down
105 changes: 80 additions & 25 deletions lib/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -46,6 +50,7 @@ export class Redbird {
private httpsServer: any;

private letsencryptHost: string;
private letsencryptServer: Server;

get defaultResolver() {
return this._defaultResolver;
Expand Down Expand Up @@ -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;
Expand All @@ -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];
}
},
//
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<void>;
register(opts: {
src: string | URL;
target: string | URL;
ssl: {
key?: string;
cert?: string;
ca?: string;
letsencrypt?: { email: string; production: boolean; lazy?: boolean };
};
}): Promise<void>;
register(src: string, opts: any): Promise<void>;
register(src: string | URL, target: string | URL, opts: any): Promise<void>;
async register(src: any, target?: any, opts?: any): Promise<void> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -626,11 +670,11 @@ export class Redbird {
url?: string,
req?: IncomingMessage
): Promise<ProxyRoute | undefined> {
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++) {
Expand Down Expand Up @@ -724,15 +768,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();
}

//
Expand Down
4 changes: 0 additions & 4 deletions test/letsencrypt_certificates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions test/letsencrypt_lazy_certificates.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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 protected]',
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();
});
});

0 comments on commit 28aaa40

Please sign in to comment.