234 lines
6.8 KiB
TypeScript
234 lines
6.8 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as plugins from '../../plugins.js';
|
|
import type { ICertificateData, ICertificates } 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: ICertificateData): 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<ICertificateData | 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, '_');
|
|
}
|
|
} |