@ -0,0 +1,212 @@
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');