fix: zone resolution logic

This commit is contained in:
2026-05-21 14:46:37 +02:00
parent e7098015de
commit bdd851ffca
4 changed files with 70 additions and 25 deletions
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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,
+34 -13
View File
@@ -63,13 +63,14 @@ function isAcmeTagged(tags: Record<string, string> | undefined): boolean {
export class DnsChallengeManager {
private readonly client: DnsManagementClient;
private zoneMap: Map<string, string> | 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<void> {
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<void> {
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<Map<string, string>> {
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);
}
}
return this.zoneMap;
}
// 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 };
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 };
}
}
+30 -6
View File
@@ -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<ProvisioningResult> {