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; private server: http.Server; private acmeClient: acme.Client | null = null; private accountKey: string | null = null; constructor() { this.domainCertificates = new Map(); // 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 { if (this.acmeClient) { return this.acmeClient; } // Generate a new account key and convert Buffer to string. this.accountKey = (await acme.forge.createPrivateKey()).toString(); 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 { 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. // 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); 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. // Convert the resulting Buffers to strings. const [csrBuffer, privateKeyBuffer] = await acme.forge.createCsr({ commonName: domain, }); const csr = csrBuffer.toString(); const privateKey = privateKeyBuffer.toString(); // 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}`); // In a production system, persist the certificate and key and reload your TLS server. } catch (error) { console.error(`Error during certificate issuance for ${domain}:`, error); const domainInfo = this.domainCertificates.get(domain); if (domainInfo) { domainInfo.obtainingInProgress = false; } } } }