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

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

View File

@@ -0,0 +1,73 @@
/**
* Supported DNS provider types. Initially Cloudflare; the abstraction is
* designed so additional providers (Route53, Gandi, DigitalOcean…) can be
* added by implementing the IDnsProvider class interface in ts/dns/providers/.
*/
export type TDnsProviderType = 'cloudflare';
/**
* Status of the last connection test against a provider.
*/
export type TDnsProviderStatus = 'untested' | 'ok' | 'error';
/**
* Cloudflare-specific credential shape.
*/
export interface ICloudflareCredentials {
apiToken: string;
}
/**
* Discriminated union of all supported provider credential shapes.
* Persisted opaquely on `IDnsProvider.credentials`.
*/
export type TDnsProviderCredentials =
| ({ type: 'cloudflare' } & ICloudflareCredentials);
/**
* A registered DNS provider account. Holds the credentials needed to
* call the provider's API and a snapshot of its last health check.
*/
export interface IDnsProvider {
id: string;
name: string;
type: TDnsProviderType;
/** Opaque credentials object — shape depends on `type`. */
credentials: TDnsProviderCredentials;
status: TDnsProviderStatus;
lastTestedAt?: number;
lastError?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
}
/**
* A redacted view of IDnsProvider safe to send to the UI / over the wire.
* Strips secret fields from `credentials` while preserving the rest.
*/
export interface IDnsProviderPublic {
id: string;
name: string;
type: TDnsProviderType;
status: TDnsProviderStatus;
lastTestedAt?: number;
lastError?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
/** Whether credentials are configured (true after creation). Never the secret itself. */
hasCredentials: boolean;
}
/**
* A domain reported by a provider's API (not yet imported into dcrouter).
*/
export interface IProviderDomainListing {
/** FQDN of the zone (e.g. 'example.com'). */
name: string;
/** Provider's internal zone identifier (zone_id for Cloudflare). */
externalId: string;
/** Authoritative nameservers reported by the provider. */
nameservers: string[];
}

View File

@@ -0,0 +1,42 @@
/**
* Supported DNS record types.
*/
export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'CAA';
/**
* Where a DNS record came from.
*
* - 'manual' → created in the dcrouter UI / API
* - 'synced' → pulled from a provider during a sync operation
*/
export type TDnsRecordSource = 'manual' | 'synced';
/**
* A DNS record. For manual (authoritative) domains, the record is registered
* with the embedded smartdns.DnsServer. For provider-managed domains, the
* record is mirrored from / pushed to the provider API and `providerRecordId`
* holds the provider's internal record id (for updates and deletes).
*/
export interface IDnsRecord {
id: string;
/** ID of the parent IDomain. */
domainId: string;
/** Fully qualified record name (e.g. 'www.example.com'). */
name: string;
type: TDnsRecordType;
/**
* Record value as a string. For MX records, formatted as
* `<priority> <exchange>` (e.g. `10 mail.example.com`).
*/
value: string;
/** TTL in seconds. */
ttl: number;
/** Cloudflare-specific: whether the record is proxied through Cloudflare. */
proxied?: boolean;
source: TDnsRecordSource;
/** Provider's internal record id (for updates/deletes). Only set for provider records. */
providerRecordId?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
}

View File

@@ -0,0 +1,35 @@
/**
* Where a domain came from / how it is managed.
*
* - 'manual' → operator added the domain manually. dcrouter is the
* authoritative DNS server for it; records are served by
* the embedded smartdns.DnsServer.
* - 'provider' → domain was imported from an external DNS provider
* (e.g. Cloudflare). The provider stays authoritative;
* dcrouter only reads/writes records via the provider API.
*/
export type TDomainSource = 'manual' | 'provider';
/**
* A domain under management by dcrouter.
*/
export interface IDomain {
id: string;
/** Fully qualified domain name (e.g. 'example.com'). */
name: string;
source: TDomainSource;
/** ID of the DnsProvider that owns this domain — only set when source === 'provider'. */
providerId?: string;
/** True when dcrouter is the authoritative DNS server for this domain (source === 'manual'). */
authoritative: boolean;
/** Authoritative nameservers (display only — populated from provider for imported domains). */
nameservers?: string[];
/** Provider's internal zone identifier — only set when source === 'provider'. */
externalZoneId?: string;
/** Last time records were synced from the provider — only set when source === 'provider'. */
lastSyncedAt?: number;
description?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
}

View File

@@ -3,4 +3,7 @@ export * from './stats.js';
export * from './remoteingress.js';
export * from './route-management.js';
export * from './target-profile.js';
export * from './vpn.js';
export * from './vpn.js';
export * from './dns-provider.js';
export * from './domain.js';
export * from './dns-record.js';

View File

@@ -14,7 +14,10 @@ export type TApiTokenScope =
| 'tokens:read' | 'tokens:manage'
| 'source-profiles:read' | 'source-profiles:write'
| 'target-profiles:read' | 'target-profiles:write'
| 'targets:read' | 'targets:write';
| 'targets:read' | 'targets:write'
| 'dns-providers:read' | 'dns-providers:write'
| 'domains:read' | 'domains:write'
| 'dns-records:read' | 'dns-records:write';
// ============================================================================
// Source Profile Types (source-side: who can access)

View File

@@ -0,0 +1,154 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type {
IDnsProviderPublic,
IProviderDomainListing,
TDnsProviderType,
TDnsProviderCredentials,
} from '../data/dns-provider.js';
// ============================================================================
// DNS Provider Endpoints
// ============================================================================
/**
* Get all DNS providers (public view, no secrets).
*/
export interface IReq_GetDnsProviders extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDnsProviders
> {
method: 'getDnsProviders';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
providers: IDnsProviderPublic[];
};
}
/**
* Get a single DNS provider by id.
*/
export interface IReq_GetDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDnsProvider
> {
method: 'getDnsProvider';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
provider: IDnsProviderPublic | null;
};
}
/**
* Create a new DNS provider.
*/
export interface IReq_CreateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateDnsProvider
> {
method: 'createDnsProvider';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
type: TDnsProviderType;
credentials: TDnsProviderCredentials;
};
response: {
success: boolean;
id?: string;
message?: string;
};
}
/**
* Update a DNS provider. Only supplied fields are updated.
* Pass `credentials` to rotate the secret.
*/
export interface IReq_UpdateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateDnsProvider
> {
method: 'updateDnsProvider';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
credentials?: TDnsProviderCredentials;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete a DNS provider. Fails if any IDomain still references it
* unless `force: true` is set.
*/
export interface IReq_DeleteDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteDnsProvider
> {
method: 'deleteDnsProvider';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
force?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Test the connection to a DNS provider. Used both for newly-saved
* providers and on demand from the UI.
*/
export interface IReq_TestDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_TestDnsProvider
> {
method: 'testDnsProvider';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
ok: boolean;
error?: string;
testedAt: number;
};
}
/**
* List the domains visible to a DNS provider's API account.
* Used when importing — does NOT persist anything.
*/
export interface IReq_ListProviderDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListProviderDomains
> {
method: 'listProviderDomains';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
providerId: string;
};
response: {
success: boolean;
domains?: IProviderDomainListing[];
message?: string;
};
}

View File

@@ -0,0 +1,113 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IDnsRecord, TDnsRecordType } from '../data/dns-record.js';
// ============================================================================
// DNS Record Endpoints
// ============================================================================
/**
* Get all DNS records for a domain.
*/
export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDnsRecords
> {
method: 'getDnsRecords';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
domainId: string;
};
response: {
records: IDnsRecord[];
};
}
/**
* Get a single DNS record by id.
*/
export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDnsRecord
> {
method: 'getDnsRecord';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
record: IDnsRecord | null;
};
}
/**
* Create a new DNS record.
*
* For manual domains: registers the record with the embedded DnsServer.
* For provider domains: pushes the record to the provider API.
*/
export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateDnsRecord
> {
method: 'createDnsRecord';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
};
response: {
success: boolean;
id?: string;
message?: string;
};
}
/**
* Update a DNS record.
*/
export interface IReq_UpdateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateDnsRecord
> {
method: 'updateDnsRecord';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
value?: string;
ttl?: number;
proxied?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete a DNS record.
*/
export interface IReq_DeleteDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteDnsRecord
> {
method: 'deleteDnsRecord';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -0,0 +1,150 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IDomain } from '../data/domain.js';
// ============================================================================
// Domain Endpoints
// ============================================================================
/**
* Get all domains under management.
*/
export interface IReq_GetDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDomains
> {
method: 'getDomains';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
domains: IDomain[];
};
}
/**
* Get a single domain by id.
*/
export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDomain
> {
method: 'getDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
domain: IDomain | null;
};
}
/**
* Create a manual (authoritative) domain. dcrouter will serve DNS
* records for this domain via the embedded smartdns.DnsServer.
*/
export interface IReq_CreateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateDomain
> {
method: 'createDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
description?: string;
};
response: {
success: boolean;
id?: string;
message?: string;
};
}
/**
* Import one or more domains from a DNS provider. For each imported
* domain, records are pulled from the provider into DnsRecordDoc.
*/
export interface IReq_ImportDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ImportDomain
> {
method: 'importDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
providerId: string;
/** FQDN(s) of the zone(s) to import — must be visible to the provider account. */
domainNames: string[];
};
response: {
success: boolean;
importedIds?: string[];
message?: string;
};
}
/**
* Update a domain's metadata. Cannot change source / providerId once set.
*/
export interface IReq_UpdateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateDomain
> {
method: 'updateDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
description?: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete a domain and all of its DNS records.
* For provider-managed domains, this only removes dcrouter's local record —
* it does NOT delete the zone at the provider.
*/
export interface IReq_DeleteDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteDomain
> {
method: 'deleteDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Force-resync a provider-managed domain: re-pulls all records from the
* provider API, replacing the cached DnsRecordDocs.
* No-op for manual domains.
*/
export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SyncDomain
> {
method: 'syncDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
recordCount?: number;
message?: string;
};
}

View File

@@ -13,4 +13,7 @@ export * from './vpn.js';
export * from './source-profiles.js';
export * from './target-profiles.js';
export * from './network-targets.js';
export * from './users.js';
export * from './users.js';
export * from './dns-providers.js';
export * from './domains.js';
export * from './dns-records.js';