feat(dns): add db-backed DNS provider, domain, and record management with ops UI support
This commit is contained in:
139
ts/dns/providers/cloudflare.provider.ts
Normal file
139
ts/dns/providers/cloudflare.provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
ts/dns/providers/factory.ts
Normal file
31
ts/dns/providers/factory.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './interfaces.js';
|
||||
export * from './cloudflare.provider.js';
|
||||
export * from './factory.js';
|
||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user