2025-02-24 09:53:39 +00:00
|
|
|
|
import * as http from 'http';
|
|
|
|
|
import * as acme from 'acme-client';
|
|
|
|
|
|
|
|
|
|
interface IDomainCertificate {
|
|
|
|
|
certObtained: boolean;
|
|
|
|
|
obtainingInProgress: boolean;
|
|
|
|
|
certificate?: string;
|
|
|
|
|
privateKey?: string;
|
|
|
|
|
challengeToken?: string;
|
|
|
|
|
challengeKeyAuthorization?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class Port80Handler {
|
|
|
|
|
private domainCertificates: Map<string, IDomainCertificate>;
|
|
|
|
|
private server: http.Server;
|
|
|
|
|
private acmeClient: acme.Client | null = null;
|
|
|
|
|
private accountKey: string | null = null;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
|
|
|
|
|
|
|
|
|
// Create and start an HTTP server on port 80.
|
|
|
|
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
|
|
|
this.server.listen(80, () => {
|
|
|
|
|
console.log('Port80Handler is listening on port 80');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Adds a domain to be managed.
|
|
|
|
|
* @param domain The domain to add.
|
|
|
|
|
*/
|
|
|
|
|
public addDomain(domain: string): void {
|
|
|
|
|
if (!this.domainCertificates.has(domain)) {
|
|
|
|
|
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
|
|
|
|
|
console.log(`Domain added: ${domain}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Removes a domain from management.
|
|
|
|
|
* @param domain The domain to remove.
|
|
|
|
|
*/
|
|
|
|
|
public removeDomain(domain: string): void {
|
|
|
|
|
if (this.domainCertificates.delete(domain)) {
|
|
|
|
|
console.log(`Domain removed: ${domain}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lazy initialization of the ACME client.
|
|
|
|
|
* Uses Let’s Encrypt’s production directory (for testing you might switch to staging).
|
|
|
|
|
*/
|
|
|
|
|
private async getAcmeClient(): Promise<acme.Client> {
|
|
|
|
|
if (this.acmeClient) {
|
|
|
|
|
return this.acmeClient;
|
|
|
|
|
}
|
2025-02-24 10:00:57 +00:00
|
|
|
|
// Generate a new account key and convert Buffer to string.
|
|
|
|
|
this.accountKey = (await acme.forge.createPrivateKey()).toString();
|
2025-02-24 09:53:39 +00:00
|
|
|
|
this.acmeClient = new acme.Client({
|
|
|
|
|
directoryUrl: acme.directory.letsencrypt.production, // Use production for a real certificate
|
|
|
|
|
// For testing, you could use:
|
|
|
|
|
// directoryUrl: acme.directory.letsencrypt.staging,
|
|
|
|
|
accountKey: this.accountKey,
|
|
|
|
|
});
|
|
|
|
|
// Create a new account. Make sure to update the contact email.
|
|
|
|
|
await this.acmeClient.createAccount({
|
|
|
|
|
termsOfServiceAgreed: true,
|
|
|
|
|
contact: ['mailto:admin@example.com'],
|
|
|
|
|
});
|
|
|
|
|
return this.acmeClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handles incoming HTTP requests on port 80.
|
|
|
|
|
* If the request is for an ACME challenge, it responds with the key authorization.
|
|
|
|
|
* If the domain has a certificate, it redirects to HTTPS; otherwise, it initiates certificate issuance.
|
|
|
|
|
*/
|
|
|
|
|
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
|
|
|
const hostHeader = req.headers.host;
|
|
|
|
|
if (!hostHeader) {
|
|
|
|
|
res.statusCode = 400;
|
|
|
|
|
res.end('Bad Request: Host header is missing');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Extract domain (ignoring any port in the Host header)
|
|
|
|
|
const domain = hostHeader.split(':')[0];
|
|
|
|
|
|
|
|
|
|
// If the request is for an ACME HTTP-01 challenge, handle it.
|
|
|
|
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
|
|
|
|
this.handleAcmeChallenge(req, res, domain);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.domainCertificates.has(domain)) {
|
|
|
|
|
res.statusCode = 404;
|
|
|
|
|
res.end('Domain not configured');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const domainInfo = this.domainCertificates.get(domain)!;
|
|
|
|
|
|
|
|
|
|
// If certificate exists, redirect to HTTPS on port 443.
|
|
|
|
|
if (domainInfo.certObtained) {
|
|
|
|
|
const redirectUrl = `https://${domain}:443${req.url}`;
|
|
|
|
|
res.statusCode = 301;
|
|
|
|
|
res.setHeader('Location', redirectUrl);
|
|
|
|
|
res.end(`Redirecting to ${redirectUrl}`);
|
|
|
|
|
} else {
|
|
|
|
|
// Trigger certificate issuance if not already running.
|
|
|
|
|
if (!domainInfo.obtainingInProgress) {
|
|
|
|
|
domainInfo.obtainingInProgress = true;
|
|
|
|
|
this.obtainCertificate(domain).catch(err => {
|
|
|
|
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
res.statusCode = 503;
|
|
|
|
|
res.end('Certificate issuance in progress, please try again later.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Serves the ACME HTTP-01 challenge response.
|
|
|
|
|
*/
|
|
|
|
|
private handleAcmeChallenge(req: http.IncomingMessage, res: http.ServerResponse, domain: string): void {
|
|
|
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
|
|
|
if (!domainInfo) {
|
|
|
|
|
res.statusCode = 404;
|
|
|
|
|
res.end('Domain not configured');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// The token is the last part of the URL.
|
|
|
|
|
const urlParts = req.url?.split('/');
|
|
|
|
|
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
|
|
|
|
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
|
|
|
|
|
res.statusCode = 200;
|
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
|
|
|
res.end(domainInfo.challengeKeyAuthorization);
|
|
|
|
|
console.log(`Served ACME challenge response for ${domain}`);
|
|
|
|
|
} else {
|
|
|
|
|
res.statusCode = 404;
|
|
|
|
|
res.end('Challenge token not found');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Uses acme-client to perform a full ACME HTTP-01 challenge to obtain a certificate.
|
|
|
|
|
* On success, it stores the certificate and key in memory and clears challenge data.
|
|
|
|
|
*/
|
|
|
|
|
private async obtainCertificate(domain: string): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const client = await this.getAcmeClient();
|
|
|
|
|
|
|
|
|
|
// Create a new order for the domain.
|
|
|
|
|
const order = await client.createOrder({
|
|
|
|
|
identifiers: [{ type: 'dns', value: domain }],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Get the authorizations for the order.
|
|
|
|
|
const authorizations = await client.getAuthorizations(order);
|
|
|
|
|
for (const authz of authorizations) {
|
|
|
|
|
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
|
|
|
|
if (!challenge) {
|
|
|
|
|
throw new Error('HTTP-01 challenge not found');
|
|
|
|
|
}
|
|
|
|
|
// Get the key authorization for the challenge.
|
|
|
|
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
|
|
|
const domainInfo = this.domainCertificates.get(domain)!;
|
|
|
|
|
domainInfo.challengeToken = challenge.token;
|
|
|
|
|
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
|
|
|
|
|
|
|
|
|
// Notify the ACME server that the challenge is ready.
|
2025-02-24 10:00:57 +00:00
|
|
|
|
// The acme-client examples show that verifyChallenge takes three arguments:
|
|
|
|
|
// (authorization, challenge, keyAuthorization). However, the official TypeScript
|
|
|
|
|
// types appear to be out-of-sync. As a workaround, we cast client to 'any'.
|
|
|
|
|
await (client as any).verifyChallenge(authz, challenge, keyAuthorization);
|
|
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
|
await client.completeChallenge(challenge);
|
|
|
|
|
// Wait until the challenge is validated.
|
|
|
|
|
await client.waitForValidStatus(challenge);
|
|
|
|
|
console.log(`HTTP-01 challenge completed for ${domain}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate a CSR and a new private key for the domain.
|
2025-02-24 10:00:57 +00:00
|
|
|
|
// Convert the resulting Buffers to strings.
|
|
|
|
|
const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({
|
2025-02-24 09:53:39 +00:00
|
|
|
|
commonName: domain,
|
|
|
|
|
});
|
2025-02-24 10:00:57 +00:00
|
|
|
|
const csr = csrBuffer.toString();
|
|
|
|
|
const privateKey = privateKeyBuffer.toString();
|
2025-02-24 09:53:39 +00:00
|
|
|
|
|
|
|
|
|
// Finalize the order and obtain the certificate.
|
|
|
|
|
await client.finalizeOrder(order, csr);
|
|
|
|
|
const certificate = await client.getCertificate(order);
|
|
|
|
|
|
|
|
|
|
const domainInfo = this.domainCertificates.get(domain)!;
|
|
|
|
|
domainInfo.certificate = certificate;
|
|
|
|
|
domainInfo.privateKey = privateKey;
|
|
|
|
|
domainInfo.certObtained = true;
|
|
|
|
|
domainInfo.obtainingInProgress = false;
|
|
|
|
|
delete domainInfo.challengeToken;
|
|
|
|
|
delete domainInfo.challengeKeyAuthorization;
|
|
|
|
|
|
|
|
|
|
console.log(`Certificate obtained for ${domain}`);
|
2025-02-24 10:00:57 +00:00
|
|
|
|
// In a production system, persist the certificate and key and reload your TLS server.
|
2025-02-24 09:53:39 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
|
|
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
|
|
|
if (domainInfo) {
|
|
|
|
|
domainInfo.obtainingInProgress = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|