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

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.5.0',
version: '13.6.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -27,6 +27,7 @@ import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -116,13 +117,6 @@ export interface IDcRouterOptions {
useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true)
}>;
/** DNS challenge configuration for ACME (optional) */
dnsChallenge?: {
/** Cloudflare API key for DNS challenges */
cloudflareApiKey?: string;
/** Other DNS providers can be added here */
};
/**
* Unified database configuration.
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
@@ -279,6 +273,9 @@ export class DcRouter {
public referenceResolver?: ReferenceResolver;
public targetProfileManager?: TargetProfileManager;
// Domain / DNS management (DB-backed providers, domains, records)
public dnsManager?: DnsManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
@@ -393,10 +390,33 @@ export class DcRouter {
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
);
// SmartProxy: critical, depends on DcRouterDb (if enabled)
// DnsManager: optional, depends on DcRouterDb — owns DB-backed DNS state
// (providers, domains, records). Must run before SmartProxy so ACME DNS-01
// wiring can look up providers.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.dnsManager = new DnsManager(this.options);
await this.dnsManager.start();
})
.withStop(async () => {
if (this.dnsManager) {
await this.dnsManager.stop();
this.dnsManager = undefined;
}
})
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
);
}
// SmartProxy: critical, depends on DcRouterDb + DnsManager (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.dbConfig?.enabled !== false) {
smartProxyDeps.push('DcRouterDb');
smartProxyDeps.push('DnsManager');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy')
@@ -415,9 +435,11 @@ export class DcRouter {
.withRetry({ maxRetries: 0 }),
);
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits
// Only registered if DNS challenge is configured
if (this.options.dnsChallenge?.cloudflareApiKey) {
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits.
// Always registered when the DB is enabled; setupSmartProxy() decides whether
// to actually instantiate SmartAcme based on whether any DnsProviderDoc exists.
// If `this.smartAcme` is unset by the time this service starts, withStart is a no-op.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartAcme')
.optional()
@@ -849,12 +871,14 @@ export class DcRouter {
};
}
// Configure DNS challenge if available
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB.
// The DnsManager dispatches each challenge to the right provider client
// based on the FQDN being certificated.
let challengeHandlers: any[] = [];
if (this.options.dnsChallenge?.cloudflareApiKey) {
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
if (this.dnsManager && (await this.dnsManager.hasAcmeCapableProvider())) {
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
challengeHandlers.push(dns01Handler);
}
@@ -1720,8 +1744,13 @@ export class DcRouter {
this.registerDnsRecords(allRecords);
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
}
// Hand the DnsServer to DnsManager so DB-backed manual records get registered too.
if (this.dnsManager && this.dnsServer) {
await this.dnsManager.attachDnsServer(this.dnsServer);
}
}
/**
* Create DNS socket handler for DoH
*/

View File

@@ -0,0 +1,63 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type {
TDnsProviderType,
TDnsProviderStatus,
TDnsProviderCredentials,
} from '../../../ts_interfaces/data/dns-provider.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public type!: TDnsProviderType;
/**
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
*/
@plugins.smartdata.svDb()
public credentials!: TDnsProviderCredentials;
@plugins.smartdata.svDb()
public status: TDnsProviderStatus = 'untested';
@plugins.smartdata.svDb()
public lastTestedAt?: number;
@plugins.smartdata.svDb()
public lastError?: string;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<DnsProviderDoc | null> {
return await DnsProviderDoc.getInstance({ id });
}
public static async findAll(): Promise<DnsProviderDoc[]> {
return await DnsProviderDoc.getInstances({});
}
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
return await DnsProviderDoc.getInstances({ type });
}
}

View File

@@ -0,0 +1,62 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public domainId!: string;
/** FQDN of the record (e.g. 'www.example.com'). */
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public type!: TDnsRecordType;
@plugins.smartdata.svDb()
public value!: string;
@plugins.smartdata.svDb()
public ttl: number = 300;
@plugins.smartdata.svDb()
public proxied?: boolean;
@plugins.smartdata.svDb()
public source!: TDnsRecordSource;
@plugins.smartdata.svDb()
public providerRecordId?: string;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<DnsRecordDoc | null> {
return await DnsRecordDoc.getInstance({ id });
}
public static async findAll(): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.getInstances({});
}
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.getInstances({ domainId });
}
}

View File

@@ -0,0 +1,66 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
/** FQDN — kept lowercased on save. */
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public source!: TDomainSource;
@plugins.smartdata.svDb()
public providerId?: string;
@plugins.smartdata.svDb()
public authoritative: boolean = false;
@plugins.smartdata.svDb()
public nameservers?: string[];
@plugins.smartdata.svDb()
public externalZoneId?: string;
@plugins.smartdata.svDb()
public lastSyncedAt?: number;
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public createdBy!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<DomainDoc | null> {
return await DomainDoc.getInstance({ id });
}
public static async findByName(name: string): Promise<DomainDoc | null> {
return await DomainDoc.getInstance({ name: name.toLowerCase() });
}
public static async findAll(): Promise<DomainDoc[]> {
return await DomainDoc.getInstances({});
}
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
return await DomainDoc.getInstances({ providerId });
}
}

View File

@@ -25,3 +25,8 @@ export * from './classes.remote-ingress-edge.doc.js';
// RADIUS document classes
export * from './classes.vlan-mappings.doc.js';
export * from './classes.accounting-session.doc.js';
// DNS / Domain management document classes
export * from './classes.dns-provider.doc.js';
export * from './classes.domain.doc.js';
export * from './classes.dns-record.doc.js';

2
ts/dns/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './manager.dns.js';
export * from './providers/index.js';

867
ts/dns/manager.dns.ts Normal file
View File

@@ -0,0 +1,867 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import {
DnsProviderDoc,
DomainDoc,
DnsRecordDoc,
} from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
import { createDnsProvider } from './providers/factory.js';
import type {
TDnsRecordType,
TDnsRecordSource,
} from '../../ts_interfaces/data/dns-record.js';
import type {
TDnsProviderType,
TDnsProviderCredentials,
IDnsProviderPublic,
IProviderDomainListing,
} from '../../ts_interfaces/data/dns-provider.js';
/**
* DnsManager — owns runtime DNS state on top of the embedded DnsServer.
*
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
* - Register manual-domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (manual domains hit smartdns,
* provider domains hit the provider API)
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
*
* Provider-managed domains are NEVER served from the embedded DnsServer — the
* provider stays authoritative. We only mirror their records locally for the UI
* and to track providerRecordIds for updates / deletes.
*/
export class DnsManager {
/**
* Reference to the active smartdns DnsServer (set by DcRouter once it exists).
* May be undefined if dnsScopes/dnsNsDomains aren't configured.
*/
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
/**
* Cached provider clients, keyed by DnsProviderDoc.id.
* Created lazily when a provider is first needed.
*/
private providerClients = new Map<string, IDnsProviderClient>();
constructor(private options: IDcRouterOptions) {}
// ==========================================================================
// Lifecycle
// ==========================================================================
/**
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
* from legacy constructor config if (and only if) the DB is empty.
*/
public async start(): Promise<void> {
logger.log('info', 'DnsManager: starting');
await this.seedFromConstructorConfigIfEmpty();
}
public async stop(): Promise<void> {
this.providerClients.clear();
this.dnsServer = undefined;
}
/**
* Wire the embedded DnsServer instance after it has been created by
* DcRouter.setupDnsWithSocketHandler(). After this, manual records loaded
* from the DB are registered with the server.
*/
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
this.dnsServer = dnsServer;
await this.applyManualDomainsToDnsServer();
}
// ==========================================================================
// First-boot seeding
// ==========================================================================
/**
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
* seed them as `source: 'manual'` records. On subsequent boots (DB has
* entries), constructor config is ignored with a warning.
*/
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
const existingDomains = await DomainDoc.findAll();
const hasLegacyConfig =
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
if (existingDomains.length > 0) {
if (hasLegacyConfig) {
logger.log(
'warn',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
'Manage DNS via the Domains UI instead.',
);
}
return;
}
if (!hasLegacyConfig) {
return;
}
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
const now = Date.now();
const seededDomains = new Map<string, DomainDoc>();
// Create one DomainDoc per dnsScope (these are the authoritative zones)
for (const scope of this.options.dnsScopes ?? []) {
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = scope.toLowerCase();
domain.source = 'manual';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'seed';
await domain.save();
seededDomains.set(domain.name, domain);
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
}
// Map each legacy dnsRecord to its parent DomainDoc
for (const rec of this.options.dnsRecords ?? []) {
const parent = this.findParentDomain(rec.name, seededDomains);
if (!parent) {
logger.log(
'warn',
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
);
continue;
}
const record = new DnsRecordDoc();
record.id = plugins.uuid.v4();
record.domainId = parent.id;
record.name = rec.name.toLowerCase();
record.type = rec.type as TDnsRecordType;
record.value = rec.value;
record.ttl = rec.ttl ?? 300;
record.source = 'manual';
record.createdAt = now;
record.updatedAt = now;
record.createdBy = 'seed';
await record.save();
}
logger.log(
'info',
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
);
}
private findParentDomain(
recordName: string,
domains: Map<string, DomainDoc>,
): DomainDoc | null {
const lower = recordName.toLowerCase().replace(/^\*\./, '');
let candidate: DomainDoc | null = null;
for (const [name, doc] of domains) {
if (lower === name || lower.endsWith(`.${name}`)) {
if (!candidate || name.length > candidate.name.length) {
candidate = doc;
}
}
}
return candidate;
}
// ==========================================================================
// Manual-domain DnsServer wiring
// ==========================================================================
/**
* Register all manual-domain records from the DB with the embedded DnsServer.
* Called once after attachDnsServer().
*/
private async applyManualDomainsToDnsServer(): Promise<void> {
if (!this.dnsServer) {
return;
}
const allDomains = await DomainDoc.findAll();
const manualDomains = allDomains.filter((d) => d.source === 'manual');
let registered = 0;
for (const domain of manualDomains) {
const records = await DnsRecordDoc.findByDomainId(domain.id);
for (const rec of records) {
this.registerRecordWithDnsServer(rec);
registered++;
}
}
logger.log('info', `DnsManager: registered ${registered} manual DNS record(s) from DB`);
}
/**
* Register a single record with the embedded DnsServer. The handler closure
* captures the record fields, so updates require a re-register cycle.
*/
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
if (question.name === rec.name && question.type === rec.type) {
return {
name: rec.name,
type: rec.type,
class: 'IN',
ttl: rec.ttl,
data: this.parseRecordData(rec.type, rec.value),
};
}
return null;
});
}
private parseRecordData(type: TDnsRecordType, value: string): any {
switch (type) {
case 'A':
case 'AAAA':
case 'CNAME':
case 'TXT':
case 'NS':
case 'CAA':
return value;
case 'MX': {
const [priorityStr, exchange] = value.split(' ');
return { priority: parseInt(priorityStr, 10), exchange };
}
case 'SOA': {
const parts = value.split(' ');
return {
mname: parts[0],
rname: parts[1],
serial: parseInt(parts[2], 10),
refresh: parseInt(parts[3], 10),
retry: parseInt(parts[4], 10),
expire: parseInt(parts[5], 10),
minimum: parseInt(parts[6], 10),
};
}
default:
return value;
}
}
// ==========================================================================
// Provider lookup (used by ACME DNS-01 + record CRUD)
// ==========================================================================
/**
* Get the provider client for a given DnsProviderDoc id, instantiating
* (and caching) it on first use.
*/
public async getProviderClientById(providerId: string): Promise<IDnsProviderClient | null> {
const cached = this.providerClients.get(providerId);
if (cached) return cached;
const doc = await DnsProviderDoc.findById(providerId);
if (!doc) return null;
const client = createDnsProvider(doc.type, doc.credentials);
this.providerClients.set(providerId, client);
return client;
}
/**
* Find the IDnsProviderClient that owns the given FQDN (by walking up its
* labels to find a matching DomainDoc with `source === 'provider'`).
* Returns null if no provider claims this FQDN.
*
* Used by:
* - SmartAcme DNS-01 wiring in setupSmartProxy()
* - DnsRecordHandler when creating provider records
*/
public async getProviderClientForDomain(fqdn: string): Promise<IDnsProviderClient | null> {
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
const allDomains = await DomainDoc.findAll();
const providerDomains = allDomains
.filter((d) => d.source === 'provider' && d.providerId)
// longest-match wins
.sort((a, b) => b.name.length - a.name.length);
for (const domain of providerDomains) {
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
return this.getProviderClientById(domain.providerId!);
}
}
return null;
}
/**
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
* to decide whether to wire SmartAcme with a DNS-01 handler.
*/
public async hasAcmeCapableProvider(): Promise<boolean> {
const providers = await DnsProviderDoc.findAll();
return providers.length > 0;
}
/**
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
* the right CloudflareDnsProvider based on the challenge's hostName.
* Returned object plugs directly into smartacme's Dns01Handler.
*/
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
const self = this;
const adapter = {
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
throw new Error(
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
'Add one in the Domains > Providers UI before issuing certificates.',
);
}
// Clean any leftover challenge records first to avoid duplicates.
try {
const existing = await client.listRecords(dnsChallenge.hostName);
for (const r of existing) {
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
}
}
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
await client.createRecord(dnsChallenge.hostName, {
name: dnsChallenge.hostName,
type: 'TXT',
value: dnsChallenge.challenge,
ttl: 120,
});
},
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
// The domain may have been removed; nothing to clean up.
return;
}
try {
const existing = await client.listRecords(dnsChallenge.hostName);
for (const r of existing) {
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
}
}
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
},
async isDomainSupported(domain: string): Promise<boolean> {
const client = await self.getProviderClientForDomain(domain);
return !!client;
},
};
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
}
// ==========================================================================
// Provider CRUD (used by DnsProviderHandler)
// ==========================================================================
public async listProviders(): Promise<IDnsProviderPublic[]> {
const docs = await DnsProviderDoc.findAll();
return docs.map((d) => this.toPublicProvider(d));
}
public async getProvider(id: string): Promise<IDnsProviderPublic | null> {
const doc = await DnsProviderDoc.findById(id);
return doc ? this.toPublicProvider(doc) : null;
}
public async createProvider(args: {
name: string;
type: TDnsProviderType;
credentials: TDnsProviderCredentials;
createdBy: string;
}): Promise<string> {
const now = Date.now();
const doc = new DnsProviderDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name;
doc.type = args.type;
doc.credentials = args.credentials;
doc.status = 'untested';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
await doc.save();
return doc.id;
}
public async updateProvider(
id: string,
args: { name?: string; credentials?: TDnsProviderCredentials },
): Promise<boolean> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) return false;
if (args.name !== undefined) doc.name = args.name;
if (args.credentials !== undefined) {
doc.credentials = args.credentials;
doc.status = 'untested';
doc.lastError = undefined;
// Invalidate cached client so the next use re-instantiates with the new credentials.
this.providerClients.delete(id);
}
doc.updatedAt = Date.now();
await doc.save();
return true;
}
public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) return { success: false, message: 'Provider not found' };
const linkedDomains = await DomainDoc.findByProviderId(id);
if (linkedDomains.length > 0 && !force) {
return {
success: false,
message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`,
};
}
// If forcing, also delete the linked domains and their records.
if (force) {
for (const domain of linkedDomains) {
await this.deleteDomain(domain.id);
}
}
await doc.delete();
this.providerClients.delete(id);
return { success: true };
}
public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) {
return { ok: false, error: 'Provider not found', testedAt: Date.now() };
}
const client = createDnsProvider(doc.type, doc.credentials);
const result = await client.testConnection();
doc.status = result.ok ? 'ok' : 'error';
doc.lastTestedAt = Date.now();
doc.lastError = result.ok ? undefined : result.error;
await doc.save();
if (result.ok) {
this.providerClients.set(id, client); // cache the working client
}
return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt };
}
public async listProviderDomains(providerId: string): Promise<IProviderDomainListing[]> {
const client = await this.getProviderClientById(providerId);
if (!client) {
throw new Error('Provider not found');
}
return await client.listDomains();
}
// ==========================================================================
// Domain CRUD (used by DomainHandler)
// ==========================================================================
public async listDomains(): Promise<DomainDoc[]> {
return await DomainDoc.findAll();
}
public async getDomain(id: string): Promise<DomainDoc | null> {
return await DomainDoc.findById(id);
}
/**
* Create a manual (authoritative) domain. dcrouter will serve DNS records
* for this domain via the embedded smartdns.DnsServer.
*/
public async createManualDomain(args: {
name: string;
description?: string;
createdBy: string;
}): Promise<string> {
const now = Date.now();
const doc = new DomainDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name.toLowerCase();
doc.source = 'manual';
doc.authoritative = true;
doc.description = args.description;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
await doc.save();
return doc.id;
}
/**
* Import one or more domains from a provider, pulling all of their DNS
* records into local DnsRecordDocs.
*/
public async importDomainsFromProvider(args: {
providerId: string;
domainNames: string[];
createdBy: string;
}): Promise<string[]> {
const provider = await DnsProviderDoc.findById(args.providerId);
if (!provider) {
throw new Error('Provider not found');
}
const client = await this.getProviderClientById(args.providerId);
if (!client) {
throw new Error('Failed to instantiate provider client');
}
const allProviderDomains = await client.listDomains();
const importedIds: string[] = [];
const now = Date.now();
for (const wantedName of args.domainNames) {
const lower = wantedName.toLowerCase();
const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower);
if (!listing) {
logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`);
continue;
}
// Skip if already imported
const existing = await DomainDoc.findByName(lower);
if (existing) {
logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`);
continue;
}
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = lower;
domain.source = 'provider';
domain.providerId = args.providerId;
domain.authoritative = false;
domain.nameservers = listing.nameservers;
domain.externalZoneId = listing.externalId;
domain.lastSyncedAt = now;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = args.createdBy;
await domain.save();
importedIds.push(domain.id);
// Pull records for the imported domain
try {
const providerRecords = await client.listRecords(lower);
for (const pr of providerRecords) {
await this.createSyncedRecord(domain.id, pr, args.createdBy);
}
logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`);
}
}
return importedIds;
}
public async updateDomain(id: string, args: { description?: string }): Promise<boolean> {
const doc = await DomainDoc.findById(id);
if (!doc) return false;
if (args.description !== undefined) doc.description = args.description;
doc.updatedAt = Date.now();
await doc.save();
return true;
}
/**
* Delete a domain and all of its DNS records. For provider domains, only
* removes the local mirror — does NOT touch the provider.
* For manual domains, also unregisters records from the embedded DnsServer.
*
* Note: smartdns has no public unregister-by-name API in the version pinned
* here, so manual record deletes only take effect after a restart. The DB
* is the source of truth and the next start will not register the deleted
* record.
*/
public async deleteDomain(id: string): Promise<boolean> {
const doc = await DomainDoc.findById(id);
if (!doc) return false;
const records = await DnsRecordDoc.findByDomainId(id);
for (const r of records) {
await r.delete();
}
await doc.delete();
return true;
}
/**
* Force-resync a provider-managed domain: re-pull all records from the
* provider API, replacing the cached DnsRecordDocs.
*/
public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
const doc = await DomainDoc.findById(id);
if (!doc) return { success: false, message: 'Domain not found' };
if (doc.source !== 'provider' || !doc.providerId) {
return { success: false, message: 'Domain is not provider-managed' };
}
const client = await this.getProviderClientById(doc.providerId);
if (!client) {
return { success: false, message: 'Provider client unavailable' };
}
const providerRecords = await client.listRecords(doc.name);
// Drop existing records and replace
const existing = await DnsRecordDoc.findByDomainId(id);
for (const r of existing) {
await r.delete();
}
for (const pr of providerRecords) {
await this.createSyncedRecord(id, pr, doc.createdBy);
}
doc.lastSyncedAt = Date.now();
doc.updatedAt = doc.lastSyncedAt;
await doc.save();
return { success: true, recordCount: providerRecords.length };
}
// ==========================================================================
// Record CRUD (used by DnsRecordHandler)
// ==========================================================================
public async listRecordsForDomain(domainId: string): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.findByDomainId(domainId);
}
public async getRecord(id: string): Promise<DnsRecordDoc | null> {
return await DnsRecordDoc.findById(id);
}
public async createRecord(args: {
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
createdBy: string;
}): Promise<{ success: boolean; id?: string; message?: string }> {
const domain = await DomainDoc.findById(args.domainId);
if (!domain) return { success: false, message: 'Domain not found' };
const now = Date.now();
const doc = new DnsRecordDoc();
doc.id = plugins.uuid.v4();
doc.domainId = args.domainId;
doc.name = args.name.toLowerCase();
doc.type = args.type;
doc.value = args.value;
doc.ttl = args.ttl ?? 300;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.source = 'manual';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
if (domain.source === 'provider') {
// Push to provider first; only persist locally on success
if (!domain.providerId) {
return { success: false, message: 'Provider domain has no providerId' };
}
const client = await this.getProviderClientById(domain.providerId);
if (!client) return { success: false, message: 'Provider client unavailable' };
try {
const created = await client.createRecord(domain.name, {
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
});
doc.providerRecordId = created.providerRecordId;
doc.source = 'synced';
} catch (err: unknown) {
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
}
} else {
// Manual / authoritative — register with embedded DnsServer immediately
this.registerRecordWithDnsServer(doc);
}
await doc.save();
return { success: true, id: doc.id };
}
public async updateRecord(args: {
id: string;
name?: string;
value?: string;
ttl?: number;
proxied?: boolean;
}): Promise<{ success: boolean; message?: string }> {
const doc = await DnsRecordDoc.findById(args.id);
if (!doc) return { success: false, message: 'Record not found' };
const domain = await DomainDoc.findById(doc.domainId);
if (!domain) return { success: false, message: 'Parent domain not found' };
if (args.name !== undefined) doc.name = args.name.toLowerCase();
if (args.value !== undefined) doc.value = args.value;
if (args.ttl !== undefined) doc.ttl = args.ttl;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.updatedAt = Date.now();
if (domain.source === 'provider') {
if (!domain.providerId || !doc.providerRecordId) {
return { success: false, message: 'Provider record metadata missing' };
}
const client = await this.getProviderClientById(domain.providerId);
if (!client) return { success: false, message: 'Provider client unavailable' };
try {
await client.updateRecord(domain.name, doc.providerRecordId, {
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
});
} catch (err: unknown) {
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
}
} else {
// Re-register the manual record so the new closure picks up the updated fields
this.registerRecordWithDnsServer(doc);
}
await doc.save();
return { success: true };
}
public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> {
const doc = await DnsRecordDoc.findById(id);
if (!doc) return { success: false, message: 'Record not found' };
const domain = await DomainDoc.findById(doc.domainId);
if (!domain) return { success: false, message: 'Parent domain not found' };
if (domain.source === 'provider') {
if (domain.providerId && doc.providerRecordId) {
const client = await this.getProviderClientById(domain.providerId);
if (client) {
try {
await client.deleteRecord(domain.name, doc.providerRecordId);
} catch (err: unknown) {
return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
}
}
}
}
// For manual records: smartdns has no unregister API in the pinned version,
// so the record stays served until the next restart. The DB delete still
// takes effect — on restart, the record will not be re-registered.
await doc.delete();
return { success: true };
}
// ==========================================================================
// Internal helpers
// ==========================================================================
private async createSyncedRecord(
domainId: string,
pr: IProviderRecord,
createdBy: string,
): Promise<void> {
const now = Date.now();
const doc = new DnsRecordDoc();
doc.id = plugins.uuid.v4();
doc.domainId = domainId;
doc.name = pr.name.toLowerCase();
doc.type = pr.type;
doc.value = pr.value;
doc.ttl = pr.ttl;
if (pr.proxied !== undefined) doc.proxied = pr.proxied;
doc.source = 'synced';
doc.providerRecordId = pr.providerRecordId;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = createdBy;
await doc.save();
}
/**
* Convert a DnsProviderDoc to its public, secret-stripped representation
* for the OpsServer API.
*/
public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic {
return {
id: doc.id,
name: doc.name,
type: doc.type,
status: doc.status,
lastTestedAt: doc.lastTestedAt,
lastError: doc.lastError,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
hasCredentials: !!doc.credentials,
};
}
/**
* Convert a DomainDoc to its plain interface representation.
*/
public toPublicDomain(doc: DomainDoc): {
id: string;
name: string;
source: 'manual' | 'provider';
providerId?: string;
authoritative: boolean;
nameservers?: string[];
externalZoneId?: string;
lastSyncedAt?: number;
description?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
} {
return {
id: doc.id,
name: doc.name,
source: doc.source,
providerId: doc.providerId,
authoritative: doc.authoritative,
nameservers: doc.nameservers,
externalZoneId: doc.externalZoneId,
lastSyncedAt: doc.lastSyncedAt,
description: doc.description,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
/**
* Convert a DnsRecordDoc to its plain interface representation.
*/
public toPublicRecord(doc: DnsRecordDoc): {
id: string;
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl: number;
proxied?: boolean;
source: TDnsRecordSource;
providerRecordId?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
} {
return {
id: doc.id,
domainId: doc.domainId,
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
source: doc.source,
providerRecordId: doc.providerRecordId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
}

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>;
}

View File

@@ -33,6 +33,9 @@ export class OpsServer {
private targetProfileHandler!: handlers.TargetProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler;
private usersHandler!: handlers.UsersHandler;
private dnsProviderHandler!: handlers.DnsProviderHandler;
private domainHandler!: handlers.DomainHandler;
private dnsRecordHandler!: handlers.DnsRecordHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -96,6 +99,9 @@ export class OpsServer {
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
this.usersHandler = new handlers.UsersHandler(this);
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
this.domainHandler = new handlers.DomainHandler(this);
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -123,6 +123,15 @@ export class ConfigHandler {
ttl: r.ttl,
}));
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
let dnsChallengeEnabled = false;
try {
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
} catch {
dnsChallengeEnabled = false;
}
const dns: interfaces.requests.IConfigData['dns'] = {
enabled: !!dcRouter.dnsServer,
port: 53,
@@ -130,7 +139,7 @@ export class ConfigHandler {
scopes: opts.dnsScopes || [],
recordCount: dnsRecords.length,
records: dnsRecords,
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
dnsChallenge: dnsChallengeEnabled,
};
// --- TLS ---

View File

@@ -0,0 +1,159 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* CRUD + connection-test handlers for DnsProviderDoc.
*
* Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or
* API token with the appropriate `dns-providers:read|write` scope.
*/
export class DnsProviderHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get all providers
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
'getDnsProviders',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { providers: [] };
return { providers: await dnsManager.listProviders() };
},
),
);
// Get single provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProvider>(
'getDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { provider: null };
return { provider: await dnsManager.getProvider(dataArg.id) };
},
),
);
// Create provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsProvider>(
'createDnsProvider',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) {
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
}
const id = await dnsManager.createProvider({
name: dataArg.name,
type: dataArg.type,
credentials: dataArg.credentials,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsProvider>(
'updateDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.updateProvider(dataArg.id, {
name: dataArg.name,
credentials: dataArg.credentials,
});
return ok ? { success: true } : { success: false, message: 'Provider not found' };
},
),
);
// Delete provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsProvider>(
'deleteDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
},
),
);
// Test provider connection
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestDnsProvider>(
'testDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) {
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
}
return await dnsManager.testProvider(dataArg.id);
},
),
);
// List domains visible to a provider's account (without importing them)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListProviderDomains>(
'listProviderDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
return { success: true, domains };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}

View File

@@ -0,0 +1,127 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* CRUD handlers for DnsRecordDoc.
*/
export class DnsRecordHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get records by domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
'getDnsRecords',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { records: [] };
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
},
),
);
// Get single record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
'getDnsRecord',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { record: null };
const doc = await dnsManager.getRecord(dataArg.id);
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
},
),
);
// Create record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
'createDnsRecord',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'dns-records:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.createRecord({
domainId: dataArg.domainId,
name: dataArg.name,
type: dataArg.type,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
createdBy: userId,
});
},
),
);
// Update record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
'updateDnsRecord',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.updateRecord({
id: dataArg.id,
name: dataArg.name,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
});
},
),
);
// Delete record
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
'deleteDnsRecord',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-records:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.deleteRecord(dataArg.id);
},
),
);
}
}

View File

@@ -0,0 +1,161 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* CRUD handlers for DomainDoc.
*/
export class DomainHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get all domains
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
'getDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { domains: [] };
const docs = await dnsManager.listDomains();
return { domains: docs.map((d) => dnsManager.toPublicDomain(d)) };
},
),
);
// Get single domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
'getDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { domain: null };
const doc = await dnsManager.getDomain(dataArg.id);
return { domain: doc ? dnsManager.toPublicDomain(doc) : null };
},
),
);
// Create manual domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
'createDomain',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const id = await dnsManager.createManualDomain({
name: dataArg.name,
description: dataArg.description,
createdBy: userId,
});
return { success: true, id };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Import domains from a provider
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportDomain>(
'importDomain',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const importedIds = await dnsManager.importDomainsFromProvider({
providerId: dataArg.providerId,
domainNames: dataArg.domainNames,
createdBy: userId,
});
return { success: true, importedIds };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Update domain metadata
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDomain>(
'updateDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.updateDomain(dataArg.id, {
description: dataArg.description,
});
return ok ? { success: true } : { success: false, message: 'Domain not found' };
},
),
);
// Delete domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDomain>(
'deleteDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.deleteDomain(dataArg.id);
return ok ? { success: true } : { success: false, message: 'Domain not found' };
},
),
);
// Force-resync provider domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomain>(
'syncDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'domains:write');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.syncDomain(dataArg.id);
},
),
);
}
}

View File

@@ -13,4 +13,7 @@ export * from './vpn.handler.js';
export * from './source-profile.handler.js';
export * from './target-profile.handler.js';
export * from './network-target.handler.js';
export * from './users.handler.js';
export * from './users.handler.js';
export * from './dns-provider.handler.js';
export * from './domain.handler.js';
export * from './dns-record.handler.js';