fix: update version to 0.5.0, add support for PEM and PFX formats, and implement certificate conversion functionality
This commit is contained in:
Generated
+15
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-acme-provisioner",
|
"name": "azure-acme-provisioner",
|
||||||
"version": "0.4.6",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "azure-acme-provisioner",
|
"name": "azure-acme-provisioner",
|
||||||
"version": "0.4.6",
|
"version": "0.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/arm-authorization": "^9.0.0",
|
"@azure/arm-authorization": "^9.0.0",
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"@peculiar/x509": "^2.0.0",
|
"@peculiar/x509": "^2.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
"commander": "^14.0.0",
|
"commander": "^14.0.0",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"node-forge": "^1.4.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"azure-acme-provisioner": "dist/cli.js"
|
"azure-acme-provisioner": "dist/cli.js"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"typescript": "^6.0.0"
|
"typescript": "^6.0.0"
|
||||||
},
|
},
|
||||||
@@ -692,6 +694,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-forge": {
|
||||||
|
"version": "1.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||||
|
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-acme-provisioner",
|
"name": "azure-acme-provisioner",
|
||||||
"version": "0.4.6",
|
"version": "0.5.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Sławomir Koszewski",
|
"name": "Sławomir Koszewski",
|
||||||
"url": "https://github.com/skoszewski"
|
"url": "https://github.com/skoszewski"
|
||||||
@@ -46,11 +46,13 @@
|
|||||||
"@peculiar/x509": "^2.0.0",
|
"@peculiar/x509": "^2.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
"commander": "^14.0.0",
|
"commander": "^14.0.0",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"node-forge": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"typescript": "^6.0.0"
|
"typescript": "^6.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+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['renewalThreshold']) process.env['ACME_RENEWAL_THRESHOLD_DAYS'] = String(options['renewalThreshold']);
|
||||||
if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']);
|
if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']);
|
||||||
if (options['http']) process.env['ACME_HTTP_PORT'] = String(options['http']);
|
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'])
|
if (options['keyvaultName'] && !options['keyvaultUrl'])
|
||||||
process.env['ACME_KEYVAULT_URL'] = `https://${options['keyvaultName']}.vault.azure.net`;
|
process.env['ACME_KEYVAULT_URL'] = `https://${options['keyvaultName']}.vault.azure.net`;
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,7 @@ sharedOptions(
|
|||||||
.command('run', { isDefault: true })
|
.command('run', { isDefault: true })
|
||||||
.description('Scan DNS zones and issue or renew certificates')
|
.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('--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')
|
.option('--dry-run', 'Show what would be done without making changes')
|
||||||
).action(async (options: Record<string, unknown>) => {
|
).action(async (options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
@@ -120,6 +122,7 @@ sharedOptions(
|
|||||||
.command('renew <domain>')
|
.command('renew <domain>')
|
||||||
.description('Force-renew a certificate for a specific domain, bypassing the renewal threshold')
|
.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('--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>) => {
|
).action(async (domain: string, options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
const config = loadConfig();
|
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) => {
|
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err instanceof Error ? err.message : String(err));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Config {
|
|||||||
dnsChallengeTtl: number;
|
dnsChallengeTtl: number;
|
||||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
httpChallengePort?: number;
|
httpChallengePort?: number;
|
||||||
|
pfx: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigError extends Error {
|
export class ConfigError extends Error {
|
||||||
@@ -65,5 +66,6 @@ export function loadConfig(): Config {
|
|||||||
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
|
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
|
||||||
logLevel: logLevel as Config['logLevel'],
|
logLevel: logLevel as Config['logLevel'],
|
||||||
httpChallengePort: optionalEnvInt('ACME_HTTP_PORT', 0) || undefined,
|
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;
|
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 = {
|
const options: ImportCertificateOptions = {
|
||||||
policy: {
|
policy: {
|
||||||
contentType: 'application/x-pem-file',
|
contentType: format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file',
|
||||||
issuerName: 'Unknown',
|
issuerName: 'Unknown',
|
||||||
subject: 'CN=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 { DnsChallengeManager, DomainRecord, scanDnsZones } from './dns.js';
|
||||||
import { HttpChallengeServer } from './http-challenge.js';
|
import { HttpChallengeServer } from './http-challenge.js';
|
||||||
import { KeyVaultStore } from './keyvault.js';
|
import { KeyVaultStore } from './keyvault.js';
|
||||||
|
import { parsePemBundle, pemToPfx, pfxToPem } from './pfx.js';
|
||||||
|
|
||||||
export interface ProvisioningResult {
|
export interface ProvisioningResult {
|
||||||
domainsScanned: number;
|
domainsScanned: number;
|
||||||
@@ -104,8 +105,10 @@ export class Provisioner {
|
|||||||
|
|
||||||
const fqdns = group.map(d => d.fqdn);
|
const fqdns = group.map(d => d.fqdn);
|
||||||
const issued = await this.acme.orderCertificate(fqdns, this.challengeHandler);
|
const issued = await this.acme.orderCertificate(fqdns, this.challengeHandler);
|
||||||
const pemBundle = issued.privateKeyPem + issued.certificatePem + issued.chainPem;
|
const [cert, format] = this.config.pfx
|
||||||
await this.store.importCertificate(certName, pemBundle);
|
? [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') {
|
if (status === 'missing') {
|
||||||
result.certificatesIssued.push(primary.fqdn);
|
result.certificatesIssued.push(primary.fqdn);
|
||||||
@@ -131,6 +134,31 @@ export class Provisioner {
|
|||||||
return result;
|
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> {
|
async download(domain: string): Promise<string> {
|
||||||
const certName = domainToCertName(domain);
|
const certName = domainToCertName(domain);
|
||||||
const pem = await this.store.getSecret(certName);
|
const pem = await this.store.getSecret(certName);
|
||||||
|
|||||||
Reference in New Issue
Block a user