fix: zone resolution logic
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
"!dist/**/*.test.*"
|
"!dist/**/*.test.*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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",
|
"build:watch": "tsc -p tsconfig.build.json --watch",
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
keyVaultUrl: string;
|
keyVaultUrl?: string;
|
||||||
acmeDirectoryUrl: string;
|
acmeDirectoryUrl: string;
|
||||||
acmeContactEmail: string;
|
acmeContactEmail?: string;
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
resourceGroups: string[];
|
resourceGroups: string[];
|
||||||
dnsZones?: string[];
|
dnsZones?: string[];
|
||||||
@@ -54,12 +54,12 @@ export function loadConfig(): Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keyVaultUrl: requireEnv('ACME_KEYVAULT_URL'),
|
keyVaultUrl: process.env['ACME_KEYVAULT_URL'],
|
||||||
acmeDirectoryUrl: optionalEnv(
|
acmeDirectoryUrl: optionalEnv(
|
||||||
'ACME_DIRECTORY_URL',
|
'ACME_DIRECTORY_URL',
|
||||||
'https://acme-v02.api.letsencrypt.org/directory'
|
'https://acme-v02.api.letsencrypt.org/directory'
|
||||||
),
|
),
|
||||||
acmeContactEmail: requireEnv('ACME_CONTACT_EMAIL'),
|
acmeContactEmail: process.env['ACME_CONTACT_EMAIL'],
|
||||||
subscriptionId: requireEnv('ACME_SUBSCRIPTION_ID'),
|
subscriptionId: requireEnv('ACME_SUBSCRIPTION_ID'),
|
||||||
resourceGroups,
|
resourceGroups,
|
||||||
dnsZones,
|
dnsZones,
|
||||||
|
|||||||
+35
-14
@@ -63,13 +63,14 @@ function isAcmeTagged(tags: Record<string, string> | undefined): boolean {
|
|||||||
|
|
||||||
export class DnsChallengeManager {
|
export class DnsChallengeManager {
|
||||||
private readonly client: DnsManagementClient;
|
private readonly client: DnsManagementClient;
|
||||||
|
private zoneMap: Map<string, string> | undefined; // zone name → resource group
|
||||||
|
|
||||||
constructor(credential: TokenCredential, private readonly config: Config) {
|
constructor(credential: TokenCredential, private readonly config: Config) {
|
||||||
this.client = new DnsManagementClient(credential, config.subscriptionId);
|
this.client = new DnsManagementClient(credential, config.subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTxtRecord(fqdn: string, value: string): Promise<void> {
|
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', {
|
await this.client.recordSets.createOrUpdate(resourceGroup, zone, name, 'TXT', {
|
||||||
ttl: this.config.dnsChallengeTtl,
|
ttl: this.config.dnsChallengeTtl,
|
||||||
txtRecords: [{ value: [value] }],
|
txtRecords: [{ value: [value] }],
|
||||||
@@ -77,7 +78,7 @@ export class DnsChallengeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteTxtRecord(fqdn: string): Promise<void> {
|
async deleteTxtRecord(fqdn: string): Promise<void> {
|
||||||
const { resourceGroup, zone, name } = this.parseFqdn(fqdn);
|
const { resourceGroup, zone, name } = await this.resolveFqdn(fqdn);
|
||||||
try {
|
try {
|
||||||
await this.client.recordSets.delete(resourceGroup, zone, name, 'TXT');
|
await this.client.recordSets.delete(resourceGroup, zone, name, 'TXT');
|
||||||
} catch {
|
} 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 rg of this.config.resourceGroups) {
|
||||||
for (const zone of this.config.dnsZones ?? []) {
|
for await (const zone of this.client.zones.listByResourceGroup(rg)) {
|
||||||
if (fqdn.endsWith(`.${zone}`) || fqdn === zone) {
|
if (!zone.name) continue;
|
||||||
const name = fqdn === zone ? '@' : fqdn.slice(0, -(zone.length + 1));
|
if (this.config.dnsZones && !this.config.dnsZones.includes(zone.name)) continue;
|
||||||
return { resourceGroup: rg, zone, name };
|
this.zoneMap.set(zone.name, rg);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// fallback: derive zone from fqdn by stripping first label
|
return this.zoneMap;
|
||||||
const parts = fqdn.split('.');
|
}
|
||||||
const zone = parts.slice(1).join('.');
|
|
||||||
const name = parts[0];
|
private async resolveFqdn(fqdn: string): Promise<{ resourceGroup: string; zone: string; name: string }> {
|
||||||
const rg = this.config.resourceGroups[0];
|
const zones = await this.loadZoneMap();
|
||||||
return { resourceGroup: rg, zone, name };
|
|
||||||
|
// 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
@@ -15,18 +15,42 @@ export interface ProvisioningResult {
|
|||||||
|
|
||||||
export class Provisioner {
|
export class Provisioner {
|
||||||
private readonly credential: DefaultAzureCredential;
|
private readonly credential: DefaultAzureCredential;
|
||||||
private readonly store: KeyVaultStore;
|
private _store: KeyVaultStore | undefined;
|
||||||
private readonly acme: AcmeClient;
|
private _acme: AcmeClient | undefined;
|
||||||
private readonly challengeManager: DnsChallengeManager;
|
private _challengeManager: DnsChallengeManager | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly log: (msg: string, ...args: unknown[]) => void = console.log
|
private readonly log: (msg: string, ...args: unknown[]) => void = console.log
|
||||||
) {
|
) {
|
||||||
this.credential = new DefaultAzureCredential();
|
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> {
|
async run(dryRun = false): Promise<ProvisioningResult> {
|
||||||
|
|||||||
Reference in New Issue
Block a user