diff --git a/package.json b/package.json index 2bc7c01..1a2f2ae 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "!dist/**/*.test.*" ], "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js", "build:watch": "tsc -p tsconfig.build.json --watch", "clean": "rimraf dist", "lint": "tsc --noEmit", diff --git a/src/lib/config.ts b/src/lib/config.ts index c1841a1..5cd135f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,7 +1,7 @@ export interface Config { - keyVaultUrl: string; + keyVaultUrl?: string; acmeDirectoryUrl: string; - acmeContactEmail: string; + acmeContactEmail?: string; subscriptionId: string; resourceGroups: string[]; dnsZones?: string[]; @@ -54,12 +54,12 @@ export function loadConfig(): Config { } return { - keyVaultUrl: requireEnv('ACME_KEYVAULT_URL'), + keyVaultUrl: process.env['ACME_KEYVAULT_URL'], acmeDirectoryUrl: optionalEnv( 'ACME_DIRECTORY_URL', 'https://acme-v02.api.letsencrypt.org/directory' ), - acmeContactEmail: requireEnv('ACME_CONTACT_EMAIL'), + acmeContactEmail: process.env['ACME_CONTACT_EMAIL'], subscriptionId: requireEnv('ACME_SUBSCRIPTION_ID'), resourceGroups, dnsZones, diff --git a/src/lib/dns.ts b/src/lib/dns.ts index cc85637..52df557 100644 --- a/src/lib/dns.ts +++ b/src/lib/dns.ts @@ -63,13 +63,14 @@ function isAcmeTagged(tags: Record | undefined): boolean { export class DnsChallengeManager { private readonly client: DnsManagementClient; + private zoneMap: Map | undefined; // zone name → resource group constructor(credential: TokenCredential, private readonly config: Config) { this.client = new DnsManagementClient(credential, config.subscriptionId); } async createTxtRecord(fqdn: string, value: string): Promise { - const { resourceGroup, zone, name } = this.parseFqdn(fqdn); + const { resourceGroup, zone, name } = await this.resolveFqdn(fqdn); await this.client.recordSets.createOrUpdate(resourceGroup, zone, name, 'TXT', { ttl: this.config.dnsChallengeTtl, txtRecords: [{ value: [value] }], @@ -77,7 +78,7 @@ export class DnsChallengeManager { } async deleteTxtRecord(fqdn: string): Promise { - const { resourceGroup, zone, name } = this.parseFqdn(fqdn); + const { resourceGroup, zone, name } = await this.resolveFqdn(fqdn); try { await this.client.recordSets.delete(resourceGroup, zone, name, 'TXT'); } catch { @@ -85,20 +86,40 @@ export class DnsChallengeManager { } } - private parseFqdn(fqdn: string): { resourceGroup: string; zone: string; name: string } { + private async loadZoneMap(): Promise> { + if (this.zoneMap) return this.zoneMap; + + this.zoneMap = new Map(); 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 }; - } + for await (const zone of this.client.zones.listByResourceGroup(rg)) { + if (!zone.name) continue; + if (this.config.dnsZones && !this.config.dnsZones.includes(zone.name)) continue; + this.zoneMap.set(zone.name, rg); } } - // 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 }; + return this.zoneMap; + } + + private async resolveFqdn(fqdn: string): Promise<{ resourceGroup: string; zone: string; name: string }> { + const zones = await this.loadZoneMap(); + + // longest-suffix match: find the most specific zone that is a suffix of fqdn + let bestZone = ''; + for (const zoneName of zones.keys()) { + if ( + (fqdn === zoneName || fqdn.endsWith(`.${zoneName}`)) && + zoneName.length > bestZone.length + ) { + bestZone = zoneName; + } + } + + if (!bestZone) { + throw new Error(`No Azure DNS zone found for FQDN: ${fqdn}`); + } + + const resourceGroup = zones.get(bestZone)!; + const name = fqdn === bestZone ? '@' : fqdn.slice(0, -(bestZone.length + 1)); + return { resourceGroup, zone: bestZone, name }; } } diff --git a/src/lib/provisioner.ts b/src/lib/provisioner.ts index 544ad48..c5c8dc3 100644 --- a/src/lib/provisioner.ts +++ b/src/lib/provisioner.ts @@ -15,18 +15,42 @@ export interface ProvisioningResult { export class Provisioner { private readonly credential: DefaultAzureCredential; - private readonly store: KeyVaultStore; - private readonly acme: AcmeClient; - private readonly challengeManager: DnsChallengeManager; + private _store: KeyVaultStore | undefined; + private _acme: AcmeClient | undefined; + private _challengeManager: DnsChallengeManager | undefined; constructor( private readonly config: Config, private readonly log: (msg: string, ...args: unknown[]) => void = console.log ) { this.credential = new DefaultAzureCredential(); - this.store = new KeyVaultStore(this.credential, config.keyVaultUrl); - this.acme = new AcmeClient(this.store, config, (msg) => this.log(msg)); - this.challengeManager = new DnsChallengeManager(this.credential, config); + } + + private get store(): KeyVaultStore { + if (!this._store) { + if (!this.config.keyVaultUrl) { + throw new Error('ACME_KEYVAULT_URL is required for this operation'); + } + this._store = new KeyVaultStore(this.credential, this.config.keyVaultUrl); + } + return this._store; + } + + private get acme(): AcmeClient { + if (!this._acme) { + if (!this.config.acmeContactEmail) { + throw new Error('ACME_CONTACT_EMAIL is required for this operation'); + } + this._acme = new AcmeClient(this.store, this.config, (msg) => this.log(msg)); + } + return this._acme; + } + + private get challengeManager(): DnsChallengeManager { + if (!this._challengeManager) { + this._challengeManager = new DnsChallengeManager(this.credential, this.config); + } + return this._challengeManager; } async run(dryRun = false): Promise {