fix: update version to 0.5.0, add support for PEM and PFX formats, and implement certificate conversion functionality
This commit is contained in:
+15
@@ -34,6 +34,7 @@ function applyOverrides(options: Record<string, unknown>): void {
|
||||
if (options['renewalThreshold']) process.env['ACME_RENEWAL_THRESHOLD_DAYS'] = String(options['renewalThreshold']);
|
||||
if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']);
|
||||
if (options['http']) process.env['ACME_HTTP_PORT'] = String(options['http']);
|
||||
if (options['pem']) process.env['ACME_CERT_FORMAT'] = 'pem';
|
||||
if (options['keyvaultName'] && !options['keyvaultUrl'])
|
||||
process.env['ACME_KEYVAULT_URL'] = `https://${options['keyvaultName']}.vault.azure.net`;
|
||||
}
|
||||
@@ -59,6 +60,7 @@ sharedOptions(
|
||||
.command('run', { isDefault: true })
|
||||
.description('Scan DNS zones and issue or renew certificates')
|
||||
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
|
||||
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
|
||||
.option('--dry-run', 'Show what would be done without making changes')
|
||||
).action(async (options: Record<string, unknown>) => {
|
||||
applyOverrides(options);
|
||||
@@ -120,6 +122,7 @@ sharedOptions(
|
||||
.command('renew <domain>')
|
||||
.description('Force-renew a certificate for a specific domain, bypassing the renewal threshold')
|
||||
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
|
||||
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
|
||||
).action(async (domain: string, options: Record<string, unknown>) => {
|
||||
applyOverrides(options);
|
||||
const config = loadConfig();
|
||||
@@ -191,6 +194,18 @@ sharedOptions(
|
||||
}
|
||||
});
|
||||
|
||||
sharedOptions(
|
||||
program
|
||||
.command('convert <domain>')
|
||||
.description('Convert a stored certificate between PFX (PKCS#12) and PEM format')
|
||||
.option('--pem', 'Convert to PEM bundle instead of PFX (PKCS#12)')
|
||||
).action(async (domain: string, options: Record<string, unknown>) => {
|
||||
applyOverrides(options);
|
||||
const config = loadConfig();
|
||||
const provisioner = new Provisioner(config);
|
||||
await provisioner.convert(domain, config.pfx ? 'pfx' : 'pem');
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Config {
|
||||
dnsChallengeTtl: number;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
httpChallengePort?: number;
|
||||
pfx: boolean;
|
||||
}
|
||||
|
||||
export class ConfigError extends Error {
|
||||
@@ -65,5 +66,6 @@ export function loadConfig(): Config {
|
||||
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
|
||||
logLevel: logLevel as Config['logLevel'],
|
||||
httpChallengePort: optionalEnvInt('ACME_HTTP_PORT', 0) || undefined,
|
||||
pfx: optionalEnv('ACME_CERT_FORMAT', 'pfx') !== 'pem',
|
||||
};
|
||||
}
|
||||
|
||||
+4
-3
@@ -47,15 +47,16 @@ export class KeyVaultStore {
|
||||
return expiresOn.getTime() - Date.now() <= thresholdMs;
|
||||
}
|
||||
|
||||
async importCertificate(name: string, pemBundle: string): Promise<void> {
|
||||
async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem'): Promise<void> {
|
||||
const options: ImportCertificateOptions = {
|
||||
policy: {
|
||||
contentType: 'application/x-pem-file',
|
||||
contentType: format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file',
|
||||
issuerName: 'Unknown',
|
||||
subject: 'CN=unknown',
|
||||
},
|
||||
};
|
||||
await this.certClient.importCertificate(name, Buffer.from(pemBundle), options);
|
||||
const certBuffer = typeof cert === 'string' ? Buffer.from(cert) : cert;
|
||||
await this.certClient.importCertificate(name, certBuffer, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as forge from 'node-forge';
|
||||
|
||||
export interface PemBundle {
|
||||
privateKeyPem: string;
|
||||
certPem: string;
|
||||
chainPem: string;
|
||||
}
|
||||
|
||||
export function pemToPfx(privateKeyPem: string, certPem: string, chainPem: string, password = ''): Buffer {
|
||||
const key = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
const cert = forge.pki.certificateFromPem(certPem);
|
||||
const chain = chainPem
|
||||
.split(/(?=-----BEGIN CERTIFICATE-----)/)
|
||||
.filter(Boolean)
|
||||
.map(p => forge.pki.certificateFromPem(p));
|
||||
|
||||
const p12 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chain], password, { algorithm: '3des' });
|
||||
const der = forge.asn1.toDer(p12).getBytes();
|
||||
return Buffer.from(der, 'binary');
|
||||
}
|
||||
|
||||
export function pfxToPem(pfxBuffer: Buffer, password = ''): PemBundle {
|
||||
const p12Asn1 = forge.asn1.fromDer(forge.util.createBuffer(pfxBuffer.toString('binary')));
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
|
||||
|
||||
const keyBags = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
|
||||
const keyBag = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]?.[0];
|
||||
const allCertBags = certBags[forge.pki.oids.certBag] ?? [];
|
||||
|
||||
if (!keyBag?.key) throw new Error('No private key found in PFX');
|
||||
if (allCertBags.length === 0) throw new Error('No certificates found in PFX');
|
||||
|
||||
const [first, ...rest] = allCertBags.map(bag => forge.pki.certificateToPem(bag.cert!));
|
||||
return {
|
||||
privateKeyPem: forge.pki.privateKeyToPem(keyBag.key),
|
||||
certPem: first,
|
||||
chainPem: rest.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePemBundle(bundle: string): PemBundle {
|
||||
const blocks = bundle.match(/-----BEGIN [^-]+-----[\s\S]+?-----END [^-]+-----/g) ?? [];
|
||||
const privateKeyPem = blocks.find(b => b.includes('PRIVATE KEY')) ?? '';
|
||||
const certs = blocks.filter(b => b.includes('CERTIFICATE'));
|
||||
return {
|
||||
privateKeyPem,
|
||||
certPem: certs[0] ?? '',
|
||||
chainPem: certs.slice(1).join(''),
|
||||
};
|
||||
}
|
||||
+30
-2
@@ -5,6 +5,7 @@ import { Config } from './config.js';
|
||||
import { DnsChallengeManager, DomainRecord, scanDnsZones } from './dns.js';
|
||||
import { HttpChallengeServer } from './http-challenge.js';
|
||||
import { KeyVaultStore } from './keyvault.js';
|
||||
import { parsePemBundle, pemToPfx, pfxToPem } from './pfx.js';
|
||||
|
||||
export interface ProvisioningResult {
|
||||
domainsScanned: number;
|
||||
@@ -104,8 +105,10 @@ export class Provisioner {
|
||||
|
||||
const fqdns = group.map(d => d.fqdn);
|
||||
const issued = await this.acme.orderCertificate(fqdns, this.challengeHandler);
|
||||
const pemBundle = issued.privateKeyPem + issued.certificatePem + issued.chainPem;
|
||||
await this.store.importCertificate(certName, pemBundle);
|
||||
const [cert, format] = this.config.pfx
|
||||
? [pemToPfx(issued.privateKeyPem, issued.certificatePem, issued.chainPem), 'pfx' as const]
|
||||
: [issued.privateKeyPem + issued.certificatePem + issued.chainPem, 'pem' as const];
|
||||
await this.store.importCertificate(certName, cert, format);
|
||||
|
||||
if (status === 'missing') {
|
||||
result.certificatesIssued.push(primary.fqdn);
|
||||
@@ -131,6 +134,31 @@ export class Provisioner {
|
||||
return result;
|
||||
}
|
||||
|
||||
async convert(domain: string, targetFormat: 'pem' | 'pfx'): Promise<void> {
|
||||
const certName = domainToCertName(domain);
|
||||
const cert = await this.store.getCertificate(certName);
|
||||
if (!cert) throw new Error(`Certificate not found in KeyVault: ${certName}`);
|
||||
|
||||
const currentFormat = cert.policy?.contentType === 'application/x-pkcs12' ? 'pfx' : 'pem';
|
||||
if (currentFormat === targetFormat) {
|
||||
this.log(`Certificate ${certName} is already in ${targetFormat.toUpperCase()} format`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secretValue = await this.store.getSecret(certName);
|
||||
if (!secretValue) throw new Error(`Certificate secret not found: ${certName}`);
|
||||
|
||||
if (currentFormat === 'pem') {
|
||||
const { privateKeyPem, certPem, chainPem } = parsePemBundle(secretValue);
|
||||
await this.store.importCertificate(certName, pemToPfx(privateKeyPem, certPem, chainPem), 'pfx');
|
||||
} else {
|
||||
const { privateKeyPem, certPem, chainPem } = pfxToPem(Buffer.from(secretValue, 'base64'));
|
||||
await this.store.importCertificate(certName, privateKeyPem + certPem + chainPem, 'pem');
|
||||
}
|
||||
|
||||
this.log(`Certificate ${certName} converted to ${targetFormat.toUpperCase()}`);
|
||||
}
|
||||
|
||||
async download(domain: string): Promise<string> {
|
||||
const certName = domainToCertName(domain);
|
||||
const pem = await this.store.getSecret(certName);
|
||||
|
||||
Reference in New Issue
Block a user