feat(dns): add db-backed DNS provider, domain, and record management with ops UI support

This commit is contained in:
2026-04-08 11:08:18 +00:00
parent e77fe9451e
commit 21c80e173d
57 changed files with 3753 additions and 65 deletions

View File

@@ -0,0 +1,139 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.js';
import type {
IDnsProviderClient,
IConnectionTestResult,
IProviderRecord,
IProviderRecordInput,
} from './interfaces.js';
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
/**
* Cloudflare implementation of IDnsProviderClient.
*
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
* an internal record id, which we surface as `providerRecordId` so the rest
* of the system can issue updates and deletes without ambiguity (Cloudflare
* can have multiple records of the same name+type).
*/
export class CloudflareDnsProvider implements IDnsProviderClient {
private cfAccount: plugins.cloudflare.CloudflareAccount;
constructor(apiToken: string) {
if (!apiToken) {
throw new Error('CloudflareDnsProvider: apiToken is required');
}
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
}
/**
* Returns the underlying CloudflareAccount — used by ACME DNS-01
* to wrap into a smartacme Dns01Handler.
*/
public getCloudflareAccount(): plugins.cloudflare.CloudflareAccount {
return this.cfAccount;
}
public async testConnection(): Promise<IConnectionTestResult> {
try {
// Listing zones is the lightest-weight call that proves the token works.
await this.cfAccount.zoneManager.listZones();
return { ok: true };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
return { ok: false, error: message };
}
}
public async listDomains(): Promise<IProviderDomainListing[]> {
const zones = await this.cfAccount.zoneManager.listZones();
return zones.map((zone) => ({
name: zone.name,
externalId: zone.id,
nameservers: zone.name_servers ?? [],
}));
}
public async listRecords(domain: string): Promise<IProviderRecord[]> {
const records = await this.cfAccount.recordManager.listRecords(domain);
return records
.filter((r) => this.isSupportedType(r.type))
.map((r) => ({
providerRecordId: r.id,
name: r.name,
type: r.type as TDnsRecordType,
value: r.content,
ttl: r.ttl,
proxied: r.proxied,
}));
}
public async createRecord(
domain: string,
record: IProviderRecordInput,
): Promise<IProviderRecord> {
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
const apiRecord: any = {
zone_id: zoneId,
type: record.type,
name: record.name,
content: record.value,
ttl: record.ttl ?? 1, // 1 = automatic
};
if (record.proxied !== undefined) {
apiRecord.proxied = record.proxied;
}
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
return {
providerRecordId: created.id,
name: created.name,
type: created.type as TDnsRecordType,
value: created.content,
ttl: created.ttl,
proxied: created.proxied,
};
}
public async updateRecord(
domain: string,
providerRecordId: string,
record: IProviderRecordInput,
): Promise<IProviderRecord> {
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
const apiRecord: any = {
zone_id: zoneId,
type: record.type,
name: record.name,
content: record.value,
ttl: record.ttl ?? 1,
};
if (record.proxied !== undefined) {
apiRecord.proxied = record.proxied;
}
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
providerRecordId,
apiRecord,
);
return {
providerRecordId: updated.id,
name: updated.name,
type: updated.type as TDnsRecordType,
value: updated.content,
ttl: updated.ttl,
proxied: updated.proxied,
};
}
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
zone_id: zoneId,
});
}
private isSupportedType(type: string): boolean {
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
}
}

View File

@@ -0,0 +1,31 @@
import type { IDnsProviderClient } from './interfaces.js';
import type {
TDnsProviderType,
TDnsProviderCredentials,
} from '../../../ts_interfaces/data/dns-provider.js';
import { CloudflareDnsProvider } from './cloudflare.provider.js';
/**
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
*
* @throws if the provider type is not supported.
*/
export function createDnsProvider(
type: TDnsProviderType,
credentials: TDnsProviderCredentials,
): IDnsProviderClient {
switch (type) {
case 'cloudflare': {
if (credentials.type !== 'cloudflare') {
throw new Error(
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
);
}
return new CloudflareDnsProvider(credentials.apiToken);
}
default: {
const _exhaustive: never = type;
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './interfaces.js';
export * from './cloudflare.provider.js';
export * from './factory.js';

View File

@@ -0,0 +1,67 @@
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
/**
* A DNS record as seen at a provider's API. The `providerRecordId` field
* is the provider's internal identifier, used for subsequent updates and
* deletes (since providers can have multiple records of the same name+type).
*/
export interface IProviderRecord {
providerRecordId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl: number;
proxied?: boolean;
}
/**
* Input shape for creating / updating a DNS record at a provider.
*/
export interface IProviderRecordInput {
name: string;
type: TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
}
/**
* Outcome of a connection test against a provider's API.
*/
export interface IConnectionTestResult {
ok: boolean;
error?: string;
}
/**
* Pluggable DNS provider client interface. One implementation per provider type
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
* instantiated by `createDnsProvider()` in factory.ts.
*
* NOT a smartdata interface — this is the *runtime* client. The persisted
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
*/
export interface IDnsProviderClient {
/** Lightweight check that credentials are valid and the API is reachable. */
testConnection(): Promise<IConnectionTestResult>;
/** List all DNS zones visible to this provider account. */
listDomains(): Promise<IProviderDomainListing[]>;
/** List all DNS records for a zone (FQDN). */
listRecords(domain: string): Promise<IProviderRecord[]>;
/** Create a new DNS record at the provider; returns the created record (with id). */
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
/** Update an existing record by provider id; returns the updated record. */
updateRecord(
domain: string,
providerRecordId: string,
record: IProviderRecordInput,
): Promise<IProviderRecord>;
/** Delete a record by provider id. */
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
}