2026-04-08 11:08:18 +00:00
|
|
|
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)
|
2026-04-08 14:54:49 +00:00
|
|
|
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
|
|
|
|
|
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
|
|
|
|
|
* smartdns, provider domains hit the provider API)
|
2026-04-08 11:08:18 +00:00
|
|
|
* - 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
|
2026-04-08 14:54:49 +00:00
|
|
|
* DcRouter.setupDnsWithSocketHandler(). After this, local records on
|
|
|
|
|
* dcrouter-hosted domains loaded from the DB are registered with the server.
|
2026-04-08 11:08:18 +00:00
|
|
|
*/
|
|
|
|
|
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
|
|
|
|
|
this.dnsServer = dnsServer;
|
2026-04-08 14:54:49 +00:00
|
|
|
await this.applyDcrouterDomainsToDnsServer();
|
2026-04-08 11:08:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// First-boot seeding
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
|
2026-04-08 14:54:49 +00:00
|
|
|
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
|
|
|
|
|
* local (`record.source: 'local'`) records. On subsequent boots (DB has
|
2026-04-08 11:08:18 +00:00
|
|
|
* 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();
|
2026-04-08 14:54:49 +00:00
|
|
|
domain.source = 'dcrouter';
|
2026-04-08 11:08:18 +00:00
|
|
|
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;
|
2026-04-08 14:54:49 +00:00
|
|
|
record.source = 'local';
|
2026-04-08 11:08:18 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
2026-04-08 14:54:49 +00:00
|
|
|
// DcRouter-hosted domain DnsServer wiring
|
2026-04-08 11:08:18 +00:00
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-08 14:54:49 +00:00
|
|
|
* Register all records from dcrouter-hosted domains in the DB with the
|
|
|
|
|
* embedded DnsServer. Called once after attachDnsServer().
|
2026-04-08 11:08:18 +00:00
|
|
|
*/
|
2026-04-08 14:54:49 +00:00
|
|
|
private async applyDcrouterDomainsToDnsServer(): Promise<void> {
|
2026-04-08 11:08:18 +00:00
|
|
|
if (!this.dnsServer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const allDomains = await DomainDoc.findAll();
|
2026-04-08 14:54:49 +00:00
|
|
|
const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
|
2026-04-08 11:08:18 +00:00
|
|
|
let registered = 0;
|
2026-04-08 14:54:49 +00:00
|
|
|
for (const domain of dcrouterDomains) {
|
2026-04-08 11:08:18 +00:00
|
|
|
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
|
|
|
|
for (const rec of records) {
|
|
|
|
|
this.registerRecordWithDnsServer(rec);
|
|
|
|
|
registered++;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 14:54:49 +00:00
|
|
|
logger.log(
|
|
|
|
|
'info',
|
|
|
|
|
`DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`,
|
|
|
|
|
);
|
2026-04-08 11:08:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-04-08 11:11:53 +00:00
|
|
|
* the right provider client (whichever provider type owns the parent zone),
|
|
|
|
|
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
|
|
|
|
|
* interface, so any registered provider implementation works.
|
2026-04-08 11:08:18 +00:00
|
|
|
* 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> {
|
2026-04-08 14:54:49 +00:00
|
|
|
if (args.type === 'dcrouter') {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' +
|
|
|
|
|
'that type is reserved for the built-in pseudo-provider surfaced at read time.',
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-08 11:08:18 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-08 14:54:49 +00:00
|
|
|
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
|
|
|
|
|
* DNS records for this domain via the embedded smartdns.DnsServer.
|
2026-04-08 11:08:18 +00:00
|
|
|
*/
|
2026-04-08 14:54:49 +00:00
|
|
|
public async createDcrouterDomain(args: {
|
2026-04-08 11:08:18 +00:00
|
|
|
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();
|
2026-04-08 14:54:49 +00:00
|
|
|
doc.source = 'dcrouter';
|
2026-04-08 11:08:18 +00:00
|
|
|
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.
|
2026-04-08 14:54:49 +00:00
|
|
|
* For dcrouter-hosted domains, also unregisters records from the embedded
|
|
|
|
|
* DnsServer.
|
2026-04-08 11:08:18 +00:00
|
|
|
*
|
|
|
|
|
* Note: smartdns has no public unregister-by-name API in the version pinned
|
2026-04-08 14:54:49 +00:00
|
|
|
* here, so local record deletes only take effect after a restart. The DB
|
2026-04-08 11:08:18 +00:00
|
|
|
* 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;
|
2026-04-08 14:54:49 +00:00
|
|
|
doc.source = 'local';
|
2026-04-08 11:08:18 +00:00
|
|
|
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 {
|
2026-04-08 14:54:49 +00:00
|
|
|
// dcrouter-hosted / authoritative — register with embedded DnsServer immediately
|
2026-04-08 11:08:18 +00:00
|
|
|
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 {
|
2026-04-08 14:54:49 +00:00
|
|
|
// Re-register the local record so the new closure picks up the updated fields
|
2026-04-08 11:08:18 +00:00
|
|
|
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}` };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 14:54:49 +00:00
|
|
|
// For local records: smartdns has no unregister API in the pinned version,
|
2026-04-08 11:08:18 +00:00
|
|
|
// 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;
|
2026-04-08 14:54:49 +00:00
|
|
|
source: 'dcrouter' | 'provider';
|
2026-04-08 11:08:18 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|