feat: initialize azure-acme-provisioner project with core functionality

- Add package.json for project metadata and dependencies
- Implement CLI in src/cli.ts for managing SSL/TLS certificates
- Create Azure Functions host configuration in src/function/host.json
- Set up timer function in src/function/index.ts for scheduled certificate management
- Define configuration loading and error handling in src/lib/config.ts
- Implement DNS zone scanning and challenge management in src/lib/dns.ts
- Develop ACME client for certificate issuance in src/lib/acme.ts
- Create KeyVault store for managing secrets and certificates in src/lib/keyvault.ts
- Implement provisioning logic in src/lib/provisioner.ts for issuing and renewing certificates
- Add TypeScript configuration files for building the project
This commit is contained in:
2026-05-21 13:40:40 +02:00
parent c2af853df6
commit e7098015de
18 changed files with 2560 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import { DnsManagementClient } from '@azure/arm-dns';
import { TokenCredential } from '@azure/identity';
import { Config } from './config.js';
export interface DomainRecord {
fqdn: string;
zoneName: string;
resourceGroup: string;
isWildcard: boolean;
}
export async function scanDnsZones(
credential: TokenCredential,
config: Config
): Promise<DomainRecord[]> {
const client = new DnsManagementClient(credential, config.subscriptionId);
const results: DomainRecord[] = [];
const seen = new Set<string>();
for (const rg of config.resourceGroups) {
for await (const zone of client.zones.listByResourceGroup(rg)) {
if (!zone.name) continue;
if (config.dnsZones && !config.dnsZones.includes(zone.name)) continue;
if (isAcmeTagged(zone.tags)) {
addDomain(results, seen, zone.name, rg, false);
addDomain(results, seen, `*.${zone.name}`, rg, true);
}
for await (const record of client.recordSets.listAllByDnsZone(rg, zone.name)) {
if (!record.name) continue;
if (!isAcmeTagged(record.metadata)) continue;
if (record.type !== 'Microsoft.Network/dnszones/A' &&
record.type !== 'Microsoft.Network/dnszones/CNAME') continue;
const fqdn = record.name === '@' ? zone.name : `${record.name}.${zone.name}`;
addDomain(results, seen, fqdn, rg, false);
}
}
}
return results;
}
function addDomain(
results: DomainRecord[],
seen: Set<string>,
fqdn: string,
resourceGroup: string,
isWildcard: boolean
): void {
if (seen.has(fqdn)) return;
seen.add(fqdn);
const zoneName = fqdn.replace(/^\*\./, '');
results.push({ fqdn, zoneName, resourceGroup, isWildcard });
}
function isAcmeTagged(tags: Record<string, string> | undefined): boolean {
if (!tags) return false;
const val = tags['acme'];
return val === 'true' || val === 'enabled';
}
export class DnsChallengeManager {
private readonly client: DnsManagementClient;
constructor(credential: TokenCredential, private readonly config: Config) {
this.client = new DnsManagementClient(credential, config.subscriptionId);
}
async createTxtRecord(fqdn: string, value: string): Promise<void> {
const { resourceGroup, zone, name } = this.parseFqdn(fqdn);
await this.client.recordSets.createOrUpdate(resourceGroup, zone, name, 'TXT', {
ttl: this.config.dnsChallengeTtl,
txtRecords: [{ value: [value] }],
});
}
async deleteTxtRecord(fqdn: string): Promise<void> {
const { resourceGroup, zone, name } = this.parseFqdn(fqdn);
try {
await this.client.recordSets.delete(resourceGroup, zone, name, 'TXT');
} catch {
// best-effort cleanup; ignore errors
}
}
private parseFqdn(fqdn: string): { resourceGroup: string; zone: string; name: string } {
for (const rg of this.config.resourceGroups) {
for (const zone of this.config.dnsZones ?? []) {
if (fqdn.endsWith(`.${zone}`) || fqdn === zone) {
const name = fqdn === zone ? '@' : fqdn.slice(0, -(zone.length + 1));
return { resourceGroup: rg, zone, name };
}
}
}
// fallback: derive zone from fqdn by stripping first label
const parts = fqdn.split('.');
const zone = parts.slice(1).join('.');
const name = parts[0];
const rg = this.config.resourceGroups[0];
return { resourceGroup: rg, zone, name };
}
}