Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07a3365496 | |||
| 1c4f7dbb11 | |||
| 1fdff79dd0 | |||
| 59b52d08fa |
@@ -1,5 +1,19 @@
|
|||||||
# 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)
|
||||||
|
support creating email domains on optional subdomains
|
||||||
|
|
||||||
|
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
|
||||||
|
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
|
||||||
|
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
|
||||||
|
|
||||||
## 2026-04-12 - 13.11.0 - feat(email-domains)
|
## 2026-04-12 - 13.11.0 - feat(email-domains)
|
||||||
add email domain management with DNS provisioning, validation, and ops dashboard support
|
add email domain management with DNS provisioning, validation, and ops dashboard support
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.11.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.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -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.78.0",
|
"@design.estate/dees-catalog": "^3.78.2",
|
||||||
"@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",
|
||||||
|
|||||||
Generated
+32
-11
@@ -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.78.0
|
specifier: ^3.78.2
|
||||||
version: 3.78.0(@tiptap/pm@2.27.2)
|
version: 3.78.2(@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.78.0':
|
'@design.estate/dees-catalog@3.78.2':
|
||||||
resolution: {integrity: sha512-doc9eYGsFV47Ui7k5FuLXpt3ytC/Q+g+yX+qGU/V4fZpc5KUXpL04/FRzO0AU1wF9Xl9GMmL39CcE2vKj88QAQ==}
|
resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
|
||||||
|
|
||||||
'@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==}
|
||||||
@@ -368,8 +368,8 @@ packages:
|
|||||||
'@design.estate/dees-wcctools@3.8.0':
|
'@design.estate/dees-wcctools@3.8.0':
|
||||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.4':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
|
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||||
|
|
||||||
'@emnapi/core@1.9.2':
|
'@emnapi/core@1.9.2':
|
||||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||||
@@ -1997,6 +1997,12 @@ packages:
|
|||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-transform@0.1.11':
|
||||||
|
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||||
|
|
||||||
|
'@types/dom-webcodecs@0.1.13':
|
||||||
|
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||||
|
|
||||||
'@types/fs-extra@11.0.4':
|
'@types/fs-extra@11.0.4':
|
||||||
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
||||||
|
|
||||||
@@ -3166,6 +3172,9 @@ packages:
|
|||||||
mdurl@2.0.0:
|
mdurl@2.0.0:
|
||||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||||
|
|
||||||
|
mediabunny@1.40.1:
|
||||||
|
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
|
||||||
|
|
||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||||
|
|
||||||
@@ -4318,7 +4327,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.78.0(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.78.2(@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
|
||||||
@@ -4847,11 +4856,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.78.0(@tiptap/pm@2.27.2)':
|
'@design.estate/dees-catalog@3.78.2(@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
|
||||||
'@design.estate/dees-wcctools': 3.8.4
|
'@design.estate/dees-wcctools': 3.9.0
|
||||||
'@fortawesome/fontawesome-svg-core': 7.2.0
|
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||||
'@fortawesome/free-brands-svg-icons': 7.2.0
|
'@fortawesome/free-brands-svg-icons': 7.2.0
|
||||||
'@fortawesome/free-regular-svg-icons': 7.2.0
|
'@fortawesome/free-regular-svg-icons': 7.2.0
|
||||||
@@ -4940,12 +4949,13 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.4':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
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
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
lit: 3.3.2
|
lit: 3.3.2
|
||||||
|
mediabunny: 1.40.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
- react
|
- react
|
||||||
@@ -6915,7 +6925,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.78.0(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.78.2(@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
|
||||||
@@ -7464,6 +7474,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-transform@0.1.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/dom-webcodecs': 0.1.13
|
||||||
|
|
||||||
|
'@types/dom-webcodecs@0.1.13': {}
|
||||||
|
|
||||||
'@types/fs-extra@11.0.4':
|
'@types/fs-extra@11.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/jsonfile': 6.1.4
|
'@types/jsonfile': 6.1.4
|
||||||
@@ -8819,6 +8835,11 @@ snapshots:
|
|||||||
|
|
||||||
mdurl@2.0.0: {}
|
mdurl@2.0.0: {}
|
||||||
|
|
||||||
|
mediabunny@1.40.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/dom-mediacapture-transform': 0.1.11
|
||||||
|
'@types/dom-webcodecs': 0.1.13
|
||||||
|
|
||||||
memory-pager@1.5.0: {}
|
memory-pager@1.5.0: {}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.11.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);
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomain
|
|||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public linkedDomainId: string = '';
|
public linkedDomainId: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public subdomain?: string;
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public dkim!: IEmailDomainDkim;
|
public dkim!: IEmailDomainDkim;
|
||||||
|
|
||||||
|
|||||||
+219
-35
@@ -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
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export class EmailDomainManager {
|
|||||||
|
|
||||||
public async createEmailDomain(opts: {
|
public async createEmailDomain(opts: {
|
||||||
linkedDomainId: string;
|
linkedDomainId: string;
|
||||||
|
subdomain?: string;
|
||||||
dkimSelector?: string;
|
dkimSelector?: string;
|
||||||
dkimKeySize?: number;
|
dkimKeySize?: number;
|
||||||
rotateKeys?: boolean;
|
rotateKeys?: boolean;
|
||||||
@@ -58,7 +59,9 @@ export class EmailDomainManager {
|
|||||||
if (!domainDoc) {
|
if (!domainDoc) {
|
||||||
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
||||||
}
|
}
|
||||||
const domainName = domainDoc.name;
|
const baseDomain = domainDoc.name;
|
||||||
|
const subdomain = opts.subdomain?.trim() || undefined;
|
||||||
|
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
const existing = await EmailDomainDoc.findByDomain(domainName);
|
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||||
@@ -90,6 +93,7 @@ export class EmailDomainManager {
|
|||||||
doc.id = plugins.smartunique.shortId();
|
doc.id = plugins.smartunique.shortId();
|
||||||
doc.domain = domainName.toLowerCase();
|
doc.domain = domainName.toLowerCase();
|
||||||
doc.linkedDomainId = opts.linkedDomainId;
|
doc.linkedDomainId = opts.linkedDomainId;
|
||||||
|
doc.subdomain = subdomain;
|
||||||
doc.dkim = {
|
doc.dkim = {
|
||||||
selector,
|
selector,
|
||||||
keySize,
|
keySize,
|
||||||
@@ -306,6 +310,7 @@ export class EmailDomainManager {
|
|||||||
id: doc.id,
|
id: doc.id,
|
||||||
domain: doc.domain,
|
domain: doc.domain,
|
||||||
linkedDomainId: doc.linkedDomainId,
|
linkedDomainId: doc.linkedDomainId,
|
||||||
|
subdomain: doc.subdomain,
|
||||||
dkim: doc.dkim,
|
dkim: doc.dkim,
|
||||||
rateLimits: doc.rateLimits,
|
rateLimits: doc.rateLimits,
|
||||||
dnsStatus: doc.dnsStatus,
|
dnsStatus: doc.dnsStatus,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export class EmailDomainHandler {
|
|||||||
try {
|
try {
|
||||||
const domain = await this.manager.createEmailDomain({
|
const domain = await this.manager.createEmailDomain({
|
||||||
linkedDomainId: dataArg.linkedDomainId,
|
linkedDomainId: dataArg.linkedDomainId,
|
||||||
|
subdomain: dataArg.subdomain,
|
||||||
dkimSelector: dataArg.dkimSelector,
|
dkimSelector: dataArg.dkimSelector,
|
||||||
dkimKeySize: dataArg.dkimKeySize,
|
dkimKeySize: dataArg.dkimKeySize,
|
||||||
rotateKeys: dataArg.rotateKeys,
|
rotateKeys: dataArg.rotateKeys,
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
|
|||||||
*/
|
*/
|
||||||
export interface IEmailDomain {
|
export interface IEmailDomain {
|
||||||
id: string;
|
id: string;
|
||||||
/** Fully qualified domain name (e.g. 'example.com'). */
|
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
|
||||||
domain: string;
|
domain: string;
|
||||||
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
|
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
|
||||||
linkedDomainId: string;
|
linkedDomainId: string;
|
||||||
|
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
|
||||||
|
subdomain?: string;
|
||||||
/** DKIM configuration and key state. */
|
/** DKIM configuration and key state. */
|
||||||
dkim: IEmailDomainDkim;
|
dkim: IEmailDomainDkim;
|
||||||
/** Optional per-domain rate limits. */
|
/** Optional per-domain rate limits. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.i
|
|||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
/** ID of the existing dcrouter DNS domain to link to. */
|
/** ID of the existing dcrouter DNS domain to link to. */
|
||||||
linkedDomainId: string;
|
linkedDomainId: string;
|
||||||
|
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
|
||||||
|
subdomain?: string;
|
||||||
/** DKIM selector (default: 'default'). */
|
/** DKIM selector (default: 'default'). */
|
||||||
dkimSelector?: string;
|
dkimSelector?: string;
|
||||||
/** RSA key size (default: 2048). */
|
/** RSA key size (default: 2048). */
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.11.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;
|
||||||
@@ -2422,6 +2448,7 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
|
|||||||
|
|
||||||
export const createEmailDomainAction = emailDomainsStatePart.createAction<{
|
export const createEmailDomainAction = emailDomainsStatePart.createAction<{
|
||||||
linkedDomainId: string;
|
linkedDomainId: string;
|
||||||
|
subdomain?: string;
|
||||||
dkimSelector?: string;
|
dkimSelector?: string;
|
||||||
dkimKeySize?: number;
|
dkimKeySize?: number;
|
||||||
rotateKeys?: boolean;
|
rotateKeys?: boolean;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -276,6 +276,11 @@ export class OpsViewEmailDomains extends DeesElement {
|
|||||||
.options=${domainOptions}
|
.options=${domainOptions}
|
||||||
.required=${true}
|
.required=${true}
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'subdomain'}
|
||||||
|
.label=${'Subdomain'}
|
||||||
|
.description=${'Leave empty for bare domain, e.g. "mail" for mail.example.com'}
|
||||||
|
></dees-input-text>
|
||||||
<dees-input-text
|
<dees-input-text
|
||||||
.key=${'dkimSelector'}
|
.key=${'dkimSelector'}
|
||||||
.label=${'DKIM Selector'}
|
.label=${'DKIM Selector'}
|
||||||
@@ -316,10 +321,12 @@ export class OpsViewEmailDomains extends DeesElement {
|
|||||||
? parseInt(data.dkimKeySize.key, 10)
|
? parseInt(data.dkimKeySize.key, 10)
|
||||||
: parseInt(data.dkimKeySize || '2048', 10);
|
: parseInt(data.dkimKeySize || '2048', 10);
|
||||||
|
|
||||||
|
const subdomain = data.subdomain?.trim() || undefined;
|
||||||
await appstate.emailDomainsStatePart.dispatchAction(
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
appstate.createEmailDomainAction,
|
appstate.createEmailDomainAction,
|
||||||
{
|
{
|
||||||
linkedDomainId,
|
linkedDomainId,
|
||||||
|
subdomain,
|
||||||
dkimSelector: data.dkimSelector || 'default',
|
dkimSelector: data.dkimSelector || 'default',
|
||||||
dkimKeySize: keySize,
|
dkimKeySize: keySize,
|
||||||
rotateKeys: Boolean(data.rotateKeys),
|
rotateKeys: Boolean(data.rotateKeys),
|
||||||
|
|||||||
Reference in New Issue
Block a user