212 lines
7.4 KiB
TypeScript
212 lines
7.4 KiB
TypeScript
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;
|
||
}
|
||
// Generate a new account key.
|
||
this.accountKey = await acme.forge.createPrivateKey();
|
||
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.
|
||
await client.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.
|
||
const [csr, privateKey] = await acme.forge.createCsr({
|
||
commonName: domain,
|
||
});
|
||
|
||
// 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 real application, you would persist the certificate and key,
|
||
// then reload your TLS server with the new credentials.
|
||
} catch (error) {
|
||
console.error(`Error during certificate issuance for ${domain}:`, error);
|
||
const domainInfo = this.domainCertificates.get(domain);
|
||
if (domainInfo) {
|
||
domainInfo.obtainingInProgress = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Example usage:
|
||
// const handler = new Port80Handler();
|
||
// handler.addDomain('example.com');
|