feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.13.0 - feat(dns)
|
||||||
|
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
|
||||||
|
|
||||||
|
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
|
||||||
|
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
|
||||||
|
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
|
||||||
|
|
||||||
## 2026-04-12 - 13.12.0 - feat(email-domains)
|
## 2026-04-12 - 13.12.0 - feat(email-domains)
|
||||||
support creating email domains on optional subdomains
|
support creating email domains on optional subdomains
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.12.0',
|
version: '13.13.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -930,15 +930,16 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
||||||
// ACME is enabled. The DnsManager dispatches each challenge to the right
|
// ACME is enabled. The DnsManager dispatches each challenge through the
|
||||||
// provider client based on the FQDN being certificated.
|
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
|
||||||
|
// zones and provider-managed zones. Only domains under management get certs.
|
||||||
let challengeHandlers: any[] = [];
|
let challengeHandlers: any[] = [];
|
||||||
if (
|
if (
|
||||||
acmeConfig &&
|
acmeConfig &&
|
||||||
this.dnsManager &&
|
this.dnsManager &&
|
||||||
(await this.dnsManager.hasAcmeCapableProvider())
|
(await this.dnsManager.hasAnyManagedDomain())
|
||||||
) {
|
) {
|
||||||
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
|
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)');
|
||||||
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
||||||
challengeHandlers.push(dns01Handler);
|
challengeHandlers.push(dns01Handler);
|
||||||
|
|||||||
@@ -296,70 +296,99 @@ export class DnsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
|
* Find the DomainDoc that covers a given FQDN, regardless of source
|
||||||
* to decide whether to wire SmartAcme with a DNS-01 handler.
|
* (dcrouter-hosted or provider-managed). Uses longest-suffix match.
|
||||||
*/
|
*/
|
||||||
public async hasAcmeCapableProvider(): Promise<boolean> {
|
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
|
||||||
const providers = await DnsProviderDoc.findAll();
|
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
|
||||||
return providers.length > 0;
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
// Sort by name length descending for longest-match-wins
|
||||||
|
allDomains.sort((a, b) => b.name.length - a.name.length);
|
||||||
|
for (const domain of allDomains) {
|
||||||
|
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
|
* Delete all DNS records matching a name and type under a domain.
|
||||||
* the right provider client (whichever provider type owns the parent zone),
|
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
|
||||||
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
|
*/
|
||||||
* interface, so any registered provider implementation works.
|
public async deleteRecordsByNameAndType(
|
||||||
* Returned object plugs directly into smartacme's Dns01Handler.
|
domainId: string,
|
||||||
|
name: string,
|
||||||
|
type: TDnsRecordType,
|
||||||
|
): Promise<void> {
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domainId);
|
||||||
|
for (const rec of records) {
|
||||||
|
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
|
||||||
|
await this.deleteRecord(rec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if any domain is under management (dcrouter-hosted or provider-managed).
|
||||||
|
* Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler.
|
||||||
|
*/
|
||||||
|
public async hasAnyManagedDomain(): Promise<boolean> {
|
||||||
|
const domains = await DomainDoc.findAll();
|
||||||
|
return domains.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through
|
||||||
|
* the DnsManager abstraction. Challenges are dispatched via createRecord() /
|
||||||
|
* deleteRecord(), which transparently handle both dcrouter-hosted zones
|
||||||
|
* (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API).
|
||||||
|
*
|
||||||
|
* Only domains under management (with a DomainDoc in DB) are supported —
|
||||||
|
* this acts as the management gate for certificate issuance.
|
||||||
*/
|
*/
|
||||||
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
||||||
const self = this;
|
const self = this;
|
||||||
const adapter = {
|
const adapter = {
|
||||||
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
||||||
if (!client) {
|
if (!domainDoc) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
|
`DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
|
||||||
'Add one in the Domains > Providers UI before issuing certificates.',
|
'Add the domain in Domains before issuing certificates.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Clean any leftover challenge records first to avoid duplicates.
|
// Clean leftover challenge records first to avoid duplicates.
|
||||||
try {
|
try {
|
||||||
const existing = await client.listRecords(dnsChallenge.hostName);
|
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||||
for (const r of existing) {
|
|
||||||
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
|
||||||
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
await client.createRecord(dnsChallenge.hostName, {
|
// Create the challenge TXT record via the unified path
|
||||||
|
await self.createRecord({
|
||||||
|
domainId: domainDoc.id,
|
||||||
name: dnsChallenge.hostName,
|
name: dnsChallenge.hostName,
|
||||||
type: 'TXT',
|
type: 'TXT',
|
||||||
value: dnsChallenge.challenge,
|
value: dnsChallenge.challenge,
|
||||||
ttl: 120,
|
ttl: 120,
|
||||||
|
createdBy: 'acme-dns01',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
||||||
if (!client) {
|
if (!domainDoc) {
|
||||||
// The domain may have been removed; nothing to clean up.
|
// The domain may have been removed; nothing to clean up.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const existing = await client.listRecords(dnsChallenge.hostName);
|
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||||
for (const r of existing) {
|
|
||||||
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
|
||||||
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async isDomainSupported(domain: string): Promise<boolean> {
|
async isDomainSupported(domain: string): Promise<boolean> {
|
||||||
const client = await self.getProviderClientForDomain(domain);
|
const domainDoc = await self.findDomainForFqdn(domain);
|
||||||
return !!client;
|
return !!domainDoc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
||||||
@@ -642,6 +671,151 @@ export class DnsManager {
|
|||||||
return await DnsRecordDoc.findById(id);
|
return await DnsRecordDoc.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Domain migration
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a domain between dcrouter-hosted and provider-managed.
|
||||||
|
* Transfers all records to the target and updates domain metadata.
|
||||||
|
*/
|
||||||
|
public async migrateDomain(args: {
|
||||||
|
id: string;
|
||||||
|
targetSource: 'dcrouter' | 'provider';
|
||||||
|
targetProviderId?: string;
|
||||||
|
deleteExistingProviderRecords?: boolean;
|
||||||
|
}): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||||
|
const domain = await DomainDoc.findById(args.id);
|
||||||
|
if (!domain) return { success: false, message: 'Domain not found' };
|
||||||
|
|
||||||
|
if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) {
|
||||||
|
return { success: false, message: 'Domain is already in the target configuration' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
||||||
|
|
||||||
|
if (args.targetSource === 'provider') {
|
||||||
|
return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false);
|
||||||
|
} else {
|
||||||
|
return this.migrateToDcrouter(domain, records);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider.
|
||||||
|
*/
|
||||||
|
private async migrateToDnsProvider(
|
||||||
|
domain: DomainDoc,
|
||||||
|
records: DnsRecordDoc[],
|
||||||
|
targetProviderId: string,
|
||||||
|
deleteExistingProviderRecords: boolean,
|
||||||
|
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||||
|
// Validate the target provider exists
|
||||||
|
const client = await this.getProviderClientById(targetProviderId);
|
||||||
|
if (!client) {
|
||||||
|
return { success: false, message: 'Target DNS provider not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the zone at the provider
|
||||||
|
const providerDomains = await client.listDomains();
|
||||||
|
const zone = providerDomains.find(
|
||||||
|
(z) => z.name.toLowerCase() === domain.name.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!zone) {
|
||||||
|
return { success: false, message: `Zone "${domain.name}" not found at the target provider` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally delete existing records at the provider
|
||||||
|
if (deleteExistingProviderRecords) {
|
||||||
|
try {
|
||||||
|
const existingProviderRecords = await client.listRecords(domain.name);
|
||||||
|
for (const pr of existingProviderRecords) {
|
||||||
|
await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {});
|
||||||
|
}
|
||||||
|
logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push each local record to the provider
|
||||||
|
let migrated = 0;
|
||||||
|
for (const rec of records) {
|
||||||
|
try {
|
||||||
|
const providerRecord = await client.createRecord(domain.name, {
|
||||||
|
name: rec.name,
|
||||||
|
type: rec.type as any,
|
||||||
|
value: rec.value,
|
||||||
|
ttl: rec.ttl,
|
||||||
|
});
|
||||||
|
// Unregister from embedded DnsServer if it was dcrouter-hosted
|
||||||
|
if (domain.source === 'dcrouter') {
|
||||||
|
this.unregisterRecordFromDnsServer(rec);
|
||||||
|
}
|
||||||
|
// Update the record doc to synced
|
||||||
|
rec.source = 'synced' as TDnsRecordSource;
|
||||||
|
rec.providerRecordId = providerRecord.providerRecordId;
|
||||||
|
await rec.save();
|
||||||
|
migrated++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update domain metadata
|
||||||
|
domain.source = 'provider';
|
||||||
|
domain.authoritative = false;
|
||||||
|
domain.providerId = targetProviderId;
|
||||||
|
domain.externalZoneId = zone.externalId;
|
||||||
|
domain.nameservers = zone.nameservers;
|
||||||
|
domain.lastSyncedAt = Date.now();
|
||||||
|
domain.updatedAt = Date.now();
|
||||||
|
await domain.save();
|
||||||
|
|
||||||
|
logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`);
|
||||||
|
return { success: true, recordsMigrated: migrated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate domain from provider-managed to dcrouter-hosted (authoritative).
|
||||||
|
*/
|
||||||
|
private async migrateToDcrouter(
|
||||||
|
domain: DomainDoc,
|
||||||
|
records: DnsRecordDoc[],
|
||||||
|
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||||
|
// Register each record with the embedded DnsServer
|
||||||
|
let migrated = 0;
|
||||||
|
for (const rec of records) {
|
||||||
|
try {
|
||||||
|
this.registerRecordWithDnsServer(rec);
|
||||||
|
// Update the record doc to local
|
||||||
|
rec.source = 'local' as TDnsRecordSource;
|
||||||
|
rec.providerRecordId = undefined;
|
||||||
|
await rec.save();
|
||||||
|
migrated++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update domain metadata
|
||||||
|
domain.source = 'dcrouter';
|
||||||
|
domain.authoritative = true;
|
||||||
|
domain.providerId = undefined;
|
||||||
|
domain.externalZoneId = undefined;
|
||||||
|
domain.nameservers = undefined;
|
||||||
|
domain.lastSyncedAt = undefined;
|
||||||
|
domain.updatedAt = Date.now();
|
||||||
|
await domain.save();
|
||||||
|
|
||||||
|
logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
|
||||||
|
return { success: true, recordsMigrated: migrated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Record CRUD
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
public async createRecord(args: {
|
public async createRecord(args: {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -759,14 +933,24 @@ export class DnsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For local records: smartdns has no unregister API in the pinned version,
|
// For dcrouter-hosted records: unregister the handler from the embedded DnsServer
|
||||||
// so the record stays served until the next restart. The DB delete still
|
// so the record stops being served immediately (not just after restart).
|
||||||
// takes effect — on restart, the record will not be re-registered.
|
if (domain.source === 'dcrouter' && this.dnsServer) {
|
||||||
|
this.unregisterRecordFromDnsServer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a record's handler from the embedded DnsServer.
|
||||||
|
*/
|
||||||
|
public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
|
||||||
|
if (!this.dnsServer) return;
|
||||||
|
this.dnsServer.unregisterHandler(rec.name, [rec.type]);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export class ConfigHandler {
|
|||||||
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
||||||
let dnsChallengeEnabled = false;
|
let dnsChallengeEnabled = false;
|
||||||
try {
|
try {
|
||||||
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
|
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
|
||||||
} catch {
|
} catch {
|
||||||
dnsChallengeEnabled = false;
|
dnsChallengeEnabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,5 +157,23 @@ export class DomainHandler {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Migrate domain between dcrouter-hosted and provider-managed
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
|
||||||
|
'migrateDomain',
|
||||||
|
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.migrateDomain({
|
||||||
|
id: dataArg.id,
|
||||||
|
targetSource: dataArg.targetSource,
|
||||||
|
targetProviderId: dataArg.targetProviderId,
|
||||||
|
deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,3 +148,31 @@ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implemen
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a domain between dcrouter-hosted and provider-managed (or between providers).
|
||||||
|
* Records are transferred to the target and the domain source/providerId are updated.
|
||||||
|
*/
|
||||||
|
export interface IReq_MigrateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_MigrateDomain
|
||||||
|
> {
|
||||||
|
method: 'migrateDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
/** Target source type. */
|
||||||
|
targetSource: import('../data/domain.js').TDomainSource;
|
||||||
|
/** Required when targetSource is 'provider'. */
|
||||||
|
targetProviderId?: string;
|
||||||
|
/** When migrating to a provider: delete all existing records at the provider first. */
|
||||||
|
deleteExistingProviderRecords?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
/** Number of records migrated. */
|
||||||
|
recordsMigrated?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.12.0',
|
version: '13.13.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1887,6 +1887,32 @@ export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const migrateDomainAction = domainsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
targetSource: interfaces.data.TDomainSource;
|
||||||
|
targetProviderId?: string;
|
||||||
|
deleteExistingProviderRecords?: boolean;
|
||||||
|
}>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_MigrateDomain
|
||||||
|
>('/typedrequest', 'migrateDomain');
|
||||||
|
const response = await request.fire({ identity: context.identity!, ...dataArg });
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Migration failed' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Migration failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const createDnsRecordAction = domainsStatePart.createAction<{
|
export const createDnsRecordAction = domainsStatePart.createAction<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -149,6 +149,15 @@ export class OpsViewDomains extends DeesElement {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Migrate',
|
||||||
|
iconName: 'lucide:arrow-right-left',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const domain = actionData.item as interfaces.data.IDomain;
|
||||||
|
await this.showMigrateDialog(domain);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
@@ -308,6 +317,96 @@ export class OpsViewDomains extends DeesElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async showMigrateDialog(domain: interfaces.data.IDomain) {
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
const providers = this.domainsState.providers;
|
||||||
|
|
||||||
|
// Build target options based on current source
|
||||||
|
const targetOptions: { option: string; key: string }[] = [];
|
||||||
|
if (domain.source === 'provider') {
|
||||||
|
targetOptions.push({ option: 'DcRouter (authoritative)', key: 'dcrouter' });
|
||||||
|
}
|
||||||
|
// Add all providers (except the current one if already provider-managed)
|
||||||
|
for (const p of providers) {
|
||||||
|
if (domain.source === 'provider' && domain.providerId === p.id) continue;
|
||||||
|
targetOptions.push({ option: `${p.name} (${p.type})`, key: `provider:${p.id}` });
|
||||||
|
}
|
||||||
|
if (domain.source === 'dcrouter') {
|
||||||
|
targetOptions.unshift({ option: 'DcRouter (authoritative)', key: 'dcrouter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetOptions.length === 0) {
|
||||||
|
DeesToast.show({
|
||||||
|
message: 'No migration targets available. Add a DNS provider first.',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLabel = domain.source === 'dcrouter'
|
||||||
|
? 'DcRouter (authoritative)'
|
||||||
|
: providers.find((p) => p.id === domain.providerId)?.name || 'Provider';
|
||||||
|
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Migrate: ${domain.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'currentSource'}
|
||||||
|
.label=${'Current source'}
|
||||||
|
.value=${currentLabel}
|
||||||
|
.disabled=${true}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'target'}
|
||||||
|
.label=${'Migrate to'}
|
||||||
|
.description=${'Select the target DNS management'}
|
||||||
|
.options=${targetOptions}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'deleteExisting'}
|
||||||
|
.label=${'Delete existing records at provider first'}
|
||||||
|
.description=${'Removes all records at the provider before pushing migrated records'}
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Migrate',
|
||||||
|
action: async (m: any) => {
|
||||||
|
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const targetKey = typeof data.target === 'object' ? data.target.key : data.target;
|
||||||
|
if (!targetKey) return;
|
||||||
|
|
||||||
|
let targetSource: interfaces.data.TDomainSource;
|
||||||
|
let targetProviderId: string | undefined;
|
||||||
|
if (targetKey === 'dcrouter') {
|
||||||
|
targetSource = 'dcrouter';
|
||||||
|
} else {
|
||||||
|
targetSource = 'provider';
|
||||||
|
targetProviderId = targetKey.replace('provider:', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.migrateDomainAction, {
|
||||||
|
id: domain.id,
|
||||||
|
targetSource,
|
||||||
|
targetProviderId,
|
||||||
|
deleteExistingProviderRecords: targetSource === 'provider' ? Boolean(data.deleteExisting) : false,
|
||||||
|
});
|
||||||
|
DeesToast.show({ message: `Domain ${domain.name} migrated successfully`, type: 'success', duration: 3000 });
|
||||||
|
m.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async deleteDomain(domain: interfaces.data.IDomain) {
|
private async deleteDomain(domain: interfaces.data.IDomain) {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
DeesModal.createAndShow({
|
DeesModal.createAndShow({
|
||||||
|
|||||||
Reference in New Issue
Block a user