feat(dns): add db-backed DNS provider, domain, and record management with ops UI support
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.6.0 - feat(dns)
|
||||||
|
add db-backed DNS provider, domain, and record management with ops UI support
|
||||||
|
|
||||||
|
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
|
||||||
|
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
|
||||||
|
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
|
||||||
|
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
|
||||||
|
|
||||||
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
|
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
|
||||||
add admin user listing to the access dashboard
|
add admin user listing to the access dashboard
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"@api.global/typedserver": "^8.4.6",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.2",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.68.0",
|
"@design.estate/dees-catalog": "^3.69.1",
|
||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.4.0",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
|||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.0
|
version: 7.1.0
|
||||||
'@design.estate/dees-catalog':
|
'@design.estate/dees-catalog':
|
||||||
specifier: ^3.68.0
|
specifier: ^3.69.1
|
||||||
version: 3.68.0(@tiptap/pm@2.27.2)
|
version: 3.69.1(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -353,8 +353,8 @@ packages:
|
|||||||
'@configvault.io/interfaces@1.0.17':
|
'@configvault.io/interfaces@1.0.17':
|
||||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.68.0':
|
'@design.estate/dees-catalog@3.69.1':
|
||||||
resolution: {integrity: sha512-4jTq/pZmhLFS2jGsF8I+bqLV+P4O9bBAyNtF5Ga1omNCwZFQmITiwPZ2brOGvVFaVrMDi8VdY4I7FTMofF7Diw==}
|
resolution: {integrity: sha512-OSpHB/hfOrL2mkAfF50TqTKJ2hvPd7Cj1WklAmFckyjloE4fd7DRDeXdI/Bziq9152gExipX5VoofTAOr4rF5w==}
|
||||||
|
|
||||||
'@design.estate/dees-comms@1.0.30':
|
'@design.estate/dees-comms@1.0.30':
|
||||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||||
@@ -4315,7 +4315,7 @@ snapshots:
|
|||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||||
'@cloudflare/workers-types': 4.20260405.1
|
'@cloudflare/workers-types': 4.20260405.1
|
||||||
'@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.69.1(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-comms': 1.0.30
|
'@design.estate/dees-comms': 1.0.30
|
||||||
'@push.rocks/lik': 6.4.0
|
'@push.rocks/lik': 6.4.0
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -4844,7 +4844,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.68.0(@tiptap/pm@2.27.2)':
|
'@design.estate/dees-catalog@3.69.1(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
@@ -6900,7 +6900,7 @@ snapshots:
|
|||||||
|
|
||||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.69.1(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.8.0
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.5.0',
|
version: '13.6.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
|||||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** 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)
|
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.
|
* Unified database configuration.
|
||||||
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
|
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
|
||||||
@@ -279,6 +273,9 @@ export class DcRouter {
|
|||||||
public referenceResolver?: ReferenceResolver;
|
public referenceResolver?: ReferenceResolver;
|
||||||
public targetProfileManager?: TargetProfileManager;
|
public targetProfileManager?: TargetProfileManager;
|
||||||
|
|
||||||
|
// Domain / DNS management (DB-backed providers, domains, records)
|
||||||
|
public dnsManager?: DnsManager;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
public detectedPublicIp: string | null = null;
|
public detectedPublicIp: string | null = null;
|
||||||
|
|
||||||
@@ -393,10 +390,33 @@ export class DcRouter {
|
|||||||
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
.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[] = [];
|
const smartProxyDeps: string[] = [];
|
||||||
if (this.options.dbConfig?.enabled !== false) {
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
smartProxyDeps.push('DcRouterDb');
|
smartProxyDeps.push('DcRouterDb');
|
||||||
|
smartProxyDeps.push('DnsManager');
|
||||||
}
|
}
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('SmartProxy')
|
new plugins.taskbuffer.Service('SmartProxy')
|
||||||
@@ -415,9 +435,11 @@ export class DcRouter {
|
|||||||
.withRetry({ maxRetries: 0 }),
|
.withRetry({ maxRetries: 0 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits
|
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits.
|
||||||
// Only registered if DNS challenge is configured
|
// Always registered when the DB is enabled; setupSmartProxy() decides whether
|
||||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
// 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(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('SmartAcme')
|
new plugins.taskbuffer.Service('SmartAcme')
|
||||||
.optional()
|
.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[] = [];
|
let challengeHandlers: any[] = [];
|
||||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
if (this.dnsManager && (await this.dnsManager.hasAcmeCapableProvider())) {
|
||||||
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
|
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
|
||||||
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
|
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
|
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
||||||
challengeHandlers.push(dns01Handler);
|
challengeHandlers.push(dns01Handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1720,8 +1744,13 @@ export class DcRouter {
|
|||||||
this.registerDnsRecords(allRecords);
|
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)`);
|
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
|
* Create DNS socket handler for DoH
|
||||||
*/
|
*/
|
||||||
|
|||||||
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
63
ts/db/documents/classes.dns-provider.doc.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
62
ts/db/documents/classes.dns-record.doc.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ts/db/documents/classes.domain.doc.ts
Normal file
66
ts/db/documents/classes.domain.doc.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,8 @@ export * from './classes.remote-ingress-edge.doc.js';
|
|||||||
// RADIUS document classes
|
// RADIUS document classes
|
||||||
export * from './classes.vlan-mappings.doc.js';
|
export * from './classes.vlan-mappings.doc.js';
|
||||||
export * from './classes.accounting-session.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
2
ts/dns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './manager.dns.js';
|
||||||
|
export * from './providers/index.js';
|
||||||
867
ts/dns/manager.dns.ts
Normal file
867
ts/dns/manager.dns.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
139
ts/dns/providers/cloudflare.provider.ts
Normal file
139
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type {
|
||||||
|
IDnsProviderClient,
|
||||||
|
IConnectionTestResult,
|
||||||
|
IProviderRecord,
|
||||||
|
IProviderRecordInput,
|
||||||
|
} from './interfaces.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare implementation of IDnsProviderClient.
|
||||||
|
*
|
||||||
|
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||||
|
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||||
|
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||||
|
* can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||||
|
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||||
|
|
||||||
|
constructor(apiToken: string) {
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||||
|
}
|
||||||
|
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying CloudflareAccount — used by ACME DNS-01
|
||||||
|
* to wrap into a smartacme Dns01Handler.
|
||||||
|
*/
|
||||||
|
public getCloudflareAccount(): plugins.cloudflare.CloudflareAccount {
|
||||||
|
return this.cfAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testConnection(): Promise<IConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
// Listing zones is the lightest-weight call that proves the token works.
|
||||||
|
await this.cfAccount.zoneManager.listZones();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||||
|
const zones = await this.cfAccount.zoneManager.listZones();
|
||||||
|
return zones.map((zone) => ({
|
||||||
|
name: zone.name,
|
||||||
|
externalId: zone.id,
|
||||||
|
nameservers: zone.name_servers ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||||
|
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||||
|
return records
|
||||||
|
.filter((r) => this.isSupportedType(r.type))
|
||||||
|
.map((r) => ({
|
||||||
|
providerRecordId: r.id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type as TDnsRecordType,
|
||||||
|
value: r.content,
|
||||||
|
ttl: r.ttl,
|
||||||
|
proxied: r.proxied,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createRecord(
|
||||||
|
domain: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1, // 1 = automatic
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||||
|
return {
|
||||||
|
providerRecordId: created.id,
|
||||||
|
name: created.name,
|
||||||
|
type: created.type as TDnsRecordType,
|
||||||
|
value: created.content,
|
||||||
|
ttl: created.ttl,
|
||||||
|
proxied: created.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1,
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||||
|
providerRecordId,
|
||||||
|
apiRecord,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
providerRecordId: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
type: updated.type as TDnsRecordType,
|
||||||
|
value: updated.content,
|
||||||
|
ttl: updated.ttl,
|
||||||
|
proxied: updated.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||||
|
zone_id: zoneId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupportedType(type: string): boolean {
|
||||||
|
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ts/dns/providers/factory.ts
Normal file
31
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IDnsProviderClient } from './interfaces.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import { CloudflareDnsProvider } from './cloudflare.provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
|
||||||
|
*
|
||||||
|
* @throws if the provider type is not supported.
|
||||||
|
*/
|
||||||
|
export function createDnsProvider(
|
||||||
|
type: TDnsProviderType,
|
||||||
|
credentials: TDnsProviderCredentials,
|
||||||
|
): IDnsProviderClient {
|
||||||
|
switch (type) {
|
||||||
|
case 'cloudflare': {
|
||||||
|
if (credentials.type !== 'cloudflare') {
|
||||||
|
throw new Error(
|
||||||
|
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new CloudflareDnsProvider(credentials.apiToken);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = type;
|
||||||
|
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './interfaces.js';
|
||||||
|
export * from './cloudflare.provider.js';
|
||||||
|
export * from './factory.js';
|
||||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||||
|
* is the provider's internal identifier, used for subsequent updates and
|
||||||
|
* deletes (since providers can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export interface IProviderRecord {
|
||||||
|
providerRecordId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input shape for creating / updating a DNS record at a provider.
|
||||||
|
*/
|
||||||
|
export interface IProviderRecordInput {
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a connection test against a provider's API.
|
||||||
|
*/
|
||||||
|
export interface IConnectionTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluggable DNS provider client interface. One implementation per provider type
|
||||||
|
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||||
|
* instantiated by `createDnsProvider()` in factory.ts.
|
||||||
|
*
|
||||||
|
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||||
|
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderClient {
|
||||||
|
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||||
|
testConnection(): Promise<IConnectionTestResult>;
|
||||||
|
|
||||||
|
/** List all DNS zones visible to this provider account. */
|
||||||
|
listDomains(): Promise<IProviderDomainListing[]>;
|
||||||
|
|
||||||
|
/** List all DNS records for a zone (FQDN). */
|
||||||
|
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||||
|
|
||||||
|
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||||
|
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Update an existing record by provider id; returns the updated record. */
|
||||||
|
updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Delete a record by provider id. */
|
||||||
|
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ export class OpsServer {
|
|||||||
private targetProfileHandler!: handlers.TargetProfileHandler;
|
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||||
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
private usersHandler!: handlers.UsersHandler;
|
private usersHandler!: handlers.UsersHandler;
|
||||||
|
private dnsProviderHandler!: handlers.DnsProviderHandler;
|
||||||
|
private domainHandler!: handlers.DomainHandler;
|
||||||
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -96,6 +99,9 @@ export class OpsServer {
|
|||||||
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||||
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
this.usersHandler = new handlers.UsersHandler(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');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ export class ConfigHandler {
|
|||||||
ttl: r.ttl,
|
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'] = {
|
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||||
enabled: !!dcRouter.dnsServer,
|
enabled: !!dcRouter.dnsServer,
|
||||||
port: 53,
|
port: 53,
|
||||||
@@ -130,7 +139,7 @@ export class ConfigHandler {
|
|||||||
scopes: opts.dnsScopes || [],
|
scopes: opts.dnsScopes || [],
|
||||||
recordCount: dnsRecords.length,
|
recordCount: dnsRecords.length,
|
||||||
records: dnsRecords,
|
records: dnsRecords,
|
||||||
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
dnsChallenge: dnsChallengeEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- TLS ---
|
// --- TLS ---
|
||||||
|
|||||||
159
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
159
ts/opsserver/handlers/dns-provider.handler.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
127
ts/opsserver/handlers/dns-record.handler.ts
Normal 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ts/opsserver/handlers/domain.handler.ts
Normal file
161
ts/opsserver/handlers/domain.handler.ts
Normal 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,7 @@ export * from './vpn.handler.js';
|
|||||||
export * from './source-profile.handler.js';
|
export * from './source-profile.handler.js';
|
||||||
export * from './target-profile.handler.js';
|
export * from './target-profile.handler.js';
|
||||||
export * from './network-target.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';
|
||||||
73
ts_interfaces/data/dns-provider.ts
Normal file
73
ts_interfaces/data/dns-provider.ts
Normal 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[];
|
||||||
|
}
|
||||||
42
ts_interfaces/data/dns-record.ts
Normal file
42
ts_interfaces/data/dns-record.ts
Normal 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;
|
||||||
|
}
|
||||||
35
ts_interfaces/data/domain.ts
Normal file
35
ts_interfaces/data/domain.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,4 +3,7 @@ export * from './stats.js';
|
|||||||
export * from './remoteingress.js';
|
export * from './remoteingress.js';
|
||||||
export * from './route-management.js';
|
export * from './route-management.js';
|
||||||
export * from './target-profile.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';
|
||||||
@@ -14,7 +14,10 @@ export type TApiTokenScope =
|
|||||||
| 'tokens:read' | 'tokens:manage'
|
| 'tokens:read' | 'tokens:manage'
|
||||||
| 'source-profiles:read' | 'source-profiles:write'
|
| 'source-profiles:read' | 'source-profiles:write'
|
||||||
| 'target-profiles:read' | 'target-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)
|
// Source Profile Types (source-side: who can access)
|
||||||
|
|||||||
154
ts_interfaces/requests/dns-providers.ts
Normal file
154
ts_interfaces/requests/dns-providers.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
113
ts_interfaces/requests/dns-records.ts
Normal file
113
ts_interfaces/requests/dns-records.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
150
ts_interfaces/requests/domains.ts
Normal file
150
ts_interfaces/requests/domains.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,4 +13,7 @@ export * from './vpn.js';
|
|||||||
export * from './source-profiles.js';
|
export * from './source-profiles.js';
|
||||||
export * from './target-profiles.js';
|
export * from './target-profiles.js';
|
||||||
export * from './network-targets.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';
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.5.0',
|
version: '13.6.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
|||||||
// Determine initial view from URL path
|
// Determine initial view from URL path
|
||||||
const getInitialView = (): string => {
|
const getInitialView = (): string => {
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||||
const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'certificates'];
|
const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'domains'];
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
return validViews.includes(view) ? view : 'overview';
|
return validViews.includes(view) ? view : 'overview';
|
||||||
@@ -465,8 +465,9 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If switching to certificates view, ensure we fetch certificate data
|
// If switching to the Domains group, ensure we fetch certificate data
|
||||||
if (viewName === 'certificates' && currentState.activeView !== 'certificates') {
|
// (Certificates is a subview of Domains).
|
||||||
|
if (viewName === 'domains' && currentState.activeView !== 'domains') {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -1555,6 +1556,403 @@ export const deleteTargetAction = profilesTargetsStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Domains State (DNS providers + domains + records)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IDomainsState {
|
||||||
|
providers: interfaces.data.IDnsProviderPublic[];
|
||||||
|
domains: interfaces.data.IDomain[];
|
||||||
|
records: interfaces.data.IDnsRecord[];
|
||||||
|
/** id of the currently-selected domain in the DNS records subview. */
|
||||||
|
selectedDomainId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const domainsStatePart = await appState.getStatePart<IDomainsState>(
|
||||||
|
'domains',
|
||||||
|
{
|
||||||
|
providers: [],
|
||||||
|
domains: [],
|
||||||
|
records: [],
|
||||||
|
selectedDomainId: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchDomainsAndProvidersAction = domainsStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providersRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetDnsProviders
|
||||||
|
>('/typedrequest', 'getDnsProviders');
|
||||||
|
const domainsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetDomains
|
||||||
|
>('/typedrequest', 'getDomains');
|
||||||
|
|
||||||
|
const [providersResponse, domainsResponse] = await Promise.all([
|
||||||
|
providersRequest.fire({ identity: context.identity }),
|
||||||
|
domainsRequest.fire({ identity: context.identity }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
providers: providersResponse.providers,
|
||||||
|
domains: domainsResponse.domains,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch domains/providers',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchDnsRecordsForDomainAction = domainsStatePart.createAction<{ domainId: string }>(
|
||||||
|
async (statePartArg, dataArg): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetDnsRecords
|
||||||
|
>('/typedrequest', 'getDnsRecords');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
domainId: dataArg.domainId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
records: response.records,
|
||||||
|
selectedDomainId: dataArg.domainId,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch DNS records',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createDnsProviderAction = domainsStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
type: interfaces.data.TDnsProviderType;
|
||||||
|
credentials: interfaces.data.TDnsProviderCredentials;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateDnsProvider
|
||||||
|
>('/typedrequest', 'createDnsProvider');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
credentials: dataArg.credentials,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to create provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateDnsProviderAction = domainsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
credentials?: interfaces.data.TDnsProviderCredentials;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateDnsProvider
|
||||||
|
>('/typedrequest', 'updateDnsProvider');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
credentials: dataArg.credentials,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to update provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteDnsProviderAction = domainsStatePart.createAction<{ id: string; force?: boolean }>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteDnsProvider
|
||||||
|
>('/typedrequest', 'deleteDnsProvider');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
force: dataArg.force,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: response.message || 'Failed to delete provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const testDnsProviderAction = domainsStatePart.createAction<{ id: string }>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_TestDnsProvider
|
||||||
|
>('/typedrequest', 'testDnsProvider');
|
||||||
|
await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to test provider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** One-shot fetch for the import-domain modal. Does NOT modify state. */
|
||||||
|
export async function fetchProviderDomains(
|
||||||
|
providerId: string,
|
||||||
|
): Promise<{ success: boolean; domains?: interfaces.data.IProviderDomainListing[]; message?: string }> {
|
||||||
|
const context = getActionContext();
|
||||||
|
if (!context.identity) return { success: false, message: 'Not authenticated' };
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ListProviderDomains
|
||||||
|
>('/typedrequest', 'listProviderDomains');
|
||||||
|
return await request.fire({ identity: context.identity, providerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createManualDomainAction = domainsStatePart.createAction<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateDomain
|
||||||
|
>('/typedrequest', 'createDomain');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
name: dataArg.name,
|
||||||
|
description: dataArg.description,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to create domain' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create domain',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importDomainsFromProviderAction = domainsStatePart.createAction<{
|
||||||
|
providerId: string;
|
||||||
|
domainNames: string[];
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ImportDomain
|
||||||
|
>('/typedrequest', 'importDomain');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
providerId: dataArg.providerId,
|
||||||
|
domainNames: dataArg.domainNames,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to import domains' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to import domains',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteDomainAction = domainsStatePart.createAction<{ id: string }>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteDomain
|
||||||
|
>('/typedrequest', 'deleteDomain');
|
||||||
|
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete domain' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete domain',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_SyncDomain
|
||||||
|
>('/typedrequest', 'syncDomain');
|
||||||
|
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to sync domain' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to sync domain',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createDnsRecordAction = domainsStatePart.createAction<{
|
||||||
|
domainId: string;
|
||||||
|
name: string;
|
||||||
|
type: interfaces.data.TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateDnsRecord
|
||||||
|
>('/typedrequest', 'createDnsRecord');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
domainId: dataArg.domainId,
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to create record' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create record',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateDnsRecordAction = domainsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
domainId: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateDnsRecord
|
||||||
|
>('/typedrequest', 'updateDnsRecord');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
});
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to update record' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update record',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string; domainId: string }>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteDnsRecord
|
||||||
|
>('/typedrequest', 'deleteDnsRecord');
|
||||||
|
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete record' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete record',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Actions
|
// Route Management Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2076,8 +2474,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh certificate data if on certificates view
|
// Refresh certificate data if on Domains > Certificates subview
|
||||||
if (currentView === 'certificates') {
|
if (currentView === 'domains' && currentSubview === 'certificates') {
|
||||||
try {
|
try {
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
const { apiTokens } = this.routeState;
|
const { apiTokens } = this.routeState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">API Tokens</dees-heading>
|
<dees-heading level="3">API Tokens</dees-heading>
|
||||||
|
|
||||||
<div class="apiTokensContainer">
|
<div class="apiTokensContainer">
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class OpsViewUsers extends DeesElement {
|
|||||||
const currentUserId = this.loginState.identity?.userId;
|
const currentUserId = this.loginState.identity?.userId;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Users</dees-heading>
|
<dees-heading level="3">Users</dees-heading>
|
||||||
|
|
||||||
<div class="usersContainer">
|
<div class="usersContainer">
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
4
ts_web/elements/domains/index.ts
Normal file
4
ts_web/elements/domains/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './ops-view-providers.js';
|
||||||
|
export * from './ops-view-domains.js';
|
||||||
|
export * from './ops-view-dns.js';
|
||||||
|
export * from './ops-view-certificates.js';
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
const { summary } = this.certState;
|
const { summary } = this.certState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Certificates</dees-heading>
|
<dees-heading level="3">Certificates</dees-heading>
|
||||||
|
|
||||||
<div class="certificatesContainer">
|
<div class="certificatesContainer">
|
||||||
${this.renderStatsTiles(summary)}
|
${this.renderStatsTiles(summary)}
|
||||||
273
ts_web/elements/domains/ops-view-dns.ts
Normal file
273
ts_web/elements/domains/ops-view-dns.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../../appstate.js';
|
||||||
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from '../shared/css.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-dns': OpsViewDns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TYPES: interfaces.data.TDnsRecordType[] = [
|
||||||
|
'A',
|
||||||
|
'AAAA',
|
||||||
|
'CNAME',
|
||||||
|
'MX',
|
||||||
|
'TXT',
|
||||||
|
'NS',
|
||||||
|
'CAA',
|
||||||
|
];
|
||||||
|
|
||||||
|
@customElement('ops-view-dns')
|
||||||
|
export class OpsViewDns extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||||
|
this.domainsState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||||
|
// If a domain is already selected (e.g. via "View Records" navigation), refresh its records
|
||||||
|
const selected = this.domainsState.selectedDomainId;
|
||||||
|
if (selected) {
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.fetchDnsRecordsForDomainAction, {
|
||||||
|
domainId: selected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.dnsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domainPicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge.manual {
|
||||||
|
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||||
|
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge.synced {
|
||||||
|
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
||||||
|
color: ${cssManager.bdTheme('#92400e', '#fde047')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const domains = this.domainsState.domains;
|
||||||
|
const selectedId = this.domainsState.selectedDomainId;
|
||||||
|
const records = this.domainsState.records;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-heading level="3">DNS Records</dees-heading>
|
||||||
|
<div class="dnsContainer">
|
||||||
|
<div class="domainPicker">
|
||||||
|
<span>Domain:</span>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.options=${domains.map((d) => ({ option: d.name, key: d.id }))}
|
||||||
|
.selectedOption=${selectedId
|
||||||
|
? { option: domains.find((d) => d.id === selectedId)?.name || '', key: selectedId }
|
||||||
|
: undefined}
|
||||||
|
@selectedOption=${async (e: CustomEvent) => {
|
||||||
|
const id = (e.detail as any)?.key;
|
||||||
|
if (!id) return;
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchDnsRecordsForDomainAction,
|
||||||
|
{ domainId: id },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${selectedId
|
||||||
|
? html`
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'DNS Records'}
|
||||||
|
.heading2=${this.domainHint(selectedId)}
|
||||||
|
.data=${records}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(r: interfaces.data.IDnsRecord) => ({
|
||||||
|
Name: r.name,
|
||||||
|
Type: r.type,
|
||||||
|
Value: r.value,
|
||||||
|
TTL: r.ttl,
|
||||||
|
Source: html`<span class="sourceBadge ${r.source}">${r.source}</span>`,
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Record',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateRecordDialog(selectedId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchDnsRecordsForDomainAction,
|
||||||
|
{ domainId: selectedId },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const rec = actionData.item as interfaces.data.IDnsRecord;
|
||||||
|
await this.showEditRecordDialog(rec);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const rec = actionData.item as interfaces.data.IDnsRecord;
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.deleteDnsRecordAction,
|
||||||
|
{ id: rec.id, domainId: rec.domainId },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
`
|
||||||
|
: html`<p style="opacity: 0.7;">Pick a domain above to view its records.</p>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private domainHint(domainId: string): string {
|
||||||
|
const domain = this.domainsState.domains.find((d) => d.id === domainId);
|
||||||
|
if (!domain) return '';
|
||||||
|
if (domain.source === 'manual') {
|
||||||
|
return 'Records are served by dcrouter (authoritative).';
|
||||||
|
}
|
||||||
|
return 'Records are stored at the provider — changes here are pushed via the provider API.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateRecordDialog(domainId: string) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Add DNS Record',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'type'}
|
||||||
|
.label=${'Type'}
|
||||||
|
.options=${RECORD_TYPES.map((t) => ({ option: t, key: t }))}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'value'}
|
||||||
|
.label=${'Value (for MX use "10 mail.example.com")'}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${'300'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const type = (data.type?.key ?? data.type) as interfaces.data.TDnsRecordType;
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.createDnsRecordAction, {
|
||||||
|
domainId,
|
||||||
|
name: String(data.name),
|
||||||
|
type,
|
||||||
|
value: String(data.value),
|
||||||
|
ttl: parseInt(String(data.ttl || '300'), 10),
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showEditRecordDialog(rec: interfaces.data.IDnsRecord) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Edit ${rec.type} ${rec.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .value=${rec.name}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'value'} .label=${'Value'} .value=${rec.value}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${String(rec.ttl)}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsRecordAction, {
|
||||||
|
id: rec.id,
|
||||||
|
domainId: rec.domainId,
|
||||||
|
name: String(data.name),
|
||||||
|
value: String(data.value),
|
||||||
|
ttl: parseInt(String(data.ttl || '300'), 10),
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
335
ts_web/elements/domains/ops-view-domains.ts
Normal file
335
ts_web/elements/domains/ops-view-domains.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../../appstate.js';
|
||||||
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from '../shared/css.js';
|
||||||
|
import { appRouter } from '../../router.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-domains': OpsViewDomains;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-domains')
|
||||||
|
export class OpsViewDomains extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||||
|
this.domainsState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.domainsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge.manual {
|
||||||
|
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||||
|
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge.provider {
|
||||||
|
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
||||||
|
color: ${cssManager.bdTheme('#92400e', '#fde047')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const domains = this.domainsState.domains;
|
||||||
|
const providersById = new Map(this.domainsState.providers.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-heading level="3">Domains</dees-heading>
|
||||||
|
<div class="domainsContainer">
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Domains'}
|
||||||
|
.heading2=${'Domains under management — manual (authoritative) or imported from a provider'}
|
||||||
|
.data=${domains}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(d: interfaces.data.IDomain) => ({
|
||||||
|
Name: d.name,
|
||||||
|
Source: this.renderSourceBadge(d, providersById),
|
||||||
|
Authoritative: d.authoritative ? 'yes' : 'no',
|
||||||
|
Nameservers: d.nameservers?.join(', ') || '-',
|
||||||
|
'Last Synced': d.lastSyncedAt
|
||||||
|
? new Date(d.lastSyncedAt).toLocaleString()
|
||||||
|
: '-',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Manual Domain',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateManualDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Import from Provider',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showImportDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchDomainsAndProvidersAction,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'View Records',
|
||||||
|
iconName: 'lucide:list',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const domain = actionData.item as interfaces.data.IDomain;
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchDnsRecordsForDomainAction,
|
||||||
|
{ domainId: domain.id },
|
||||||
|
);
|
||||||
|
appRouter.navigateToView('domains', 'dns');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sync Now',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const domain = actionData.item as interfaces.data.IDomain;
|
||||||
|
if (domain.source !== 'provider') {
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.show({
|
||||||
|
message: 'Sync only applies to provider-managed domains',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.syncDomainAction, {
|
||||||
|
id: domain.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const domain = actionData.item as interfaces.data.IDomain;
|
||||||
|
await this.deleteDomain(domain);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSourceBadge(
|
||||||
|
d: interfaces.data.IDomain,
|
||||||
|
providersById: Map<string, interfaces.data.IDnsProviderPublic>,
|
||||||
|
): TemplateResult {
|
||||||
|
if (d.source === 'manual') {
|
||||||
|
return html`<span class="sourceBadge manual">Manual</span>`;
|
||||||
|
}
|
||||||
|
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
|
||||||
|
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateManualDialog() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Add Manual Domain',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'FQDN (e.g. example.com)'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'description'} .label=${'Description (optional)'}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||||
|
dcrouter will become the authoritative DNS server for this domain. You'll need to
|
||||||
|
delegate the domain's nameservers to dcrouter to make this effective.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.createManualDomainAction, {
|
||||||
|
name: String(data.name),
|
||||||
|
description: data.description ? String(data.description) : undefined,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showImportDialog() {
|
||||||
|
const providers = this.domainsState.providers;
|
||||||
|
if (providers.length === 0) {
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.show({
|
||||||
|
message: 'Add a DNS provider first (Domains > Providers)',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 3500,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Import Domains from Provider',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'providerId'}
|
||||||
|
.label=${'Provider'}
|
||||||
|
.options=${providers.map((p) => ({ option: p.name, key: p.id }))}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'domainNames'}
|
||||||
|
.label=${'Comma-separated FQDNs to import (e.g. example.com, foo.com)'}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||||
|
Tip: use "List Provider Domains" to see what's available before typing.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'List Provider Domains',
|
||||||
|
action: async (_modalArg: any) => {
|
||||||
|
const form = _modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const providerKey = data.providerId?.key ?? data.providerId;
|
||||||
|
if (!providerKey) {
|
||||||
|
DeesToast.show({ message: 'Pick a provider first', type: 'warning', duration: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await appstate.fetchProviderDomains(String(providerKey));
|
||||||
|
if (!result.success) {
|
||||||
|
DeesToast.show({
|
||||||
|
message: result.message || 'Failed to fetch domains',
|
||||||
|
type: 'error',
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = (result.domains ?? []).map((d) => d.name).join(', ');
|
||||||
|
DeesToast.show({
|
||||||
|
message: `Provider has: ${list || '(none)'}`,
|
||||||
|
type: 'info',
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Import',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const providerKey = data.providerId?.key ?? data.providerId;
|
||||||
|
if (!providerKey) {
|
||||||
|
DeesToast.show({ message: 'Pick a provider', type: 'warning', duration: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = String(data.domainNames || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (names.length === 0) {
|
||||||
|
DeesToast.show({ message: 'Enter at least one FQDN', type: 'warning', duration: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.importDomainsFromProviderAction,
|
||||||
|
{ providerId: String(providerKey), domainNames: names },
|
||||||
|
);
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteDomain(domain: interfaces.data.IDomain) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Delete domain ${domain.name}?`,
|
||||||
|
content: html`
|
||||||
|
<p>
|
||||||
|
${domain.source === 'provider'
|
||||||
|
? 'This removes the domain and its cached records from dcrouter only. The zone at the provider is NOT touched.'
|
||||||
|
: 'This removes the domain and all of its DNS records from dcrouter. dcrouter will no longer answer queries for this domain after the next restart.'}
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.deleteDomainAction, {
|
||||||
|
id: domain.id,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
283
ts_web/elements/domains/ops-view-providers.ts
Normal file
283
ts_web/elements/domains/ops-view-providers.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../../appstate.js';
|
||||||
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from '../shared/css.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-providers': OpsViewProviders;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-providers')
|
||||||
|
export class OpsViewProviders extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||||
|
this.domainsState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.providersContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.ok {
|
||||||
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.error {
|
||||||
|
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.untested {
|
||||||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||||
|
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const providers = this.domainsState.providers;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-heading level="3">DNS Providers</dees-heading>
|
||||||
|
<div class="providersContainer">
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Providers'}
|
||||||
|
.heading2=${'External DNS provider accounts (Cloudflare, etc.)'}
|
||||||
|
.data=${providers}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
|
||||||
|
Name: p.name,
|
||||||
|
Type: p.type,
|
||||||
|
Status: this.renderStatusBadge(p.status),
|
||||||
|
'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never',
|
||||||
|
Error: p.lastError || '-',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Provider',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await appstate.domainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchDomainsAndProvidersAction,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Test Connection',
|
||||||
|
iconName: 'lucide:plug',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
|
||||||
|
await this.testProvider(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
|
||||||
|
await this.showEditDialog(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
|
||||||
|
await this.deleteProvider(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatusBadge(status: interfaces.data.TDnsProviderStatus): TemplateResult {
|
||||||
|
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateDialog() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Add DNS Provider',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Provider name'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'apiToken'}
|
||||||
|
.label=${'Cloudflare API token'}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, {
|
||||||
|
name: String(data.name),
|
||||||
|
type: 'cloudflare',
|
||||||
|
credentials: { type: 'cloudflare', apiToken: String(data.apiToken) },
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Edit Provider: ${provider.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Provider name'} .value=${provider.name}></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'apiToken'}
|
||||||
|
.label=${'New API token (leave blank to keep current)'}
|
||||||
|
></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot
|
||||||
|
?.querySelector('.content')
|
||||||
|
?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const apiToken = data.apiToken ? String(data.apiToken) : '';
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, {
|
||||||
|
id: provider.id,
|
||||||
|
name: String(data.name),
|
||||||
|
credentials: apiToken
|
||||||
|
? { type: 'cloudflare', apiToken }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testProvider(provider: interfaces.data.IDnsProviderPublic) {
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.testDnsProviderAction, {
|
||||||
|
id: provider.id,
|
||||||
|
});
|
||||||
|
const updated = appstate.domainsStatePart
|
||||||
|
.getState()!
|
||||||
|
.providers.find((p) => p.id === provider.id);
|
||||||
|
if (updated?.status === 'ok') {
|
||||||
|
DeesToast.show({
|
||||||
|
message: `${provider.name}: connection OK`,
|
||||||
|
type: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
DeesToast.show({
|
||||||
|
message: `${provider.name}: ${updated?.lastError || 'connection failed'}`,
|
||||||
|
type: 'error',
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteProvider(provider: interfaces.data.IDnsProviderPublic) {
|
||||||
|
const linkedDomains = this.domainsState.domains.filter((d) => d.providerId === provider.id);
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
const doDelete = async (force: boolean) => {
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.deleteDnsProviderAction, {
|
||||||
|
id: provider.id,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (linkedDomains.length > 0) {
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Provider in use`,
|
||||||
|
content: html`
|
||||||
|
<p>
|
||||||
|
Provider <strong>${provider.name}</strong> is referenced by ${linkedDomains.length}
|
||||||
|
domain(s). Deleting will also remove the imported domain(s) and their cached
|
||||||
|
records (the records at ${provider.type} are NOT touched).
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Force Delete',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await doDelete(true);
|
||||||
|
modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await doDelete(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ export class OpsViewEmailSecurity extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Email Security</dees-heading>
|
<dees-heading level="3">Email Security</dees-heading>
|
||||||
|
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Email Log</dees-heading>
|
<dees-heading level="3">Email Log</dees-heading>
|
||||||
<div class="viewContainer">
|
<div class="viewContainer">
|
||||||
${this.currentView === 'detail' && this.selectedEmail
|
${this.currentView === 'detail' && this.selectedEmail
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export * from './email/index.js';
|
|||||||
export * from './ops-view-logs.js';
|
export * from './ops-view-logs.js';
|
||||||
export * from './access/index.js';
|
export * from './access/index.js';
|
||||||
export * from './security/index.js';
|
export * from './security/index.js';
|
||||||
export * from './ops-view-certificates.js';
|
export * from './domains/index.js';
|
||||||
export * from './shared/index.js';
|
export * from './shared/index.js';
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Network Activity</dees-heading>
|
<dees-heading level="3">Network Activity</dees-heading>
|
||||||
|
|
||||||
<div class="networkContainer">
|
<div class="networkContainer">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Network Targets</dees-heading>
|
<dees-heading level="3">Network Targets</dees-heading>
|
||||||
<div class="targetsContainer">
|
<div class="targetsContainer">
|
||||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Remote Ingress</dees-heading>
|
<dees-heading level="3">Remote Ingress</dees-heading>
|
||||||
|
|
||||||
${this.riState.newEdgeId ? html`
|
${this.riState.newEdgeId ? html`
|
||||||
<div class="secretDialog">
|
<div class="secretDialog">
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Route Management</dees-heading>
|
<dees-heading level="3">Route Management</dees-heading>
|
||||||
|
|
||||||
<div class="routesContainer">
|
<div class="routesContainer">
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export class OpsViewSourceProfiles extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Source Profiles</dees-heading>
|
<dees-heading level="3">Source Profiles</dees-heading>
|
||||||
<div class="profilesContainer">
|
<div class="profilesContainer">
|
||||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Target Profiles</dees-heading>
|
<dees-heading level="3">Target Profiles</dees-heading>
|
||||||
<div class="profilesContainer">
|
<div class="profilesContainer">
|
||||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
<dees-table
|
<dees-table
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">VPN</dees-heading>
|
<dees-heading level="3">VPN</dees-heading>
|
||||||
<div class="vpnContainer">
|
<div class="vpnContainer">
|
||||||
|
|
||||||
${this.vpnState.newClientConfig ? html`
|
${this.vpnState.newClientConfig ? html`
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { IView } from '@design.estate/dees-catalog';
|
|||||||
|
|
||||||
// Top-level / flat views
|
// Top-level / flat views
|
||||||
import { OpsViewLogs } from './ops-view-logs.js';
|
import { OpsViewLogs } from './ops-view-logs.js';
|
||||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
|
||||||
|
|
||||||
// Overview group
|
// Overview group
|
||||||
import { OpsViewOverview } from './overview/ops-view-overview.js';
|
import { OpsViewOverview } from './overview/ops-view-overview.js';
|
||||||
@@ -43,6 +42,12 @@ import { OpsViewSecurityOverview } from './security/ops-view-security-overview.j
|
|||||||
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
|
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
|
||||||
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
|
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
|
||||||
|
|
||||||
|
// Domains group
|
||||||
|
import { OpsViewProviders } from './domains/ops-view-providers.js';
|
||||||
|
import { OpsViewDomains } from './domains/ops-view-domains.js';
|
||||||
|
import { OpsViewDns } from './domains/ops-view-dns.js';
|
||||||
|
import { OpsViewCertificates } from './domains/ops-view-certificates.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
|
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
|
||||||
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
|
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
|
||||||
@@ -128,9 +133,14 @@ export class OpsDashboard extends DeesElement {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Certificates',
|
name: 'Domains',
|
||||||
iconName: 'lucide:badgeCheck',
|
iconName: 'lucide:globe',
|
||||||
element: OpsViewCertificates,
|
subViews: [
|
||||||
|
{ slug: 'providers', name: 'Providers', iconName: 'lucide:plug', element: OpsViewProviders },
|
||||||
|
{ slug: 'domains', name: 'Domains', iconName: 'lucide:globe', element: OpsViewDomains },
|
||||||
|
{ slug: 'dns', name: 'DNS', iconName: 'lucide:list', element: OpsViewDns },
|
||||||
|
{ slug: 'certificates', name: 'Certificates', iconName: 'lucide:badgeCheck', element: OpsViewCertificates },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Logs</dees-heading>
|
<dees-heading level="3">Logs</dees-heading>
|
||||||
|
|
||||||
<dees-chart-log
|
<dees-chart-log
|
||||||
.label=${'Application Logs'}
|
.label=${'Application Logs'}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Configuration</dees-heading>
|
<dees-heading level="3">Configuration</dees-heading>
|
||||||
|
|
||||||
${this.configState.isLoading
|
${this.configState.isLoading
|
||||||
? html`
|
? html`
|
||||||
@@ -227,7 +227,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
|
|
||||||
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
|
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
|
||||||
const actions: IConfigSectionAction[] = [
|
const actions: IConfigSectionAction[] = [
|
||||||
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
|
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'domains', subview: 'certificates' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Stats</dees-heading>
|
<dees-heading level="3">Stats</dees-heading>
|
||||||
|
|
||||||
${this.statsState.isLoading ? html`
|
${this.statsState.isLoading ? html`
|
||||||
<div class="loadingMessage">
|
<div class="loadingMessage">
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export class OpsViewSecurityAuthentication extends DeesElement {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Authentication</dees-heading>
|
<dees-heading level="3">Authentication</dees-heading>
|
||||||
|
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Blocked IPs</dees-heading>
|
<dees-heading level="3">Blocked IPs</dees-heading>
|
||||||
|
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export class OpsViewSecurityOverview extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="hr">Overview</dees-heading>
|
<dees-heading level="3">Overview</dees-heading>
|
||||||
|
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as appstate from './appstate.js';
|
|||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||||
|
|
||||||
// Flat top-level views (no subviews)
|
// Flat top-level views (no subviews)
|
||||||
const flatViews = ['logs', 'certificates'] as const;
|
const flatViews = ['logs'] as const;
|
||||||
|
|
||||||
// Tabbed views and their valid subviews
|
// Tabbed views and their valid subviews
|
||||||
const subviewMap: Record<string, readonly string[]> = {
|
const subviewMap: Record<string, readonly string[]> = {
|
||||||
@@ -13,6 +13,7 @@ const subviewMap: Record<string, readonly string[]> = {
|
|||||||
email: ['log', 'security'] as const,
|
email: ['log', 'security'] as const,
|
||||||
access: ['apitokens', 'users'] as const,
|
access: ['apitokens', 'users'] as const,
|
||||||
security: ['overview', 'blocked', 'authentication'] as const,
|
security: ['overview', 'blocked', 'authentication'] as const,
|
||||||
|
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default subview when user visits the bare parent URL
|
// Default subview when user visits the bare parent URL
|
||||||
@@ -22,6 +23,7 @@ const defaultSubview: Record<string, string> = {
|
|||||||
email: 'log',
|
email: 'log',
|
||||||
access: 'apitokens',
|
access: 'apitokens',
|
||||||
security: 'overview',
|
security: 'overview',
|
||||||
|
domains: 'domains',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;
|
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user