3 Commits

Author SHA1 Message Date
slawek 221a4fcd6e docs: add deployment and function triggering instructions to README 2026-05-23 11:42:38 +02:00
slawek 37cfd237cc fix: Applied missing version 0.6.2 bump. 2026-05-23 10:30:33 +02:00
slawek 668f3c3e28 fix: update importCertificate method to handle certificate policy updates and improve PFX import logic
fix: modify pfxToPem function to export private key as PKCS#8 for Azure Key Vault compatibility
2026-05-23 10:26:29 +02:00
5 changed files with 83 additions and 22 deletions
+61
View File
@@ -401,6 +401,67 @@ func azure functionapp publish <function-app-name> --no-build
`--no-build` tells `func` to skip its own build step since we already compiled the TypeScript output. To also push application settings from `local.settings.json` in the same step, append `--publish-local-settings --overwrite-settings`. This is useful for the initial deployment or when settings and code change together. `--no-build` tells `func` to skip its own build step since we already compiled the TypeScript output. To also push application settings from `local.settings.json` in the same step, append `--publish-local-settings --overwrite-settings`. This is useful for the initial deployment or when settings and code change together.
### Triggering and verifying a deployed function
Set these variables once, then all snippets below are copy-pastable:
```sh
FUNCTION_APP=<function-app-name>
RESOURCE_GROUP=<resource-group>
APP_INSIGHTS=<app-insights-name> # only needed for the query at the end
```
After deployment, trigger the timer function immediately without waiting for the schedule:
```sh
MASTER_KEY=$(az functionapp keys list \
--name $FUNCTION_APP \
--resource-group $RESOURCE_GROUP \
--query masterKey -o tsv)
curl -X POST \
"https://${FUNCTION_APP}.azurewebsites.net/admin/functions/acmeProvisioner" \
-H "Content-Type: application/json" \
-H "x-functions-key: ${MASTER_KEY}" \
-d '{}'
```
Stream live logs while the function runs:
```sh
func azure functionapp logstream $FUNCTION_APP
```
Or tail logs via the Azure CLI:
```sh
az webapp log tail \
--name $FUNCTION_APP \
--resource-group $RESOURCE_GROUP
```
A successful run produces output similar to:
```
Initializing ACME account
Scanning DNS zones
Found 2 domain(s) tagged for ACME management
Issuing certificate for *.example.com, example.com
Certificate stored in KeyVault as 'cert-wildcard-example-com'
Done. Issued: 1, Renewed: 0, Skipped: 0, Errors: 0
```
If Application Insights is configured, query recent invocations with:
```sh
az monitor app-insights query \
--app $APP_INSIGHTS \
--resource-group $RESOURCE_GROUP \
--analytics-query "traces | where timestamp > ago(1h) | order by timestamp desc"
```
---
### Local testing ### Local testing
Create `local.settings.json` at the project root (gitignored) and fill in your values: Create `local.settings.json` at the project root (gitignored) and fill in your values:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@slawek/azure-acme-provisioner", "name": "@slawek/azure-acme-provisioner",
"version": "0.6.1", "version": "0.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@slawek/azure-acme-provisioner", "name": "@slawek/azure-acme-provisioner",
"version": "0.6.1", "version": "0.6.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@azure/arm-authorization": "^9.0.0", "@azure/arm-authorization": "^9.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@slawek/azure-acme-provisioner", "name": "@slawek/azure-acme-provisioner",
"version": "0.6.1", "version": "0.6.2",
"author": { "author": {
"name": "Sławomir Koszewski", "name": "Sławomir Koszewski",
"url": "https://github.com/skoszewski" "url": "https://github.com/skoszewski"
+12 -18
View File
@@ -1,6 +1,7 @@
import { TokenCredential } from '@azure/identity'; import { TokenCredential } from '@azure/identity';
import { import {
CertificateClient, CertificateClient,
CertificatePolicy,
KeyVaultCertificateWithPolicy, KeyVaultCertificateWithPolicy,
} from '@azure/keyvault-certificates'; } from '@azure/keyvault-certificates';
import { SecretClient } from '@azure/keyvault-secrets'; import { SecretClient } from '@azure/keyvault-secrets';
@@ -48,24 +49,17 @@ export class KeyVaultStore {
async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem', password?: string): Promise<void> { async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem', password?: string): Promise<void> {
const certBuffer = typeof cert === 'string' ? Buffer.from(cert) : cert; const certBuffer = typeof cert === 'string' ? Buffer.from(cert) : cert;
// The high-level CertificateClient spreads `policy` into import params but the const contentType = format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file';
// generated serializer reads `certificatePolicy` — a key mismatch that silently try {
// drops content_type from the REST body. Call the internal client directly so // When a certificate already exists, Azure validates the incoming bytes against
// secret_props.content_type reaches Azure (required for PFX; without it Azure // its stored policy's content_type. Updating the policy first tells Azure to
// defaults to PEM parsing and rejects binary PFX data). // expect the new format; without this, converting PEM→PFX (or vice-versa)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // fails because Azure tries to parse binary PFX data as PEM.
const internalClient = (this.certClient as any).client; await this.certClient.updateCertificatePolicy(name, { contentType } as CertificatePolicy);
await internalClient.importCertificate(name, { } catch {
base64EncodedCertificate: format === 'pem' // Certificate doesn't exist yet — no policy to update, proceed to import.
? certBuffer.toString('ascii') }
: certBuffer.toString('base64'), await this.certClient.importCertificate(name, certBuffer, { password });
password,
certificatePolicy: {
secretProperties: {
contentType: format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file',
},
},
}, {});
} }
} }
+7 -1
View File
@@ -32,9 +32,15 @@ export function pfxToPem(pfxBuffer: Buffer, password = ''): PemBundle {
if (!keyBag?.key) throw new Error('No private key found in PFX'); if (!keyBag?.key) throw new Error('No private key found in PFX');
if (allCertBags.length === 0) throw new Error('No certificates found in PFX'); if (allCertBags.length === 0) throw new Error('No certificates found in PFX');
// Export as PKCS#8 — Azure Key Vault rejects PKCS#1 (BEGIN RSA PRIVATE KEY) in PEM imports
const pkcs8Asn1 = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(keyBag.key));
const pkcs8Der = forge.asn1.toDer(pkcs8Asn1).getBytes();
const pkcs8B64 = forge.util.encode64(pkcs8Der).match(/.{1,64}/g)!.join('\n');
const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${pkcs8B64}\n-----END PRIVATE KEY-----\n`;
const [first, ...rest] = allCertBags.map(bag => forge.pki.certificateToPem(bag.cert!)); const [first, ...rest] = allCertBags.map(bag => forge.pki.certificateToPem(bag.cert!));
return { return {
privateKeyPem: forge.pki.privateKeyToPem(keyBag.key), privateKeyPem,
certPem: first, certPem: first,
chainPem: rest.join(''), chainPem: rest.join(''),
}; };