964 lines
31 KiB
TypeScript
964 lines
31 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import { IncomingMessage, ServerResponse } from 'http';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
// ACME HTTP-01 challenge handler storing tokens in memory (diskless)
|
|
class DisklessHttp01Handler {
|
|
private storage: Map<string, string>;
|
|
constructor(storage: Map<string, string>) { this.storage = storage; }
|
|
public getSupportedTypes(): string[] { return ['http-01']; }
|
|
public async prepare(ch: any): Promise<void> {
|
|
this.storage.set(ch.token, ch.keyAuthorization);
|
|
}
|
|
public async verify(ch: any): Promise<void> {
|
|
return;
|
|
}
|
|
public async cleanup(ch: any): Promise<void> {
|
|
this.storage.delete(ch.token);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom error classes for better error handling
|
|
*/
|
|
export class Port80HandlerError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'Port80HandlerError';
|
|
}
|
|
}
|
|
|
|
export class CertificateError extends Port80HandlerError {
|
|
constructor(
|
|
message: string,
|
|
public readonly domain: string,
|
|
public readonly isRenewal: boolean = false
|
|
) {
|
|
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
|
|
this.name = 'CertificateError';
|
|
}
|
|
}
|
|
|
|
export class ServerError extends Port80HandlerError {
|
|
constructor(message: string, public readonly code?: string) {
|
|
super(message);
|
|
this.name = 'ServerError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Domain forwarding configuration
|
|
*/
|
|
export interface IForwardConfig {
|
|
ip: string;
|
|
port: number;
|
|
}
|
|
|
|
/**
|
|
* Domain configuration options
|
|
*/
|
|
export interface IDomainOptions {
|
|
domainName: string;
|
|
sslRedirect: boolean; // if true redirects the request to port 443
|
|
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
|
|
forward?: IForwardConfig; // forwards all http requests to that target
|
|
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
|
|
}
|
|
|
|
/**
|
|
* Represents a domain configuration with certificate status information
|
|
*/
|
|
interface IDomainCertificate {
|
|
options: IDomainOptions;
|
|
certObtained: boolean;
|
|
obtainingInProgress: boolean;
|
|
certificate?: string;
|
|
privateKey?: string;
|
|
expiryDate?: Date;
|
|
lastRenewalAttempt?: Date;
|
|
}
|
|
|
|
/**
|
|
* Configuration options for the Port80Handler
|
|
*/
|
|
interface IPort80HandlerOptions {
|
|
port?: number;
|
|
contactEmail?: string;
|
|
useProduction?: boolean;
|
|
httpsRedirectPort?: number;
|
|
enabled?: boolean; // Whether ACME is enabled at all
|
|
autoRenew?: boolean; // Whether to automatically renew certificates
|
|
certificateStore?: string; // Directory to store certificates
|
|
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
|
|
}
|
|
|
|
/**
|
|
* Certificate data that can be emitted via events or set from outside
|
|
*/
|
|
export interface ICertificateData {
|
|
domain: string;
|
|
certificate: string;
|
|
privateKey: string;
|
|
expiryDate: Date;
|
|
}
|
|
|
|
/**
|
|
* Events emitted by the Port80Handler
|
|
*/
|
|
export enum Port80HandlerEvents {
|
|
CERTIFICATE_ISSUED = 'certificate-issued',
|
|
CERTIFICATE_RENEWED = 'certificate-renewed',
|
|
CERTIFICATE_FAILED = 'certificate-failed',
|
|
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
|
MANAGER_STARTED = 'manager-started',
|
|
MANAGER_STOPPED = 'manager-stopped',
|
|
REQUEST_FORWARDED = 'request-forwarded',
|
|
}
|
|
|
|
/**
|
|
* Certificate failure payload type
|
|
*/
|
|
export interface ICertificateFailure {
|
|
domain: string;
|
|
error: string;
|
|
isRenewal: boolean;
|
|
}
|
|
|
|
/**
|
|
* Certificate expiry payload type
|
|
*/
|
|
export interface ICertificateExpiring {
|
|
domain: string;
|
|
expiryDate: Date;
|
|
daysRemaining: number;
|
|
}
|
|
|
|
/**
|
|
* Port80Handler with ACME certificate management and request forwarding capabilities
|
|
* Now with glob pattern support for domain matching
|
|
*/
|
|
export class Port80Handler extends plugins.EventEmitter {
|
|
private domainCertificates: Map<string, IDomainCertificate>;
|
|
// In-memory storage for ACME HTTP-01 challenge tokens
|
|
private acmeHttp01Storage: Map<string, string> = new Map();
|
|
// SmartAcme instance for certificate management
|
|
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
|
private server: plugins.http.Server | null = null;
|
|
// Renewal scheduling is handled externally by SmartProxy
|
|
// (Removed internal renewal timer)
|
|
private isShuttingDown: boolean = false;
|
|
private options: Required<IPort80HandlerOptions>;
|
|
|
|
/**
|
|
* Creates a new Port80Handler
|
|
* @param options Configuration options
|
|
*/
|
|
constructor(options: IPort80HandlerOptions = {}) {
|
|
super();
|
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
|
|
|
// Default options
|
|
this.options = {
|
|
port: options.port ?? 80,
|
|
contactEmail: options.contactEmail ?? 'admin@example.com',
|
|
useProduction: options.useProduction ?? false, // Safer default: staging
|
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
|
enabled: options.enabled ?? true, // Enable by default
|
|
autoRenew: options.autoRenew ?? true, // Auto-renew by default
|
|
certificateStore: options.certificateStore ?? './certs', // Default store location
|
|
skipConfiguredCerts: options.skipConfiguredCerts ?? false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Starts the HTTP server for ACME challenges
|
|
*/
|
|
public async start(): Promise<void> {
|
|
if (this.server) {
|
|
throw new ServerError('Server is already running');
|
|
}
|
|
|
|
if (this.isShuttingDown) {
|
|
throw new ServerError('Server is shutting down');
|
|
}
|
|
|
|
// Skip if disabled
|
|
if (this.options.enabled === false) {
|
|
console.log('Port80Handler is disabled, skipping start');
|
|
return;
|
|
}
|
|
// Initialize SmartAcme for ACME challenge management (diskless HTTP handler)
|
|
if (this.options.enabled) {
|
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
accountEmail: this.options.contactEmail,
|
|
certManager: new plugins.smartacme.MemoryCertManager(),
|
|
environment: this.options.useProduction ? 'production' : 'integration',
|
|
challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ],
|
|
challengePriority: ['http-01'],
|
|
});
|
|
await this.smartAcme.start();
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
// Load certificates from store if enabled
|
|
if (this.options.certificateStore) {
|
|
this.loadCertificatesFromStore();
|
|
}
|
|
|
|
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
|
|
|
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'EACCES') {
|
|
reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
|
|
} else if (error.code === 'EADDRINUSE') {
|
|
reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
|
|
} else {
|
|
reject(new ServerError(error.message, error.code));
|
|
}
|
|
});
|
|
|
|
this.server.listen(this.options.port, () => {
|
|
console.log(`Port80Handler is listening on port ${this.options.port}`);
|
|
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
|
|
|
|
// Start certificate process for domains with acmeMaintenance enabled
|
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
|
// Skip glob patterns for certificate issuance
|
|
if (this.isGlobPattern(domain)) {
|
|
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
|
|
continue;
|
|
}
|
|
|
|
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
|
|
this.obtainCertificate(domain).catch(err => {
|
|
console.error(`Error obtaining initial certificate for ${domain}:`, err);
|
|
});
|
|
}
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error starting server';
|
|
reject(new ServerError(message));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stops the HTTP server and renewal timer
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
if (!this.server) {
|
|
return;
|
|
}
|
|
|
|
this.isShuttingDown = true;
|
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
if (this.server) {
|
|
this.server.close(() => {
|
|
this.server = null;
|
|
this.isShuttingDown = false;
|
|
this.emit(Port80HandlerEvents.MANAGER_STOPPED);
|
|
resolve();
|
|
});
|
|
} else {
|
|
this.isShuttingDown = false;
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds a domain with configuration options
|
|
* @param options Domain configuration options
|
|
*/
|
|
public addDomain(options: IDomainOptions): void {
|
|
if (!options.domainName || typeof options.domainName !== 'string') {
|
|
throw new Port80HandlerError('Invalid domain name');
|
|
}
|
|
|
|
const domainName = options.domainName;
|
|
|
|
if (!this.domainCertificates.has(domainName)) {
|
|
this.domainCertificates.set(domainName, {
|
|
options,
|
|
certObtained: false,
|
|
obtainingInProgress: false
|
|
});
|
|
|
|
console.log(`Domain added: ${domainName} with configuration:`, {
|
|
sslRedirect: options.sslRedirect,
|
|
acmeMaintenance: options.acmeMaintenance,
|
|
hasForward: !!options.forward,
|
|
hasAcmeForward: !!options.acmeForward
|
|
});
|
|
|
|
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
|
|
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
|
|
this.obtainCertificate(domainName).catch(err => {
|
|
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
|
|
});
|
|
}
|
|
} else {
|
|
// Update existing domain with new options
|
|
const existing = this.domainCertificates.get(domainName)!;
|
|
existing.options = options;
|
|
console.log(`Domain ${domainName} configuration updated`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets a certificate for a domain directly (for externally obtained certificates)
|
|
* @param domain The domain for the certificate
|
|
* @param certificate The certificate (PEM format)
|
|
* @param privateKey The private key (PEM format)
|
|
* @param expiryDate Optional expiry date
|
|
*/
|
|
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
|
if (!domain || !certificate || !privateKey) {
|
|
throw new Port80HandlerError('Domain, certificate and privateKey are required');
|
|
}
|
|
|
|
// Don't allow setting certificates for glob patterns
|
|
if (this.isGlobPattern(domain)) {
|
|
throw new Port80HandlerError('Cannot set certificate for glob pattern domains');
|
|
}
|
|
|
|
let domainInfo = this.domainCertificates.get(domain);
|
|
|
|
if (!domainInfo) {
|
|
// Create default domain options if not already configured
|
|
const defaultOptions: IDomainOptions = {
|
|
domainName: domain,
|
|
sslRedirect: true,
|
|
acmeMaintenance: true
|
|
};
|
|
|
|
domainInfo = {
|
|
options: defaultOptions,
|
|
certObtained: false,
|
|
obtainingInProgress: false
|
|
};
|
|
this.domainCertificates.set(domain, domainInfo);
|
|
}
|
|
|
|
domainInfo.certificate = certificate;
|
|
domainInfo.privateKey = privateKey;
|
|
domainInfo.certObtained = true;
|
|
domainInfo.obtainingInProgress = false;
|
|
|
|
if (expiryDate) {
|
|
domainInfo.expiryDate = expiryDate;
|
|
} else {
|
|
// Extract expiry date from certificate
|
|
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
|
|
}
|
|
|
|
console.log(`Certificate set for ${domain}`);
|
|
|
|
// Save certificate to store if enabled
|
|
if (this.options.certificateStore) {
|
|
this.saveCertificateToStore(domain, certificate, privateKey);
|
|
}
|
|
|
|
// Emit certificate event
|
|
this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, {
|
|
domain,
|
|
certificate,
|
|
privateKey,
|
|
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the certificate for a domain if it exists
|
|
* @param domain The domain to get the certificate for
|
|
*/
|
|
public getCertificate(domain: string): ICertificateData | null {
|
|
// Can't get certificates for glob patterns
|
|
if (this.isGlobPattern(domain)) {
|
|
return null;
|
|
}
|
|
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
|
|
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
domain,
|
|
certificate: domainInfo.certificate,
|
|
privateKey: domainInfo.privateKey,
|
|
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Saves a certificate to the filesystem store
|
|
* @param domain The domain for the certificate
|
|
* @param certificate The certificate (PEM format)
|
|
* @param privateKey The private key (PEM format)
|
|
* @private
|
|
*/
|
|
private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void {
|
|
// Skip if certificate store is not enabled
|
|
if (!this.options.certificateStore) return;
|
|
|
|
try {
|
|
const storePath = this.options.certificateStore;
|
|
|
|
// Ensure the directory exists
|
|
if (!fs.existsSync(storePath)) {
|
|
fs.mkdirSync(storePath, { recursive: true });
|
|
console.log(`Created certificate store directory: ${storePath}`);
|
|
}
|
|
|
|
const certPath = path.join(storePath, `${domain}.cert.pem`);
|
|
const keyPath = path.join(storePath, `${domain}.key.pem`);
|
|
|
|
// Write certificate and private key files
|
|
fs.writeFileSync(certPath, certificate);
|
|
fs.writeFileSync(keyPath, privateKey);
|
|
|
|
// Set secure permissions for private key
|
|
try {
|
|
fs.chmodSync(keyPath, 0o600);
|
|
} catch (err) {
|
|
console.log(`Warning: Could not set secure permissions on ${keyPath}`);
|
|
}
|
|
|
|
console.log(`Saved certificate for ${domain} to ${certPath}`);
|
|
} catch (err) {
|
|
console.error(`Error saving certificate for ${domain}:`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads certificates from the certificate store
|
|
* @private
|
|
*/
|
|
private loadCertificatesFromStore(): void {
|
|
if (!this.options.certificateStore) return;
|
|
|
|
try {
|
|
const storePath = this.options.certificateStore;
|
|
|
|
// Ensure the directory exists
|
|
if (!fs.existsSync(storePath)) {
|
|
fs.mkdirSync(storePath, { recursive: true });
|
|
console.log(`Created certificate store directory: ${storePath}`);
|
|
return;
|
|
}
|
|
|
|
// Get list of certificate files
|
|
const files = fs.readdirSync(storePath);
|
|
const certFiles = files.filter(file => file.endsWith('.cert.pem'));
|
|
|
|
// Load each certificate
|
|
for (const certFile of certFiles) {
|
|
const domain = certFile.replace('.cert.pem', '');
|
|
const keyFile = `${domain}.key.pem`;
|
|
|
|
// Skip if key file doesn't exist
|
|
if (!files.includes(keyFile)) {
|
|
console.log(`Warning: Found certificate for ${domain} but no key file`);
|
|
continue;
|
|
}
|
|
|
|
// Skip if we should skip configured certs
|
|
if (this.options.skipConfiguredCerts) {
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
if (domainInfo && domainInfo.certObtained) {
|
|
console.log(`Skipping already configured certificate for ${domain}`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Load certificate and key
|
|
try {
|
|
const certificate = fs.readFileSync(path.join(storePath, certFile), 'utf8');
|
|
const privateKey = fs.readFileSync(path.join(storePath, keyFile), 'utf8');
|
|
|
|
// Extract expiry date
|
|
let expiryDate: Date | undefined;
|
|
try {
|
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
if (matches && matches[1]) {
|
|
expiryDate = new Date(matches[1]);
|
|
}
|
|
} catch (err) {
|
|
console.log(`Warning: Could not extract expiry date from certificate for ${domain}`);
|
|
}
|
|
|
|
// Check if domain is already registered
|
|
let domainInfo = this.domainCertificates.get(domain);
|
|
if (!domainInfo) {
|
|
// Register domain if not already registered
|
|
domainInfo = {
|
|
options: {
|
|
domainName: domain,
|
|
sslRedirect: true,
|
|
acmeMaintenance: true
|
|
},
|
|
certObtained: false,
|
|
obtainingInProgress: false
|
|
};
|
|
this.domainCertificates.set(domain, domainInfo);
|
|
}
|
|
|
|
// Set certificate
|
|
domainInfo.certificate = certificate;
|
|
domainInfo.privateKey = privateKey;
|
|
domainInfo.certObtained = true;
|
|
domainInfo.expiryDate = expiryDate;
|
|
|
|
console.log(`Loaded certificate for ${domain} from store, valid until ${expiryDate?.toISOString() || 'unknown'}`);
|
|
} catch (err) {
|
|
console.error(`Error loading certificate for ${domain}:`, err);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading certificates from store:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a domain is a glob pattern
|
|
* @param domain Domain to check
|
|
* @returns True if the domain is a glob pattern
|
|
*/
|
|
private isGlobPattern(domain: string): boolean {
|
|
return domain.includes('*');
|
|
}
|
|
|
|
/**
|
|
* Get domain info for a specific domain, using glob pattern matching if needed
|
|
* @param requestDomain The actual domain from the request
|
|
* @returns The domain info or null if not found
|
|
*/
|
|
private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
|
|
// Try direct match first
|
|
if (this.domainCertificates.has(requestDomain)) {
|
|
return {
|
|
domainInfo: this.domainCertificates.get(requestDomain)!,
|
|
pattern: requestDomain
|
|
};
|
|
}
|
|
|
|
// Then try glob patterns
|
|
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
|
|
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
|
|
return { domainInfo, pattern };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if a domain matches a glob pattern
|
|
* @param domain The domain to check
|
|
* @param pattern The pattern to match against
|
|
* @returns True if the domain matches the pattern
|
|
*/
|
|
private domainMatchesPattern(domain: string, pattern: string): boolean {
|
|
// Handle different glob pattern styles
|
|
if (pattern.startsWith('*.')) {
|
|
// *.example.com matches any subdomain
|
|
const suffix = pattern.substring(2);
|
|
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
|
|
} else if (pattern.endsWith('.*')) {
|
|
// example.* matches any TLD
|
|
const prefix = pattern.substring(0, pattern.length - 2);
|
|
const domainParts = domain.split('.');
|
|
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
|
|
} else if (pattern === '*') {
|
|
// Wildcard matches everything
|
|
return true;
|
|
} else {
|
|
// Exact match (shouldn't reach here as we check exact matches first)
|
|
return domain === pattern;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Handles incoming HTTP requests
|
|
* @param req The HTTP request
|
|
* @param res The HTTP response
|
|
*/
|
|
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.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];
|
|
|
|
// Get domain config, using glob pattern matching if needed
|
|
const domainMatch = this.getDomainInfoForRequest(domain);
|
|
|
|
if (!domainMatch) {
|
|
res.statusCode = 404;
|
|
res.end('Domain not configured');
|
|
return;
|
|
}
|
|
|
|
const { domainInfo, pattern } = domainMatch;
|
|
const options = domainInfo.options;
|
|
|
|
// Serve or forward ACME HTTP-01 challenge requests
|
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) {
|
|
// Forward ACME requests if configured
|
|
if (options.acmeForward) {
|
|
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
|
|
return;
|
|
}
|
|
// Serve challenge response from in-memory storage
|
|
const token = req.url.split('/').pop() || '';
|
|
const keyAuth = this.acmeHttp01Storage.get(token);
|
|
if (keyAuth) {
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.end(keyAuth);
|
|
console.log(`Served ACME challenge response for ${domain}`);
|
|
} else {
|
|
res.statusCode = 404;
|
|
res.end('Challenge token not found');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if we should forward non-ACME requests
|
|
if (options.forward) {
|
|
this.forwardRequest(req, res, options.forward, 'HTTP');
|
|
return;
|
|
}
|
|
|
|
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
|
|
// (Skip for glob patterns as they won't have certificates)
|
|
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
|
|
const httpsPort = this.options.httpsRedirectPort;
|
|
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
|
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
|
|
|
res.statusCode = 301;
|
|
res.setHeader('Location', redirectUrl);
|
|
res.end(`Redirecting to ${redirectUrl}`);
|
|
return;
|
|
}
|
|
|
|
// Handle case where certificate maintenance is enabled but not yet obtained
|
|
// (Skip for glob patterns as they can't have certificates)
|
|
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
|
|
// Trigger certificate issuance if not already running
|
|
if (!domainInfo.obtainingInProgress) {
|
|
this.obtainCertificate(domain).catch(err => {
|
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
|
domain,
|
|
error: errorMessage,
|
|
isRenewal: false
|
|
});
|
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
|
});
|
|
}
|
|
|
|
res.statusCode = 503;
|
|
res.end('Certificate issuance in progress, please try again later.');
|
|
return;
|
|
}
|
|
|
|
// Default response for unhandled request
|
|
res.statusCode = 404;
|
|
res.end('No handlers configured for this request');
|
|
}
|
|
|
|
/**
|
|
* Forwards an HTTP request to the specified target
|
|
* @param req The original request
|
|
* @param res The response object
|
|
* @param target The forwarding target (IP and port)
|
|
* @param requestType Type of request for logging
|
|
*/
|
|
private forwardRequest(
|
|
req: plugins.http.IncomingMessage,
|
|
res: plugins.http.ServerResponse,
|
|
target: IForwardConfig,
|
|
requestType: string
|
|
): void {
|
|
const options = {
|
|
hostname: target.ip,
|
|
port: target.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: { ...req.headers }
|
|
};
|
|
|
|
const domain = req.headers.host?.split(':')[0] || 'unknown';
|
|
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
|
|
|
|
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
|
// Copy status code
|
|
res.statusCode = proxyRes.statusCode || 500;
|
|
|
|
// Copy headers
|
|
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
if (value) res.setHeader(key, value);
|
|
}
|
|
|
|
// Pipe response data
|
|
proxyRes.pipe(res);
|
|
|
|
this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
|
|
domain,
|
|
requestType,
|
|
target: `${target.ip}:${target.port}`,
|
|
statusCode: proxyRes.statusCode
|
|
});
|
|
});
|
|
|
|
proxyReq.on('error', (error) => {
|
|
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
|
|
if (!res.headersSent) {
|
|
res.statusCode = 502;
|
|
res.end(`Proxy error: ${error.message}`);
|
|
} else {
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
// Pipe original request to proxy request
|
|
if (req.readable) {
|
|
req.pipe(proxyReq);
|
|
} else {
|
|
proxyReq.end();
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Obtains a certificate for a domain using ACME HTTP-01 challenge
|
|
* @param domain The domain to obtain a certificate for
|
|
* @param isRenewal Whether this is a renewal attempt
|
|
*/
|
|
/**
|
|
* Obtains a certificate for a domain using SmartAcme HTTP-01 challenges
|
|
* @param domain The domain to obtain a certificate for
|
|
* @param isRenewal Whether this is a renewal attempt
|
|
*/
|
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
|
if (this.isGlobPattern(domain)) {
|
|
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
|
|
}
|
|
const domainInfo = this.domainCertificates.get(domain)!;
|
|
if (!domainInfo.options.acmeMaintenance) {
|
|
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
|
|
return;
|
|
}
|
|
if (domainInfo.obtainingInProgress) {
|
|
console.log(`Certificate issuance already in progress for ${domain}`);
|
|
return;
|
|
}
|
|
if (!this.smartAcme) {
|
|
throw new Port80HandlerError('SmartAcme is not initialized');
|
|
}
|
|
domainInfo.obtainingInProgress = true;
|
|
domainInfo.lastRenewalAttempt = new Date();
|
|
try {
|
|
// Request certificate via SmartAcme
|
|
const certObj = await this.smartAcme.getCertificateForDomain(domain);
|
|
const certificate = certObj.publicKey;
|
|
const privateKey = certObj.privateKey;
|
|
const expiryDate = new Date(certObj.validUntil);
|
|
domainInfo.certificate = certificate;
|
|
domainInfo.privateKey = privateKey;
|
|
domainInfo.certObtained = true;
|
|
domainInfo.expiryDate = expiryDate;
|
|
|
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
|
if (this.options.certificateStore) {
|
|
this.saveCertificateToStore(domain, certificate, privateKey);
|
|
}
|
|
const eventType = isRenewal
|
|
? Port80HandlerEvents.CERTIFICATE_RENEWED
|
|
: Port80HandlerEvents.CERTIFICATE_ISSUED;
|
|
this.emitCertificateEvent(eventType, {
|
|
domain,
|
|
certificate,
|
|
privateKey,
|
|
expiryDate: expiryDate || this.getDefaultExpiryDate()
|
|
});
|
|
} catch (error: any) {
|
|
const errorMsg = error?.message || 'Unknown error';
|
|
console.error(`Error during certificate issuance for ${domain}:`, error);
|
|
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
|
|
domain,
|
|
error: errorMsg,
|
|
isRenewal
|
|
} as ICertificateFailure);
|
|
throw new CertificateError(errorMsg, domain, isRenewal);
|
|
} finally {
|
|
domainInfo.obtainingInProgress = false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Extract expiry date from certificate using a more robust approach
|
|
* @param certificate Certificate PEM string
|
|
* @param domain Domain for logging
|
|
* @returns Extracted expiry date or default
|
|
*/
|
|
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
|
|
try {
|
|
// This is still using regex, but in a real implementation you would use
|
|
// a library like node-forge or x509 to properly parse the certificate
|
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
if (matches && matches[1]) {
|
|
const expiryDate = new Date(matches[1]);
|
|
|
|
// Validate that we got a valid date
|
|
if (!isNaN(expiryDate.getTime())) {
|
|
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
|
|
return expiryDate;
|
|
}
|
|
}
|
|
|
|
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
|
|
return this.getDefaultExpiryDate();
|
|
} catch (error) {
|
|
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
|
|
return this.getDefaultExpiryDate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a default expiry date (90 days from now)
|
|
* @returns Default expiry date
|
|
*/
|
|
private getDefaultExpiryDate(): Date {
|
|
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
|
|
}
|
|
|
|
/**
|
|
* Emits a certificate event with the certificate data
|
|
* @param eventType The event type to emit
|
|
* @param data The certificate data
|
|
*/
|
|
private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
|
|
this.emit(eventType, data);
|
|
}
|
|
|
|
/**
|
|
* Gets all domains and their certificate status
|
|
* @returns Map of domains to certificate status
|
|
*/
|
|
public getDomainCertificateStatus(): Map<string, {
|
|
certObtained: boolean;
|
|
expiryDate?: Date;
|
|
daysRemaining?: number;
|
|
obtainingInProgress: boolean;
|
|
lastRenewalAttempt?: Date;
|
|
}> {
|
|
const result = new Map<string, {
|
|
certObtained: boolean;
|
|
expiryDate?: Date;
|
|
daysRemaining?: number;
|
|
obtainingInProgress: boolean;
|
|
lastRenewalAttempt?: Date;
|
|
}>();
|
|
|
|
const now = new Date();
|
|
|
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
|
// Skip glob patterns
|
|
if (this.isGlobPattern(domain)) continue;
|
|
|
|
const status: {
|
|
certObtained: boolean;
|
|
expiryDate?: Date;
|
|
daysRemaining?: number;
|
|
obtainingInProgress: boolean;
|
|
lastRenewalAttempt?: Date;
|
|
} = {
|
|
certObtained: domainInfo.certObtained,
|
|
expiryDate: domainInfo.expiryDate,
|
|
obtainingInProgress: domainInfo.obtainingInProgress,
|
|
lastRenewalAttempt: domainInfo.lastRenewalAttempt
|
|
};
|
|
|
|
// Calculate days remaining if expiry date is available
|
|
if (domainInfo.expiryDate) {
|
|
const daysRemaining = Math.ceil(
|
|
(domainInfo.expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
|
);
|
|
status.daysRemaining = daysRemaining;
|
|
}
|
|
|
|
result.set(domain, status);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Gets information about managed domains
|
|
* @returns Array of domain information
|
|
*/
|
|
public getManagedDomains(): Array<{
|
|
domain: string;
|
|
isGlobPattern: boolean;
|
|
hasCertificate: boolean;
|
|
hasForwarding: boolean;
|
|
sslRedirect: boolean;
|
|
acmeMaintenance: boolean;
|
|
}> {
|
|
return Array.from(this.domainCertificates.entries()).map(([domain, info]) => ({
|
|
domain,
|
|
isGlobPattern: this.isGlobPattern(domain),
|
|
hasCertificate: info.certObtained,
|
|
hasForwarding: !!info.options.forward,
|
|
sslRedirect: info.options.sslRedirect,
|
|
acmeMaintenance: info.options.acmeMaintenance
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Gets configuration details
|
|
* @returns Current configuration
|
|
*/
|
|
public getConfig(): Required<IPort80HandlerOptions> {
|
|
return { ...this.options };
|
|
}
|
|
|
|
/**
|
|
* Request a certificate renewal for a specific domain.
|
|
* @param domain The domain to renew.
|
|
*/
|
|
public async renewCertificate(domain: string): Promise<void> {
|
|
if (!this.domainCertificates.has(domain)) {
|
|
throw new Port80HandlerError(`Domain not managed: ${domain}`);
|
|
}
|
|
// Trigger renewal via ACME
|
|
await this.obtainCertificate(domain, true);
|
|
}
|
|
} |