2025-11-18 19:34:26 +00:00
|
|
|
/**
|
|
|
|
|
* SQLite-based Certificate Manager for SmartACME
|
|
|
|
|
*
|
|
|
|
|
* Implements ICertManager interface to store SSL certificates in SQLite database
|
|
|
|
|
* and write PEM files to filesystem for use by the reverse proxy.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import * as plugins from '../plugins.ts';
|
|
|
|
|
import { logger } from '../logging.ts';
|
2025-11-25 04:20:19 +00:00
|
|
|
import { getErrorMessage } from '../utils/error.ts';
|
2025-11-18 19:34:26 +00:00
|
|
|
import { OneboxDatabase } from './database.ts';
|
|
|
|
|
|
|
|
|
|
export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|
|
|
|
private database: OneboxDatabase;
|
|
|
|
|
private certBasePath: string;
|
|
|
|
|
|
|
|
|
|
constructor(database: OneboxDatabase, certBasePath = './.nogit/ssl/live') {
|
|
|
|
|
this.database = database;
|
|
|
|
|
this.certBasePath = certBasePath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize the certificate manager
|
|
|
|
|
*/
|
|
|
|
|
async init(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
// Ensure certificate directory exists
|
|
|
|
|
await Deno.mkdir(this.certBasePath, { recursive: true });
|
|
|
|
|
logger.info(`Certificate manager initialized (path: ${this.certBasePath})`);
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
logger.error(`Failed to initialize certificate manager: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retrieve a certificate by domain name
|
|
|
|
|
*/
|
|
|
|
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
|
|
|
|
try {
|
|
|
|
|
const dbCert = this.database.getSSLCertificate(domainName);
|
|
|
|
|
|
|
|
|
|
if (!dbCert) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert database format to SmartacmeCert format
|
|
|
|
|
const cert = new plugins.smartacme.Cert({
|
|
|
|
|
id: dbCert.id?.toString() || domainName,
|
|
|
|
|
domainName: dbCert.domain,
|
|
|
|
|
created: dbCert.createdAt,
|
|
|
|
|
privateKey: await this.readPemFile(dbCert.keyPath),
|
|
|
|
|
publicKey: await this.readPemFile(dbCert.fullChainPath), // Full chain as public key
|
|
|
|
|
csr: '', // CSR not stored separately
|
|
|
|
|
validUntil: dbCert.expiryDate,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return cert;
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
logger.warn(`Failed to retrieve certificate for ${domainName}: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Store a certificate
|
|
|
|
|
*/
|
|
|
|
|
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const domain = cert.domainName;
|
|
|
|
|
const domainPath = `${this.certBasePath}/${domain}`;
|
|
|
|
|
|
|
|
|
|
// Create domain-specific directory
|
|
|
|
|
await Deno.mkdir(domainPath, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Write PEM files
|
|
|
|
|
const keyPath = `${domainPath}/privkey.pem`;
|
|
|
|
|
const certPath = `${domainPath}/cert.pem`;
|
|
|
|
|
const fullChainPath = `${domainPath}/fullchain.pem`;
|
|
|
|
|
|
|
|
|
|
await Deno.writeTextFile(keyPath, cert.privateKey);
|
|
|
|
|
await Deno.writeTextFile(fullChainPath, cert.publicKey);
|
|
|
|
|
|
|
|
|
|
// Extract certificate from full chain (first certificate in the chain)
|
|
|
|
|
const certOnly = this.extractCertFromChain(cert.publicKey);
|
|
|
|
|
await Deno.writeTextFile(certPath, certOnly);
|
|
|
|
|
|
|
|
|
|
// Store/update in database
|
|
|
|
|
const existing = this.database.getSSLCertificate(domain);
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
this.database.updateSSLCertificate(domain, {
|
|
|
|
|
certPath,
|
|
|
|
|
keyPath,
|
|
|
|
|
fullChainPath,
|
|
|
|
|
expiryDate: cert.validUntil,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await this.database.createSSLCertificate({
|
|
|
|
|
domain,
|
|
|
|
|
certPath,
|
|
|
|
|
keyPath,
|
|
|
|
|
fullChainPath,
|
|
|
|
|
expiryDate: cert.validUntil,
|
|
|
|
|
issuer: 'Let\'s Encrypt',
|
|
|
|
|
createdAt: cert.created,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.success(`Certificate stored for ${domain}`);
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
logger.error(`Failed to store certificate for ${cert.domainName}: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a certificate
|
|
|
|
|
*/
|
|
|
|
|
async deleteCertificate(domainName: string): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const dbCert = this.database.getSSLCertificate(domainName);
|
|
|
|
|
|
|
|
|
|
if (dbCert) {
|
|
|
|
|
// Delete PEM files
|
|
|
|
|
const domainPath = `${this.certBasePath}/${domainName}`;
|
|
|
|
|
try {
|
|
|
|
|
await Deno.remove(domainPath, { recursive: true });
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
logger.warn(`Failed to delete PEM files for ${domainName}: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete from database
|
|
|
|
|
this.database.deleteSSLCertificate(domainName);
|
|
|
|
|
|
|
|
|
|
logger.info(`Certificate deleted for ${domainName}`);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
logger.error(`Failed to delete certificate for ${domainName}: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Close the certificate manager
|
|
|
|
|
*/
|
|
|
|
|
async close(): Promise<void> {
|
|
|
|
|
// SQLite database is managed by OneboxDatabase, nothing to close here
|
|
|
|
|
logger.info('Certificate manager closed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wipe all certificates (for testing)
|
|
|
|
|
*/
|
|
|
|
|
async wipe(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const certs = this.database.getAllSSLCertificates();
|
|
|
|
|
|
|
|
|
|
for (const cert of certs) {
|
|
|
|
|
await this.deleteCertificate(cert.domain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.warn('All certificates wiped');
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
logger.error(`Failed to wipe certificates: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read PEM file from filesystem
|
|
|
|
|
*/
|
|
|
|
|
private async readPemFile(path: string): Promise<string> {
|
|
|
|
|
try {
|
|
|
|
|
return await Deno.readTextFile(path);
|
|
|
|
|
} catch (error) {
|
2025-11-25 04:20:19 +00:00
|
|
|
throw new Error(`Failed to read PEM file ${path}: ${getErrorMessage(error)}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract the first certificate from a PEM chain
|
|
|
|
|
*/
|
|
|
|
|
private extractCertFromChain(fullChain: string): string {
|
|
|
|
|
const certMatch = fullChain.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/);
|
|
|
|
|
return certMatch ? certMatch[0] : fullChain;
|
|
|
|
|
}
|
|
|
|
|
}
|