update structure

This commit is contained in:
2025-05-09 17:00:27 +00:00
parent 4a72d9f3bf
commit f1c0b8bfb7
60 changed files with 4919 additions and 1400 deletions

View File

@ -1,7 +1,9 @@
import * as fs from 'fs';
import * as path from 'path';
import type { IAcmeOptions } from './types.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import type { AcmeOptions } from '../models/certificate-types.js';
import { ensureCertificateDirectory } from '../utils/certificate-helpers.js';
// We'll need to update this import when we move the Port80Handler
import { Port80Handler } from '../../port80handler/classes.port80handler.js';
/**
* Factory to create a Port80Handler with common setup.
@ -10,14 +12,37 @@ import { Port80Handler } from '../port80handler/classes.port80handler.js';
* @returns A new Port80Handler instance
*/
export function buildPort80Handler(
options: IAcmeOptions
options: AcmeOptions
): Port80Handler {
if (options.certificateStore) {
const certStorePath = path.resolve(options.certificateStore);
if (!fs.existsSync(certStorePath)) {
fs.mkdirSync(certStorePath, { recursive: true });
console.log(`Created certificate store directory: ${certStorePath}`);
}
ensureCertificateDirectory(options.certificateStore);
console.log(`Ensured certificate store directory: ${options.certificateStore}`);
}
return new Port80Handler(options);
}
/**
* Creates default ACME options with sensible defaults
* @param email Account email for ACME provider
* @param certificateStore Path to store certificates
* @param useProduction Whether to use production ACME servers
* @returns Configured ACME options
*/
export function createDefaultAcmeOptions(
email: string,
certificateStore: string,
useProduction: boolean = false
): AcmeOptions {
return {
accountEmail: email,
enabled: true,
port: 80,
useProduction,
httpsRedirectPort: 443,
renewThresholdDays: 30,
renewCheckIntervalHours: 24,
autoRenew: true,
certificateStore,
skipConfiguredCerts: false
};
}

View File

@ -0,0 +1,110 @@
import * as plugins from '../../plugins.js';
import type { AcmeOptions, CertificateData } from '../models/certificate-types.js';
import { CertificateEvents } from '../events/certificate-events.js';
/**
* Manages ACME challenges and certificate validation
*/
export class AcmeChallengeHandler extends plugins.EventEmitter {
private options: AcmeOptions;
private client: any; // ACME client from plugins
private pendingChallenges: Map<string, any>;
/**
* Creates a new ACME challenge handler
* @param options ACME configuration options
*/
constructor(options: AcmeOptions) {
super();
this.options = options;
this.pendingChallenges = new Map();
// Initialize ACME client if needed
// This is just a placeholder implementation since we don't use the actual
// client directly in this implementation - it's handled by Port80Handler
this.client = null;
console.log('Created challenge handler with options:',
options.accountEmail,
options.useProduction ? 'production' : 'staging'
);
}
/**
* Gets or creates the ACME account key
*/
private getAccountKey(): Buffer {
// Implementation details would depend on plugin requirements
// This is a simplified version
if (!this.options.certificateStore) {
throw new Error('Certificate store is required for ACME challenges');
}
// This is just a placeholder - actual implementation would check for
// existing account key and create one if needed
return Buffer.from('account-key-placeholder');
}
/**
* Validates a domain using HTTP-01 challenge
* @param domain Domain to validate
* @param challengeToken ACME challenge token
* @param keyAuthorization Key authorization for the challenge
*/
public async handleHttpChallenge(
domain: string,
challengeToken: string,
keyAuthorization: string
): Promise<void> {
// Store challenge for response
this.pendingChallenges.set(challengeToken, keyAuthorization);
try {
// Wait for challenge validation - this would normally be handled by the ACME client
await new Promise(resolve => setTimeout(resolve, 1000));
this.emit(CertificateEvents.CERTIFICATE_ISSUED, {
domain,
success: true
});
} catch (error) {
this.emit(CertificateEvents.CERTIFICATE_FAILED, {
domain,
error: error instanceof Error ? error.message : String(error),
isRenewal: false
});
throw error;
} finally {
// Clean up the challenge
this.pendingChallenges.delete(challengeToken);
}
}
/**
* Responds to an HTTP-01 challenge request
* @param token Challenge token from the request path
* @returns The key authorization if found
*/
public getChallengeResponse(token: string): string | null {
return this.pendingChallenges.get(token) || null;
}
/**
* Checks if a request path is an ACME challenge
* @param path Request path
* @returns True if this is an ACME challenge request
*/
public isAcmeChallenge(path: string): boolean {
return path.startsWith('/.well-known/acme-challenge/');
}
/**
* Extracts the challenge token from an ACME challenge path
* @param path Request path
* @returns The challenge token if valid
*/
public extractChallengeToken(path: string): string | null {
if (!this.isAcmeChallenge(path)) return null;
const parts = path.split('/');
return parts[parts.length - 1] || null;
}
}

View File

@ -0,0 +1,3 @@
/**
* ACME certificate provisioning
*/

View File

@ -0,0 +1,32 @@
/**
* Certificate-related events emitted by certificate management components
*/
export enum CertificateEvents {
CERTIFICATE_ISSUED = 'certificate-issued',
CERTIFICATE_RENEWED = 'certificate-renewed',
CERTIFICATE_FAILED = 'certificate-failed',
CERTIFICATE_EXPIRING = 'certificate-expiring',
CERTIFICATE_APPLIED = 'certificate-applied',
}
/**
* Port80Handler-specific events including certificate-related ones
*/
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 provider events
*/
export enum CertProvisionerEvents {
CERTIFICATE_ISSUED = 'certificate',
CERTIFICATE_RENEWED = 'certificate',
CERTIFICATE_FAILED = 'certificate-failed'
}

67
ts/certificate/index.ts Normal file
View File

@ -0,0 +1,67 @@
/**
* Certificate management module for SmartProxy
* Provides certificate provisioning, storage, and management capabilities
*/
// Certificate types and models
export * from './models/certificate-types.js';
// Certificate events
export * from './events/certificate-events.js';
// Certificate providers
export * from './providers/cert-provisioner.js';
// ACME related exports
export * from './acme/acme-factory.js';
export * from './acme/challenge-handler.js';
// Certificate utilities
export * from './utils/certificate-helpers.js';
// Certificate storage
export * from './storage/file-storage.js';
// Convenience function to create a certificate provisioner with common settings
import { CertProvisioner } from './providers/cert-provisioner.js';
import { buildPort80Handler } from './acme/acme-factory.js';
import type { AcmeOptions, DomainForwardConfig } from './models/certificate-types.js';
import type { DomainConfig } from '../forwarding/config/domain-config.js';
/**
* Creates a complete certificate provisioning system with default settings
* @param domainConfigs Domain configurations
* @param acmeOptions ACME options for certificate provisioning
* @param networkProxyBridge Bridge to apply certificates to network proxy
* @param certProvider Optional custom certificate provider
* @returns Configured CertProvisioner
*/
export function createCertificateProvisioner(
domainConfigs: DomainConfig[],
acmeOptions: AcmeOptions,
networkProxyBridge: any, // Placeholder until NetworkProxyBridge is migrated
certProvider?: any // Placeholder until cert provider type is properly defined
): CertProvisioner {
// Build the Port80Handler for ACME challenges
const port80Handler = buildPort80Handler(acmeOptions);
// Extract ACME-specific configuration
const {
renewThresholdDays = 30,
renewCheckIntervalHours = 24,
autoRenew = true,
domainForwards = []
} = acmeOptions;
// Create and return the certificate provisioner
return new CertProvisioner(
domainConfigs,
port80Handler,
networkProxyBridge,
certProvider,
renewThresholdDays,
renewCheckIntervalHours,
autoRenew,
domainForwards
);
}

View File

@ -0,0 +1,97 @@
import * as plugins from '../../plugins.js';
/**
* Certificate data structure containing all necessary information
* about a certificate
*/
export interface CertificateData {
domain: string;
certificate: string;
privateKey: string;
expiryDate: Date;
// Optional source and renewal information for event emissions
source?: 'static' | 'http01' | 'dns01';
isRenewal?: boolean;
}
/**
* Certificates pair (private and public keys)
*/
export interface Certificates {
privateKey: string;
publicKey: string;
}
/**
* Certificate failure payload type
*/
export interface CertificateFailure {
domain: string;
error: string;
isRenewal: boolean;
}
/**
* Certificate expiry payload type
*/
export interface CertificateExpiring {
domain: string;
expiryDate: Date;
daysRemaining: number;
}
/**
* Domain forwarding configuration
*/
export interface ForwardConfig {
ip: string;
port: number;
}
/**
* Domain-specific forwarding configuration for ACME challenges
*/
export interface DomainForwardConfig {
domain: string;
forwardConfig?: ForwardConfig;
acmeForwardConfig?: ForwardConfig;
sslRedirect?: boolean;
}
/**
* Domain configuration options
*/
export interface DomainOptions {
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?: ForwardConfig; // forwards all http requests to that target
acmeForward?: ForwardConfig; // forwards letsencrypt requests to this config
}
/**
* Unified ACME configuration options used across proxies and handlers
*/
export interface AcmeOptions {
accountEmail?: string; // Email for Let's Encrypt account
enabled?: boolean; // Whether ACME is enabled
port?: number; // Port to listen on for ACME challenges (default: 80)
useProduction?: boolean; // Use production environment (default: staging)
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
renewThresholdDays?: number; // Days before expiry to renew certificates
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
domainForwards?: DomainForwardConfig[]; // Domain-specific forwarding configs
}
// Backwards compatibility interfaces
export interface ICertificates extends Certificates {}
export interface ICertificateData extends CertificateData {}
export interface ICertificateFailure extends CertificateFailure {}
export interface ICertificateExpiring extends CertificateExpiring {}
export interface IForwardConfig extends ForwardConfig {}
export interface IDomainForwardConfig extends DomainForwardConfig {}
export interface IDomainOptions extends DomainOptions {}
export interface IAcmeOptions extends AcmeOptions {}

View File

@ -1,27 +1,40 @@
import * as plugins from '../plugins.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { ICertificateData } from '../common/types.js';
import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
import * as plugins from '../../plugins.js';
import type { DomainConfig } from '../../forwarding/config/domain-config.js';
import type { CertificateData, DomainForwardConfig, DomainOptions } from '../models/certificate-types.js';
import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js';
import { Port80Handler } from '../../port80handler/classes.port80handler.js';
// We need to define this interface until we migrate NetworkProxyBridge
interface NetworkProxyBridge {
applyExternalCertificate(certData: CertificateData): void;
}
// This will be imported after NetworkProxyBridge is migrated
// import type { NetworkProxyBridge } from '../../proxies/smart-proxy/network-proxy-bridge.js';
// For backward compatibility
export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
/**
* Type for static certificate provisioning
*/
export type CertProvisionObject = plugins.tsclass.network.ICert | 'http01' | 'dns01';
/**
* CertProvisioner manages certificate provisioning and renewal workflows,
* unifying static certificates and HTTP-01 challenges via Port80Handler.
*/
export class CertProvisioner extends plugins.EventEmitter {
private domainConfigs: IDomainConfig[];
private domainConfigs: DomainConfig[];
private port80Handler: Port80Handler;
private networkProxyBridge: NetworkProxyBridge;
private certProvisionFunction?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>;
private certProvisionFunction?: (domain: string) => Promise<CertProvisionObject>;
private forwardConfigs: DomainForwardConfig[];
private renewThresholdDays: number;
private renewCheckIntervalHours: number;
private autoRenew: boolean;
private renewManager?: plugins.taskbuffer.TaskManager;
// Track provisioning type per domain: 'http01' or 'static'
private provisionMap: Map<string, 'http01' | 'static'>;
// Track provisioning type per domain
private provisionMap: Map<string, 'http01' | 'dns01' | 'static'>;
/**
* @param domainConfigs Array of domain configuration objects
@ -31,16 +44,17 @@ export class CertProvisioner extends plugins.EventEmitter {
* @param renewThresholdDays Days before expiry to trigger renewals
* @param renewCheckIntervalHours Interval in hours to check for renewals
* @param autoRenew Whether to automatically schedule renewals
* @param forwardConfigs Domain forwarding configurations for ACME challenges
*/
constructor(
domainConfigs: IDomainConfig[],
domainConfigs: DomainConfig[],
port80Handler: Port80Handler,
networkProxyBridge: NetworkProxyBridge,
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>,
certProvider?: (domain: string) => Promise<CertProvisionObject>,
renewThresholdDays: number = 30,
renewCheckIntervalHours: number = 24,
autoRenew: boolean = true,
forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = []
forwardConfigs: DomainForwardConfig[] = []
) {
super();
this.domainConfigs = domainConfigs;
@ -59,99 +73,180 @@ export class CertProvisioner extends plugins.EventEmitter {
*/
public async start(): Promise<void> {
// Subscribe to Port80Handler certificate events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: false });
},
onCertificateRenewed: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: true });
}
});
this.setupEventSubscriptions();
// Apply external forwarding for ACME challenges
this.setupForwardingConfigs();
// Apply external forwarding for ACME challenges (e.g. Synology)
for (const f of this.forwardConfigs) {
this.port80Handler.addDomain({
domainName: f.domain,
sslRedirect: f.sslRedirect,
acmeMaintenance: false,
forward: f.forwardConfig,
acmeForward: f.acmeForwardConfig
});
}
// Initial provisioning for all domains
const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
for (const domain of domains) {
const isWildcard = domain.includes('*');
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
if (this.certProvisionFunction) {
try {
provision = await this.certProvisionFunction(domain);
} catch (err) {
console.error(`certProvider error for ${domain}:`, err);
}
} else if (isWildcard) {
// No certProvider: cannot handle wildcard without DNS-01 support
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
continue;
}
if (provision === 'http01') {
if (isWildcard) {
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
continue;
}
this.provisionMap.set(domain, 'http01');
this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true });
} else {
// Static certificate (e.g., DNS-01 provisioned or user-provided) supports wildcard domains
this.provisionMap.set(domain, 'static');
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
}
}
await this.provisionAllDomains();
// Schedule renewals if enabled
if (this.autoRenew) {
this.renewManager = new plugins.taskbuffer.TaskManager();
const renewTask = new plugins.taskbuffer.Task({
name: 'CertificateRenewals',
taskFunction: async () => {
for (const [domain, type] of this.provisionMap.entries()) {
// Skip wildcard domains
if (domain.includes('*')) continue;
try {
if (type === 'http01') {
await this.port80Handler.renewCertificate(domain);
} else if (type === 'static' && this.certProvisionFunction) {
const provision2 = await this.certProvisionFunction(domain);
if (provision2 !== 'http01') {
const certObj = provision2 as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: true });
}
}
} catch (err) {
console.error(`Renewal error for ${domain}:`, err);
}
}
}
this.scheduleRenewals();
}
}
/**
* Set up event subscriptions for certificate events
*/
private setupEventSubscriptions(): void {
// We need to reimplement subscribeToPort80Handler here
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: CertificateData) => {
this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, { ...data, source: 'http01', isRenewal: false });
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: CertificateData) => {
this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, { ...data, source: 'http01', isRenewal: true });
});
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => {
this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error);
});
}
/**
* Set up forwarding configurations for the Port80Handler
*/
private setupForwardingConfigs(): void {
for (const config of this.forwardConfigs) {
const domainOptions: DomainOptions = {
domainName: config.domain,
sslRedirect: config.sslRedirect || false,
acmeMaintenance: false,
forward: config.forwardConfig,
acmeForward: config.acmeForwardConfig
};
this.port80Handler.addDomain(domainOptions);
}
}
/**
* Provision certificates for all configured domains
*/
private async provisionAllDomains(): Promise<void> {
const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
for (const domain of domains) {
await this.provisionDomain(domain);
}
}
/**
* Provision a certificate for a single domain
* @param domain Domain to provision
*/
private async provisionDomain(domain: string): Promise<void> {
const isWildcard = domain.includes('*');
let provision: CertProvisionObject = 'http01';
// Try to get a certificate from the provision function
if (this.certProvisionFunction) {
try {
provision = await this.certProvisionFunction(domain);
} catch (err) {
console.error(`certProvider error for ${domain}:`, err);
}
} else if (isWildcard) {
// No certProvider: cannot handle wildcard without DNS-01 support
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
return;
}
// Handle different provisioning methods
if (provision === 'http01') {
if (isWildcard) {
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
return;
}
this.provisionMap.set(domain, 'http01');
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
const hours = this.renewCheckIntervalHours;
const cronExpr = `0 0 */${hours} * * *`;
this.renewManager.addAndScheduleTask(renewTask, cronExpr);
this.renewManager.start();
} else if (provision === 'dns01') {
// DNS-01 challenges would be handled by the certProvisionFunction
this.provisionMap.set(domain, 'dns01');
// DNS-01 handling would go here if implemented
} else {
// Static certificate (e.g., DNS-01 provisioned or user-provided)
this.provisionMap.set(domain, 'static');
const certObj = provision as plugins.tsclass.network.ICert;
const certData: CertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil),
source: 'static',
isRenewal: false
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData);
}
}
/**
* Schedule certificate renewals using a task manager
*/
private scheduleRenewals(): void {
this.renewManager = new plugins.taskbuffer.TaskManager();
const renewTask = new plugins.taskbuffer.Task({
name: 'CertificateRenewals',
taskFunction: async () => await this.performRenewals()
});
const hours = this.renewCheckIntervalHours;
const cronExpr = `0 0 */${hours} * * *`;
this.renewManager.addAndScheduleTask(renewTask, cronExpr);
this.renewManager.start();
}
/**
* Perform renewals for all domains that need it
*/
private async performRenewals(): Promise<void> {
for (const [domain, type] of this.provisionMap.entries()) {
// Skip wildcard domains for HTTP-01 challenges
if (domain.includes('*') && type === 'http01') continue;
try {
await this.renewDomain(domain, type);
} catch (err) {
console.error(`Renewal error for ${domain}:`, err);
}
}
}
/**
* Renew a certificate for a specific domain
* @param domain Domain to renew
* @param provisionType Type of provisioning for this domain
*/
private async renewDomain(domain: string, provisionType: 'http01' | 'dns01' | 'static'): Promise<void> {
if (provisionType === 'http01') {
await this.port80Handler.renewCertificate(domain);
} else if ((provisionType === 'static' || provisionType === 'dns01') && this.certProvisionFunction) {
const provision = await this.certProvisionFunction(domain);
if (provision !== 'http01' && provision !== 'dns01') {
const certObj = provision as plugins.tsclass.network.ICert;
const certData: CertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil),
source: 'static',
isRenewal: true
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, certData);
}
}
}
@ -159,7 +254,6 @@ export class CertProvisioner extends plugins.EventEmitter {
* Stop all scheduled renewal tasks.
*/
public async stop(): Promise<void> {
// Stop scheduled renewals
if (this.renewManager) {
this.renewManager.stop();
}
@ -171,30 +265,62 @@ export class CertProvisioner extends plugins.EventEmitter {
*/
public async requestCertificate(domain: string): Promise<void> {
const isWildcard = domain.includes('*');
// Determine provisioning method
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
let provision: CertProvisionObject = 'http01';
if (this.certProvisionFunction) {
provision = await this.certProvisionFunction(domain);
} else if (isWildcard) {
// Cannot perform HTTP-01 on wildcard without certProvider
throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`);
}
if (provision === 'http01') {
if (isWildcard) {
throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`);
}
await this.port80Handler.renewCertificate(domain);
} else if (provision === 'dns01') {
// DNS-01 challenges would be handled by external mechanisms
// This is a placeholder for future implementation
console.log(`DNS-01 challenge requested for ${domain}`);
} else {
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
const certData: CertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
expiryDate: new Date(certObj.validUntil),
source: 'static',
isRenewal: false
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData);
}
}
}
/**
* Add a new domain for certificate provisioning
* @param domain Domain to add
* @param options Domain configuration options
*/
public async addDomain(domain: string, options?: {
sslRedirect?: boolean;
acmeMaintenance?: boolean;
}): Promise<void> {
const domainOptions: DomainOptions = {
domainName: domain,
sslRedirect: options?.sslRedirect || true,
acmeMaintenance: options?.acmeMaintenance || true
};
this.port80Handler.addDomain(domainOptions);
await this.provisionDomain(domain);
}
}
// For backward compatibility
export { CertProvisioner as CertificateProvisioner }

View File

@ -0,0 +1,3 @@
/**
* Certificate providers
*/

View File

@ -0,0 +1,234 @@
import * as fs from 'fs';
import * as path from 'path';
import * as plugins from '../../plugins.js';
import type { CertificateData, Certificates } from '../models/certificate-types.js';
import { ensureCertificateDirectory } from '../utils/certificate-helpers.js';
/**
* FileStorage provides file system storage for certificates
*/
export class FileStorage {
private storageDir: string;
/**
* Creates a new file storage provider
* @param storageDir Directory to store certificates
*/
constructor(storageDir: string) {
this.storageDir = path.resolve(storageDir);
ensureCertificateDirectory(this.storageDir);
}
/**
* Save a certificate to the file system
* @param domain Domain name
* @param certData Certificate data to save
*/
public async saveCertificate(domain: string, certData: CertificateData): Promise<void> {
const sanitizedDomain = this.sanitizeDomain(domain);
const certDir = path.join(this.storageDir, sanitizedDomain);
ensureCertificateDirectory(certDir);
const certPath = path.join(certDir, 'fullchain.pem');
const keyPath = path.join(certDir, 'privkey.pem');
const metaPath = path.join(certDir, 'metadata.json');
// Write certificate and private key
await fs.promises.writeFile(certPath, certData.certificate, 'utf8');
await fs.promises.writeFile(keyPath, certData.privateKey, 'utf8');
// Write metadata
const metadata = {
domain: certData.domain,
expiryDate: certData.expiryDate.toISOString(),
source: certData.source || 'unknown',
issuedAt: new Date().toISOString()
};
await fs.promises.writeFile(
metaPath,
JSON.stringify(metadata, null, 2),
'utf8'
);
}
/**
* Load a certificate from the file system
* @param domain Domain name
* @returns Certificate data if found, null otherwise
*/
public async loadCertificate(domain: string): Promise<CertificateData | null> {
const sanitizedDomain = this.sanitizeDomain(domain);
const certDir = path.join(this.storageDir, sanitizedDomain);
if (!fs.existsSync(certDir)) {
return null;
}
const certPath = path.join(certDir, 'fullchain.pem');
const keyPath = path.join(certDir, 'privkey.pem');
const metaPath = path.join(certDir, 'metadata.json');
try {
// Check if all required files exist
if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
return null;
}
// Read certificate and private key
const certificate = await fs.promises.readFile(certPath, 'utf8');
const privateKey = await fs.promises.readFile(keyPath, 'utf8');
// Try to read metadata if available
let expiryDate = new Date();
let source: 'static' | 'http01' | 'dns01' | undefined;
if (fs.existsSync(metaPath)) {
const metaContent = await fs.promises.readFile(metaPath, 'utf8');
const metadata = JSON.parse(metaContent);
if (metadata.expiryDate) {
expiryDate = new Date(metadata.expiryDate);
}
if (metadata.source) {
source = metadata.source as 'static' | 'http01' | 'dns01';
}
}
return {
domain,
certificate,
privateKey,
expiryDate,
source
};
} catch (error) {
console.error(`Error loading certificate for ${domain}:`, error);
return null;
}
}
/**
* Delete a certificate from the file system
* @param domain Domain name
*/
public async deleteCertificate(domain: string): Promise<boolean> {
const sanitizedDomain = this.sanitizeDomain(domain);
const certDir = path.join(this.storageDir, sanitizedDomain);
if (!fs.existsSync(certDir)) {
return false;
}
try {
// Recursively delete the certificate directory
await this.deleteDirectory(certDir);
return true;
} catch (error) {
console.error(`Error deleting certificate for ${domain}:`, error);
return false;
}
}
/**
* List all domains with stored certificates
* @returns Array of domain names
*/
public async listCertificates(): Promise<string[]> {
try {
const entries = await fs.promises.readdir(this.storageDir, { withFileTypes: true });
return entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
} catch (error) {
console.error('Error listing certificates:', error);
return [];
}
}
/**
* Check if a certificate is expiring soon
* @param domain Domain name
* @param thresholdDays Days threshold to consider expiring
* @returns Information about expiring certificate or null
*/
public async isExpiringSoon(
domain: string,
thresholdDays: number = 30
): Promise<{ domain: string; expiryDate: Date; daysRemaining: number } | null> {
const certData = await this.loadCertificate(domain);
if (!certData) {
return null;
}
const now = new Date();
const expiryDate = certData.expiryDate;
const timeRemaining = expiryDate.getTime() - now.getTime();
const daysRemaining = Math.floor(timeRemaining / (1000 * 60 * 60 * 24));
if (daysRemaining <= thresholdDays) {
return {
domain,
expiryDate,
daysRemaining
};
}
return null;
}
/**
* Check all certificates for expiration
* @param thresholdDays Days threshold to consider expiring
* @returns List of expiring certificates
*/
public async getExpiringCertificates(
thresholdDays: number = 30
): Promise<Array<{ domain: string; expiryDate: Date; daysRemaining: number }>> {
const domains = await this.listCertificates();
const expiringCerts = [];
for (const domain of domains) {
const expiring = await this.isExpiringSoon(domain, thresholdDays);
if (expiring) {
expiringCerts.push(expiring);
}
}
return expiringCerts;
}
/**
* Delete a directory recursively
* @param directoryPath Directory to delete
*/
private async deleteDirectory(directoryPath: string): Promise<void> {
if (fs.existsSync(directoryPath)) {
const entries = await fs.promises.readdir(directoryPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
await this.deleteDirectory(fullPath);
} else {
await fs.promises.unlink(fullPath);
}
}
await fs.promises.rmdir(directoryPath);
}
}
/**
* Sanitize a domain name for use as a directory name
* @param domain Domain name
* @returns Sanitized domain name
*/
private sanitizeDomain(domain: string): string {
// Replace wildcard and any invalid filesystem characters
return domain.replace(/\*/g, '_wildcard_').replace(/[/\\:*?"<>|]/g, '_');
}
}

View File

@ -0,0 +1,3 @@
/**
* Certificate storage mechanisms
*/

View File

@ -1,17 +1,18 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { Certificates } from '../models/certificate-types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface ICertificates {
privateKey: string;
publicKey: string;
}
export function loadDefaultCertificates(): ICertificates {
/**
* Loads the default SSL certificates from the assets directory
* @returns The certificate key pair
*/
export function loadDefaultCertificates(): Certificates {
try {
const certPath = path.join(__dirname, '..', 'assets', 'certs');
// Need to adjust path from /ts/certificate/utils to /assets/certs
const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs');
const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8');
const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8');
@ -28,3 +29,22 @@ export function loadDefaultCertificates(): ICertificates {
throw error;
}
}
/**
* Checks if a certificate file exists at the specified path
* @param certPath Path to check for certificate
* @returns True if the certificate exists, false otherwise
*/
export function certificateExists(certPath: string): boolean {
return fs.existsSync(certPath);
}
/**
* Ensures the certificate directory exists
* @param dirPath Path to the certificate directory
*/
export function ensureCertificateDirectory(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}