Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for lazy let's encrypt certificates #300

Merged
merged 2 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
101 changes: 79 additions & 22 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,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)))
Expand Down
64 changes: 58 additions & 6 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 @@ -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;
Expand All @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 protected]',
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();
});
});
Loading